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

608 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`)。实测能跑通的组合是两者叠加:
```bash
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`
```ts
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`
```ts
// 只在 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`
```ts
/** 角色列解析:只认「管理员」类词;其余(含空)返回 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`)
```ts
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`
```ts
// 时间配置(导入的时间表)里如果只有部分节次,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`
```ts
.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`
```ts
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`
```ts
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`
```ts
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`
```ts
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`
```css
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`
```ts
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`
```vue
<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`)
```ts
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 的文档/注释漂移