- 灵动岛:移除 win.setBackgroundMaterial() 与 applyBackgroundMaterial/getBackgroundMaterial。它把系统材质刷在整个窗口矩形上(岛是"固定大包围盒窗口",卡片外还有留白),且明暗跟随应用的浅色主题 → 卡片周围出现一圈浅色面板;更糟的是调用过一次(含 none)后窗口就回不到逐像素透明,从 glass 切到 black/tinted 会永久留一圈纯白。glass 因此改为纯 CSS 半透明深色卡(WinIsland 拿不到 host backdrop 时的降级色 rgba(32,32,36,.804)) - 回归用例:新增「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」——整屏截图取样,并在岛底下垫一块已知颜色底板,让"环 = 底板色"可被精确断言(webContents.capturePage() 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因) - 边距:左右边距只在"左/右停靠"时生效(默认顶部居中时水平锚点就是屏幕中线),设置页把该滑块置灰并说明,避免被当成"调了不生效" - 课间:镜像回 ClassIsland 的课表改为每两节之间补一段课间、最后一节之后不补(放学);服务端 synthesizeTimeLayout 同口径,不再依赖导入的时间表里有没有课间点 - 文档:README / AGENTS.md / docs/winisland-design-tokens.md / 插件 README 同步 门禁:pnpm lint 0 error、pnpm typecheck 四包通过。未跑:verify:desktop(需先 build:desktop)、verify:e2e / verify:classisland(需后端启动)。
420 lines
36 KiB
Markdown
420 lines
36 KiB
Markdown
# AGENTS.md — 班级小助手(Class Helper)
|
||
|
||
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
|
||
|
||
---
|
||
|
||
## 1. 这是什么
|
||
|
||
班级信息管理系统,**pnpm monorepo**,一份代码产出三个交付物:
|
||
|
||
| 交付物 | 位置 | 形态 |
|
||
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
|
||
| 后端服务 | `packages/server` | Express + Prisma + Socket.IO,`/api` 前缀 |
|
||
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用) |
|
||
| 桌面客户端 | `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)
|
||
packages/shared/src/ types / constants / permissions / utils
|
||
packages/server/
|
||
prisma/schema.prisma 数据模型(sqlite;另有 schema.mysql.prisma)
|
||
prisma/migrations/ 迁移历史(已生成并应用)
|
||
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
|
||
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
|
||
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
|
||
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
|
||
src/config/env.ts zod 环境变量校验 + 生产自检
|
||
src/lib/ db / jwt / access(RBAC) / http / mappers / term / web-static
|
||
src/middleware/ auth / validate / error / security
|
||
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
|
||
src/modules/ 功能模块 + registry.ts(模块注册表)
|
||
scripts/verify-e2e.mjs 后端端到端验收(163 项)
|
||
packages/web-admin/src/ api / stores / router / layouts / views / styles / components
|
||
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
|
||
manifest.yml 插件清单:id / apiVersion / entranceAssembly / version
|
||
src/Plugin.cs 入口:读 Settings.json → 注册提醒提供方 / 设置页 / BridgeService
|
||
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(静态契约校验)
|
||
packages/desktop-client/
|
||
src/main/ 主进程:窗口 / 单实例 / IPC 配置 / 托盘 / 灵动岛 / smoke
|
||
src/preload/ contextBridge 白名单桥(**不暴露 ipcRenderer 本体**)
|
||
src/island/ 灵动岛独立透明置顶窗口的渲染进程
|
||
src/renderer/ 学生端渲染进程:api / stores / cache(IndexedDB) / views
|
||
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
|
||
scripts/ build-main / dev / smoke / dist-win
|
||
scripts/ use-database.mjs / generate-icons.mjs / dist-server.mjs / ui-smoke/
|
||
deploy/ Dockerfile / docker-compose.yml / nginx.conf / install-linux.sh
|
||
docs/ production.md(生产部署)/ mysql.md / winisland-design-tokens.md
|
||
README.md 完整产品与交付说明(改动交付形态时同步更新)
|
||
```
|
||
|
||
---
|
||
|
||
## 3. 本机环境(已配置好,可直接开工)
|
||
|
||
| 项 | 状态 |
|
||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| Node | v22.15.0(要求 ≥ 20.19,Prisma 7 硬性要求) |
|
||
| pnpm | 11.19.0(`packageManager` 锁定 11.8.0) |
|
||
| npm registry | `https://registry.npmmirror.com/`(全局 pnpm 配置;仓库内无 `.npmrc`) |
|
||
| 依赖 | `pnpm install` 已完成(707 包) |
|
||
| 后端 `.env` | 已由 `.env.example` 生成 `packages/server/.env`(**gitignored**),`JWT_SECRET` 为本机随机值 |
|
||
| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
|
||
| 数据库 | SQLite `packages/server/prisma/dev.db`,5 个迁移已应用、种子已写入 |
|
||
| Electron 二进制 | 已下载(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`) |
|
||
| .NET SDK | 8.0.303(构建 ClassIsland 插件用;插件与 pnpm workspace 无关,不参与 `pnpm install`) |
|
||
| ClassIsland 本体 | `F:\classisland\ClassIsland.Desktop.exe`(2.1.0.1,与本插件目标版本一致);**数据根目录是 `F:\data`**(配置 `F:\data\Config`、档案 `F:\data\Profiles`、日志 `F:\data\Logs`、插件 `F:\data\Plugins\<id>`),因此插件可以真跑起来验证 |
|
||
|
||
### 从零重建环境
|
||
|
||
```bash
|
||
pnpm install
|
||
|
||
# 后端环境变量(.env 不入库,必须自己生成)
|
||
cp packages/server/.env.example packages/server/.env # 然后至少改 JWT_SECRET
|
||
|
||
pnpm db:generate
|
||
pnpm db:deploy # 应用迁移(见 §7 第 6 条:不要用 db:migrate)
|
||
pnpm db:seed # 写演示数据
|
||
|
||
pnpm build:shared # 三端都依赖 shared 的 dist,先构建它
|
||
```
|
||
|
||
Electron 二进制**不会**随 `pnpm install` 自动下载(本机 postinstall 被跳过),需要手动补:
|
||
|
||
```powershell
|
||
$env:ELECTRON_MIRROR="https://cdn.npmmirror.com/binaries/electron/"
|
||
$env:ELECTRON_CACHE="F:\ClassHelper\.cache\electron"
|
||
$env:NODE_OPTIONS="--use-system-ca"
|
||
node install.js # 工作目录:node_modules/.pnpm/electron@44.3.0/node_modules/electron
|
||
```
|
||
|
||
### 演示账号(`pnpm db:seed` 产出)
|
||
|
||
| 角色 | 用户名 | 密码 |
|
||
| -------------- | ------------------------------- | ------------ |
|
||
| 管理员 | `admin` | `admin123` |
|
||
| 班主任 | `teacher1` / `teacher2` | `teacher123` |
|
||
| 学生个人号 | `student01` … `student15` | `student123` |
|
||
| 学生端班级账号 | 班级码 `G101` / `G102` / `G103` | `123456` |
|
||
|
||
---
|
||
|
||
## 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 |
|
||
| `pnpm db:generate` | 生成 Prisma Client |
|
||
| `pnpm db:deploy` | 应用已有迁移(**本机推荐**) |
|
||
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
|
||
| `pnpm db:studio` | Prisma Studio |
|
||
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider(配合 `docs/mysql.md`) |
|
||
| `pnpm verify:e2e` | 后端端到端验收 163 项(**需后端已启动**) |
|
||
| `pnpm verify:web` | Web 管理端真实点击回归 27 项(需后端已启动且 Web 产物已构建) |
|
||
| `pnpm verify:desktop` | 客户端冒烟 77+ 项(Electron,无人工点击) |
|
||
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟 |
|
||
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约,需后端已启动) |
|
||
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由) |
|
||
| `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/`)
|
||
|
||
打包产物统一归集到仓库根的 `releases/`(已被忽略,不入库):
|
||
|
||
```
|
||
releases/
|
||
client/ win-unpacked/ 免安装目录(verify:packaged 直接指它)
|
||
班级小助手-<版本>-x64-setup.exe 学生端 NSIS 安装包
|
||
班级小助手-<版本>-x64-portable.exe 学生端单文件版
|
||
server/ classhelper-server/ 免安装目录(内置 node.exe,双击 start.cmd 即用)
|
||
班级小助手服务端-<版本>-x64-setup.exe 服务端安装程序(含 Web 管理端)
|
||
classisland-plugin/
|
||
ClassHelper.ClassIslandPlugin.cipx ClassIsland 插件包(从文件安装)
|
||
ClassHelper.ClassIslandPlugin/ 免安装目录(直接丢进 ClassIsland 的 Plugins)
|
||
```
|
||
|
||
```bash
|
||
# 客户端:electron-builder 支持直接指定输出目录(dist-win.mjs 会把额外参数透传给它)
|
||
pnpm build:desktop
|
||
node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client
|
||
|
||
# 服务端:脚本输出路径写死在 release-server/,打完需要自己移进 releases/server/
|
||
pnpm dist:server # 首次会跑 46s 左右的 npm install;重试可加 --reuse-deps 跳过
|
||
|
||
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
|
||
pnpm dist:classisland-plugin
|
||
```
|
||
|
||
---
|
||
|
||
## 5. 架构硬约定(改代码前必须知道)
|
||
|
||
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
|
||
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。新增/下线功能**只改
|
||
`src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
|
||
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
|
||
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
|
||
3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用
|
||
`@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/ STUDENT / NONE`,
|
||
权限判 `canManageSchedule` / `canPublishContent` / `canManageRoster` 等,**不要在页面里另写一套规则**。
|
||
4. **学生端主体是「班级账号」。** 班级码 + 班级密码登录后,`classId` 即身份;该设备的「已读 / 完成」
|
||
代全班操作,个人学生登录已停用。API 里对此有专门分支,别按个人账号语义改。
|
||
5. **实时推送只走房间。** 服务端 `src/realtime/`(`socket.ts` 房间 `class:{classId}`、`user:{id}`;`bus.ts` 事件总线),
|
||
客户端 `renderer/stores/realtime.ts` 订阅。**新增实时事件要同时改三处**:服务端 bus/socket、Web 端订阅、
|
||
客户端订阅(灵动岛也依赖它)。
|
||
6. **共享契约只在 `packages/shared`。** 三端都 `import ... from '@classhelper/shared'`;它产出 `dist`,
|
||
改完要 `pnpm build:shared`(或跑着 `pnpm dev` 的 watch)。类型/常量**不要在两端各写一份**。
|
||
7. **数据模型跨库通用。** `schema.prisma` 只用 String / Int / Float / Boolean / DateTime,不用 Prisma enum、
|
||
不用 `@db.*`;角色与优先级存字符串并在 API 层用 zod 校验(这样 SQLite / MySQL 行为一致)。
|
||
改模型要同步 `schema.mysql.prisma` 和 `prisma/migrations/`。
|
||
8. **周次口径唯一。** 当前教学周 = `shared.resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)`,
|
||
`MAX_TERM_WEEK = 30`,班级还有自己的 `Class.termWeeks`(默认 20)。别在别处重新实现周次换算。
|
||
9. **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:拖拽滑块即时改变灵动岛外观」就是为它加的回归。
|
||
|
||
10. **客户端离线优先。** 渲染进程数据先落 IndexedDB(`renderer/cache/`),断网回退缓存并在顶部提示离线;
|
||
新增页面/数据源要按这个模式接。
|
||
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
|
||
(`--ch-radius-*` / `--ch-shadow-*`),组件里**不要写死数值**;Element Plus 通过覆盖它的 CSS 变量对齐。
|
||
手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card` 约定。
|
||
12. **ClassIsland 联动是独立鉴权通道,不要塞进 JWT。** 插件用设备令牌(`X-ClassIsland-Token`,库里只存 sha256)
|
||
调 `/api/integrations/classisland/*`;Web 端管理设备仍走 JWT + `requireRole`。新增插件接口时,
|
||
路由挂 `integrations.module.ts`,并在 `integrations.schemas.ts` 里写 zod —— 插件侧的 C# DTO
|
||
必须**逐字段对齐**(`pnpm verify:classisland-plugin` 会校验字段名与路由)。
|
||
13. **课表镜像契约必须带 `startTime`/`endTime`。** ClassIsland 的 `ClassPlan.Classes[i]` 强制与时间表里
|
||
**第 i 个 `TimeType == 0`(上课)的时间点**对齐:镜像下发少一个时间点,整份课表就会错位,
|
||
而且表现为"课表看着有内容、每节课都对不上",极难排查。
|
||
|
||
---
|
||
|
||
14. **所有发布入口共用同一份 ClassIsland 推送实现**(`integrations.service.ts` 的 `pushToClassIsland`):
|
||
通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时**必须**复用,不要再各写一套 ——
|
||
否则又会出现「某个页面发的通知不联动 ClassIsland」。
|
||
15. **提醒弹在哪个端由教室客户端选**,值存在 `Class.notificationChannel`(`both` / `client` / `classisland`):
|
||
客户端设置页写它(班级账号也有写权限,见 `PATCH /api/classes/:id/notification-channel`),
|
||
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
|
||
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
|
||
|
||
16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
|
||
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
|
||
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
|
||
17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课),
|
||
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
|
||
服务端 `synthesizeTimeLayout` 同口径。
|
||
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
|
||
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);`urgent` 同样立刻弹。
|
||
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
|
||
客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。
|
||
19. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期
|
||
(`YYYY-MM-DD`,不带时区),按天查看 / 日期高亮 / 客户端"今天"都看它。
|
||
UTC 切片(`toISOString().slice(0,10)`)在晚上的录入会算到第二天 —— 统一用 `@classhelper/shared` 的
|
||
`dayKeyLocal / isDayKey / shiftDayKey`。新增按天接口时同样用 `?date=YYYY-MM-DD` + zod 的 `isDayKey` 校验。
|
||
20. **教室机器能录入作业**:`POST /homeworks` 的路由**故意不加 `requireRole`**,权限在
|
||
`homeworks.service.createHomework` 里判(staff 或本班班级账号)。班级账号的 `sub` 是**班级 id**,
|
||
写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键。
|
||
21. **灵动岛不要用窗口背景材质(`win.setBackgroundMaterial()`)**。它看起来像 WinIsland 的
|
||
`DWMWA_USE_HOSTBACKDROPBRUSH`,其实是**整窗系统材质**:刷在整个窗口矩形上(岛是"固定大包围盒窗口",
|
||
卡片之外还有留白),且明暗跟随应用主题(本客户端是浅色 Fluent 主题 ⇒ 材质必然是浅色),
|
||
于是卡片周围会出现一圈浅色"白底面板"。更坑的是**调用过一次就再也回不去逐像素透明**
|
||
(含 `'none'`:Electron 会让窗口回落到默认不透明底色 `#FFF`),所以从 `glass` 切到
|
||
`black`/`tinted` 后会永久留一圈**纯白**。CSS `backdrop-filter` 也替代不了(透明窗口里采样不到桌面)。
|
||
`glass` 因此用 CSS 半透明深色卡(WinIsland 无 backdrop 时的降级色 `rgba(32,32,36,.804)`)。
|
||
回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」—— 它**整屏截图**取样
|
||
(`webContents.capturePage()` 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因),
|
||
并在岛底下垫一块已知颜色的底板窗口,好让"环 = 底板色"可以被精确断言。
|
||
|
||
## 6. 代码风格
|
||
|
||
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
||
(写 `./foo.js`,即使源文件是 `foo.ts`)。`strict` 开启。
|
||
- **Prettier**:单引号、`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. 本机环境已知坑(踩过,别再踩)
|
||
|
||
1. **系统代理会拦截 `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 项断言盯着这段逻辑,别删。
|
||
2. **`pnpm` 由 Electron 宿主承载**(`pnpm config list` 里 `userAgent` 是 `node/v24.19.0`,而 `node -v` 是 v22.15.0)。
|
||
个别情况下依赖的 `.bin` 不会进子进程 PATH,`pnpm run <script>` 报「不是内部或外部命令」。
|
||
规避:直接用 Node 调入口,例如 `node packages/server/node_modules/prisma/build/index.js generate`
|
||
(`prisma.config.ts` 里的 seed 命令已按这个思路写成 `node ./node_modules/tsx/dist/cli.mjs`)。
|
||
3. **启动 Electron 的脚本必须清掉 `ELECTRON_RUN_AS_NODE`**,且不要 `import 'electron'` 取可执行文件路径。
|
||
`scripts/lib/electron-env.mjs`、`scripts/lib/node-runtime.mjs` 已封装,新脚本请复用。
|
||
4. **Electron 二进制要手动装**(见 §3),否则 `pnpm dev:desktop` / `verify:desktop` / `dist:win` 全部失败。
|
||
5. **依赖二进制下载需要 `NODE_OPTIONS=--use-system-ca`**(本机 TLS 中间人证书),否则报
|
||
`unable to verify the first certificate`。
|
||
6. **`pnpm db:migrate`(= `prisma migrate dev`)在本机不可靠**:首次报 `Schema engine error:`(空消息),
|
||
数据库建好后再跑会**长时间挂起**(无输出,需手动结束进程)。
|
||
日常用 **`pnpm db:deploy`**;要写种子直接 `node ./node_modules/tsx/dist/cli.mjs prisma/seed.ts`(cwd 为 `packages/server`)。
|
||
7. **后端启动时才发现 Web 产物**:`packages/web-admin/dist` 不存在就跳过静态托管,`http://127.0.0.1:4000/`
|
||
只会返回服务首页(文案「Web 管理端未随本服务托管」)。要跑 `verify:web`,先 `pnpm build` 再重启后端。
|
||
8. **原生模块按 Electron ABI 构建**:本项目因此用 libSQL 适配器(`@libsql/win32-x64-msvc` 预编译),
|
||
不要为了图快换回 `better-sqlite3` 之类需要 node-gyp 的方案。
|
||
9. **Prisma 版本锁死 7.10.0**:npm `latest` 已指向 8.x-rc;生成器是 `prisma-client`,输出到
|
||
`src/generated/prisma`(gitignored),连接串在 `prisma.config.ts`,并且**必须走 driver adapter**。
|
||
10. **服务端安装包里的 `.cmd` 提示是英文**:cmd.exe 按 GBK 解析脚本,中文会乱码,属有意为之(中文在安装向导与 `README.txt`)。
|
||
11. **`pnpm dist:server` 生成安装程序时可能报 NSIS `Internal compiler error #12345: error creating mmap the size of N`。**
|
||
原因是模板用了 `SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件;
|
||
本机 `C:` 只剩约 100 MB 时会失败(免安装目录其实已经生成好了,只是最后一步打不出安装包)。
|
||
规避:把临时目录指到空闲的盘再重试,并加 `--reuse-deps` 跳过依赖安装:
|
||
`$env:TEMP='F:\ClassHelper\.cache\tmp'; $env:TMP=$env:TEMP; node scripts/dist-server.mjs --reuse-deps`。
|
||
12. **仓库根 `.gitignore` 是 GBK 编码**(含一段乱码注释),`apply_patch` 与 Prettier 都读不了它。
|
||
要补忽略规则请写 `.git/info/exclude`(本地生效、不入库),别试图整文件重写。
|
||
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore,用来忽略 bin/obj/cipx。)
|
||
13. **`dotnet` 会把 NuGet 缓存与临时文件写进 C 盘**(`%USERPROFILE%\.nuget`、`%TEMP%`),
|
||
C 盘紧张时直接构建失败。`pnpm build:classisland-plugin` 已把 `NUGET_PACKAGES/TEMP/TMP/DOTNET_CLI_HOME`
|
||
指到仓库内 `.cache/`;手敲 `dotnet build` 时请自己带上这几个变量。
|
||
14. **`.cipx` 打包最后一步要 PowerShell**:SDK 的 `CreateCipx` 目标默认调 `pwsh`,
|
||
本机 PATH 里没有 pwsh 时会失败。构建脚本会探测并把结果透传给 MSBuild
|
||
(`-p:PowershellBinaryName=…`),直接跑 `dotnet build -p:CreateCipx=true` 则要自己加。
|
||
15. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
|
||
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
|
||
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉)。
|
||
|
||
### 已知脆弱用例(不是环境问题,别去"修环境")
|
||
|
||
| 用例 | 触发条件 |
|
||
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `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`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
|
||
|
||
---
|
||
|
||
16. **改完插件必须更新 `F:\data\Plugins\classhelper.classisland.bridge\` 里的 DLL 并重启 ClassIsland**,
|
||
否则跑的还是旧副本(ClassIsland 运行时会锁住 DLL,必须先退出再覆盖)。
|
||
**别把该目录下的 `Settings.json` 覆盖掉** —— 那里面是这台机器的服务器地址与设备令牌。
|
||
17. **ClassIsland 的提醒链路有三个"上机才暴露"的硬约束**(都已在插件里处理,并写进 `verify:classisland-plugin` 的静态断言,别删):
|
||
- 提醒内容**必须**在 UI 线程构造 + 显示,否则 `LucideIconSource → AvaloniaObject` 构造时抛
|
||
`Call from invalid thread`,表现为"ClassHelper 客户端收到了、ClassIsland 毫无反应";
|
||
- 服务端"回执才算送达",因此插件必须对**播放中的提醒按 id 去重**,否则轮询间隔 < 提醒时长时会反复弹;
|
||
- `Subject.Fallback` 的名字就是 `???` 占位符,上报前要归一化成"没有科目"。
|
||
|
||
18. **读 ClassIsland 控制台日志要用 GBK**:`.NET` 的控制台输出是**系统 ANSI(GBK)**编码,
|
||
用 `-Encoding UTF8` 读会变成乱码、**中文关键字一条都搜不到**(只能搜到 ASCII 的类名)。
|
||
正确姿势:`[System.IO.File]::ReadAllLines(path, [System.Text.Encoding]::GetEncoding(936))`;
|
||
日志文件被运行中的进程占用时先 `Copy-Item` 一份再读。
|
||
19. **改插件设置要趁 ClassIsland 退出时改**:插件配置在 `F:\data\Config\Plugins\<id>\Settings.json`,
|
||
ClassIsland 运行时会在内存里持有并随时回写,运行中改文件会被覆盖。改完 DLL/设置都要重启 ClassIsland。
|
||
20. **打包版服务端有生产自检**:`NODE_ENV=production` 时拒绝 `dev.db`("生产环境仍在使用 dev.db"),
|
||
想用演示数据跑打包版就把库**复制**成 `data/classhelper.db` 再启动。
|
||
21. **服务端改了代码要重启才生效**(`tsx src/index.ts` 不热载,`pnpm dev:server` 才有 watch)。
|
||
验证脚本报「某个断言突然不对」时,先确认后端跑的是不是最新代码 —— 这个坑踩过一次。
|
||
|
||
22. **灵动岛的位置由「屏幕工作区 + 边距」算,改边距默认值会改外观**:`appearance.marginX/marginY` 默认
|
||
必须与老行为一致(8px)—— `verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标,
|
||
默认值一动它就红(我先把 marginX 设成 16 踩过一次)。新增边距类设置请只改"可调范围",别动默认值。
|
||
|
||
## 8. 交付前检查
|
||
|
||
```bash
|
||
pnpm lint # ESLint(0 error 为底线)
|
||
pnpm typecheck # 全仓库类型检查
|
||
|
||
pnpm dev:server # 另开终端
|
||
pnpm verify:e2e # 后端 163 项
|
||
pnpm verify:web # Web 29 项(其中 1 项是"已知脆弱用例",见上表)
|
||
pnpm verify:classisland # ClassIsland 联动链路 24 项
|
||
pnpm verify:classisland-plugin # 插件静态契约 54 项(不需要后端)
|
||
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟
|
||
```
|
||
|
||
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
|
||
并**在真机上过一遍**(本机就有 ClassIsland,见 §3):
|
||
|
||
```powershell
|
||
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
|
||
Get-Process ClassIsland.Desktop | Stop-Process -Force
|
||
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll F:\data\Plugins\classhelper.classisland.bridge\ -Force
|
||
Start-Process F:\classisland\ClassIsland.Desktop.exe -WorkingDirectory F:\classisland
|
||
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看启动时控制台/日志里依次出现:
|
||
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
|
||
```
|
||
|
||
交付时要说明"编译 / 打包 / 静态契约已过,运行期需在装了 ClassIsland 的机器上实测"。
|
||
|
||
`pnpm format:check` **在 master 基线上本来就是失败的**(175 个文件未过 Prettier,含 `tsconfig.base.json`
|
||
与 `scripts/*`),不要拿它当门禁、也不要为了让它变绿去批量格式化。只检查你改过的文件:
|
||
`pnpm exec prettier --check <file>`。
|
||
|
||
改动涉及打包/交付形态时,额外跑 `pnpm dist:all`(或 `dist:server` + `dist:win`),
|
||
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
|
||
开发产物通过不等于用户机器上的副本通过。
|
||
|
||
自测本地接口记得 `-NoProxy`(见 §7 第 1 条)。
|