Files
ClassHelper/代码审查报告-2026-10-17.md
T

41 KiB
Raw Blame History

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)会被三条路径调用重建窗口:

  1. 崩溃自愈:index.ts:189-196 的 recoverMainWindow → ensureMainWindow();
  2. 托盘「显示主窗口」→ showMainWindow()(index.ts:266-267);
  3. 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)。第二条判据恒假 ⇒ 返回 []。

两个后果

  1. 教室机器显式 class:join 本班房间会被拒(socket.ts:111 的 allowed.includes(classId) 恒假,报「无权订阅该班级」);
  2. 教师/管理员每次建立 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 的 isStaff
  • router/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.0 vs 1.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 来自服务端转发的 GitHub html_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.1)—— 纯前端数组 + 补一条反向自检
  2. assignDate 一行(1.2)+ 存量数据回填(1.3)
  3. pendingUpdateFile 加路径白名单(1.7)
  4. parseTeacherRole 的 return null(2.1)
  5. --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 的文档/注释漂移