feat(server,web,shared,desktop): 上传超限正确报错 + 两个登录入口都限流 + 作业按天深链,文档与验证脚本同步

服务端加固:
- express.json 上限 2mb → 12mb:表格导入把文件以 base64 放进 JSON(约 4/3 倍),
  8MB 的声明上限原先到 1.5MB 就被拦掉;超限现在由 error 中间件映射成
  413 IMPORT_TOO_LARGE(中文提示"请拆分后分批导入"),不再落进 500 兜底分支抛英文原文;
- loginRateLimiter 同时挂 /auth/login 与 /auth/class-login(班级密码默认 123456,
  班级登录曾是唯一没有暴力破解防护的入口),两个路径共享同一计数器;
- CORS_ORIGIN=* 时启动告警(不拦截启动,避免挡住教室机 file:// 的合法部署);
- 删除已无用的 DEFAULT_STUDENT_PASSWORD;班级 termWeeks 上限 40 → 30,
  与 MAX_TERM_WEEK / weekNumberSchema 对齐(原先对外承诺了系统兑现不了的学期长度)。

Web 端:
- 作业页 ?date= 深链在同路由再推 query 时也生效(watch route.query.date);
- 表格导入对话框适配 8MB 上限与 413 提示。

冒烟与文档:
- 新增 scripts/lib/smoke-class.mjs:验证用临时班级,避免探针通知弹到用户自己的客户端与灵动岛;
- README 与 AGENTS.md 同步本轮全部行为变更(含 schedule source、学生账号清理、凭据门禁)。
This commit is contained in:
LoveChengke committed 2026-10-02 00:25:21 +08:00
1 parent 3880eee08e
commit 20fcca0388
28 files changed
+1649 -915

No files matched your search

@@ -6,6 +6,11 @@ directories:
output: release
buildResources: build
# 注意:这里**不要**加 afterPack 去改 appOutDir 的完整性标签。
# 归档内部的载荷标签不会被还原(装到正常位置拿到的是默认 Medium 文件),
# 而在打包前把 appOutDir 改成 Medium 会让 7za(自身带 Low 标签、进程被降到 Low)
# 因"不能向上读"而无法生成归档。交付物的标签统一在 scripts/dist-win.mjs 收尾时修正。
# 渲染进程产物已由 Vite 打包、主进程/preload 已由 esbuild 打包,
# 运行时不需要任何 node_modules(electron 模块由 Electron 运行时提供)。
files:
-1
View File
@@ -30,7 +30,6 @@ CORS_ORIGIN=*
BCRYPT_ROUNDS=10
# 教师新增学生账号时未指定密码所使用的初始密码
DEFAULT_STUDENT_PASSWORD=123456
# 学期配置:第 1 教学周的周一日期(YYYY-MM-DD),用于“当前周次”换算
TERM_START_DATE=2026-02-23
+6 -7
View File
@@ -86,10 +86,10 @@ async function main(): Promise<void> {
const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次
const [adminHash, teacherHash, studentHash, classPasswordHash] = await Promise.all([
// 学生没有密码:个人学生账号已清理(2026-10-01),学生端统一用班级码 + 班级密码登录
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
hashPassword('admin123'),
hashPassword('teacher123'),
hashPassword('student123'),
// 班级账号默认密码(学生端「班级登录」)
hashPassword('123456'),
]);
@@ -142,7 +142,8 @@ async function main(): Promise<void> {
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
role: 'STUDENT',
classId: targetClass.id,
passwordHash: studentHash,
// 空串占位:User.passwordHash 必填,但学生登录的 403 判定在密码校验之前,永远用不到
passwordHash: '',
},
});
students.push(student);
@@ -376,10 +377,8 @@ async function main(): Promise<void> {
console.log(`管理员 admin / admin123 (${admin.name})`);
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`);
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`);
console.log('学生 student01 / student123 (高一(1)班)');
console.log('学生 student06 / student123 (高一(2)班)');
console.log('学生 student11 / student123 (高二(3)班)');
console.log('学生 student15 / student123 (高二(3)班)');
console.log('学生端(班级账号):G101 / G102 / G203,密码 123456');
console.log('(学生只有名单、没有个人账号,成绩/未交/叫人按名单记录)');
console.log('==========================================\n');
}
+69 -9
View File
@@ -676,12 +676,13 @@ async function main() {
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
);
const resetResult = await api(`/students/${addedStudentId}/reset-password`, {
// 学生没有密码相关接口(个人学生账号已清理,重置密码入口已下线):这里改为断言接口确实不存在
const resetGone = await api(`/students/${addedStudentId}/reset-password`, {
method: 'POST',
token: adminToken,
body: {},
});
record('重置学生密码', resetResult.status === 200, `status=${resetResult.status}`);
record('学生重置密码接口已下线(404)', resetGone.status === 404, `status=${resetGone.status}`);
const renamed = await api(`/students/${addedStudentId}`, {
method: 'PATCH',
@@ -1597,11 +1598,8 @@ async function main() {
// 8.4 学生名单导入(仅管理员)+ 数据清理
const importedUsername = `imp${Date.now().toString(36).slice(-6)}`;
const studentsCsv = [
'用户名,姓名,初始密码',
`${importedUsername},导入测试生,imp123456`,
'bad user,非法用户名,imp123456',
].join(NL);
// 学生导入模板已无"初始密码"列(学生没有账号属性)
const studentsCsv = ['用户名,姓名', `${importedUsername},导入测试生`, 'bad user,非法用户名'].join(NL);
const studentsCommit = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
@@ -1610,7 +1608,7 @@ async function main() {
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名', password: '初始密码' },
mapping: { username: '用户名', name: '姓名' },
mode: 'append',
},
});
@@ -1740,7 +1738,7 @@ async function main() {
classId,
fileName: 'students.csv',
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
mapping: { username: '用户名', name: '姓名', password: '初始密码' },
mapping: { username: '用户名', name: '姓名' },
mode: 'append',
},
});
@@ -2228,6 +2226,68 @@ async function main() {
`班主任账号=${deleteHeadTeacher.status}(${deleteHeadTeacher.payload?.message ?? ''}) 冒烟账号=${deleteSmokeTeacher.status}`,
);
// 级联删除护栏:`Homework.createdBy` / `Notification.createdBy` 都是 ON DELETE CASCADE,
// 因此"已不在任何班级、名下无课程,但发布过内容"的账号也必须被拒 ——
// 否则"班主任转给别人 → 解除协作 → 删除账号"会静默清掉他在各班发过的作业与通知。
const cascadeTeacherUsername = `smoke_cas_${Date.now().toString().slice(-8)}`;
const cascadeTeacher = await api('/teachers', {
method: 'POST',
token: adminToken,
body: { username: cascadeTeacherUsername, name: '冒烟级联教师', password: 'cascade123456' },
});
const cascadeTeacherId = cascadeTeacher.payload?.data?.id;
const cascadeTeacherLogin = await api('/auth/login', {
method: 'POST',
body: { username: cascadeTeacherUsername, password: 'cascade123456' },
});
const cascadeTeacherToken = cascadeTeacherLogin.payload?.data?.token;
// 1) 先让他成为该班科任(这样才有发布权限),发一条通知
await api(`/classes/${classId}/teachers`, {
method: 'POST',
token: adminToken,
body: { teacherId: cascadeTeacherId },
});
const cascadeNotification = await api('/notifications', {
method: 'POST',
token: cascadeTeacherToken,
body: { classId, title: '级联删除回归', content: '用于验证删除教师不会连带删除已发布内容' },
});
const cascadeNotificationId = cascadeNotification.payload?.data?.id;
// 2) 解除班级分配:此时他"不在任何班级、名下无课程",但发布过内容
await api(`/classes/${classId}/teachers/${cascadeTeacherId}`, {
method: 'DELETE',
token: adminToken,
});
// 3) 删除账号应当被拒(409),且那条通知必须还在
const cascadeDelete = await api(`/teachers/${cascadeTeacherId}`, {
method: 'DELETE',
token: adminToken,
});
const afterCascadeList = await api(`/notifications?classId=${classId}`, { token: adminToken });
const notificationAlive = (afterCascadeList.payload?.data ?? []).some(
(item) => item.id === cascadeNotificationId,
);
record(
'删除教师护栏覆盖"发布过作业/通知"的账号(不再级联删除内容)',
cascadeDelete.status === 409 && notificationAlive,
`删除=${cascadeDelete.status}(${cascadeDelete.payload?.message ?? ''}) 通知仍在=${notificationAlive}`,
);
// 清理:内容删掉后账号即可删除
if (cascadeNotificationId) {
await api(`/notifications/${cascadeNotificationId}`, { method: 'DELETE', token: adminToken });
}
const cascadeDeleteAfter = await api(`/teachers/${cascadeTeacherId}`, {
method: 'DELETE',
token: adminToken,
});
record(
'清理:内容删除后该教师账号可删除',
cascadeDeleteAfter.status === 200,
`status=${cascadeDeleteAfter.status}`,
);
// 创建班级仅管理员 + 设置/更改班主任
const teacherCreateClass = await api('/classes', {
method: 'POST',
+13 -3
View File
@@ -145,8 +145,15 @@ export function createApp(): Express {
app.use(securityHeaders());
app.use(compression());
app.use(cors({ origin: env.corsOrigins, credentials: true }));
app.use(express.json({ limit: '2mb' }));
app.use(express.urlencoded({ extended: true }));
/**
* 请求体上限:表格导入是把文件以 **base64** 放进 JSON 的,base64 约为原文件的 4/3,
* 再算上 JSON 包装,8MB 的表格文件约需 11MB 请求体。
* 这里必须留够余量,否则「导入模板写的 8MB 上限」永远到不了(文件到 1.5MB 就被 body-parser 拦掉),
* 而且用户看到的是一句 500「服务器内部错误:request entity too large」——
* 与 `table-import.service.ts` 的 MAX_IMPORT_BYTES(8MB) 校验完全对不上。
*/
app.use(express.json({ limit: '12mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(requestLogger());
// 探针(不参与限流)
@@ -173,7 +180,10 @@ export function createApp(): Express {
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
app.use(API_PREFIX, apiRateLimiter());
app.use(`${API_PREFIX}/auth/login`, loginRateLimiter());
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(学生端
// 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载,
// 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。
app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter());
// 模块化装配:注册表里的每个模块挂载到 /api<basePath>
for (const module of apiModules) {
+16 -2
View File
@@ -27,7 +27,6 @@ const envSchema = z.object({
JWT_EXPIRES_IN: z.string().default('7d'),
CORS_ORIGIN: z.string().default('*'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
@@ -109,6 +108,22 @@ if (isProduction) {
'请修改 packages/server/.env 后重试。',
);
}
/**
* CORS 默认值告警(**不拦截启动**)。
*
* `CORS_ORIGIN=*` 与 `credentials: true` 同时开启时,cors 中间件会反射请求方 Origin 并带上
* `Access-Control-Allow-Credentials: true`。本项目的鉴权走 `Authorization` 头(不是 cookie),
* 第三方页面拿不到用户的 token,因此实际风险有限;但"允许任意站点带凭据访问"是没有必要的暴露面,
* 生产部署应填成实际域名。这里只告警:真要卡死会挡住"教室机 file:// 页面直连后端"这类合法部署
* (那类请求的 Origin 是 `file://`,无法提前枚举),代价不值得。
*/
if (raw.CORS_ORIGIN.trim() === '*') {
console.warn(
'⚠ CORS_ORIGIN=* 且已开启 credentials:生产环境建议改成具体来源(逗号分隔),' +
'例如 CORS_ORIGIN=https://your-domain.example(教室机 file:// 客户端不受影响)。',
);
}
}
/** trust proxy 支持 false / 数字跳数 / loopback 等字符串形式 */
@@ -142,7 +157,6 @@ export const env = {
.filter(Boolean),
bcryptRounds: raw.BCRYPT_ROUNDS,
/** 新增学生时未指定密码时的初始密码 */
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
/** 新建班级时的默认班级密码(班级账号登录) */
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
/** 新建/重置教师账号时的初始密码 */
+19
View File
@@ -31,5 +31,24 @@ export const booleanFlagSchema = z
/** 关键字查询 */
export const keywordSchema = z.string().trim().max(64).optional();
/**
* 附件链接。
*
* 只允许 http(s) 绝对地址或站内相对路径:这一层是为**渲染端**兜底 ——
* 该字段最终会绑到前端的 `<a href>` / `el-link :href` 上,
* 若放行 `javascript:` / `data:text/html` 就会变成一条存储型 XSS 的注入点。
* 空串与 null 都表示"没有附件"。
*/
export const attachmentUrlSchema = z
.string()
.trim()
.max(500)
.refine(
(value) =>
value === '' || value.startsWith('/') || /^https?:\/\//i.test(value),
{ message: '附件链接需为 http(s):// 地址或站内相对路径' },
)
.nullish();
/** 周次:1..30 */
export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
+18
View File
@@ -40,6 +40,24 @@ export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
return;
}
/**
* 请求体超限(body-parser 抛的 `entity.too.large`)。
*
* 必须单独处理:它带 `status: 413` 但不是 ApiError,落进下面的兜底分支会变成
* 「500 服务器内部错误:request entity too large」——既是错的状态码,也把英文原文抛给用户,
* 而用户真正需要知道的是"文件太大了、请拆分"(表格导入的 8MB 上限见 table-import.service)。
*/
if ((error as { type?: string }).type === 'entity.too.large') {
logger.warn(`请求体超过上限:${req.method} ${req.originalUrl}`);
sendError(
res,
413,
'上传内容过大:请把文件拆分后分批导入(表格文件上限 8MB)',
'IMPORT_TOO_LARGE',
);
return;
}
// Prisma 已知错误码(P2002 唯一约束 / P2025 记录不存在 / P2003 外键约束)
const prismaCode = (error as { code?: string }).code;
if (prismaCode === 'P2002') {
@@ -17,8 +17,9 @@ router.get('/summary', async (req, res) => {
/**
* GET /api/dashboard/term?classId= - 学期信息(当前教学周 + 本班教学周数)
*
* `maxWeek` 取**班级的教学周数**(`Class.termWeeks`,班主任可在班级设置里调,默认 20),
* `maxWeek` 取**班级的教学周数**(`Class.termWeeks`,管理员在班级设置里调,默认 20),
* 这样周次选择器不会再出现"实际 20 周却给到 30 周"的情况;不带 classId 时退回全局默认值。
* 带 classId 时会先校验班级访问权限(见 resolveTermWeeks)。
*/
router.get('/term', async (req, res) => {
const user = getAuthUser(req);
@@ -1,5 +1,6 @@
import type { DashboardSummary, HomeworkDto, NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
classScopeIdFilter,
classScopeWhere,
isStudent,
@@ -20,12 +21,14 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
*/
/**
* 本班教学周数(班主任可调,默认 20)。
* 本班教学周数(默认 20)。
* - 学生 / 班级会话:固定为自己所在班级;
* - 教师 / 管理员:优先用显式 classId(越权查不到时回退默认值);
* - 教师 / 管理员:优先用显式 classId,**并且必须通过班级访问校验**(与其他接口同口径,
* 否则任意登录用户都能拿 classId 探测别班的教学周数);
* - 都不传:全局默认(MAX_TERM_WEEK 兜底)。
*/
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
if (classId) await assertClassAccess(user, classId);
const targetClassId = classId ?? (isStudent(user) ? requireStudentClassId(user) : undefined);
if (!targetClassId) return MAX_TERM_WEEK;
const record = await prisma.class.findUnique({
@@ -1,6 +1,6 @@
import { z } from 'zod';
import { isDayKey } from '@classhelper/shared';
import { booleanFlagSchema } from '../../lib/schemas.js';
import { attachmentUrlSchema, booleanFlagSchema } from '../../lib/schemas.js';
/** YYYY-MM-DD,且必须是真实存在的日期 */
const dayKeySchema = z
@@ -32,7 +32,7 @@ export const createHomeworkSchema = z.object({
courseId: z.string().min(1).nullish(),
title: z.string().trim().min(1, '请输入作业标题').max(120),
content: z.string().min(1, '请输入作业内容').max(5000),
attachmentUrl: z.string().trim().max(500).nullish(),
attachmentUrl: attachmentUrlSchema,
/** 作业所属日期(YYYY-MM-DD):客户端/Web 录入时选,不传按服务器当天 */
assignDate: dayKeySchema.nullish(),
});
@@ -43,7 +43,7 @@ export const updateHomeworkSchema = z.object({
assignDate: dayKeySchema.optional(),
title: z.string().trim().min(1).max(120).optional(),
content: z.string().min(1).max(5000).optional(),
attachmentUrl: z.string().trim().max(500).nullish(),
attachmentUrl: attachmentUrlSchema,
});
export const updateHomeworkStatusSchema = z.object({
@@ -268,12 +268,16 @@ export async function updateHomeworkStatus(
return dto;
}
// 教师 / 管理员分支:必须落到**该班学生**身上。旧实现允许不传 userId 并默默写到
// 操作者自己的账号上(教师时 user.sub 就是教师 id),那条记录既不在学生名单里,
// 又会被教师视角的 completedCount 计入,导致"完成人数 > 班级人数"。
const targetUserId = input.userId ?? user.sub;
if (input.userId) {
const target = await prisma.user.findUnique({ where: { id: targetUserId }, select: { classId: true } });
if (!target || target.classId !== homework.classId) {
throw ApiError.badRequest('目标学生不属于该作业所在班级');
}
const target = await prisma.user.findUnique({
where: { id: targetUserId },
select: { role: true, classId: true },
});
if (!target || target.role !== 'STUDENT' || target.classId !== homework.classId) {
throw ApiError.badRequest('请指定该作业所在班级的学生(userId),未指定时只能操作自己的账号');
}
const status = await prisma.homeworkStatus.upsert({
@@ -1,7 +1,8 @@
import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
IMPORT_KINDS,
@@ -140,9 +141,10 @@ router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (r
});
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
router.delete('/time-layout/:id', async (req, res) => {
await deleteTimeLayout(getAuthUser(req), req.params.id ?? '');
sendOk(res, { id: req.params.id }, '时间配置已删除');
router.delete('/time-layout/:id', validate({ params: idParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await deleteTimeLayout(getAuthUser(req), id);
sendOk(res, { id }, '时间配置已删除');
});
/**
@@ -1,10 +1,10 @@
import bcrypt from 'bcryptjs';
import * as XLSX from 'xlsx';
import { prisma } from '../../lib/db.js';
import { env } from '../../config/env.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { hashPassword } from '../../lib/password.js';
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
@@ -46,9 +46,9 @@ export const IMPORT_FIELDS: Record<
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
],
students: [
// 学生只有名单没有账号(不能登录、无密码),模板不再有"初始密码"列
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
],
teachers: [
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '工号', '账号'] },
@@ -371,7 +371,6 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
@@ -395,14 +394,14 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: classId } });
updated += 1;
} else {
const initialPassword = password || env.defaultStudentPassword;
await prisma.user.create({
data: {
username,
name,
role: 'STUDENT',
classId: classId,
passwordHash: await bcrypt.hash(initialPassword, 10),
// 学生不设密码(空串占位,登录 403 判定在密码校验之前),见"学生只有名单没有账号"
passwordHash: '',
},
});
inserted += 1;
@@ -478,7 +477,7 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
username,
name,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await bcrypt.hash(initialPassword, 10),
passwordHash: await hashPassword(initialPassword),
},
});
inserted += 1;
+1 -1
View File
@@ -6,7 +6,7 @@ import type { UserRole } from './types.js';
* - `ADMIN` 系统管理员:班级增删改、人员分配、全部数据
* - `HEAD` 班主任(`Class.teacherId`):本班课表 + 本班成绩(含导入)+ 作业/通知/叫人
* - `SUBJECT` 科任老师(`ClassTeacher`):仅作业/通知/叫人
* - `STUDENT` 学生(个人账号或班级账号):只读
* - `STUDENT` 学生(班级账号;个人学生账号已清理):只读
* - `NONE` 与该班级无关
*/
export type ClassRole = 'ADMIN' | 'HEAD' | 'SUBJECT' | 'STUDENT' | 'NONE';
+16 -2
View File
@@ -66,7 +66,7 @@ export interface LoginRequest {
/**
* 登录会话主体。
* - 普通账号(教师/管理员/个人学生):就是用户本身;
* - 普通账号(教师/管理员):就是用户本身;学生个人账号已清理,学生只有名单记录;
* - 班级账号(班级设备):`classSession = true`,此时 `id` 与 `classId` 都是班级 id,
* `name` 是班级名,个人数据由服务端按"全班"范围读写。
*/
@@ -129,12 +129,16 @@ export interface CreateClassRequest {
grade: string;
/** 可选:自定义班级码,留空自动生成 */
code?: string;
/** 本学期教学周数(1~30,仅管理员可调);不传由服务端用默认值 20 */
termWeeks?: number;
/** 仅管理员可指定班主任;不传时由创建者本人担任 */
teacherId?: string;
}
export interface UpdateClassRequest {
name?: string;
grade?: string;
/** 本学期教学周数(1~40,班主任可调) */
/** 本学期教学周数(1~30,仅管理员可调) */
termWeeks?: number;
}
@@ -591,6 +595,16 @@ export interface IslandState {
currentPeriodEnd: string | null;
/** 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 */
reason: 'new' | 'after-class' | 'urgent' | 'call' | null;
/**
* 多条通知时是否已点过"展开更多"(展开后显示全部放得下的通知)。
* 由主进程持有:卡片高度与窗口包围盒都由它决定,渲染进程只负责照此渲染。
*/
listExpanded: boolean;
/**
* 展开卡在当前屏幕与停靠位置下的**可用高度上限**(CSS px,已经扣到任务栏/工作区下沿)。
* 渲染进程用它决定"列表能显示几条",主进程用它给窗口包围盒封顶 —— 两边算的是同一个数。
*/
maxCardHeight: number;
updatedAt: number;
}
+3 -8
View File
@@ -189,22 +189,17 @@ export const gradeApi = {
/* ------------------------------------------------------------------ 学生 */
// 学生是"名单"不是"账号":没有密码相关接口(登录统一走班级码 + 班级密码)
export const studentApi = {
list: (params?: { classId?: string; keyword?: string }): Promise<StudentDto[]> =>
api.get(API_PATHS.students, params),
create: (payload: {
username: string;
name: string;
password?: string;
classId?: string | null;
}): Promise<StudentDto> => api.post(API_PATHS.students, payload),
create: (payload: { username: string; name: string; classId?: string | null }): Promise<StudentDto> =>
api.post(API_PATHS.students, payload),
update: (
id: string,
payload: { username?: string; name?: string; classId?: string | null },
): Promise<StudentDto> => api.patch(`${API_PATHS.students}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.students}/${id}`),
resetPassword: (id: string, newPassword?: string): Promise<unknown> =>
api.post(`${API_PATHS.students}/${id}/reset-password`, newPassword ? { newPassword } : {}),
};
/* ------------------------------------------------------------------ 仪表盘 */
@@ -105,33 +105,52 @@ function close(): void {
visible.value = false;
}
/** 模板下载:带上 JWT 直接取二进制,避免暴露匿名下载入口 */
/**
* 模板下载。
*
* 两种格式在服务端的返回形态**不同**,必须分开处理:
* - xlsx:直接 `res.send(Buffer)` 返回二进制,因此这里用 fetch 取流;
* - csv:走统一 JSON 响应体(`{ data: { content } }`),必须解析出 content 再自己生成文件。
* 之前两种格式共用同一段 `response.blob()`,于是"下载 CSV 模板"得到的是一个
* 名为 template-grades.csv、内容却是一整行 JSON 的文件,老师照它填完再导入必然失败。
*/
async function downloadTemplate(format: 'csv' | 'xlsx'): Promise<void> {
try {
if (format === 'csv') {
const result = await importApi.template(props.kind, 'csv');
saveBlob(new Blob([result.content], { type: 'text/csv;charset=utf-8' }), result.fileName);
ElMessage.success('模板已开始下载');
return;
}
const token = localStorage.getItem(STORAGE_KEYS.token) ?? '';
const response = await fetch(
`${API_BASE_URL}${API_PATHS.imports}/template?kind=${props.kind}&format=${format}`,
`${API_BASE_URL}${API_PATHS.imports}/template?kind=${props.kind}&format=xlsx`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
);
if (!response.ok) {
ElMessage.error(`模板下载失败(${response.status})`);
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `template-${props.kind}.${format}`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
saveBlob(await response.blob(), `template-${props.kind}.xlsx`);
ElMessage.success('模板已开始下载');
} catch (error) {
ElMessage.error(`模板下载失败:${(error as Error).message}`);
}
}
/** 触发浏览器保存一个 Blob */
function saveBlob(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function pickFile(): void {
fileInputRef.value?.click();
}
@@ -180,6 +199,10 @@ async function onFileChange(event: Event): Promise<void> {
} catch {
preview.value = null;
contentBase64.value = '';
// 必须把 input 的值也清掉:否则修好表格后重选**同一个文件名**时 @change 不会触发
// (input 的 value 没变),界面会停在失败状态、"重新选择文件"按钮点了没反应。
if (fileInputRef.value) fileInputRef.value.value = '';
fileName.value = '';
} finally {
loading.value = false;
}
+13
View File
@@ -20,6 +20,19 @@ app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
/**
* 全局错误处理。
*
* Element Plus 的 ElMessageBox 用 **rejection** 表达"用户取消"(reason 为 'cancel' / 'close'),
* 而调用点普遍写成 `await ElMessageBox.confirm(...)`,于是每次点「取消」都会以
* `Uncaught (in promise) cancel` 冒到控制台。这类噪音会淹没真正的异常(排查线上问题时最要命),
* 因此在统一入口吞掉它;其余错误照常记录,行为不变。
*/
app.config.errorHandler = (error, _instance, info) => {
if (error === 'cancel' || error === 'close') return;
console.error(`[web-admin] 未处理的异常(${info})`, error);
};
app.mount('#app');
/**
+6 -1
View File
@@ -63,8 +63,12 @@ async function loadClasses(): Promise<void> {
}
}
/** 请求序号:快速切班级时先发的慢响应不能覆盖新数据(否则出现"显示 A 班成绩、按钮按 B 班权限") */
let gradesRequestId = 0;
async function loadGrades(): Promise<void> {
if (!filter.classId) return;
const requestId = ++gradesRequestId;
loading.value = true;
try {
const params: { classId?: string; courseId?: string; examName?: string } = { classId: filter.classId };
@@ -72,11 +76,12 @@ async function loadGrades(): Promise<void> {
if (filter.examName) params.examName = filter.examName;
const [list, statistic] = await Promise.all([gradeApi.list(params), gradeApi.stats(params)]);
if (requestId !== gradesRequestId) return;
grades.value = list;
stats.value = statistic;
renderCharts();
} finally {
loading.value = false;
if (requestId === gradesRequestId) loading.value = false;
}
}
+42 -5
View File
@@ -33,6 +33,18 @@ const dayMarks = ref<Map<string, number>>(new Map());
const detailVisible = ref(false);
const current = ref<HomeworkDto | null>(null);
/**
* 详情里的附件链接。
*
* 服务端已限制为 http(s) / 站内相对路径,这里在渲染端再兜一层:
* 该值最终会进 `<a href>`,一旦放行 `javascript:` 就是一条存储型 XSS(老师写、管理员点)。
*/
const attachmentHref = computed(() => {
const value = current.value?.attachmentUrl;
if (!value) return '';
return value.startsWith('/') || /^https?:\/\//i.test(value) ? value : '';
});
const classStudents = ref<Array<{ id: string; name: string }>>([]);
const completedMap = computed(() => {
const total = classStudents.value.length;
@@ -48,34 +60,57 @@ const submissionsSaving = ref(false);
const submissionStudents = ref<HomeworkSubmissionsDto['students']>([]);
/** 勾选 = 未交(保存后其余学生一律标记为已交) */
const notSubmittedIds = ref<string[]>([]);
/**
* 当前这名单属于**哪一份作业**。
*
* 上面两个 ref 是页面级共享状态,而详情抽屉可以连续打开不同作业(loadSubmissions 是
* 并发发出的),因此必须记住数据归属并给请求编号:否则慢响应会把 A 的名单显示成 B 的,
* 老师点"保存"就把 A 的未交名单整份写到 B 上(服务端会对全班逐个 upsert,且没有二次确认)。
*/
const submissionsHomeworkId = ref('');
let submissionsRequestId = 0;
const notSubmittedNames = computed(() =>
submissionStudents.value.filter((item) => notSubmittedIds.value.includes(item.userId)),
);
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
async function loadSubmissions(homeworkId: string): Promise<void> {
async function loadSubmissions(homeworkId: string): Promise<boolean> {
const requestId = ++submissionsRequestId;
submissionsLoading.value = true;
try {
const result = await homeworkApi.submissions(homeworkId);
// 迟到的响应:用户已经切到别的作业了,直接丢弃
if (requestId !== submissionsRequestId) return false;
submissionStudents.value = result.students;
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
submissionsHomeworkId.value = homeworkId;
return true;
} catch {
if (requestId !== submissionsRequestId) return false;
submissionStudents.value = [];
notSubmittedIds.value = [];
submissionsHomeworkId.value = homeworkId;
return false;
} finally {
submissionsLoading.value = false;
if (requestId === submissionsRequestId) submissionsLoading.value = false;
}
}
async function openSubmissions(): Promise<void> {
if (!current.value) return;
if (submissionStudents.value.length === 0) await loadSubmissions(current.value.id);
// 每次都以"当前这份作业"为准重新拉一次:不能因为共享状态里还有数据就跳过 ——
// 那些数据可能属于上一次打开的作业,保存时会写到错误的作业上。
await loadSubmissions(current.value.id);
submissionsVisible.value = true;
}
async function saveSubmissions(): Promise<void> {
if (!current.value) return;
if (submissionsHomeworkId.value !== current.value.id) {
ElMessage.warning('未交名单尚未加载完成,请稍候再保存');
return;
}
submissionsSaving.value = true;
try {
const result = await homeworkApi.saveSubmissions(current.value.id, notSubmittedIds.value);
@@ -320,6 +355,8 @@ function onHomeworkEvent(): void {
onMounted(async () => {
await loadClasses();
// 首屏也要拉一次"哪些天有作业",否则日期选择器上没有任何高亮,直到手动切一次班级才出现
await loadDayMarks();
await loadHomeworks();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
@@ -509,8 +546,8 @@ onUnmounted(() => {
<p class="content-block">{{ current.content }}</p>
</div>
<div v-if="current.attachmentUrl" class="mt-12">
<el-link type="primary" :href="current.attachmentUrl" target="_blank">查看附件</el-link>
<div v-if="attachmentHref" class="mt-12">
<el-link type="primary" :href="attachmentHref" target="_blank">查看附件</el-link>
</div>
<el-alert
@@ -304,7 +304,13 @@ async function submitNotify(): Promise<void> {
priority: notifyForm.priority,
});
if (result.targetCount === 0) {
// 先看服务端给的"为什么没送":教室选了「只在 ClassHelper 客户端显示」时也是
// targetCount=0,但原因与"没有接入设备"完全不同,提示错了会把老师引去查设备。
if (result.skipped === 'channel-client') {
ElMessage.warning(
'提醒已保存到通知中心,但该班教室已设置为「只在 ClassHelper 客户端显示」,本次未推送到 ClassIsland',
);
} else if (result.targetCount === 0) {
ElMessage.warning('提醒已保存,但该班级还没有已接入的 ClassIsland 设备');
} else {
ElMessage.success(`已下发到 ${result.delivered}/${result.targetCount} 台 ClassIsland 设备`);
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
CALL_QUICK_PHRASES,
@@ -21,6 +22,7 @@ import { useRealtimeStore } from '@/stores/realtime';
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
const realtime = useRealtimeStore();
const route = useRoute();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
@@ -290,8 +292,10 @@ async function doPublish(classIds: string[], confirmDuringClass: boolean): Promi
reportPublishResult(successCount, reported);
if (successCount > 0) {
// 有班级发布成功:关掉弹窗并刷新列表(仍需二次确认时保留弹窗内的数据)
formVisible.value = !pendingConfirm;
// 有班级发布成功:关掉弹窗并刷新列表;只有"还有班级等着二次确认"时才保留弹窗内的数据。
// 注意这里是 pendingConfirm 而不是 !pendingConfirm —— 写成取反会让弹窗在**发布成功后仍然开着**,
// 老师以为没发出去再点一次「立即发布」,同一批班级就重复收到通知(还会重复广播与推 ClassIsland)。
formVisible.value = pendingConfirm;
await loadNotifications();
}
}
@@ -343,10 +347,29 @@ function onNotificationEvent(): void {
void loadNotifications();
}
/**
* 从仪表盘点「最新通知」跳过来时带的 `?highlight=<通知 id>`:
* 高亮那一行并滚动过去。之前这个参数没有任何人消费,
* 用户点完只是跳到通知列表,看不出去的是哪一条。
*/
const highlightedId = computed(() => {
const value = route.query.highlight;
return typeof value === 'string' ? value : '';
});
function rowClassName({ row }: { row: NotificationDto }): string {
return row.id === highlightedId.value ? 'row-highlight' : '';
}
onMounted(async () => {
await loadClasses();
await loadNotifications();
realtime.on(SOCKET_EVENTS.notificationNew, onNotificationEvent);
// 列表渲染完再滚动,否则表格行还没生成
if (highlightedId.value) {
await nextTick();
document.querySelector('.row-highlight')?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
onUnmounted(() => {
@@ -399,7 +422,12 @@ onUnmounted(() => {
</div>
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="notifications" empty-text="暂无通知">
<el-table
v-loading="loading"
:data="notifications"
empty-text="暂无通知"
:row-class-name="rowClassName"
>
<el-table-column label="优先级" width="90">
<template #default="{ row }">
<el-tag
@@ -560,3 +588,10 @@ onUnmounted(() => {
/>
</div>
</template>
<style scoped>
/* 从仪表盘点「最新通知」跳转过来的定位高亮(?highlight=<通知 id>) */
:deep(.row-highlight) td {
background: var(--el-color-primary-light-9) !important;
}
</style>
+10 -1
View File
@@ -127,7 +127,15 @@ async function resolveCourseId(): Promise<string | null> {
return created.id;
}
/**
* 列表/周视图请求的序号:快速的"切班级/切周次"会让多个请求同时在途,
* 先发出的慢响应若后返回就会覆盖掉新数据(表现为"显示 A 班的课表、按钮却按 B 班权限")。
* 因此每次请求领一个号,回来时不是最新一次就直接丢弃。
*/
let schedulesRequestId = 0;
async function loadSchedules(): Promise<void> {
const requestId = ++schedulesRequestId;
if (!query.classId) {
grid.value = null;
rawSchedules.value = [];
@@ -139,10 +147,11 @@ async function loadSchedules(): Promise<void> {
scheduleApi.grid({ classId: query.classId, week: query.week }),
scheduleApi.list({ classId: query.classId, week: query.week }),
]);
if (requestId !== schedulesRequestId) return;
grid.value = weekGrid;
rawSchedules.value = list;
} finally {
loading.value = false;
if (requestId === schedulesRequestId) loading.value = false;
}
}