- 服务端新增 database 模块(/api/database/*,仅 ADMIN):全库 JSON 快照(备份 / 恢复 / 导入导出 / 定时备份
共用同一格式,14 张表按拓扑序导出、恢复时关外键检查)、连接测试与目标库校验、一键 SQLite ⇄ MySQL 切换
(备份 → 导出 → 测试连接 → 校验空库 → 改 schema → 子进程 generate / tsc / db push → 写数据,全部成功才改写
.env,中途失败回滚 schema);XML 快照与 SQLite 数据文件均可下载;打包形态随包带上 prisma CLI 与 typescript
(prisma-client 生成物是 .ts,需再编进 dist/generated,安装包因之变大)
- Web 管理端新增「数据库管理」页(仅管理员),并加首次登录新手引导(el-tour,可跳过、可随时重看,版本号写
localStorage,改版后老用户会再收到一次)
- 客户端设置改版:侧边栏改为分组折叠菜单,设置拆成 6 个子路由
(/settings/{general,appearance,island,reminder,account,about}),新增深色模式(主题持久化到主进程配置,
主窗口底色跟主题走,避免"先白后黑"闪一下),新增「关于」页(应用信息 / 诊断信息)
- 客户端新增首启六步引导(OnboardingWelcome,状态存主进程配置 onboardingDone)
- 客户端主窗口初始尺寸与 ClassIsland 对齐(1242x582 / minHeight 540 / 居中)
- 版本号统一提升到 1.0.0:5 个 package.json、部署配置与镜像标签(.env.example / docker-compose / Dockerfile)、
服务端 /health 与首页、插件 manifest.yml 与 BridgeService.PluginVersion、文档里的安装包名
- 文档与验收同步:AGENTS.md 补第 37~51 条约定(深色主题实现边界、设置子路由、数据库管理三件事、侧栏与窗口
尺寸、引导在自动化里的判据等),README 补「数据库管理 / 新手引导 / 设置改版」三节;verify:e2e 增补 13 条
数据库管理用例,客户端冒烟与 Web UI 冒烟跟进子路由、深色主题、侧栏与引导
验证:pnpm lint 0 error;pnpm typecheck 全过;verify:e2e 186/186;verify:classisland-plugin 68/68;
build:desktop 通过(含产物凭据门禁);verify:desktop 99/102(3 项为上课时段下的"真实通知链路"用例,
客户端按产品规则暂存普通通知所致,见下);插件 dotnet 编译 0 错误。
注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有
「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例,
与本次改动无关。
73 KiB
AGENTS.md — 班级小助手(Class Helper)
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。 文中「本机环境」「已知坑」两节按实测写成;若与本机现状不符,以本机实测为准并顺手改掉这里。
1. 这是什么
班级信息管理系统,pnpm monorepo,一份代码产出多个交付物:
| 交付物 | 位置 | 形态 |
|---|---|---|
| 后端服务 | packages/server |
Express 5 + Prisma 7 + Socket.IO,/api 前缀 |
| Web 管理端 | packages/web-admin |
Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
| 桌面客户端 | packages/desktop-client |
Electron + Vue 3(学生用,含「灵动岛」浮窗) |
| 共享契约 | packages/shared |
三端共用的类型 / 常量 / 权限 / 工具函数 |
| ClassIsland 联动插件 | packages/classisland-plugin |
.NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
核心链路:
教师在 Web 端发布内容
│ REST API(JWT 鉴权)
▼
后端服务(Express + Prisma)
│ 写库 + 按班级房间广播
▼
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
ClassIsland(教室机器)──► 班级小助手联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
▲ │
└──── 老师下发的提醒(上报返回值里顺带带回)◄┘
- 远端:
LoveChengke/ClassHelper(仓库名大小写不敏感,本地 remote 存的是小写classhelper.git) - 默认分支:
master - 语言:文档、注释、提交信息、界面文案一律中文
2. 目录速查
package.json 根脚本(dev / build / db:* / verify:* / dist:* / icons)
pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白名单,勿随手删条目)
tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict)
eslint.config.mjs ESLint 扁平配置(TS + Vue);ignores 含 releases/ / release-server/ / src/generated/
.prettierrc.json 单引号 / printWidth 110 / trailingComma all / LF / 2 空格
build/icon.{ico,png} 应用图标(由 pnpm icons 生成)
scripts/
use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema)
generate-icons.mjs Electron 渲染 SVG → PNG/ICO
icons/render.cjs 图标渲染(CommonJS)
dist-server.mjs 服务端 + Web 管理端打包(产物落在 release-server/)
nsis/server-installer.nsi 服务端 NSIS 安装脚本
verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟
ui-smoke/{run,main.cjs,live-probe.mjs} Web 管理端真实点击回归(Electron 驱动)
lib/{electron-env,node-runtime}.mjs 启动 Electron / 定位真实 node.exe
deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / install-linux.sh
classhelper.service / package.runtime.json / .env{,.sqlite}.example
docs/ production.md(生产部署)/ mysql.md(MySQL 切换)/ winisland-design-tokens.md
screenshots/{island,client,web-mobile,classisland}/
packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts
packages/server/
prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
prisma/migrations/ 10 个迁移(init / time_layout / class_account / schedule_week_parity /
class_term_weeks / classisland_integration / class_notification_channel /
homework_assign_date / push_kind / schedule_source)
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,实测 173 项)
scripts/verify-classisland.mjs 联动链路验收(实测 42 项)
scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用)
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
src/config/env.ts zod 环境变量校验 + 生产自检
src/lib/ access(RBAC) class-account db db-bootstrap http jwt logger mappers
password schemas session term web-static
src/middleware/ auth validate error security
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
src/modules/ 13 个功能模块 + registry.ts + module.types.ts
packages/web-admin/src/ api stores router layouts views(11) components(4) composables styles config.ts
packages/desktop-client/
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / logger / smoke
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
src/island/ 灵动岛渲染进程:IslandApp.vue / spring.ts / squircle.ts / main.ts
src/renderer/ 学生端渲染进程:api / cache(IndexedDB) / stores / views / island / router
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
scripts/ build-main / dev / smoke / dist-win
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.0.0.0
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
src/Models/PluginSettings.cs
src/Services/ BridgeService(上报·接收·镜像)/ ScheduleMapper / ClassPlanWriter
ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上)
src/Views/BridgeSettingsPage.axaml(.cs):Avalonia 设置页
src/Interop/ClassHelperClient.cs:HTTP 客户端 + 与服务端逐个字段对齐的 DTO
scripts/ build.mjs(编译 / 打包 .cipx)/ verify.mjs(静态契约校验,实测 68 项)
3. 本机环境(2026-09-30 实测)
| 项 | 状态 |
|---|---|
| Node | v24.19.0(D:\nodejs\node.exe;要求 ≥ 20.19,Prisma 7 硬性要求) |
| pnpm | 11.8.0(packageManager 锁定 11.8.0),宿主就是上面这个 node |
| npm registry | https://registry.npmjs.org/(仓库内无 .npmrc,取全局配置) |
| 依赖 | 已装(707 包,pnpm install 实测 2m45s) |
后端 .env |
已由 .env.example 生成(gitignored),JWT_SECRET 为本机随机值,TERM_START_DATE=2026-09-07 |
| Prisma Client | 已生成到 packages/server/src/generated/prisma(gitignored) |
| 数据库 | SQLite packages/server/prisma/dev.db:10 个迁移已应用、种子已写入(用户 18 / 班级 3 / 课程 54 / 课表 93 / 作业 15 / 通知 12 / 成绩 90) |
shared/dist |
已构建(pnpm build:shared)—— 它是三端与 seed 的前置产物,缺了会 ERR_MODULE_NOT_FOUND |
| Electron 二进制 | 已手动装好(node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe,246 MB) |
| .NET SDK | ✅ 8.0.425(C:\Program Files\dotnet\sdk,dotnet --list-sdks 有输出)⇒ 插件可编译/打包;别再照旧文档说"没装"(dotnet 在 PATH 里,--list-sdks 才是权威判据) |
| ClassIsland | 2.1.0.1,装在 D:\Classisland(启动器 D:\Classisland\ClassIsland.exe,实际进程 D:\Classisland\app-2.1.0.1-0\ClassIsland.Desktop.exe) |
| ClassIsland 数据 | 数据根 D:\Classisland\data:Config/、Profiles/、Plugins/(当前为空,尚未装本插件)、Logs/、Cache/、Settings.json |
| 桌面会话 | 有(ClassIsland.Desktop.exe 跑在 session 1);Electron GUI 在 AI 会话里也能跑(需先修完整性标签,见 §7 第 7 条) |
旧文档里的
F:\classisland/F:\data已失效:本机只有C:与D:,F:盘不存在。 另有一份旧副本在C:\Users\胡\Desktop\classisland(数据在它自己的data/下,2026-09-18 用过),当前以D:\Classisland为准。
从零重建环境(本机实测可行的顺序)
pnpm install # 需要下载 Electron 时加 NODE_OPTIONS=--use-system-ca
# 后端环境变量(.env 不入库,必须自己生成)
cp packages/server/.env.example packages/server/.env # 至少改 JWT_SECRET;TERM_START_DATE 建议 2026-09-07
pnpm db:generate # 生成 Prisma Client
pnpm --filter @classhelper/server db:deploy # 应用迁移(根 scripts 里没有 db:deploy,见 §7)
pnpm build:shared # 必须先构建 shared,否则 seed / 服务端启动会失败
pnpm db:seed # 写演示数据
# Electron 二进制不会随 pnpm install 下载,需要手动补:
# 工作目录 node_modules/.pnpm/electron@44.3.0/node_modules/electron
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ node install.js
# ClassIsland 插件(.NET 8 SDK 已装:8.0.425)
pnpm build:classisland-plugin
演示账号(pnpm db:seed 产出)
| 角色 | 用户名 | 密码 |
|---|---|---|
| 管理员 | admin |
admin123 |
| 教师 | teacher1 / teacher2 |
teacher123 |
| 学生端班级账号 | 班级码 G101 / G102 / G203 |
123456 |
密码是种子脚本里硬编码的(
admin123/teacher123/ 班级123456);.env的DEFAULT_CLASS_PASSWORD只影响新建班级时的初始班级密码。 学生没有个人账号(2026-10-01 清理):student01…15只是名单记录(成绩/未交/叫人/已读按名单),User.passwordHash为空串、不能登录,也没有任何密码接口;学生端统一用班级码 + 班级密码。 班级码G203是「高二(3)班」,不是G103。
4. 常用命令
| 命令 | 说明 |
|---|---|
pnpm dev |
并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
pnpm dev:server / pnpm dev:web |
只启动后端 / 只启动 Web 端 |
pnpm dev:desktop |
客户端开发模式(Vite 5174 + Electron,热更新) |
pnpm build |
构建 shared + 后端 + Web 端 + 客户端 |
pnpm build:shared |
只构建 shared(改完 packages/shared 必须跑) |
pnpm build:desktop |
只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
pnpm build:classisland-plugin |
构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 .cache/) |
pnpm dist:classisland-plugin |
打包插件为 .cipx 并归集到 releases/classisland-plugin/ |
pnpm typecheck |
全仓库类型检查(含 vue-tsc) |
pnpm lint / pnpm lint:fix |
ESLint |
pnpm format / pnpm format:check |
Prettier(format:check 在 master 基线上本就失败,见 §7 第 21 条) |
pnpm db:generate |
生成 Prisma Client |
pnpm db:migrate |
prisma migrate dev(本机不可靠,别用;见 §7) |
pnpm --filter @classhelper/server db:deploy |
应用已有迁移(本机推荐;根 scripts 里没有 db:deploy) |
pnpm db:seed / pnpm db:reset |
写种子 / 重置并重播种子 |
pnpm db:studio |
Prisma Studio |
pnpm db:switch:mysql / db:switch:sqlite |
改写 schema.prisma 的 provider(配合 docs/mysql.md) |
pnpm verify:e2e |
后端端到端验收(需后端已启动;2026-09-30 实测 173 项全过) |
pnpm verify:web |
Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
pnpm verify:desktop |
客户端冒烟(Electron,无人工点击) |
pnpm verify:packaged |
对打包后/已安装的客户端 EXE 跑同一套冒烟(--exe 指定路径) |
pnpm verify:classisland |
ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 42 项) |
pnpm verify:classisland-plugin |
插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) |
pnpm dist:server / dist:win / dist:dir / dist:all |
打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
pnpm icons |
生成应用图标(Electron 渲染 SVG → PNG/ICO) |
端口
| 端口 | 用途 |
|---|---|
| 4000 | 后端 REST + Socket.IO + (生产/构建后)Web 管理端静态托管 |
| 5173 | Web 管理端开发服务器(/api、/socket.io 代理到 4000) |
| 5174 | 桌面客户端渲染进程开发服务器 |
交付产物归集
打包脚本各自输出到固定目录,归集到仓库根 releases/ 是本仓库的人工约定(该目录 gitignore + eslint ignore):
release-server/ pnpm dist:server 的输出(免安装目录 + NSIS 安装程序)
packages/desktop-client/release/ pnpm dist:win / dist:dir 的输出(win-unpacked + setup.exe + portable.exe)
releases/ 本仓库约定的归集处(把上面的产物拷进 client/ server/ classisland-plugin/)
# 客户端:electron-builder 支持指定输出目录(dist-win.mjs 会把额外参数透传)
node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client
# 服务端:输出路径写死在 release-server/,打完自行移进 releases/server/
pnpm dist:server # 首次会跑一次 npm install;重试可加 --reuse-deps 跳过
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
pnpm dist:classisland-plugin
5. 架构硬约定(改代码前必须知道)
-
模块注册表是唯一入口。 服务端每个功能是
src/modules/<name>/(*.module.ts路由、*.schemas.tszod 校验、*.service.ts业务)。当前 13 个:auth / classes / courses / schedules / homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard。 新增/下线功能只改src/modules/registry.ts(加一行 / 改enabled: false),不要动app.ts。 -
统一响应与校验。 用
src/lib/http.ts的sendOk / sendCreated / …返回,用middleware/validate.ts挂 zod schema;错误交给middleware/error.ts,不要各写一套。 -
鉴权 = JWT + RBAC + 班级内角色。 路由级用
requireRole('ADMIN', 'TEACHER');班级维度用@classhelper/shared的resolveClassRole→ADMIN / HEAD(班主任)/ SUBJECT(科任)/ STUDENT / NONE, 权限判canManageSchedule/canPublishContent/canManageRoster/canManageGrades等 (人类可读矩阵是PERMISSION_MATRIX,README 直接引用它),不要在页面里另写一套规则。 -
学生端主体是「班级账号」。 班级码 + 班级密码登录后,
classId即身份;该设备的「已读 / 完成」 代全班操作,个人学生登录已停用。API 里对此有专门分支,别按个人账号语义改。 -
实时推送只走房间。 服务端
src/realtime/(SOCKET_ROOMS:class:{classId}、user:{id}、teacher:{id}、role:{role}、students、teachers;bus.ts事件总线),客户端renderer/stores/realtime.ts订阅。新增实时事件要同时改三处:服务端 bus/socket、Web 端订阅、 客户端订阅(灵动岛也依赖它)。事件名一律取自共享常量SOCKET_EVENTS,不要手写字符串。 -
共享契约只在
packages/shared。 三端都import ... from '@classhelper/shared';它产出dist, 改完要pnpm build:shared(或跑着pnpm dev的 watch)。类型/常量不要在两端各写一份。 -
数据模型跨库通用。
schema.prisma只用 String / Int / Float / Boolean / DateTime,不用 Prisma enum、 不用@db.*;角色与优先级存字符串并在 API 层用 zod 校验(这样 SQLite / MySQL 行为一致)。 只有一份schema.prisma:切库是改写它的provider(pnpm db:switch:mysql),没有schema.mysql.prisma。 改模型要同步prisma/migrations/(本机用pnpm --filter @classhelper/server db:deploy应用)。 -
周次口径唯一。 当前教学周 =
shared.resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK),MAX_TERM_WEEK = 30,班级还有自己的Class.termWeeks(默认 20,上限也是 30)。别在别处重新实现周次换算。 周次上限三处必须一致:MAX_TERM_WEEK、lib/schemas.ts的weekNumberSchema、classes.schemas.ts的termWeeks(曾经班级能设到 40 而课表只到 30,等于对外承诺了系统兑现不了的学期长度)。Class.termWeeks只有管理员能改(assertCanManageClasses),班主任不行。 -
Electron 安全边界。
src/preload/只通过 contextBridge 暴露白名单方法,不要暴露ipcRenderer本体; 主进程 ↔ 渲染进程的类型统一写在src/types/desktop.d.ts。跨桥接只能传纯数据。 contextBridge 在参数跨越「主窗口 → 隔离世界」时就做结构化克隆, 传 Vue 的
ref.value/reactive()对象(Proxy)会抛An object could not be cloned.—— 而且它在进入 preload 函数体之前就失败了,所以桥接层无法兜底,只能由调用方展开成 字面量再传(saveConfig({ island: { ...island.value } }))。这类失败是"半静默"的: 界面数字照常变化、只多一行 console error,看起来就是"设置不生效"。设置页曾因此翻车,verify:desktop里的「设置页真实 UI:拖拽滑块即时改变灵动岛外观」就是为它加的回归。 -
客户端离线优先。 渲染进程数据先落 IndexedDB(
renderer/cache/),断网回退缓存并在顶部提示离线; 新增页面/数据源要按这个模式接。 -
Web 端设计令牌集中。 圆角/投影写在
packages/web-admin/src/styles/index.css的:root(--ch-radius-xl/lg/md/sm/pill、--ch-shadow-sm/md),组件里不要写死数值;Element Plus 通过覆盖它的 CSS 变量对齐。手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用useResponsive.ts+.table-card。 -
ClassIsland 联动是独立鉴权通道,不要塞进 JWT。 插件用设备令牌(
X-ClassIsland-Token,库里只存 sha256, 明文前缀为chci_)调/api/integrations/classisland/*;Web 端管理设备仍走 JWT +requireRole。 新增插件接口时,路由挂integrations.module.ts,并在integrations.schemas.ts里写 zod —— 插件侧的 C# DTO 必须逐字段对齐(pnpm verify:classisland-plugin会校验字段名与路由)。 -
课表镜像契约必须带
startTime/endTime。 ClassIsland 的ClassPlan.Classes[i]强制与时间表里 第 i 个TimeType == 0(上课)的时间点对齐:镜像下发少一个时间点,整份课表就会错位, 而且表现为"课表看着有内容、每节课都对不上",极难排查。 -
所有发布入口共用同一份 ClassIsland 推送实现(
integrations.service.ts的pushToClassIsland): 通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时必须复用,不要再各写一套 —— 否则又会出现「某个页面发的通知不联动 ClassIsland」。 -
提醒弹在哪个端由教室客户端选,值存在
Class.notificationChannel(both/client/classisland): 客户端设置页写它(班级账号也有写权限,见PATCH /api/classes/:id/notification-channel), 服务端据此决定是否推 ClassIsland(shouldPushToClassIsland)。改动这条链路要同时动三处: 客户端设置页与本地配置、lib/mappers的 DTO、服务端推送判断。 -
灵动岛的左右边距只在「左/右停靠」时生效:默认是「顶部居中」,水平锚点就是屏幕中线, 此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 —— 用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
-
镜像进 ClassIsland 的课表要带课间:每两节之间补一段(上一节下课 → 下一节上课), 最后一节之后不补(放学)。实现见
ClassPlanWriter.BuildTimeLayout(自己按课目时间算), 服务端合成节次时间表同口径。 -
提醒分两类,决定"上课时段弹不弹":
ClassIslandPush.kind=notification(通知类,上课时段 客户端与插件都暂存、下课补弹)/call(叫人,算「主动通知」,立刻弹);紧急(URGENT)同样立刻弹。 插件侧的实现在ClassHelperNotificationProvider(_deferred+FlushDeferred,订阅课程事件), 客户端的实现在renderer/island/bridge.ts。改这条规则要两端一起改。 -
作业按「所属日期」归类,不要用 createdAt 当"哪一天":
Homework.assignDate是本地日期 (YYYY-MM-DD,不带时区),按天查看 / 日期高亮 / 客户端"今天"都看它。 UTC 切片(toISOString().slice(0,10))在晚上的录入会算到第二天 —— 统一用@classhelper/shared的dayKeyLocal / isDayKey / shiftDayKey。新增按天接口时同样用?date=YYYY-MM-DD+ zod 的isDayKey校验。 -
教室机器能录入作业:
POST /homeworks的路由故意不加requireRole,权限在homeworks.service.createHomework里判(staff 或本班班级账号)。班级账号的sub是班级 id, 写createdBy前要换成该班班主任,否则撞User外键。 -
灵动岛不要用窗口背景材质(
win.setBackgroundMaterial())。它看起来像 WinIsland 的DWMWA_USE_HOSTBACKDROPBRUSH,其实是整窗系统材质:刷在整个窗口矩形上(岛是"固定大包围盒窗口", 卡片之外还有留白),且明暗跟随应用主题(本客户端是浅色 Fluent 主题 ⇒ 材质必然是浅色), 于是卡片周围会出现一圈浅色"白底面板"。更坑的是调用过一次就再也回不去逐像素透明 (含'none':Electron 会让窗口回落到默认不透明底色#FFF),所以从glass切到black/tinted后会永久留一圈纯白。CSSbackdrop-filter也替代不了(透明窗口里采样不到桌面)。glass因此用 CSS 半透明深色卡(WinIsland 无 backdrop 时的降级色rgba(32,32,36,.804))。 回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」—— 它整屏截图取样 (webContents.capturePage()拍不到窗口之后的材质,是当初漏掉这个 bug 的原因), 并在岛底下垫一块已知颜色的底板窗口,好让"环 = 底板色"可以被精确断言。 -
灵动岛位置由「屏幕工作区 + 边距」算,默认边距 8px(
DEFAULT_ISLAND_APPEARANCE.marginX/marginY): 改默认值会直接改外观,verify:desktop的「停靠位置生效(6 个锚点)」断言的是像素坐标,一动就红。 新增边距类设置请只改"可调范围",别动默认值(夹紧规则收口在主进程normalizeIslandAppearance)。 -
课表行有"来源"字段
Schedule.source(manual/classisland)。插件每次全量上报时, 服务端只清理「source='classisland'且本次未再上报」的行 —— 这样老师在 ClassIsland 里删掉/挪动一节 能真正同步下来(原先 merge 只 upsert,服务端会永远留着幽灵课),老师在小助手这边手排的课一律不删。 因此:新增写课表的入口要显式定 source(Web 端录入/导入保持默认manual,只有插件上报用classisland), 否则要么幽灵课清不掉,要么手排的课被误删。回归用例见verify:classisland的「幽灵行清理」两条。source只在 create 时确定,update 一律保持原值。 merge 的去重键是「星期 + 开始时间 + 单双周」, 老师在 Web 端手排的同一时间槽会被上报"收养";若收养时把来源改成classisland, 下一次清理就会把这行手排的课删掉 —— 这个坑踩过一次:种子课表被删掉 2 行,verify:e2e的「上课状态接口」用例(AGENTS.md §7 已列为脆弱用例)随即报红。 同步时不要覆盖weekStart/weekEnd:插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。 -
两个登录入口都必须挂
loginRateLimiter()(app.ts用数组形式同时挂/auth/login与/auth/class-login)。 班级密码默认是123456、班级码是固定字符集,班级登录曾是唯一没有暴力破解防护的入口。 另外auth.service.login里角色判定必须在密码校验之前:否则"密码错(401) / 密码对但学生账号已停用(403)" 的差异会把学生账号密码变成可离线验证的预言机。 -
凡是会渲染成链接的字段都要校验 scheme。
Homework.attachmentUrl在服务端用attachmentUrlSchema(只允许http(s)://或/开头的站内相对路径),Web 端渲染前再兜一层 (HomeworksView.vue的attachmentHref)。放行javascript:/data:text/html就是一条存储型 XSS。 -
桌面端必须保留 CSP(
index.html/island.html的meta http-equiv="Content-Security-Policy")。 渲染进程通过 preload 能拿到getConfig()的明文 token(代表全班身份),CSP 是唯一能拦住 "加载并执行远程脚本"的一层。配套的两条导航防线也别删:主窗口isAllowedNavigation(按同源/同一文件精确比对, 不要退回startsWith)、灵动岛窗口的setWindowOpenHandler+will-navigate全拒。 -
桌面端产物里不得出现任何凭据,构建有门禁。
dist/main/index.js与dist/renderer/assets/*.js会被 electron-builder 打进app.asar发给每台学生机,而 asar 可直接解包 (历史问题:冒烟/自检代码里写死了teacher1/teacher123,grep产物即命中)。 冒烟与自检用的账号口令、班级码/班级密码一律通过环境变量注入 (ELECTRON_SMOKE_USER/ELECTRON_SMOKE_PASSWORD/ELECTRON_SMOKE_CLASS_CODE/..._CLASS_PASSWORD): preload 读环境变量 →window.desktop.smokeCredentials→setSmokeTeacherCredentials(), 由scripts/smoke.mjs与scripts/verify-packaged.mjs提供(那两个脚本不进安装包)。 把静态 import 改成await import('./smoke.js')解决不了这个问题:esbuild 在format:'cjs'+bundle:true下不做代码分割,动态 import 同样被内联进同一个产物(已实测)。pnpm build:desktop结尾会跑check-bundle-secrets(packages/desktop-client/scripts/check-bundle-secrets.mjs), 产物里出现种子口令/把种子班级码当凭据的写法即构建失败 —— 别把这条门禁摘掉。 -
表格导入的体积上限是"请求体 12MB ↔ 文件 8MB":文件以 base64 放进 JSON(约 4/3 倍), 因此
express.json({ limit: '12mb' })必须留够余量,否则声明了 8MB 却连 1.5MB 的文件都传不进来。 超限由middleware/error.ts映射成 413IMPORT_TOO_LARGE(不要让它落进 500 兜底分支: 原先用户看到的是一句英文的「服务器内部错误:request entity too large」)。 -
删除类接口的护栏要与 schema 的级联关系对齐。
deleteTeacher的检查项必须覆盖Class.teacherId/Course.teacherId/ClassTeacher.teacherId/Homework.createdBy/Notification.createdBy五项(后两项曾经漏检,会静默删掉该教师在各班发过的作业与通知; 回归用例见verify:e2e的「删除教师护栏覆盖"发布过作业/通知"的账号」)。 改 schema 时先看哪些外键指向User且是 Cascade,再回来核对这个清单。 -
灵动岛在触摸屏上必须走「触摸模式」(
touchMode)。 打开命中的两条链路 (渲染进程的 mousemove 转发、主进程读screen.getCursorScreenPoint())都以"鼠标指针移动" 为前提,而手指触摸既不产生 mousemove、也不移动系统光标 ⇒ 窗口永远停在setIgnoreMouseEvents(true)的穿透态,手指点不开岛(用户反馈:希沃白板)。 触摸屏上唯一的解法是"触摸点处窗口可命中":Electron 没有SetWindowRgn, 所以渲染进程按navigator.maxTouchPoints > 0上报(island:set-touch-mode)后, 主进程把窗口贴合岛体(windowBox= 当前形态 + 2×阴影留白)并始终接收输入 (setInteractive在触摸模式下把入参强制为 true)。三条护栏别动:
- 变小要等岛真的收小(
cardFitsFormSize+ 渲染进程上报的岛体矩形)—— 立刻缩小会把形变中的卡片裁掉; expectedHitRect必须按窗口实际尺寸算偏移(触摸模式下窗口不再是固定包围盒);- 失焦宽限期用
BLUR_GRACE_TOUCH_MS(1200ms):触摸没有光标,isCursorOnIsland()恒为 false, 而实测假失焦出现在 70~520ms(跨过 500ms 边界 ⇒ 会把刚展开的岛缩回胶囊)。 非触摸屏机器完全不走这条路(窗口仍是固定大包围盒 + 按光标命中),verify:desktop会先setTouchMode(false)再跑既有的光标注入用例,末尾单独断言触摸模式。
-
"改密码"的入口只剩 Web 管理端两处,客户端一个都没有。 教师/管理员改本人密码走 Web 顶栏下拉(
PATCH /api/auth/password);班级密码由管理员在「班级管理 → 修改班级账号」 (PATCH /classes/:id/class-account)维护;教师设指定新密码用POST /teachers/:id/reset-password(可选newPassword,留空 = 默认初始密码)。 2026-10-01 起学生个人账号整体清理:学生只是名单(成绩/未交/叫人/已读按名单记录),POST /students/:id/reset-password已删除(调用返回 404,e2e 有负断言)、新建学生不设密码 (User.passwordHash空串占位,STUDENT的 403 登录判定在密码校验之前)、DEFAULT_STUDENT_PASSWORD已删;客户端设置页的「修改密码」按钮是按用户要求主动移除的 (防教室机器误改班级密码)——别当成"丢入口"的回归补回来。 -
灵动岛卡片的投影(CSS
filter)必须常驻,形态切换只能改参数。 不要把filter: drop-shadow(...)只挂在.island-card.expanded上:给元素增删 CSS filter 会让 Chromium 新建/销毁它的渲染表面(effect node),首帧的合成可能早于新表面栅格化完成, 那一帧被当成空内容画出去 —— 而卡片的底色恰好画在这个被过滤的元素上(.shape path的fill), 于是开/合的那一瞬间底座会闪掉一帧、只剩未过滤的文字层(用户反馈的"开合时一瞬间的闪动")。 正确写法见IslandApp.vue的.shape path:filter 常驻,胶囊态用0 0 0的退化投影 (硬轮廓正好压在路径底下,肉眼等同无投影,与 WinIsland「只有展开态有投影」的观感一致)。 回归用例:verify:desktop/verify:packaged的「灵动岛任意形态的投影都常驻」。 -
多条通知时展开是「竖排列表」,卡片高度由主进程与渲染进程各算一次同一个数**。**
@classhelper/shared的islandListLayout()(度量表ISLAND_LIST_METRICS)是唯一算法: 默认只显示defaultVisibleRows(3)条 +展开更多(还有 N 条),按重要程度排序 (PRIORITY_RANK,同级按时间新的在前);点"展开更多"后铺到maxCardHeight为止, 仍然放不下的改成「更多请前往应用内操作」——maxCardHeight是主进程按工作区/任务栏算出的可用高度, 随状态下发。两条硬约束:- 窗口包围盒必须跟着列表长高(
IslandApp.vue的列表比任何普通形态都高,窗口不够高会把底部按钮裁掉); 长高发生在卡片形变之前(setState里先relayout()再emit()),用户看不到这一步; - 列表态 CSS 的每个尺寸(行高/行距/内边距/标题行/提示行/按钮行)都要与那份度量一一对应,
改 CSS 必须改常量,否则"最后一行被切掉"这类问题会以"看着有内容、按钮点不到"的形式出现。
回归用例:
verify:desktop的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。 写这类用例时注意:断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先drainIsland()排空队列, 否则量到的是列表高度。
- 窗口包围盒必须跟着列表长高(
-
"知道了 / 标为已读"之后是「状态同步落地 + 窗口淡出」,没有跨进程的收回握手。 收起一律是主进程单向的:
handleAction()收到dismiss/dismiss-all/mark-read/mark-all-read→dismissActive()(还有下一条则退胶囊)或clearPendingForClose()→hide()→fadeOut()(每帧setOpacity(-0.2),到 0.02 后win.hide())。 状态在同一帧就写成hidden/ 清空,不等渲染进程任何回执 —— 调用方与冒烟按"同步语义"断言 (island.ts的clearPendingForClose()注释:「状态同步落地(渲染进程随之收起卡片,窗口由hide()淡出)」)。 渲染进程那侧只有本地弹簧形变(spring.ts的targetSize/progress),是纯视觉动画,不参与淡出时机。曾经有过一层自研的"收回快照"动画,已按用户要求整体删除。 它叫
closing/closingSnapshot: 点"知道了"后把卡片按"胶囊摘要"收回再淡出,是自研的形变状态机,实测在触摸屏上会露出旧帧/重影 (触摸模式窗口贴合岛体、卡片形变时窗口跟着动),用户明确要求"动画就用仓库里那套",于是整段删掉 —— 现状见IslandApp.vue顶部注释。别照着一份不存在的握手机制去改代码。 (smoke.ts里"收回动画是渲染进程收敛上报 → 主进程淡出"只是一句过时注释,代码里没有这条链路。)多条通知时这两个按钮都是整批操作:
dismiss-all/mark-all-read(作业卡的合成 idhomework-<id>要在主进程按 kind 过滤掉,否则通知中心会去查一条不存在的通知 —— 见handleAction()里mark-all-read分支的.filter((item) => item.kind !== 'homework'))。 回归用例:verify:desktop的「多条通知点「知道了」:整批关闭(状态同步清空,窗口淡出隐藏)」。排查"收回时闪一下"这类反馈时注意:先看是不是自动化验证实例的叠影(见第 41、42 条), 别往这套已经不存在的握手机制上找原因。
-
本机录入的作业不再上灵动岛。 服务端是先广播
homework:new、后回响应的, 等响应拿到 id 再登记就晚了(事件可能已经到了、岛已经弹出来了)——所以renderer/island/bridge.ts在发请求之前按内容登记指纹(班级 + 日期 + 标题 + 正文), 回执拿到 id 后再补记一次,投递前用 id / 指纹双重判定。改作业录入入口时别忘了在请求前调markHomeworkCreatedLocally()。回归用例:「本机录入的作业不再上灵动岛」。 -
触摸模式下收回动画期间,窗口必须一动不动(
syncTouchLayout()开头if (this.closing) return;)。 触摸模式窗口是"贴合岛体"的,而收回时卡片正在缩小:若跟着缩窗口,中心停靠下窗口 x 还要右移, "让出去"的那块区域会留着上一帧(DWM 还没重画桌面)——同一帧里能看到"旧展开卡的左半截 + 新胶囊" 两份画面(用户拍照反馈的"收回一瞬间闪一下")。窗口尺寸一律等隐藏之后再对齐 (syncWindow里"不可见时对齐包围盒"那条,不可见时改尺寸不会有残影)。 回归用例:「触摸模式收回:动画期间窗口不动」逐帧采样窗口矩形(只在"窗口可见 / 正在收回"时追究, 收完隐藏后对齐尺寸是正常的);「收回动画期间卡片不被窗口裁切」。 另:cardFitsFormSize()的判据里不能再塞closing—— 那条早退已经把收回整个排除了, 早先漏掉这一条时窗口会在卡片还很大时就缩成胶囊尺寸,卡片底部被平切("上半剩圆角、下半截断"); 而"隐藏态改边距不生效"那条修法仍要保留:this.win.isVisible() && (mode !== 'hidden' || idleSliver)。 -
开合时胶囊内容禁止跟着卡片重排/横移。 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动; 胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回" (用户反馈的"新消息那行字往右跳一下再缩回")。所以
IslandApp.vue的胶囊层里套了一层.pill-inner,固定成胶囊自己的几何(width: var(--island-w)),并按停靠方式对齐 (data-anchor= left/center/right →justify-contentflex-start/center/flex-end): 左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。 回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样.pill-title左右边缘,波动必须 ≤ 2px。 -
跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。
verify:desktop/verify:packaged会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端 默认也在同一位置(他的设置常是top-center+ 较大marginY)—— 两个岛一高一低叠在一起,用户会以为 "屏幕上出现了第二个灵动岛",而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。 实测踩过:用户连续两条反馈("收回去的时候闪一下/下半被截断")其实都是这个叠影造成的。 所以runIslandChecks开头把验证实例的岛做成一眼可辨:island.setAppearance({ accent: '#e91e8c', style: 'tinted' })(洋红 = 验证实例;跑完不必还原, 验证用的是独立 profile),并且全程island.setTestInputPassthrough(true)让真实点击穿透。 只改颜色、不要改停靠位置:命中/几何断言都是按默认位置写的,把岛挪到角落会把 「空闲细缝态命中跟随光标」这类用例弄红(实测踩过)。 另外:冒烟会真发通知给当前班级(实时链路用例必须打到客户端所在班),所以验证期间用户的通知列表/ 岛上会短暂出现几条「张老师 · 自检」——它们用完即删(createSmokeNotification/islandRealtimeCleanup), 若验证被中途 kill 会留下残条,收尾时顺手清一下(标题里带「自检 / 红点位置校验」的那几条)。 -
冒烟期间要让真实鼠标点击穿透(
island.setTestInputPassthrough(true))。 自动化要跑几分钟, 而用户很可能正在同一块屏幕上 —— 他的客户端那个岛和冒烟这个窗口都在屏幕顶部居中、互相叠着, 他点到的是冒烟这个窗口:实测冒烟收到了人发出的collapse/mark-read,把「点胶囊展开」 「点空白处收起」等用例整片弄红。开关只忽略真实输入:cursorOverride(setHitTestCursor) 一旦注入就照常按注入位置判定,所以「光标轮询校正命中」「岛外穿透」「触摸模式始终接收输入」 这些断言不受影响(触摸模式那条会临时关掉开关)。跑完在收尾处恢复。 -
作业页的
?date=深链必须在"已在作业页"时也生效。route.query.date只在组件 setup 里读 一次的话,同路由再推 query(冒烟切到"最近有作业的日期"、用户从别处深链回来)会被复用的组件 无视,看板停在原日期 —— 表现为"看板明明有历史作业却显示空"。修法是 watchroute.query.date(HomeworkView.vue,与selectDate的router.replace形成环时靠"值相同就跳过"防死循环)。 同理,冒烟里"切到有作业的日期"的判据是看板里没有.board-card,不是"没有.board-host" (空看板照样渲染容器,拿容器当判据兜底永远不会触发)。 -
打包版冒烟自检发的通知要"任何时段都可见":用
priority: 'URGENT'+confirmDuringClass: true(上课时段普通通知会被正确地暂存到下课、紧急通知不带确认会被 409URGENT_DURING_CLASS拒掉 —— 两个都是产品功能,别为了测试去改产品行为)。凡是断言"窗口已隐藏"的用例一律轮询等待, 不要写死 sleep:dismiss现在带 ~360ms 收回动画再淡出,机器忙时固定 sleep 会间歇性踩空 (「关闭空闲细缝后空闲再次完全隐藏」曾因此 4 连红)。 -
用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物(2026-10-01 实测,两次全是环境): ① 后端起没起 —— 客户端配置指向
127.0.0.1:4000,后端不在就进离线模式、数据全空, 观感就是"软件坏了";② 托盘单实例锁 —— 关窗是隐藏到托盘、进程还在,再双击 EXE 会被 单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。 完整性标签(§7 第 7 条)用icacls | findstr Mandatory一眼可辨,优先排除。 -
Web 端新手引导(
el-tour)在自动化里的判据有三条坑(2026-10-02 实测,verify:web「首次登录展示新手引导且可跳过」踩过): ① 外壳先出现、内容晚一拍 ——document.querySelector('.el-tour')先于步骤卡片渲染成立, 急着找.el-tour__closebtn会拿到 null;出现判据要等 × 按钮渲染出来(等 10s 不为过)。 ② 关闭后内容元素残留 —— 和 el-dialog 一样(本表第 22 条),点 × 生效后.el-tour仍在 DOM (父级.el-popper被隐藏,computed display 仍是 block);关闭判据是.el-tour__mask卸载, 不是.el-tour消失。跳过 / 走完都会写classhelper.onboarding(版本号),可顺带断言。 ③ 全高元素(侧边栏)作锚点必须显式placement: 'right'—— 默认 bottom 会把卡片推到 视口外(表现为"遮罩挖了洞、卡片不见了")。 另外ui-smoke管理员用例里的localStorage.clear()会把「已看过」标记一并清掉,管理员登录后 引导会再次弹出 —— 该用例里必须先跳过它再继续,别只处理首次登录那一次。 -
数据库管理模块(服务端) 见 §5 第 36 条;做"切库/备份/迁移"类改动前先读那一条, 那里写清了快照格式、外键陷阱、切换为什么必须走子进程、以及打包为何要带上 prisma CLI + tsc。
-
客户端设置页已拆成子路由(2026-10-02):
/settings/{general,appearance,island,reminder,account,about},/settings重定向到/settings/general;旧的单一views/SettingsView.vue已删除。 改设置相关冒烟用例时必须改到对应子路由(smoke.ts里location.hash = '#/settings'已全部改掉), 否则会停在通用页、找不到灵动岛滑块或「ClassIsland 联动」卡片而误报失败。 -
深色主题的实现边界:颜色一律走
--ch-*/--el-*变量(styles/index.css的:root是浅色值、html.dark是深色值),组件里不要写死颜色。已知的固定深色区域(灵动岛、 登录页、App 启动过渡页、全屏作业看板)刻意不随主题 —— 改它们会被当成回归。 主题状态在stores/ui.ts(持久化到主进程配置theme),主窗口backgroundColor也按主题设置 (深色下若仍用浅色底会"先白后黑"闪一下)。 -
数据库管理模块(
/api/database/*,仅 ADMIN)——改它之前必须知道的四件事: ① 一切都建立在lib/snapshot.ts的 JSON 快照上(备份 / 导入导出 / 跨库迁移共用一种格式, 14 张表按拓扑序导出、恢复时临时关外键检查、分批createMany)。恢复时必须先关外键检查: schema 里User.classId → Class与Class.teacherId → User互相引用,任何排序都会被一侧卡死。 ② 一键切换必须走子进程:运行中的服务进程缓存的是旧方言的 Prisma Client,改完 provider 后 只有新进程才能加载重新生成的客户端 —— 因此generate/db push/ 写数据分别用spawn(process.execPath, ...)拉起(数据写入走dist/tools/apply-snapshot.js)。 顺序不能换:备份 → 导出 → 测试连接 → 校验目标库为空 → 改 schema → generate → 编译 → db push → 写数据 → 改 .env;.env只在全部成功后才改写,中途失败回滚schema.prisma(现有库不受影响)。 ③prisma generate的产物是.ts(prisma-client生成器),所以:dev 由 tsx 直接跑 src; 打包形态必须再跑一次tsc -p tsconfig.generate.json把新客户端编进dist/generated, 生产包因此要随包带上prismaCLI、typescript与schema.prisma/prisma.config.ts(scripts/dist-server.mjs的buildRuntime已拷;安装包 ~34MB → ~71MB)。 ④ 主库只支持 SQLite / MySQL:Redis 等键值库 Prisma 不支持(无法建表/迁移), schema 层直接 422 拒绝;这是产品口径,别"顺手"加上。 e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空),不真跑切换(会改写 .env); 真实切换用子进程链路单独实测(快照 728 行逐表一致)。切换后必须重启服务端才生效。 -
客户端主窗口初始尺寸与 ClassIsland 对齐(1242×582)。这个数不是拍的:用
GetWindowRect+DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)量运行中的ClassIsland.Desktop主窗口(实测可视区 1242×582,屏幕工作区 1600×852、DPI 100%)。main/index.ts的createWindow按它设 width/height +center: true,minHeight必须 ≤ 582(否则窗口达不到目标高度,用户会看到"设置不了那个大小")。 冒烟断言:「初始窗口尺寸与 ClassIsland 对齐(1242x582)」(win.getSize()),改尺寸即红。 -
侧栏(分组折叠菜单)的三条硬要求,都是用户报过的问题固化的: ① 折叠态文字必须
display: none—— Element Plus 自己的隐藏方式是"把 span 的宽高压成 0 + overflow",外层只要有缩进/margin/transition 干扰,文字就会以"0 宽溢出"留在画面上 (用户截图里的半截字)。规则写在styles/index.css的.ch-nav .el-menu--collapse ... > span:not(.menu-icon-slot)。 ② 折叠态图标必须与顶部汉堡按钮同一竖列(冒烟断言中心差 ≤ 2px)—— 一个居中、一个偏左就会 看起来"没对齐"。汉堡在.brand里也是居中的,两边都在 64/2 = 32px。 ③ 菜单区必须min-height: 0+overflow-y: auto—— 否则菜单项多时会把底部 「第 N 周 / 最近同步」挤出窗口(窗口 582px 高、两组全展开时必然发生)。footer 是 aside 的 兄弟 flex 子项,菜单区滚动后它恒定可见(冒烟断言「侧栏底部可见」)。 另:分组默认只展开当前路由所在分组(ClassIsland 同款,也让小窗口侧栏不溢出);el-sub-menu__title的点击是 toggle,自动化里要先判"子项是否可见"再决定点不点标题 (盲点会把已展开的组收起来,后续点不到子项)。 -
深色主题:渲染进程里除固定深色区域外,颜色一律走
--ch-*变量。 §5 第 46 条讲了机制,这里补具体踩坑:课表页时间轴卡片曾写死rgba(255, 255, 255, 0.6), 深色主题下变成"浅灰卡 + 近白字"几乎不可读(用户截图)。自查命令:grep -rn "rgba(255, 255, 255\|rgba(0, 0, 0\|#fff" packages/desktop-client/src/renderer, 除明确固定深色的区域(全屏作业看板、登录页渐变、App 启动过渡页、灵动岛自身、 以及 accent 渐变底上的白字)外,都应该是变量。 -
跑
verify:desktop/verify:packaged若大面积报"教师登录失败",先核对本机 teacher1 的密码: 本机实测是123456,不是种子默认的teacher123—— 因为「修改密码」弹窗留空会落到DEFAULT_TEACHER_PASSWORD(=123456),某次人工/脚本操作就会把演示账号改成它。 处理方式是用环境变量传实际凭据(脚本原生支持,不要去改数据):set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop。
6. 代码风格
- TypeScript ESM + NodeNext:
package.json全是"type": "module",相对导入必须带.js后缀 (写./foo.js,即使源文件是foo.ts)。strict开启。 - Prettier(
.prettierrc.json):单引号、printWidth: 110、trailingComma: all、arrowParens: always、 行尾 LF、缩进 2 空格。 - ESLint(
eslint.config.mjs):no-explicit-any为 warn;未使用变量为 warn,可用_前缀豁免;consistent-type-imports建议用import type。.cjs(Electron 图标渲染、UI 冒烟)允许require。 - Vue:视图命名
XxxView.vue,组件多单词;路由入口用meta.roles控制(如['ADMIN']), 隐藏入口之外服务端也必须拦(前端隐藏 ≠ 权限)。 - 提交信息:Conventional Commits + 模块 scope,描述用中文,可多 scope,例如
fix(island): 胶囊"点不动"的根因——窗口必须始终可激活、feat(teachers,classes,schedules,web): 教师录入(仅管理员)。 - 改动交付形态 / 新增功能后,同步更新
README.md(它兼作产品说明书与验收对照表)。
7. 本机环境已知坑(踩过,别再踩)
-
系统代理会拦截
127.0.0.1的自测请求。Invoke-WebRequest http://127.0.0.1:4000/api/health会返回别人的 404(响应带Alt-Svc: h3=…、空 body), 看起来像后端路由没挂上。实际是走了代理。自测请加-NoProxy��ˆcurl.exe用--noproxy '*')。 项目内的 Node 脚本(verify:*)直连,不受影响。 同一个坑对 ClassIsland 插件同样成立:.NET 的HttpClient默认跟随系统代理, 教室机器上表现为"填好令牌后立刻 HTTP 404"(其实是代理的错误页)。插件里ClassHelperClient用LocalAwareProxy对本地/内网地址强制直连,verify:classisland-plugin有 4 项断言盯着这段逻辑,别删。 -
db:deploy只在packages/server包里,根package.json没有这个脚本。 根上跑pnpm db:deploy会报Command "db:deploy" not found(还会附带一句莫名其妙的文件名、目录名或卷标语法不正确。)。正确写法:pnpm --filter @classhelper/server db:deploy。 根上只有db:generate / db:migrate / db:seed / db:reset / db:studio。 -
pnpm db:migrate(=prisma migrate dev)在本机不可靠:首次报Schema engine error:(空消息), 数据库建好后再跑会长时间挂起(无输出,需手动结束进程)。日常用db:deploy; 要写种子直接pnpm db:seed,或node ./node_modules/tsx/dist/cli.mjs prisma/seed.ts(cwd 为packages/server)。 -
先
pnpm build:shared,否则后面全崩:packages/shared/dist不存在时,pnpm db:seed与任何 消费@classhelper/shared的进程都会ERR_MODULE_NOT_FOUND(报错指向packages/server/node_modules/@classhelper/shared/dist/index.js)。pnpm dev*/build/typecheck脚本已内联build:shared,单独跑db:seed时记得自己先构建。 -
Electron 二进制要手动装:
pnpm install实测不会下载它(allowBuilds里虽然写了electron: true, 但 postinstall 没跑;日志里只看到 esbuild / prisma)。缺失时pnpm dev:desktop/verify:desktop/verify:web/dist:win全部失败。装法(本机已装好):cd node_modules/.pnpm/electron@44.3.0/node_modules/electron ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ NODE_OPTIONS=--use-system-ca node install.js -
"AI 会话里启动 Electron GUI 会崩"是一个误诊 —— 真凶是下一条的 Low 完整性标签。 2026-09-30 实测:把 Electron 发行目录的标签从 Low 改成 Medium 后,同一个
electron.exe在 AI 会话里正常启动(窗口标题Electron、userData 目录正常创建),pnpm verify:packaged也能完整跑完并出结果(86/90、77/78)。 ⇒verify:desktop/verify:web/verify:packaged/dist:win在 AI 会话里都能跑; 一旦它们又"启动即崩",先按第 7 条查完整性标签,别急着甩锅给"会话环境"。 -
交付物被打了 Low 完整性标签 ⇒ 客户端装不上、便捷版双击没反应、应用 0x80000003 秒崩。 症状极易误判:
electron.exe --version都不打印就退出(0x80000003 STATUS_BREAKPOINT), 加--no-sandbox变成 0xC0000005,任何日志都写不出来(崩在日志初始化之前), 但ELECTRON_RUN_AS_NODE=1跑同一个 exe 完全正常、Edge 等系统 Chromium 也正常。 根因:本仓库目录树被宿主沙箱打上了Mandatory Label\Low Mandatory Level:(OI)(CI)(可继承), 于是仓库内构建出的一切都继承 Low 标签。后果是双重的:- Windows 会把带 Low 标签的 exe 降级到 Low 完整性运行:
setup.exe写不进%LOCALAPPDATA%(报"拒绝访问"→ 用户看到"装不上")、便捷版解不出%TEMP%(→ 双击没反应); - 即便装上了,落在 Low 标签目录里的应用文件也读不了 —— Chromium 的 GPU/渲染子进程跑在 AppContainer(更低完整性)里,按"不能向上读"直接秒崩。
判据与修法(都是实测过的):
# 判据:看交付物(以及 node_modules/electron/dist)有没有 Low 标签 icacls packages\desktop-client\release\win-unpacked\班级小助手.exe | findstr Mandatory # 修法:把交付物改成 Medium。dist-win.mjs 收尾会自动做,手工补救用: icacls packages\desktop-client\release /setintegritylevel Medium /T /C # 目录自身要还原成继承,否则下次构建时自带 Low 标签的 7za / makensis 写不进去 icacls packages\desktop-client\release /setintegritylevel "(OI)(CI)Low"连带的三条经验:归档内部的载荷标签无所谓(装到仓库外得到的是默认 Medium 文件), 所以修标签必须在打包完成之后做,打包前改 appOutDir 反而会让 7za 读不到而失败; 别把安装测试装进仓库树(
install-test之类),那里的文件又会继承 Low; 覆盖已存在的产物会被沙箱 ACL 拒绝(Can't open output file/拒绝访问),dist-win.mjs因此会先清掉上一轮的*.exe / *.7z / *.blockmap再打包。 - Windows 会把带 Low 标签的 exe 降级到 Low 完整性运行:
-
.NET 8 SDK 已装(8.0.425),
pnpm build:classisland-plugin/pnpm dist:classisland-plugin都能跑 (2026-09-30 实测:-t:Rebuild全量编译 8.8s,1 个警告 0 个错误;.cipx打包成功, 包内 DLL 的 sha256 与bin/Release产物一致)。脚本会自动把NUGET_PACKAGES / TEMP / TMP / DOTNET_CLI_HOME指到仓库内.cache/(dotnet默认往 C 盘写, C 盘紧张时会构建失败);手敲dotnet build时请自己带上这几个变量。两个容易自我欺骗的地方:
- 光看输出会误判"编译通过":源码没变时
dotnet build是空转(打印所有项目均是最新的,无法还原+已成功生成 / 0 警告 0 错误),CoreCompile根本没执行 —— 那行ClassHelper.ClassIslandPlugin -> …dll是拷贝回执,不是编译证据。 要证明"真的编译过",用node packages/classisland-plugin/scripts/build.mjs -t:Rebuild -v:n看有没有csc.dll调用,并核对 DLL 的 mtime 晚于所有.cs。 - 那 1 个警告是预期的,别去"修":
AVLN3001: XAML resource "…/BridgeSettingsPage.axaml" won't be reachable via runtime loader, as no public constructor was found—— 设置页只有BridgeSettingsPage(PluginSettings, BridgeService)这个 DI 构造函数、 没有公开无参构造,Avalonia 于是警告它无法走运行时加载器;本插件走的是编译期 XAML + ClassIsland 的 DI 实例化,加个无参构造反而会把 DI 弄坏。
- 光看输出会误判"编译通过":源码没变时
-
.cipx打包最后一步要 PowerShell:SDK 的CreateCipx目标默认调pwsh, 本机 PATH 里没有 pwsh 时会失败。构建脚本会探测并把结果透传给 MSBuild (-p:PowershellBinaryName=…),直接跑dotnet build -p:CreateCipx=true则要自己加。 -
仓库根
.gitignore的第 42 行是 GBK 编码(整文件 CRLF,只有那一行是 GBK 注释),apply_patch与 Prettier 都读不了它,按 UTF-8 读会看到\uFFFD。要补忽略规则只能用追加的方式 (printf 'xxx\n' >> .gitignore,原有字节一个都别动),别试图整文件重写;临时性的可写.git/info/exclude(本地生效、不入库)。 (packages/classisland-plugin/.gitignore是本仓库里唯一能正常编辑的 .gitignore。) -
releases/(复数)必须在 ESLint 忽略列表里:本地跑过pnpm dist:*之后, 这个目录里是构建后的压缩 JS,不忽略的话pnpm lint会报出几千条与源码无关的 error (eslint.config.mjs里已补**/releases/**,别再删掉;release-server/同理)。 它一度只在 ESLint 里被忽略、.gitignore里漏了(后者只有单数的release/和release-server/): 归集一次客户端产物就有 225MB 的 EXE 变成未跟踪文件,git add -A会直接把安装包提交入库。 2026-09-30 已在.gitignore末尾补上releases/。 -
ClassIsland 的数据根是
D:\Classisland\data(不是旧文档写的F:\data,F 盘已不存在):- 插件 DLL 覆盖到
D:\Classisland\data\Plugins\classhelper.classisland.bridge\(ClassIsland 运行时会锁住 DLL,必须先退出它再覆盖:Get-Process ClassIsland.Desktop | Stop-Process -Force); - 插件配置在
D:\Classisland\data\Config\Plugins\<id>\Settings.json,ClassIsland 运行中改会被内存里的 副本随时回写覆盖,要改请趁它退出时改;该文件里存着这台机器的服务器地址与设备令牌,别覆盖; - 改完 DLL 或配置都要重启 ClassIsland,否则跑的还是旧副本。
- 插件 DLL 覆盖到
-
读 ClassIsland 控制台日志要用 GBK:
.NET的控制台输出是**系统 ANSI(GBK)**编码, 用-Encoding UTF8读会变成乱码、中文关键字一条都搜不到(只能搜到 ASCII 的类名)。 正确姿势:[System.IO.File]::ReadAllLines($path, [System.Text.Encoding]::GetEncoding(936)); 日志文件被运行中的进程占用时先Copy-Item一份再读。 -
服务端改了代码要重启才生效(
tsx src/index.ts不热载,pnpm dev:server才有 watch)。 验证脚本报「某个断言突然不对」时,先确认后端跑的是不是最新代码 —— 这个坑踩过一次。 -
后端启动时才发现 Web 产物:
packages/web-admin/dist不存在就跳过静态托管,http://127.0.0.1:4000/只会返回服务首页(文案「Web 管理端未随本服务托管」)。要跑verify:web,先pnpm build再重启后端。 -
打包版服务端有生产自检:
NODE_ENV=production时拒绝dev.db("生产环境仍在使用 dev.db")、 拒绝短JWT_SECRET与示例密钥;想用演示数据跑打包版就把库复制成data/classhelper.db再启动。 -
makensis 建不了临时文件时,NSIS 那一步会失败(
dist:server与dist:win都会踩)。 两种根因、 同一个排查动作 —— 先把TEMP指到仓库内一个真实存在的 Windows 绝对路径再重试:-
pnpm dist:server报Internal compiler error #12345: error creating mmap the size of N:模板用了SetCompressor /SOLID lzma,makensis 会在%TEMP%所在的盘建一个上百 MB 的 mmap 临时文件,盘紧就失败 (免安装目录其实已生成好,只是最后一步打不出安装包)。加--reuse-deps还能跳过依赖安装。 -
pnpm dist:win报!tempfile: Unable to create temporary file!/Error in macro _Switch:不是空间不足, 而是 Git Bash 里TEMP=TMP=/tmp—— makensis 是原生程序,把/tmp当成「当前盘根下的 tmp」解析成D:\tmp,该目录不存在。/c有 79G、/d有 124G 也会照样失败(2026-09-30 实测)。win-unpacked与release/*.nsis.7z此时都已生成,只需重跑 electron-builder,不必重建前端:mkdir -p .cache/tmp TEMP='D:\classhelper\.cache\tmp' TMP='D:\classhelper\.cache\tmp' \ node packages/desktop-client/scripts/dist-win.mjs
-
-
依赖二进制下载需要
NODE_OPTIONS=--use-system-ca(本机 TLS 中间人证书),否则报unable to verify the first certificate。 -
Prisma 版本锁死 7.10.0:npm
latest已指向 8.x;生成器是prisma-client,输出到src/generated/prisma(gitignored),连接串在prisma.config.ts,并且必须走 driver adapter (@prisma/adapter-libsql+ 预编译的@libsql/win32-x64-msvc,避开 electron ABI 的原生编译坑)。 -
服务端安装包里的
.cmd提示是英文:cmd.exe 按 GBK 解析脚本,中文会乱码,属有意为之 (中文在安装向导与README.txt)。 -
pnpm format:check在 master 基线上本来就是失败的(含tsconfig.base.json与scripts/*), 不要拿它当门禁、也不要为了让它变绿去批量格式化。只检查你改过的文件:pnpm exec prettier --check <file>。 注意它会因为 CRLF 而"全面误报":core.autocrlf=true让 git 里存 LF、工作区签出成 CRLF, 而 Prettier 配的是endOfLine: lf,于是几乎每个文件都被标红。要判断是不是你的代码不合风格, 用sed 's/\r$//' <file> | pnpm exec prettier --check --stdin-filepath <file>。 -
写冒烟断言时别用"元素还在不在"判断 Element Plus 的弹框关没关。
el-dialog关闭后 DOM 会留在文档里(Element Plus 只把.el-overlay置成display: none),document.querySelector('.el-dialog')恒为真 ——verify:desktop/verify:packaged里 「设置页『修改密码』对话框取消后未关闭」就是这么误报出来的。正确判据:getComputedStyle(dialog.closest('.el-overlay')).display !== 'none'。 同理,断言岛体尺寸别写死"胶囊尺寸 + 留白":那一刻是胶囊还是卡片取决于前面用例留下的状态, 应改成与形态无关的不变量(如"窗口 = 卡片实测尺寸 + 2×阴影留白")。
已知脆弱用例(不是环境问题,别去"修环境")
| 用例 | 触发条件 |
|---|---|
verify:e2e「上课状态接口(at=周一 08:10)」 |
种子把「周一第 1 节」限制在第 1 周,且用例把 2026-09-07 当第 1 周。TERM_START_DATE 对齐到 2026-09-07 即全绿(本机 .env 已这样配;.env.example 仍是 2026-02-23,重置 .env 时记得改回来) |
verify:desktop「课表今天时间轴」 |
凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,已结束=false 判定失败 |
verify:web「课表科目为全校统一目录」 |
种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
| 岛交互 / 岛动画 / 岛截图留档这几个用例 | AI 会话里会间歇性失败:同一份产物连跑两次,失败项会在「点击展开 / 紧急展开动画 / 各状态截图留档」之间跳(2026-09-30 实测三轮结果各不相同)。根因是置顶透明小窗的 rAF 被限流、形变采样抓不到帧,属时序敏感;先原样重跑一次再动手改代码,别把偶发当成回归 |
8. 交付前检查
pnpm lint # ESLint(0 error 为底线)
pnpm typecheck # 全仓库类型检查
pnpm dev:server # 另开终端
pnpm verify:e2e # 后端端到端(2026-09-30 实测 173 项全过)
pnpm verify:classisland # ClassIsland 联动链路(实测 42 项)
pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 68 项)
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
pnpm build && pnpm verify:web # Web 端真实点击回归(同上)
条目数由脚本自行统计并打印(=== 结果:N/N 项通过 ===、ClassIsland 联动插件静态校验:N/N 项通过),
文档不要写死容易过期的总数。
改动到 ClassIsland 插件时,额外跑 pnpm dist:classisland-plugin 确认能打出 .cipx;
本机已装 ClassIsland 2.1.0.1(D:\Classisland)与 .NET 8 SDK(8.0.425),因此真机验证是可行的
(直接 pnpm build:classisland-plugin 编译,再按 §7 第 12 条部署):
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
Get-Process ClassIsland.Desktop | Stop-Process -Force
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看日志里依次出现:
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
交付时要说明"编译 / 打包 / 静态契约已过,运行期需在装了 ClassIsland 的机器上实测"。
改动涉及打包/交付形态时,额外跑 pnpm dist:all(或 dist:server + dist:win),
并用 pnpm verify:packaged --exe "<客户端路径>" 对打包后的副本复验 ——
开发产物通过不等于用户机器上的副本通过。
自测本地接口记得绕过代理(见 §7 第 1 条)。