41 KiB
ClassHelper 代码审查报告
审查日期:2026-10-17
审查基线:master @ b7cdc7c(版本号 1.3.2),含工作区未提交改动(ClassIsland 联动退役那一批,116 个文件 / -10171 行)
审查范围:packages/{server,web-admin,desktop-client,shared} 全量源码
审查方式:逐文件读代码 + 静态门禁。未修改任何代码。
0. 门禁结果
| 检查 | 结果 | 备注 |
|---|---|---|
pnpm lint |
✅ 通过 | 无输出 |
pnpm typecheck |
✅ 通过 | shared / server / web-admin / desktop-client 四个包全 Done(含 vue-tsc) |
pnpm version:check |
✅ 通过 | 12 处落点全为 1.3.2 |
pnpm check:icons |
✅ 通过 | 两端各 33 个图标全部注册 |
pnpm exec prettier --check --end-of-line auto(92 个改动文件) |
⚠️ 17 个 .md 未过 |
代码文件全部干净;md 是这轮改动新写脏的 |
基线本身是绿的:以下全部是"读代码 + 对照契约"找出的逻辑/一致性问题,不是编译或类型错误。
未跑的验收:verify:e2e / verify:web / verify:desktop / verify:packaged(会真实写库、发通知、开 Electron 窗口,本次为只读审查故未执行)。
0.1 环境备注:AGENTS.md 记的 pnpm 绕过办法在 Git Bash 下不完整
AGENTS.md §3 的「AI 会话里跑 pnpm」一节给的是 PowerShell 写法(设 DSH_NODE + DSH_PNPM_BIN 后照常写 pnpm xxx)。在 Git Bash 里这么写会报 pnpm: command not found(PATH 上没有 pnpm)。实测能跑通的组合是两者叠加:
export DSH_NODE='D:\Node.js\node.exe'
export DSH_PNPM_BIN='C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
node "C:/Users/hck16/AppData/Local/node/corepack/v1/pnpm/11.8.0/bin/pnpm.cjs" typecheck
即:环境变量负责让嵌套的 pnpm -r 落进转发壳后用真实 node,直接调 pnpm.cjs 负责解决最外层没有 pnpm 命令。建议把这一条补进 AGENTS.md。
1. 严重(静默出错,用户看不出来)
1.1 侧栏少了三个管理页的入口,只能手输 URL 进入
位置
- 菜单数组:
packages/web-admin/src/layouts/AdminLayout.vue:99-135(当前 12 项) - 缺失的三条路由:
packages/web-admin/src/router/index.ts:41-47(学期周次)、:48-54(毕业归档)、:101-108(权限组管理)
| 页面 | 路由 | 现状 |
|---|---|---|
| 学期周次 | /term-weeks |
从未接过菜单项(1.2.0 起) |
| 毕业归档 | /archives |
从未接过菜单项(1.2.0 起) |
| 权限组管理 | /permissions |
1.3.0 有菜单项,1.3.1 误删 |
证据链
git show 227117c -- packages/web-admin/src/layouts/AdminLayout.vue的 diff 里有- path: '/permissions'/- title: '权限组',而该提交的信息只讲「客户端页合并」,一个字没提删权限组。- 菜单项计数:1.2.0 = 10 项 → 1.3.0 = 14 项 → 1.3.1 及现在 = 12 项(少的正是 ClassIsland 联动那一项〔有意合并〕+ 权限组这一项〔误伤〕)。
git log -S"term-weeks"/-S"/archives"在AdminLayout.vue的历史里零命中 ⇒ 这两页从 1.2.0 上线起就没接线。- 代码自己还留着念想:
AdminLayout.vue:294注释「菜单里的『权限组』要等它回来才显示」,但数组里已经没有它;src/styles/index.css:223注释仍写「Web 端目前 14 个菜单项」。 - 文档三处都按「有侧栏入口」写:
docs/reference.md:750 / 777 / 807。 - 全前端 grep:这三条路径没有任何其他链接指向它们(顶栏下拉只有改密码、使用引导、检查更新、退出)。
后果:管理员只能靠记 URL 进入这三个页面。currentTitle(AdminLayout.vue:156)对它们也回退成 APP_TITLE,小屏顶栏标题不对。
为什么没被发现:同文件 :353-361 的开发期自检只校验「菜单里的路径必须有对应路由」,不校验反向(有路由但没菜单)。建议顺手补上反向断言。
修法:menuItems 数组补三行即可(权限组那条带 permission: 'permission:manage',另两条 roles: ['ADMIN']),并在 :354-361 的自检里加上反向检查。
1.2 「叫人」的记录不写 assignDate,按天查会被整个吞掉
位置:packages/server/src/modules/calls/calls.service.ts:48-59
const created = await prisma.notification.create({
data: {
classId: input.classId,
title: buildCallTitle(studentName, teacherName),
content: message,
priority: input.urgent ? 'URGENT' : 'HIGH',
createdBy: user.sub,
// ← 缺 assignDate
},
根因:Notification.assignDate 是 String @default("")(prisma/schema.prisma:356),不显式赋值就是空串。
连环后果
notifications.service.ts:118-121的.filter((item) => item.date !== '')⇒ 日期栏里永远不会出现有「叫人」的那一天;notifications.service.ts:49-67的archiveWhere按assignDate过滤 ⇒ 通知页选中任何日期/区间,叫人记录全部消失(不选日期时才看得到)。
旁证:lib/mappers.ts:399-400 特意写了「assignDate 为空时按 createdAt 兜底」——那条兜底只修了 DTO 的展示字段,修不了查询与分组(查询走的是库里的原始空串)。同模块 createNotification(notifications.service.ts:231)老老实实写了 dayKeyLocal(new Date()),这里漏了。
修法:加一行 assignDate: dayKeyLocal(new Date())。
存量数据:库里已有的叫人记录 assignDate 仍是空串,需要一条一次性回填(按 createdAt 的本地日期),否则它们永远进不了日期栏。
1.3 主窗口重建后「关闭即隐藏到托盘」失效,点 X 会真的销毁窗口
位置:packages/desktop-client/src/main/index.ts:232-237
// 只在 app.whenReady() 里给第一个窗口挂
mainWindow?.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
mainWindow?.hide();
});
而 createWindow()(index.ts:63-108)本身从不挂这个处理器,ensureMainWindow()(index.ts:116-126)会被三条路径调用重建窗口:
- 崩溃自愈:
index.ts:189-196的recoverMainWindow→ensureMainWindow(); - 托盘「显示主窗口」→
showMainWindow()(index.ts:266-267); app.on('activate')(index.ts:329-334)。
触发链:主窗口渲染进程崩溃 → 静默重建(用户无明显感知)→ 用户点右上角 X → isQuitting 为 false 但没有 close 监听 → 窗口被真销毁。而 app.on('window-all-closed')(index.ts:314-317)是空实现(只打日志不退出)⇒ 托盘进程还在跑、通知还在弹,但屏幕上没有任何窗口,点托盘图标才能找回来。
修法:把这段 close 处理器移进 createWindow()。
1.4 客户端离线缓存键不含班级,换班登录会串上一个班的数据
关键前提:auth.logout()(renderer/stores/auth.ts:115-125)只清 token,不清缓存(ClientLayout.vue:357 的提示自己也写着「离线缓存会保留」)。
只有作业页做了班级分键(HomeworkView.vue:289 的 ${classId}@${day}),其余全是全局键:
| 数据 | 缓存键 | 位置 |
|---|---|---|
| 课表 | week:${week} |
island/bridge.ts:52、views/ScheduleView.vue:191 |
| 班级/学期 | 'term' |
views/ScheduleView.vue:175 |
| 成绩 | 'my' |
views/GradeView.vue:109 |
| 通知 | 'self' |
stores/notifications.ts:25 |
| 个人信息 | 'me' |
— |
触发:A 班登出 → B 班用班级码登录 → 首屏(离线,或首个请求失败)直接显示 A 班的课表 / 成绩 / 通知 / 周次。更麻烦的是这些数据还会喂给灵动岛的上下课判定(见 1.6)。
修法:缓存键统一带上 classId(classId:xxx@week:n),或 clearConfig() 时按前缀清缓存。
1.5 IndexedDB 打开失败会被永久缓存,本次会话离线回退彻底失效
位置:packages/desktop-client/src/renderer/cache/db.ts:35-58
databasePromise 被赋值为可能 reject 的 Promise,且 onerror / onblocked(:53-54)之后不清空该变量。
后果:只要失败一次(被别的标签页占用 / 磁盘受限 / 首次升级失败),之后所有 cacheGet / cacheSet 都返回同一个已 reject 的 Promise ⇒ fetchWithCache(cache/index.ts:32-36)永远拿不到缓存、直接落到 fallback,persist() 静默失败。界面表现为「永远是空数据 / 离线什么都没有」,而且不报错。
与 1.4 叠加:这是「离线优先」这条设计在教室机上的两个独立致命洞——一个让数据串班,一个让缓存整个失效。
修法:onerror / onblocked / reject 时把 databasePromise 置回 null,下次调用重新尝试打开。
1.6 灵动岛的「是否在上课」有三处独立破口
上下课判定是灵动岛的立身之本(决定通知立即弹还是暂存到下课),现在有三条互不相关的失败路径。
(a) 可能拿「第 1 周」的课表去算当前周
appStore.currentWeek 全仓库只有 views/ScheduleView.vue:179 一处写入,而读者有四处:island/bridge.ts:49(取课表的周次 + 缓存键)、:62(resolveClassStatus 的周次过滤)、:72(上报给主进程)、layouts/ClientLayout.vue:475(侧栏「第 X 周」)。
而 bridge 的课表只在 bridge.ts:26,323 的 5 分钟定时器或 schedule:updated 时重取。
触发:启动/登录后 bridge 先跑(App.vue:111),此时 currentWeek 还是 1,于是取回并按 week:1 缓存;课表页随后才把 currentWeek 改成真实周次(如 10)。在这 5 分钟内,schedules 里没有 weekStart > 1 才开始的课,却按第 10 周过滤 ⇒ 上课期间 inClass 恒为 false ⇒ 灵动岛照常弹普通通知,正是该功能要拦的事。
bridge.ts:22-24 注释承诺的「最多 10 秒内自动弹出」在这种状态下也不成立。
(b) 课表刷新失败会把已加载的课表清空成 []
bridge.ts:50-57:网络抖动 + 该周次无缓存时 fetchWithCache 回退成 [],代码直接 schedules = result.data,紧接着 force=true 把 inClass: false 推给主进程。
周次翻页后第一次刷新正好是「新 key 无缓存」的时刻 ⇒ 每次翻周都有一个窗口期,且要等 5 分钟才自愈。
(c) 主窗口隐藏到托盘后,10 秒复算被 Chromium 后台节流
主窗口 webPreferences(main/index.ts:78-84)没有 backgroundThrottling: false,而灵动岛窗口显式关了它(main/island.ts:301)——对比说明作者知道这件事。而「是否在上课」的定时器跑在主窗口渲染进程里(bridge.ts:322 的 10s tick + stores/app.ts:121 的 20s 健康轮询)。
教室机的常态就是「关到托盘后台运行」⇒ 长时间隐藏后 Chromium 降到约 1 次/分钟 ⇒ 上课开始/结束时灵动岛最多延迟约一分钟才隐藏/补弹。课间只有 10 分钟,体感明显。
修法:(a) 让 currentWeek 有单一数据源并在变化时触发 bridge 重取,或让 bridge 自己算当前周;(b) 失败时保留旧 schedules(result.error 非空且 data.length === 0 就不覆盖);(c) 给主窗口加 backgroundThrottling: false,或把上课判定挪到灵动岛渲染进程。
1.7 渲染进程能通过配置让主进程执行任意 exe
位置
- 写入:
packages/desktop-client/src/main/config.ts:476-482—— 无条件接受渲染进程传来的pendingUpdateFile字符串(注释还写着「渲染进程传进来也照收」); - 执行:
packages/desktop-client/src/main/auto-update.ts:270-341—— 只校验「文件存在、比当前版本新、策略匹配、不在上课、窗口隐藏够久」,然后就把它当安装器启动。
对比:真正下载的路径有文件名白名单(auto-update.ts:63 的 SAFE_FILE_NAME)和 sha256 校验(:205-219),而这条配置路径一条校验都没有。
同一个文件里的正面例子:config.ts:485-487 明确拒绝渲染进程改写 deviceKey(「它是这台机器在服务端的身份,不允许变更」)——同一个威胁模型,pendingUpdateFile 漏了。
触发:渲染进程出现一次注入(未来的 v-html、被投毒依赖、XSS)→ 写 pendingUpdateFile=C:\...\x.exe + autoUpdate 开着 → 机器空闲时被静默安装。
修法:config.ts 里只接受位于 pendingUpdateDir() 之下的路径(与 deviceKey 同样的收口方式)。
2. 中等(功能异常或越权,但触发条件较窄)
2.1 教师名单导入会把管理员静默降级成教师
位置:packages/server/src/modules/imports/table-import.service.ts:895-900
/** 角色列解析:只认「管理员」类词;其余(含空)返回 null 交给调用方取默认值 */
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
const value = cell.trim().toUpperCase();
if (!value) return null;
if (value === 'ADMIN' || cell.includes('管理员') || value === '管理员') return 'ADMIN';
return 'TEACHER'; // ← 与注释矛盾
}
文档说「其余(含空)返回 null」,实现却是 return 'TEACHER'。而调用处(:653)是 parseTeacherRole(roleCell) ?? existing.role —— null 永远不会出现 ⇒ 任何不认识的角色单元格(包括空单元格)都会把已有账号改成 TEACHER。
触发:默认 mode 就是 'upsert'(imports.schemas.ts:25)⇒ 管理员照模板导一份教师名单、角色列没填 → 自己(或其他管理员)变成普通教师。
修法:return null(把非空但不认识的值也归为 null,或在调用处区分「空」与「不认识」)。
2.2 六个权限键在界面上可分配,服务端从不校验
6 个键写在 packages/shared/src/permissions.ts:184-208,也出现在权限组勾选树(:284 / :295 / :300),但把服务端所有 requirePermission / requireAnyPermission / hasAuthPermission 调用点扫一遍后,这 6 个键一次都没出现:
student:grade-query、dashboard:read、update:check、update:force、log:read、developer:mode
后果:权限组的语义被破坏。给某位老师分配「只含 dashboard:read 的组」后,他照样能调 /api/grades/student/:studentNo(grades.module.ts:44 刻意不挂键)、/api/dashboard/*、/api/update/check。管理员在界面上勾的框不生效——比「没有这个功能」更难发现。
update:force 尤其明确:意图是「只有勾了它的人能绕过缓存」,实现却是 update.module.ts:33 的 user.role === 'ADMIN' 硬判。
修法:要么把键挂到对应路由上,要么从 PERMISSION_KEYS / 勾选树里下线(现在这样是"看起来能管、实际管不了")。
2.3 socket 侧重建班级端身份时丢了 classSession
位置:packages/server/src/realtime/socket.ts:57-63(过滤 token)→ :76-82(拼回 TokenPayload)
socket.data.user = {
id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
name: payload.name,
role: payload.role,
classId: payload.classId,
// ← 丢了 classSession
};
getAccessibleClassIds(lib/access.ts:74-90)判班级端时走 classDeviceClassId() → isClassDevice()(看 role === 'CLASS_DEVICE')与 classSessionClassId()(看 user.classSession)。第二条判据恒假 ⇒ 返回 []。
两个后果
- 教室机器显式
class:join本班房间会被拒(socket.ts:111的allowed.includes(classId)恒假,报「无权订阅该班级」); - 教师/管理员每次建立 WS 连接都会执行
listClassStudentIds(lib/session.ts:27-33),把整班学生 id 全拉出来,只为了算出一个空集合。
:55-56 的注释直接指着本文件说「房间据此加入」,实际那段逻辑不参与判断。
修法:socket.data.user 里带上 classSession: payload.classSession === true,或在 getAccessibleClassIds 改用 role 单一判据。
2.4 导入课表把学期长度写死成 20 周
位置:packages/server/src/modules/imports/class-plan.service.ts:301-303
// 时间配置(导入的时间表)里如果只有部分节次,weekEnd 用默认 20 周
const weekStart = input.termStartWeek ?? 1;
const weekEnd = 20;
学期周数现在是可配的(Class.termWeeks,上限 30)。班级设 30 周时,导进去的课表到第 20 周就没了,第 21–30 周在教室大屏上是空白。
修法:读 Class.termWeeks(与 schedules.service.ts:108-114 的 createSchedule 同一口径)。
2.5 建课表的交叉校验有缺口(schema 按 20 兜底、服务端按班级配置落库)
位置:packages/server/src/modules/schedules/schedules.schemas.ts:50
.refine((data) => (data.weekStart ?? 1) <= (data.weekEnd ?? 20), { ... })
而 createSchedule(schedules.service.ts:126-127)真正落库用的是 input.weekEnd ?? termWeeks。
触发:termWeeks = 10 的班级可创建 weekStart = 15, weekEnd = 10 —— schema 判它合规(15 ≤ 20 通过),落库得到一条永远不生效的课表行。updateSchedule 那条路径有合并后校验(:158-160)兜住,创建这条没有。
修法:schema 里去掉 20 这个魔数(只校验上下界),把交叉校验交给 service —— 与 updateSchedule 同一做法。
2.6 成绩导入按姓名匹配时,同名学生的成绩会张冠李戴
位置:packages/server/src/modules/imports/table-import.service.ts:337
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
同班同名学生的后一条会覆盖前一条。byStudentNo 查不到时落到 byName(:373-375),于是成绩会绑到其中一个人身上,且没有任何提示。
修法:命中重复姓名时不入 map,在用到时给出「该班有 N 个同名同学,请填写学号」的行级错误。
2.7 通知删除不广播,教室机器上会一直留着已删的通知
位置:packages/server/src/modules/notifications/notifications.service.ts:242-247
export async function deleteNotification(user, notificationId): Promise<void> {
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!current) throw ApiError.notFound('通知不存在');
await assertCanPublishNotification(user, current.classId);
await prisma.notification.delete({ where: { id: notificationId } });
// ← 没有 emitToClass
}
后果:教师在 Web 端删掉通知后,教室大屏的通知列表不会刷新,会一直显示这条已删除的记录(直到用户手动下拉或重连)。
修法:删完 emitToClass(current.classId, SOCKET_EVENTS.notificationDeleted, { id }),并同步给两端订阅方(新增事件要同时改服务端 + Web 端 + 客户端,见 AGENTS.md §5 第 63 条)。
2.8 批量录成绩只让第一个班的客户端知道
位置:packages/server/src/modules/grades/grades.service.ts:190-196
if (results[0]) {
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, { ...results[0], bulk: true, count: results.length });
}
一次批量跨多个班级时,只有第一个班房间收到事件,其他班教室机器的成绩页不刷新。
修法:按 classId 分组广播(或逐个 emitToClass(dto.classId, ...))。
2.9 读接口挂在写权限键上
packages/server/src/modules/students/students.module.ts:36:GET /api/students挂requirePermission('roster:manage')packages/server/src/modules/teachers/teachers.module.ts:30:GET /api/teachers挂requirePermission('teacher:manage')
教师默认有这两个键,所以日常不暴露;但自定义权限组一多就会撞上——按权限键的命名语义申请「只读名单」的组会直接 403。
修法:拆出 roster:read,或像 grades 的两条读接口那样把键摘掉(边界交给 service 的 resolveClassScope)。
2.10 调班历史没有校验学生与班级的关系
位置:packages/server/src/modules/students/students.service.ts:247-249
if (!isAdmin(user) && !options.classId && !options.studentId) {
throw ApiError.forbidden('查看全部调班历史需要管理员权限');
}
触发:教师只要提供某个 studentId(而不是 classId)就能读它的调班历史,学生属于哪个班并不校验。教师之间本来互不越界,这里是个口子。
修法:走 studentId 分支时补一次 assertClassAccess(user, student.classId)。
2.11 深色模式下引导正文用了从未定义的 CSS 变量
位置:packages/desktop-client/src/renderer/components/OnboardingWelcome.vue:253
color: var(--ch-text-regular, #606266);
全客户端 grep:只定义了 --ch-text / --ch-text-secondary / --ch-text-tertiary,--ch-text-regular 一次都没有定义(浅色 styles/index.css:41 区块与深色 :126 区块都没有)。
后果:深色主题下正文恒定落到 #606266,而卡片底是 --ch-layer-solid(深色为 #1e1f23)⇒ 六步引导的文字几乎看不清。
修法:改用 --ch-text-secondary 或 --ch-text-tertiary。
2.12 随便按一下 Esc 就把「已看过首次引导」写掉了
位置:packages/desktop-client/src/renderer/components/OnboardingWelcome.vue:104-109
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') finish(); // ← 没判引导是否可见
}
if (typeof window !== 'undefined') window.addEventListener('keydown', onKeydown);
组件随 App.vue:165 常驻整个应用生命周期,而 finish()(:91-102)会 saveConfig({ onboardingDone: true })。
触发:新机器首次启动,用户在登录页随手按 Esc(或关掉任意弹窗)⇒ 引导标记被写掉 ⇒ 首次启动引导再也不自动弹出(App.vue:128-135 的判定失效)。
修法:if (!onboarding.visible) return;
2.13 成绩明细表的「等级」列把老师手改/自定义等级算回去
位置:packages/desktop-client/src/renderer/views/GradeView.vue:385-389
<el-table-column label="等级" width="80">
<template #default="{ row }">
{{ gradeLevel(gradePercent(row.score, row.totalScore)) }}
而同文件 :55-62 的等级分布用的是 item.level || gradeLevel(...),:51-53 的注释正是声明要避免这件事:
优先用库里存的等级(教师可以手改、也可能是自定义等级),只有老数据没有等级时才按得分率现算 —— 否则教师在 Web 端改过的等级在这里会被"算回去"。
后果:同一页上「等级分布」与「明细表」给出两个互相矛盾的等级。
修法:改成 row.level || gradeLevel(gradePercent(...))。
2.14 任何主进程未处理拒绝都会触发 app.relaunch(),而启动路径的加载失败没有 catch
位置
- 判据:
main/crash-guard.ts:73-76→restartIfIdle(:157-187,app.relaunch()在:185),条件是「本机空闲」(index.ts:197-200:不在上课 + 主窗口不可见) - 未捕获点:
index.ts:120的void loadRenderer(mainWindow)与index.ts:185的await loadRenderer(mainWindow)(在app.whenReady().then(async …)内,无.catch)
触发:开机自启(--hidden,主窗口不可见 = 空闲)时一次渲染页加载失败(loadFailed / ERR_ABORTED)⇒ 变成未处理拒绝 ⇒ 进程静默 relaunch 自己,最多 10 分钟内 3 次。用户看到的是「应用莫名重启」。
修法:给这两处加 .catch(),并让 restartIfIdle 只对真正的致命错误(如 uncaughtException)生效。
2.15 AI 助手「叫人」缺少「消息非空」校验,必然吃 400
位置:packages/web-admin/src/views/AssistantView.vue:103-105(rules)与 :337-341(publishOnce)
if (action === 'call_student') {
base.studentId = [{ required: true, message: '请选择要叫的同学', trigger: 'change' }];
// ← 没有 message / quickPhrase 的规则
}
...
await callApi.create({ classId, studentId: form.studentId, message: form.message.trim() || undefined });
后端契约:server/src/modules/calls/calls.schemas.ts:20-23 有 .refine((d) => Boolean(d.message || d.quickPhrase), { message: '请选择快捷短语或填写自定义消息' })。
触发:表单的 message 是从模型参数回填的(AssistantView.vue:247 的 form.message = str(params.message)),模型没给 message 时表单为空,用户直接点「确认发布」⇒ 服务端 400。同项目的 StudentsView.vue:238-241、NotificationsView.vue:166-169 都写了这条前置校验,助手这条路径漏了(助手也从不传 quickPhrase)。
2.16 学期周次页可保存未填日期的行,导致整批保存被服务端拒绝
位置:packages/web-admin/src/views/TermWeeksView.vue:121-124(addWeek 推入 { startDate: '', endDate: '' })+ :93-119(save 无任何本地校验)
后端:server/src/modules/term/term.schemas.ts:5-10 要求 startDate / endDate 通过 isDayKey。
触发:点「添加一周」但没选日期就点「保存」⇒ 400「开始日期格式应为 YYYY-MM-DD」,连同一请求里其它已经填好的周次也一起不落库。
修法:shared 里已有现成的 validateTermWeeks()(packages/shared/src/utils.ts:377,服务端 term.service.ts:79 正在用),本页保存前调用一次即可。
2.17 通知页换日期后「发布人」筛选不跟着收敛
位置:packages/web-admin/src/views/NotificationsView.vue:446-449(发布人下拉没有 @change,而日期控件是 @change="refreshList")
filter.createdBy 会跨日期残留 ⇒ 出现「选了一天 + 一个当天没发过通知的人」= 空列表,且界面没有任何解释。
3. 轻量(体验/维护性)
3.1 三个列表页缺请求序号,快速切筛选时旧响应覆盖新数据
views/HomeworksView.vue:149-162(被onClassChange:164-169连等三次往返放大)views/NotificationsView.vue:99-118(班级/优先级/日期/发布人/关键词五个控件都会触发)views/StudentsView.vue:33-44
对照:GradesView.vue:119-137、SchedulesView.vue:143-164、HomeworksView.vue:73-128 都显式实现了 requestId 丢弃 ⇒ 这三处是漏加。
3.2 多个提交按钮无 :loading,双击即重复落库
| 位置 | 后端 | 后果 |
|---|---|---|
SchedulesView.vue:449 |
POST /schedules(schedules.service.ts:118,无唯一约束) |
两条课表行 |
ClassesView.vue:604 |
POST /classes(classes.service.ts:173,无重名校验) |
两个同名班级 + 两套班级账号 |
HomeworksView.vue:626 |
POST /homeworks(homeworks.service.ts:171,无去重) |
两份作业 + 两次广播 |
NotificationsView.vue:567 |
POST /notifications / bulk |
每个班收到两条通知 |
GradesView.vue:659 / :722 |
成绩录入/提交 | 同页 :764 的批量改等级却加了 levelBatchSaving,说明是遗漏 |
反证这不是项目约定:TeachersView.vue:236、ArchivesView.vue:330、TermWeeksView.vue:179、ClientsView.vue:952 都写了 :loading。
3.3 成绩页「考试」下拉的选项来自被筛选后的结果,选中即自锁
views/GradesView.vue:65:examOptions 从 grades(已按 examName 过滤)反推。选中「期中考试」后列表只剩它、下拉也只剩它,必须先点 clearable 的 × 清空才能换考试。同一个 computed 还被「批量改等级」弹窗复用(:345、:747-749)。
3.4 成绩页「考试口径」开关存在竞态 + 跨快照读取
views/GradesView.vue:79-86:classId 在 await 前读、examName 在 await 后读;连续切班级/考试时,A 班的 listExams 后返回 ⇒ 用 A 班的考试列表匹配 B 班的 examName("期中考试"跨班同名极常见),把开关写成 A 班的口径。而用户接着拨这个开关时,setExamCountInTotal:88-104 会把这个错值通过 PUT /grades/exams 写到当前班级。调用它的 watch:107 在两个字段上都触发,问题被放大到两次并发。
3.5 归档弹窗会把「已归档的届别」作为默认选中项
views/ArchivesView.vue:92-97 的 ?? suggested[0] 在所有建议届别都已归档时会选中一个已归档年份,而该 option 正是 :disabled="archivedYears.has(year)"(:320)⇒ 点「确认归档」必吃 409(archives.service.ts:150-154)。回退值应为 createForm.enrollmentYear。
3.6 深色主题下写死的语义色
components/TableImportDialog.vue:452 / 467 / 470 / 473:.required-hint{color:#f56c6c}、.result-stats .ok{#67c23a}、.warn{#e6a23c}、.bad{#f56c6c}——四个类在模板:338 / :391-394都真的用了。深色下 EP 的--el-color-*由dark/css-vars.css调亮过,这里写的是浅色原始值,暗底对比度偏低。views/HomeworksView.vue:802-804.text-danger{color:#f56c6c}:与紧邻的.form-hint重复定义(被覆盖),且本文件模板里零使用 —— 死样式 + 写死颜色。
(components/UrgentClassWarning.vue 里大量 #f56c6c 属刻意的警示红,卡片底走 --ch-surface,深色下仍可读,不计。)
3.7 附件链接放行了协议相对 URL
- 服务端:
packages/server/src/lib/schemas.ts:47-52(value.startsWith('/') || /^https?:\/\//i.test(value)) - 前端:
packages/web-admin/src/views/HomeworksView.vue:44-48(同一份逻辑)
//evil.com 能过检;el-link :href 会把它当协议相对 URL 打开 ⇒ 跳到外部站点。不是 javascript: 级的问题(scheme 白名单确实挡住了),但 // 应一起拒。
3.8 通知页在班级名册取不到时把已读率算成 0 并显示 "X/0 人"
views/NotificationsView.vue:79-97(loadClassStudentCounts 对每班 .catch(() => []) 吞掉失败)、:357-361(readRate 直接 return 0)、:524。「请求失败」与「真的 0 人」不可区分:跨班视图会给那个班的所有通知显示 0% 和 12/0 人,单班视图显示「共 0 人」。
3.9 发布作业弹窗换班级时不清空课程列表
views/HomeworksView.vue:240-253:loadFormCourses() 只置 formCoursesLoading = true 就发请求,不先清空 formCourses;ownFormCourses(:230-232)在加载期间照旧渲染旧数据(:583 的 v-if 只挡住「其他科目(新建)」那一组)。触发窗口只有几十~几百毫秒,故列轻量:改班级后抢在加载完成前选中旧班科目 ⇒ 提交属于别班的 courseId,被 assertCourseInClass 拒绝。
3.10 灵动岛渲染进程崩溃会被误判成主窗口崩溃
main/crash-guard.ts:114-118 的 isMainWindowContents() 在主窗口引用为 null 时 return true(注释说是为了"启动早期不漏掉"),但 app.on('render-process-gone') 是应用级事件,灵动岛同样走这里。触发:主窗口已销毁而灵动岛仍在运行 ⇒ 灵动岛崩溃 ⇒ 被当作主窗口崩溃 ⇒ 重建出一个隐藏的主窗口,并占掉 10 分钟 3 次的重建额度。
3.11 取消/更换更新目标后,已下载的安装包不从磁盘删除
main/auto-update.ts:54-56 只清 pendingUpdateVersion / pendingUpdateFile 两个配置字段,不删文件。换过一次目标就留下一个几十 MB 的 .exe,且再也不会被清理(教室机磁盘普遍紧张)。
3.12 六处 ElMessageBox.confirm 的取消分支没 catch
ClientLayout.vue:357、views/settings/AccountSettingsView.vue:16、GeneralSettingsView.vue:96,154、DeveloperSettingsView.vue:201、IslandSettingsView.vue:68:用户点「取消」⇒ 控制台每次一条 Uncaught (in promise),淹没真实错误。
3.13 通知列表整体覆盖会抹掉飞行中的实时消息
stores/notifications.ts:45 的 items.value = result.data(load() 内)与实时推送(ClientLayout.vue:370-372 → pushRealtime,notifications.ts:57-62 插到数组头部)并发时,响应快照会覆盖列表,新通知连同未读红点一起消失,直到下一次 load()。
3.14 activate 路径会并发加载两次渲染页
main/index.ts:329-334:ensureMainWindow() 内部已经 void loadRenderer(mainWindow)(:120),activate 回调又 await loadRenderer(win) 一次 ⇒ 同一 webContents 两次 loadURL/loadFile,第二次会 abort 第一次。仅 macOS 触发(本项目只发 Windows),但它是 2.14 的一个额外未处理拒绝来源。
3.15 文档/注释与实现不符(会影响后来者判断)
| 位置 | 问题 |
|---|---|
packages/server/src/modules/auth/auth.module.ts:19 |
「登录(公开):教师 / 管理员 / 个人学生账号」——学生账号已整体删除 |
packages/server/src/modules/classes/classes.module.ts:163 |
「班级端自己可改」——ACL 已改,班级端会 403 |
packages/web-admin/src/views/ClientsView.vue:17-19 |
「教师默认没有这三个键」——PERMISSION_DEFAULT_ROLES 里 client:read 默认含 TEACHER |
packages/web-admin/src/api/index.ts:578-582 |
「list / remove 需要 client:manage」——实际是 requireAnyPermission('client:read', ...) |
packages/server/src/realtime/socket.ts:143 |
日志写「房间规则:class:{classId} / user:{id}」——已改成 session:{id} |
packages/server/src/app.ts:185-188 |
「两个路径共享同一个计数器」——loginRateLimiter() 每次调用都新建 store,实际是各算各的 |
packages/web-admin/src/styles/index.css:223 |
「Web 端目前 14 个菜单项」——现在 12 个(见 1.1) |
packages/web-admin/src/layouts/AdminLayout.vue:294 |
「菜单里的『权限组』要等它回来才显示」——数组里已经没有它 |
4. 存疑(需要运行/抓现场才能定)
| # | 位置 | 需要确认什么 |
|---|---|---|
| 4.1 | packages/server/src/modules/grades/grades.service.ts:319-323 |
批量重算等级时,非 percent 口径的行沿用旧 level 却把 levelType 改了 ⇒ 可能产出「数字等级 + custom 口径」。要看 UI 是否会传这种组合 |
| 4.2 | packages/server/src/modules/ai/ai.service.ts:349-358 |
AiAudit 只写 status: 'pending',用户确认后调的是既有端点,没有任何地方把它改成 done/succeeded ⇒ status 可能恒为 pending |
| 4.3 | packages/server/src/modules/ai/ai.service.ts:302-322 |
resolveContext 按 getAccessibleClassIds 生成提示词上下文,但用户的 input.classId 不校验可达性 ⇒ 可否用别班 classId 换出该班名/科目名 |
| 4.4 | packages/desktop-client/src/island/IslandApp.vue:211 |
item.priority.toLowerCase() 没有像 badgeClass:164 那样写 ?? 'NORMAL'。若某条 DTO 缺 priority ⇒ 抛异常 ⇒ 整张展开卡不渲染(白卡)。需要造一条缺字段的通知 |
| 4.5 | packages/desktop-client/src/main/crash-guard.ts:140-153 |
recovering 标志在同步路径上等于没生效(recover() 是同步返回)。要看实机崩溃日志里「开始重建主窗口」是否连续出现 |
| 4.6 | packages/desktop-client/src/main/island.ts:1889-1891 |
island:set-appearance 无节流,拖滑块时每条 IPC 都触发 recomputeSizes + refreshViewport + relayout。是否造成可见抖动需真机观察 |
5. 死代码与退役遗留
有意保留(别删):components/TimeLayoutImportDialog.vue 与 /imports/time-layout/* 四个接口 —— docs/app/imports.md:101 明确说明是「保留给旧脚本调用」。
真正的零调用(可清理):
packages/web-admin/src/api/index.ts:previewTimeLayout / listTimeLayouts / importTimeLayout / removeTimeLayout(:452-473)、classApi.getGradeQuery(:132-133)、studentApi.transfers(:334-339)、courseApi.update(:147-148)components/TableImportDialog.vue的classTeachers分支(:24 / :61-67 / :75-77 / :113 / :118 / :120 / :372):三个调用点分别传grades/students/teachers,没有任何入口能传classTeachers—— 而服务端table-import.service.ts:702 / :841 / :858完整实现了它,shared/src/permissions.ts:144还专门配了权限stores/realtime.ts:19-26 / :162-164的EVENT_LABELS+eventLabel()、stores/auth.ts:50的isStaffrouter/index.ts:115的{ path: 'client-devices', redirect: '/clients', meta: { hidden: true } }:meta.hidden全仓无读取方
6. 已核对、确认没问题的点(避免重复投入)
服务端
- 版本号 12 处落点一致
deleteTeacher的级联护栏五项齐全(Class/Course/Homework/Notification;ClassTeacher表确实已删)- 待办清单里那五项之外,没有别的
onDelete: Cascade指向User的关系漏检(HomeworkStatus/NotificationRead/Grade属个人数据,随账号删除是正确行为) assertCanManageSubjectContent的科目维度规则、resolveClassScope/classScopeWhere的口径- 课表周次区间计算、
resolveCurrentWeek、resolveWeekFromRanges - 排名口径:同一学生同一科目取最高分、并列同名次(1、2、2、4)、按科目去重
compareVersions/isNewerVersion/normalizeVersion(含1.0.0vs1.0.0-fix的口径)gradePercent/averageOf/normalizeStudentNo的边界(空数组、NaN、除零)- 权限键目录、
ROLE_DEFAULT_PERMISSIONS与各requireXxx的对应关系(除 2.2 那 6 个) attachmentUrlSchema挡得住javascript:/data:update模块的版本号路径穿越防护(SAFE_SEGMENT)importClassPlan的事务与 replace/merge 语义createSchedule/updateSchedule的「结束时间晚于开始时间」合并校验- 学生名单导入的班级重名消歧、教师导入的
createdTeachers复用
Web 端
- 跳转 XSS:唯一用户可控 URL 点(
HomeworksView.vue:44-48)已做 scheme 白名单;UpdateCheckDialog.vue:52的 url 来自服务端转发的 GitHubhtml_url,非用户可控;全仓无v-html/innerHTML - 按钮对错误角色可见:
GradesView/HomeworksView把写入口对全体 TEACHER 显示,是GradesView.vue:31-38注释里明确写下的项目决策,不是缺陷 :key="index"的用法均出现在整体替换、无内部状态的列表(告警文本、tour 步骤),无实际影响- 接口契约逐项核对通过:
{items,total}、BulkPublishResult.results[].code/details.current、calls的urgent/message/quickPhrase、grades的exams/levels/ranking、PUT /database/backup-schedule、/dashboard/term、/imports/table/*、/clients/devices - 图标名全部在
icons.ts注册表内 - 已正确实现竞态守卫的地方:
GradesView.vue:119-137、SchedulesView.vue:143-164、HomeworksView.vue:73-128
桌面客户端 / shared
- contextBridge:所有
saveConfig/islandSetAppearance/islandPush调用点都展开成字面量,没有把 Vue Proxy 直接过桥的地方 - preload:
preload/index.ts与preload/island.ts只暴露具名方法,没有泄漏ipcRenderer本体;通道名与主进程逐一对应 - 导航白名单:
index.ts:51-61与island.ts:30-40都是精确比对(清 hash/search 后比 href),不是startsWith - CSP:
index.html/island.html都在;全仓无v-html/eval/new Function;openExternal只放行 http/https - 凭据未进产物:冒烟凭据只从环境变量读,源码里没有硬编码账号口令
shared的日期/周次/版本/度量表逐条核过边界,无硬伤
7. 建议的动手顺序
第一批(各 1~2 行,收益最高、风险最低)
- 补回三个菜单项(1.1)—— 纯前端数组 + 补一条反向自检
assignDate一行(1.2)+ 存量数据回填(1.3)pendingUpdateFile加路径白名单(1.7)parseTeacherRole的return null(2.1)--ch-text-regular改名(2.11)、Esc 加可见性判断(2.12)、等级列改用row.level(2.13)
第二批(客户端上下课判定的三个破口,得一起修) 1.4 缓存键带 classId + 1.5 IndexedDB 失败重试 + 1.6 的 (a)(b)(c) —— 这五处是同一类「静默出错」在离线优先链路的不同环节,分开修会互相掩盖。
第三批(一致性与体验)
2.2 六个权限键的归置(挂上或下线)、2.3 socket 的 classSession、2.4/2.5 课表与学期长度的口径统一、2.6 同名消歧、2.7/2.8 广播补全。
再之后是第 3 节的竞态、loading、深色主题那批。
收尾
pnpm exec prettier --write <那 17 个 md>- 把 §0.1 的 Git Bash pnpm 绕过办法补进 AGENTS.md
- 逐条核对 §3.15 的文档/注释漂移