608 lines
41 KiB
Markdown
608 lines
41 KiB
Markdown
# 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 的文档/注释漂移
|