LoveChengke 7b1ae29774 feat(desktop-client,build): 换用设计导出的新图标(客户端 / 服务端安装程序 / 托盘)
build/classhelper.{ico,png} 是新的图标来源(设计导出、手工放入),不再走 `pnpm icons` 那条
SVG 渲染流水线(那条只会重写 icon.*,两套并存容易搞混):

- packages/desktop-client/build/classhelper.{ico,png}:客户端安装包与应用图标;
- packages/desktop-client/public/tray.png:托盘图标(由 classhelper.png 覆盖);
- 服务端安装程序与客户端的"接线"分别在 scripts/dist-server.mjs 的 @ICON@ 与
  packages/desktop-client/electron-builder.yml 的 win.icon —— 这两个文件当前另有未提交改动
  (另一处工作在进行),那两行会随那批改动一起入库。

说明:Web 管理端(PWA)的 favicon / icon-192 / icon-512 未动,仍是原图标。
2026-10-05 21:13:52 +08:00

班级小助手(Class Helper)

班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:后端服务、Web 管理端(教师 / 管理员)、 桌面客户端(学生端 / 教室机器)与 ClassIsland 联动插件(装在教室的 ClassIsland 上)。当前版本 1.1.1。

核心链路:

教师在 Web 端发布内容
        │  REST API(JWT 鉴权)
        ▼
   后端服务(Express + Prisma)
        │  写库 + 按班级房间广播
        ▼
 Socket.IO ──►  class:{classId} 房间  ──►  学生桌面客户端实时更新 UI(含灵动岛)

ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
                                  ▲                            │
                                  └──── 老师下发的提醒 ◄────────┘

本文档是产品说明书 + 验收对照表:功能行为、接口契约与验收证据都在这里。 工程约定(改代码前必读)与本机踩坑在 AGENTS.md;生产部署在 docs/production.md;切换数据库在 docs/mysql.md。 改动交付形态 / 新增功能后请一并更新本文;文中计数均标注实测日期,条目数由脚本自行统计, 不要写死容易过期的总数。

目录

1. 组成部分

交付物 位置 形态 谁在用
后端服务 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 上(上报课表与上课状态、弹提醒) 教室机器(独立于 pnpm workspace)

设计取舍(与原始提示词的两处偏差,已与用户确认):

  1. 桌面客户端采用 Electron + Vue 3(而非 Avalonia/FluentAvalonia)——需求正文 90% 的内容 (Socket.IO Client、Pinia、IndexedDB 离线缓存、electron-builder)都基于 Electron;
  2. 数据库先用 SQLite(libSQL 内嵌)跑通,并支持切到 MySQL:docs/mysql.md 给出切换步骤, deploy/ 同时提供 MySQL 版与 SQLite 版 Compose。

2. 交付产物与部署形态

产物 产物文件位置(打包后) 用途
服务端 + Web 管理端安装程序 releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe 装到教师电脑 / 校服务器即完整系统,内置 Node 运行时,双击安装、开机自启、自动建库建号
服务端 Linux 安装包 releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz 给 deploy/install.sh 用(另见同名 .sha256);由 Actions 在 Linux 上构建后挂 Release
学生客户端安装程序 releases/client/<版本>/安装包/班级小助手-<版本>-x64-setup.exe 学生机安装(NSIS)
学生客户端单文件版 releases/client/<版本>/安装包/班级小助手-<版本>-x64-portable.exe 免安装直接运行(U 盘分发)
Web 管理端(PWA) 由服务端在 / 直接托管 浏览器打开即用,可在 Edge/Chrome 中「安装为应用」;已适配手机小屏
ClassIsland 联动插件 releases/classisland-plugin/<版本>/安装包/ClassHelper.ClassIslandPlugin.cipx 装在教室机器的 ClassIsland 上(也可把 ClassHelper.ClassIslandPlugin/ 目录丢进 Plugins)

这些产物都不入库(releases/ 已 gitignore + eslint ignore)。三个打包脚本直接输出到 releases/<组件>/<版本>/,每个版本目录下按 安装包/、免安装/、构建中间/ 分层, 历史版本原样保留 —— 详见 AGENTS.md §4。

四种部署形态按场景选(Linux 完整步骤见 docs/linux-deploy.md, 其余见 docs/production.md):

场景 形态 入口
学校机房 / 教师电脑(Windows 单机) Windows 服务端安装程序(内置 Node,双击即用) releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe
云服务器 / 多终端共享(推荐长期) Docker Compose + MySQL(另有 SQLite 单容器版) deploy/Dockerfile、deploy/docker-compose{,.sqlite}.yml
已有 Linux 服务器 一键安装器 + classhelper 运维命令(systemd / docker) sudo bash deploy/install.sh(先 --check 体检);运维见 classhelper help
自定义 / 已有 Node 环境 手动部署免安装目录 pnpm dist:server → releases/server/<版本>/免安装/

Linux 服务器最快路径是一行命令:脚本会自己从 GitHub Release 取最新版服务端包、取发布页上的 .sha256 校验,再装成 systemd 服务并带上 classhelper 运维命令。完整步骤与验收清单见 docs/linux-deploy.md。

curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && sudo bash /tmp/classhelper-install.sh

参数接在最后即可,例如先只体检(不需要 root、不改动系统):把末尾换成 sudo bash /tmp/classhelper-install.sh --check。 别写成 bash -c "$(curl …)" --check:bash -c '代码' 第一个参数 里那个参数会变成 $0 而不是 $1,脚本收不到(实测踩到:想体检却走进了交互式安装)。

3. 快速开始

# 1. 安装依赖(Node >= 20.19,pnpm >= 10)
pnpm install

# 2. 准备环境变量(后端)
cp packages/server/.env.example packages/server/.env
#    → 至少修改 JWT_SECRET;TERM_START_DATE 设为当学期第 1 教学周的周一
#      (它决定"现在是第几教学周",验证脚本的课表用例也依赖它)

# 3. 生成 Prisma Client、应用迁移、写入种子数据
pnpm db:generate
pnpm --filter @classhelper/server db:deploy   # 注意:根 scripts 里没有 db:deploy
pnpm build:shared                             # shared 是三端与 seed 的前置产物,必须先构建
pnpm db:seed

# 4. 启动(后端 4000 + Web 端 5173 一起启动)
pnpm dev

启动后访问:

服务 地址
Web 管理端(开发) http://127.0.0.1:5173
后端服务首页(浏览器可读,列出已挂载模块) http://127.0.0.1:4000
后端 API http://127.0.0.1:4000/api
健康检查(含版本号与已挂载模块) http://127.0.0.1:4000/api/health

后端只提供 REST API 与 WebSocket,没有业务网页界面。直接访问 http://127.0.0.1:4000/ 会返回服务首页 (带 Accept: application/json 时返回结构化信息);其它未匹配路径统一返回 { "success": false, "code": "NOT_FOUND" }。Web 管理端开发地址是 5173,构建后由后端在 4000 托管。 也可以分开启动:pnpm dev:server(仅后端)/ pnpm dev:web(仅 Web 端)。

启动客户端(学生端)

# 开发模式:自动构建主进程 + 启动 Vite 渲染进程(5174) + 打开 Electron 窗口(热更新)
pnpm dev:desktop

# 已构建产物的冒烟验证(不弹窗,跑完自动退出)
pnpm build:desktop
pnpm verify:desktop

Electron 二进制不会随 pnpm install 自动下载,缺失时先手动装一次(见 AGENTS.md §7 第 5 条)。

客户端首次启动会带上默认服务器地址 http://127.0.0.1:4000,输入班级码 + 班级密码 (种子数据:G101 / 123456)即可进入班级。想验证「断网可查看缓存」:登录并浏览若干页面后停掉后端 (Ctrl+C 结束 pnpm dev:server),客户端顶部会提示「当前处于离线状态」并继续显示本地缓存数据, 重启后端后自动同步。

端口

端口 用途
4000 后端 REST + Socket.IO +(生产 / 构建后)Web 管理端静态托管
5173 Web 管理端开发服务器(/api、/socket.io 代理到 4000)
5174 桌面客户端渲染进程开发服务器

默认账号与种子数据

pnpm db:seed 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印,2026-09-30 实测: 用户 18(1 管理员 + 2 教师 + 15 名学生名单)/ 班级 3 / 课程 54(每班 18 科)/ 课表 93 / 作业 15 / 通知 12 / 成绩 90):

角色 用户名 密码 说明
管理员 admin admin123 可访问全部班级
教师 teacher1 teacher123 张老师:高一(1)班、高二(3)班班主任
教师 teacher2 teacher123 李老师:高一(2)班班主任,高二(3)班协作教师
班级 班级码 G101 123456 高一(1)班(学生端唯一登录方式)
班级 班级码 G102 123456 高一(2)班
班级 班级码 G203 123456 高二(3)班(不是 G103)
  • 密码由种子脚本硬编码(admin123 / teacher123 / 班级 123456);.env 的 DEFAULT_CLASS_PASSWORD 只决定新建班级时的初始班级密码。
  • 学生没有个人账号(2026-10-01 起彻底清理):学生只是名单记录(成绩 / 未交名单 / 已读人数 / 叫人都按名单), 没有用户名密码、不能登录;学生端统一用班级码 + 班级密码。
  • 教学周由 TERM_START_DATE(第 1 教学周的周一)换算,当前周次用于课表默认视图。

常用命令

命令 说明
pnpm dev 并行启动 shared(tsc watch) + 后端 + Web 端
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)
pnpm dist:classisland-plugin 打包插件为 .cipx 并归集到 releases/classisland-plugin/<版本>/
pnpm icons 生成应用图标(Electron 渲染 SVG → PNG/ICO)
pnpm dist:server 打包服务端 + Web 管理端(免安装目录 + NSIS 安装程序,内置 Node)
pnpm dist:dir 打包客户端免安装目录 release/win-unpacked(最快)
pnpm dist:win 打包客户端 nsis 安装包 + portable 单文件 EXE
pnpm dist:all 服务端安装程序 + 客户端安装包 + 插件一起打
pnpm verify:e2e 后端端到端验收(需服务端已启动)
pnpm verify:web Web 管理端真实点击回归(Electron 驱动,需 Web 产物已构建)
pnpm verify:desktop 客户端冒烟(Electron,无人工点击)
pnpm verify:packaged 对打包后 / 已安装的客户端 EXE 跑同一套冒烟(--exe 指定路径)
pnpm verify:classisland ClassIsland 联动链路(需服务端已启动)
pnpm verify:classisland-plugin 插件静态契约校验(不需要服务端)
pnpm typecheck 全仓库类型检查(含 vue-tsc)
pnpm lint / pnpm lint:fix ESLint 检查 / 自动修复
pnpm format / pnpm format:check Prettier 格式化 / 检查(format:check 在 master 基线上本就失败,只检查改过的文件)
pnpm db:generate 生成 Prisma Client(输出到 packages/server/src/generated/prisma)
pnpm db:migrate 创建并应用迁移(prisma migrate dev;本机不可靠,用 db:deploy)
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(无第二份 schema)

4. 功能说明

4.1 Web 管理端

布局沿用 1Panel 风格:固定深色侧边栏(#1f2d3d + 蓝色圆角选中态)+ 白色顶栏(当前页标题 / 连接状态 / 用户菜单)+ 浅灰底 + 白色圆角卡片内容区。手机小屏下侧边栏收进抽屉,由顶栏汉堡按钮唤出。

页面(路由)共 12 个:登录、仪表盘、班级管理、学生管理、教师管理、课表管理、作业发布、通知发布、成绩录入、 ClassIsland 联动、数据库管理、页面不存在。其中班级管理 / 学生管理 / 教师管理 / 数据库管理为 meta.roles: ['ADMIN'](直接输网址会被挡回仪表盘,服务端同样拦),ClassIsland 联动为 ['ADMIN','TEACHER']。前端隐藏 ≠ 权限:所有写入都由服务层再断言一次。

新手引导(首次登录 + 随时重看):首次登录进入主布局后自动弹出聚焦式引导(Element Plus el-tour): 欢迎页 → 侧边菜单(小屏为汉堡按钮,锚点不存在时卡片自动改为屏幕居中)→ 实时通道状态 → 账号菜单 → 完成。 走完或跳过(右上角 × / Esc)即记为「已看过」(localStorage classhelper.onboarding,值为引导版本号, 内容改版后 +1 可让老用户再看一次),之后不再自动弹出;右上角头像菜单 →「使用引导」可随时重看。

通用设计令牌

全站圆角与柔和投影集中定义在 packages/web-admin/src/styles/index.css 的 :root 令牌里,组件层不写死数值; Element Plus 也通过对齐它的 --el-border-radius-* 一并改造:

令牌 用在哪
--ch-radius-xl / lg / md / sm 弹窗与抽屉 22px / 卡片 18px / 下拉与提示 14px / 控件 10px
--ch-radius-pill(999px) 标签、开关、进度条、圆形按钮
--ch-shadow-sm / --ch-shadow-md 卡片默认 / 悬停与弹窗

配套观感:卡片去掉 1px 灰边框(改用轻投影 + 悬停微浮起)、顶栏底部圆角、深色侧栏右侧圆角、 表头底色统一为 #f7f9fc、弹窗头尾留白加大。全部通过 CSS 变量与少量类选择器覆盖,未改组件源码。

手机小屏适配

断点 布局行为
> 992px 桌面 常驻 210px 深色侧边栏 + 顶栏显示连接状态与「已接收 N 条实时事件」
769–992px 平板 保留侧边栏,内容区内边距压缩(14px)
<= 768px 手机 侧边栏不渲染,顶栏汉堡按钮 → 240px 深色抽屉菜单;顶栏显示当前页标题;用户菜单收成头像图标

手机端细节(控件纵向铺满、表格卡片内滚动、弹窗自适应):抽屉点菜单项自动收起;页面工具栏选择器 / 搜索框 / 按钮 铺满整行;表格在卡片内横向滚动(overflow-x: auto)而不让页面横滚;弹窗宽度 calc(100vw - 24px); 详情抽屉宽 86vw 且 el-descriptions 由 2~3 列降为单列;统计卡片两列。 实现位置:src/composables/useResponsive.ts、src/layouts/AdminLayout.vue、src/styles/index.css。

实拍(pnpm verify:web 自动留档到 docs/screenshots/web-mobile/):

场景 截图
手机抽屉菜单 手机抽屉菜单
手机通知页(工具栏铺满 + 表格卡片内滚动) 手机通知页
手机弹窗自适应 手机弹窗

手机访问:与电脑同一局域网时用 http://<主机IP>:4000 打开管理端;支持「添加到主屏幕」(PWA)。

人员与班级(仅管理员)

能力 说明
教师管理(独立页面) 单个录入(姓名 / 用户名 / 初始密码 / 角色)+导入名单(xlsx/csv)+编辑(改名 / 改角色)+修改密码+删除
录入教师仅管理员 页面只对 ADMIN 显示,路由 meta.roles=['ADMIN'];服务端 /api/teachers 全量接口 requireRole('ADMIN')(教师调用 403)
删除教师有护栏 四种情况一律 409:还是班主任、名下有课程 / 班级分配、发布过作业或通知(后两项也是级联删除,删账号会连带删掉他在各班发过的内容)
创建班级仅管理员 POST /api/classes = requireRole('ADMIN'),教师(含班主任)403
设置 / 更改班主任 新建班级时可选班主任(默认自己);班级详情顶部「更换班主任」下拉随时改(PATCH /api/classes/:id/head-teacher,仅管理员),原班主任仍留在协作教师名单
学生录入 「学生管理」:单个新建(登录名自动生成)+导入名单;学生端主体是班级账号

学生名单接口(/api/students)与教师全量接口(/api/teachers)都是 requireRole('ADMIN'); 「班级 → 学生名单」(/api/classes/:id/students)读接口对班级可见,写接口为 ADMIN/TEACHER(服务层再按班级角色兜底)。

授课科目统一

全校固定科目目录定义在 @classhelper/shared 的 SUBJECT_CATALOG(共 18 科):语文 / 数学 / 英语 / 政治 / 历史 / 物理 / 化学 / 地理 / 生物 / 信息技术 / 体育 / 美术 / 音乐 / 班会 / 早读 / 晚读 / 通用技术 / 听力。

  • 课表「新增课表」与成绩「单条 / 批量录入」的科目下拉直接列出这个统一目录(该班还没有的科目显示在 「统一科目(选中后自动建课)」分组里);
  • 选中后由前端自动为该班 POST /api/courses 建同名课程(已存在则复用),再写入课表 / 成绩 —— 科任与班主任都不必先去「班级 → 课程」把科目建一遍;
  • ClassIsland 课程表导入本来就会按导入内容自动补建课程,两者口径一致。

登录页

管理端与客户端登录页都不含任何示例 / 演示内容(没有「演示账号点击填充」、没有示例班级码占位符、 不提示种子账号);verify:web 与 verify:desktop 各自断言登录页正文与占位符里不出现 演示 / 示例 / admin123 / teacher123 / G101 等字样,防止以后又被加回去。

4.2 桌面客户端

进程结构与安全基线

进程 文件 职责
主进程 src/main/* → dist/main/index.js 窗口 / 单实例锁、配置持久化(safeStorage 加密 token)、IPC、托盘、灵动岛、冒烟验证
预加载 src/preload/* → dist/preload/* 通过 contextBridge 暴露最小 API(配置读写 / 应用信息 / 灵动岛控制),不暴露 ipcRenderer 本体
渲染进程 src/renderer/** → dist/renderer/* Vue 3 界面、Axios 请求、Socket.IO 实时、IndexedDB 离线缓存
  • contextIsolation: true、nodeIntegration: false、sandbox: true、webSecurity: true; 外部链接一律交给系统浏览器,禁止应用内导航到非本地地址(按同源 / 同一文件精确比对,不是 startsWith)。
  • 渲染进程启用 CSP(index.html / island.html 的 meta http-equiv):渲染进程能通过桥接拿到明文登录令牌, CSP 是唯一能拦住「加载并执行远程脚本」的那一层;灵动岛窗口另有独立导航防线(拒绝一切导航与新开窗口)。
  • 主窗口初始尺寸与 ClassIsland 对齐(1242×582):数值是从运行中的 ClassIsland 主窗口实测来的,启动时居中显示; 窗口底色按主题设置,避免深色下「先白后黑」。
  • 产物里不得出现任何凭据:app.asar 可直接解包,因此冒烟 / 自检用的账号口令一律由 scripts/smoke.mjs / scripts/verify-packaged.mjs 通过环境变量(ELECTRON_SMOKE_USER / ELECTRON_SMOKE_PASSWORD / ..._CLASS_CODE / ..._CLASS_PASSWORD)注入;pnpm build:desktop 结尾会跑 check-bundle-secrets 门禁,产物里一旦出现种子口令即构建失败。

功能页面

页面 功能
登录 服务器地址 + 班级码 + 班级密码,附带「测试连接」;已登录但服务器不可达时可「离线进入」;下方有「查看使用引导」入口
课表 按周展示 7 列课表(周次下拉 + 单双周标签),另有「今天」时间轴视图(大时钟 + 当前状态卡 + 已结束 / 正在上 / 下一节)
作业 看板模式(按科目卡片、全屏放大、显示时间开关、看板字号)+列表模式、详情抽屉、附件链接(只接受 http(s):// 或站内相对路径);教室设备可录入作业与勾选未交名单
通知 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉
成绩 班级账号看到全班成绩总览:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新(图表为内置 HTML/SVG 实现,不引图表库)
设置 六个子页:通用、外观、灵动岛、提醒、账号、关于(见下)

设置改版:分组侧边栏 + 子页 + 深色模式 + 关于页

侧边栏(ClassIsland 同款 NavigationView):主侧边栏是四个一级项(课表 / 作业 / 通知 / 成绩) 加上一个可展开分组「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,每个子项是独立路由 /settings/*);顶部汉堡按钮把整栏收成 64px 图标导轨(折叠状态持久化到主进程配置,重启仍记忆)。 收放是连续的 CSS 过渡(栏宽 + 文字收放 + 图标位移,260ms),全程没有结构切换;导轨里文字淡出、 分组子项与一级项都以图标列出、图标与汉堡按钮同一竖列,整条导轨一眼看全、不出滚动条 (页脚在折叠态连高度一起收掉)。红点徽标仍挂在「通知」项图标右上方。

设置子页:原单页设置按卡片拆为 6 个子页 —— 通用(服务器连接 + 离线缓存 + 作业录入短语 +「使用引导」)、 外观、灵动岛、提醒、账号、关于;/settings 重定向到 /settings/general。

黑夜 / 白天模式:外观子页选「浅色 / 深色」,顶栏右上角有日月快捷切换按钮;切换立即生效 (html.dark + Element Plus dark css-vars + 品牌变量覆盖)并写入主进程配置(theme 字段)。 颜色一律走 --ch-* / --el-* 变量;灵动岛、登录页、全屏作业看板刻意固定深色,不随主题变化。

关于页:应用信息(版本 / Electron / Chromium / Node / 常用链接)、诊断信息(服务器与连接状态、 缓存条目、数据目录、配置文件路径)、鸣谢。

初次启动引导(六步向导,可重看)

客户端首次启动(配置里没有 onboardingDone)会在登录页上自动弹出六步向导: 欢迎 → 连接服务器 → 登录班级 → 登录之后(主界面与离线缓存)→ 灵动岛提醒 → 完成。 完成或跳过(右上角「跳过引导」/ Esc)都会把 onboardingDone: true 写入主进程配置,之后不再自动弹出; 登录页「查看使用引导」与「设置 → 通用 → 使用引导」可随时重看。引导是自绘遮罩 + v-if (renderer/components/OnboardingWelcome.vue),关闭即从 DOM 卸载(不用 el-dialog,避免「关闭后 DOM 残留」 的断言陷阱);显隐由 stores/onboarding.ts 承载,登录页 / 设置页 / App.vue 三处共用一个开关。

作业看板(学生端)

默认按科目分卡片(每张卡列该科作业,条目可点开详情),工具栏可切「看板 / 列表」、开关「显示时间」、 拖「字号」(11–28px 即时生效),点卡片或「全屏看板」进入全屏放大视图;这些偏好写入客户端配置 (homeworkBoard),重启后仍生效。

需求 实现
只看当天的作业 工具栏「只看今天」开关(默认开):按 Homework.assignDate(本地日期)过滤,昨天的作业不再混进看板
作业要完整显示 卡片里同时渲染标题 + 作业要求全文(white-space: pre-wrap,不截断),列表模式也有「作业要求」列
全部铺满屏幕、不用上下翻页 看板外层高度固定 + overflow: hidden,按「内容高度 / 可用高度」算缩放系数用 transform: scale() 整体缩到刚好放下(字号仍由滑块决定,工具栏显示当前缩放比例)
全屏看板要自适应 全屏弹窗打开后按「可用宽 × 可用高」枚举 1..N 列,取单卡面积最大且宽高比最接近 1.15 的方案注入 --board-cols(fitFullscreen() 轮询等待弹窗挂载后再算);列数变了再量一次缩放系数
当天时间显示在标题栏正中 全屏看板标题栏用 #header 插槽:标题左对齐、当天日期时间绝对居中显示(受「显示时间」开关控制,每分钟刷新)
未交名单 作业列表 / 详情里点「未交名单」→ 勾选没交作业的同学 → 保存后其余同学自动标记为已完成(PATCH /api/homeworks/:id/submissions);Web 管理端作业详情抽屉里同样可看可改

界面风格(ClassIsland 同款 Fluent)

  • 浅色 Mica 底(#f3f3f3)+ 半透明亚克力卡片(rgba(255,255,255,.7) + backdrop-filter: blur(20px))
  • 强调色 #0F6CBD(悬停 #115EA3);选中导航项 = 强调色淡底 + 左侧 3px 强调条
  • 圆角统一:卡片 8px / 控件 4px;描边用半透明黑(rgba(0,0,0,.06))代替灰线;阴影极轻
  • 字体 Segoe UI Variable / 微软雅黑 UI;表头无底色、行分隔为 1px 细线;滚动条改为细圆角
  • Element Plus 变量(--el-color-primary、--el-border-radius-base 等)整体对齐上述规范

课表「今天」时间轴(ClassIsland 标志性观感),今天 / 本周 双标签:

元素 说明
大时钟 时:分 大字 + 秒小字(每秒刷新)+ 日期 / 星期 / 第 N 教学周
当前状态卡 「正在上 XXX」+ 时段地点 + 距下课 X 分钟;无课时显示「今天的课都上完了 / 今天没有课程安排」
时间轴 左侧时间栏(开始 / 结束)+ 竖向轨道与节点 + 右侧课卡:已结束(置灰)、正在上课(强调色实底 + 进度条 + 距下课倒计时)、下一节(橙色标记 + 还有多久开始)
周视图 保留「节次 × 星期」表格(本周 标签)

实拍(docs/screenshots/client/):

页面 截图
课表 · 今天(时间轴) 客户端课表时间轴
作业 客户端作业
通知 客户端通知
成绩 客户端成绩
设置 客户端设置

个性化设置与系统托盘

设置项 区间 / 取值 说明
灵动岛高度 36–72px 胶囊高度(默认 44);展开卡按同差值联动
灵动岛宽度 220–420px 默认 268;展开卡按差值联动
圆角 8–32px 默认 20;胶囊取 min(圆角, h/2),展开卡取 min(48×圆角/20, w/2, h/2)
不透明度 40%–100% 窗口整体不透明度(所有显示路径都会应用,含「隐藏后再弹出胶囊」)
字号 11–20px(默认 13) 基础字号:岛内所有文本 = 基础字号 × 排版系数(标题 1.08 / 正文 0.95 / 次要 0.78 / 徽标与按钮 0.76 / 胶囊标题 0.92 / 胶囊副题 0.74)
动画速度 0.5×–2× 形变 / 淡入时长倍率
主题色 取色器(默认 Apple 系统蓝 #0a84ff) 用于高亮、按钮、进度条、主题色风格
视觉风格 纯黑(默认)/ 毛玻璃 / 主题色渐变 纯黑 = WinIsland default;毛玻璃 = CSS 半透明深色卡(rgba(32,32,36,.804))。不用 setBackgroundMaterial():它是整窗系统材质,会把卡片外那一圈垫成白底
显示位置 顶部 / 底部 × 左 / 中 / 右(6 个锚点) 默认「顶部居中」;按 workArea 计算,顶 / 底决定不动的是上边还是下边,左 / 中 / 右决定水平锚点
左右 / 上下边距 0–200px / 0–160px(默认都是 8px) 贴边距离。左右边距只在「左 / 右停靠」时生效:默认「顶部居中」的水平锚点就是屏幕中线,设置页会把该滑块置灰并说明
跟随鼠标屏幕 开 / 关 多显示器教室电脑:岛出现在鼠标所在那块屏
动画开关 开 / 关 关闭后展开 / 收起瞬时生效(不再逐帧插值)
始终置顶 开 / 关(默认开) 对应 setAlwaysOnTop
空闲细缝 开 / 关(默认关) 参考 WinIsland hidden_width:开启后没有消息时不再完全隐藏,而是留一条 6px 宽的竖条
  • 越界值在主进程统一夹紧(normalizeIslandAppearance),用户填坏配置也不会让布局错乱。
  • 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸 / 配色 / 动画;预览走 context.preview 专用通道 (示例岛直接展开、失焦不收起、30 秒后自行消失、点「知道了」不留残影)。
  • 落盘位置:客户端 config.json 的 island 字段(旧配置没有该字段时自动补默认值,向后兼容)。
  • 没有改密入口(按用户要求主动移除):班级密码由管理员在 Web 端「班级管理 → 修改班级账号」维护, 教师 / 管理员本人密码在 Web 管理端顶栏下拉修改 —— 防止教室机器上误改班级密码后其他机器被锁在门外。

系统托盘:关闭主窗口不退出程序,而是隐藏到托盘后台继续接收通知;托盘图标(左键单击 / 双击)恢复主窗口, 右键菜单提供「显示主窗口 / 隐藏到托盘 / 退出班级小助手」。退出时统一清理:销毁托盘 → 销毁灵动岛 (含帧循环与定时器)→ 销毁全部窗口,并用 app.exit(0) 兜底,任务管理器中不留残留进程。

离线缓存与自动同步

                     ┌────────────── 在线 ──────────────┐
请求 → fetchWithCache(store, key, loader, fallback)
                     └── 失败 → 读取 IndexedDB 缓存 → 返回旧数据(UI 标记"离线缓存")
实时推送(notification:new / grade:updated ...) → 更新内存状态 + 写回 IndexedDB
服务器不可达 → 可达(health 轮询 20s 或任意请求成功)→ 触发 onServerRecovered → 各页自动重新拉取
  • 缓存按业务域分为 6 个 object store:profile / classes / schedules / homeworks / notifications / grades。
  • 缓存写入前统一做 JSON 纯化(Vue 响应式对象是 Proxy,直接写入 IndexedDB 会抛 could not be cloned)。
  • 登录令牌由主进程用 Electron safeStorage(Windows 下为 DPAPI)加密后落盘,因此重启客户端可离线进入; 令牌缺失 / 失效时保持缓存资料可见,恢复网络后自动刷新。

4.3 灵动岛(桌面通知浮窗)

客户端有一个独立于主窗口的灵动岛:无边框、透明、置顶、不占任务栏的窗口,是「消息到达」的第一现场。 视觉与架构参考 WinIsland(Rust/Direct2D 的 Windows 动态岛): 连续圆角(超椭圆 squircle)而非 border-radius、纯黑 / 半透明毛玻璃 / 主题色三种底、Apple 系统色强调色、 白字 alpha 分级、所有文本 = 基础字号 × 排版系数、6 个停靠位、可选「空闲细缝」。 逐项 token 见 docs/winisland-design-tokens.md。

架构要点(照搬 WinIsland 的做法,而不是「仿个外观」):

  • 窗口一次创建成固定包围盒(最大形态 + 阴影留白),开合过程中一帧都不移动 / 缩放窗口; 形变全部发生在窗口内的渲染层,由 WinIsland 的弹簧驱动(spring.ts),并加零过冲钳制。
  • 鼠标命中照搬 set_cursor_hittest:窗口默认 setIgnoreMouseEvents(true, { forward: true }) 整块穿透, 渲染层做命中测试后只在指针进入岛体时打开命中 —— 大窗口不会吞掉桌面点击; 另有双保险:渲染进程上报岛体矩形,主进程每 120ms 用 screen.getCursorScreenPoint() 校正命中。
  • 触摸屏(希沃白板等)走「触摸模式」:上面两条链路都以「鼠标指针移动」为前提,而手指触摸既不产生 mousemove 也不移动系统光标 ⇒ 窗口会永远停在穿透态、手指点不开岛。修法是渲染进程按 navigator.maxTouchPoints > 0 上报触摸模式,主进程把窗口贴合岛体并始终接收输入 (Windows 上唯一可行的办法,Electron 没有 SetWindowRgn 那种窗口区域能力)。 三条护栏:变小要等岛真的收小、命中矩形按窗口实际尺寸算、失焦宽限期放宽到 1200ms。非触摸屏机器不走这条路。
  • 卡片投影(CSS filter)必须常驻,形态切换只改参数 —— 给元素增删 filter 会让 Chromium 重建渲染表面, 首帧可能被当成空内容画出去(表现为开合瞬间底座闪一下)。
场景 灵动岛行为
收到通知(非上课时段) 直接出现胶囊(无入场动画):新消息 · 共 N 条 + 发送人 + 点击查看
多条未处理消息 胶囊标题汇总类型 新消息:叫人/作业/通知(共 3 条),右侧附类型小标签
点击胶囊(只有一条) 展开为详情卡:标题、内容、时间、打开应用 / 标为已读 / 知道了
点击胶囊(多条) 展开为竖排列表:按重要程度排序(紧急 > 重要 > 普通 > 低,同级按时间新的在前),默认只显示前三条 + 展开更多(还有 N 条);列表只有一排按钮
点「展开更多」 把放得下的通知都铺出来;铺到工作区下沿(任务栏)仍放不下时改成 更多请前往应用内操作(卡片高度始终 ≤ 可用高度)
多条时的「知道了 / 标为已读」 都是整批操作:知道了 整批关闭(通知中心仍为未读)、标为已读 把这一批全部标为已读并关闭;两者都是状态同步清空 + 窗口淡出隐藏
上课时段的「普通叫人」 不自动展开(不打断课堂),但保留胶囊并允许学生主动点开;下课自动展开(isOpenable:紧急消息与任何叫人都可点开)
收起后胶囊常驻 自动 / 手动收起只把卡片回缩为胶囊,有未处理通知时胶囊不会消失;彻底消失只在「知道了 / 标为已读」、队列清空或进入上课时段
点击卡片空白处 / 屏幕任意位置 回缩为胶囊(不直接消失,仍可再次点开);展开时窗口临时可聚焦,点到别处即失焦收起
点「标为已读」 通知中心同步标记已读、未读红点立即减少(多条时是整批)
新作业发布 也上岛:胶囊显示「新作业」(青蓝描边 + 书本图标),展开可见作业要求;本机刚录入的那条不上岛(自己通知自己没意义)
紧急叫人(老师点名 · 紧急) 上课时段也立即展开:琥珀金卡片、叫人 徽标、「请 XXX 同学找 XXX 老师」、按钮为「收到」
普通叫人(默认级别) 课间先显示 叫人 胶囊、点击展开;上课时段只进队列(不打断课堂),下课后自动弹出详情
上课时间段收到普通通知 完全不显示(窗口直接隐藏)并进入待发队列;下课后自动弹出详情 → 收起为胶囊
上课时间段收到紧急通知 无论是否上课立刻展开(带内部红色呼吸光晕与「紧急」角标,无需点击),45 秒后收起
上课时间段内的任何点击 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上
退出 / 断开 主窗口退出时灵动岛一并关闭;上课状态来自 GET /api/schedules/current
  • 动画:展开 300ms、收回 220ms,统一 easeOutCubic 且单调不过冲(早期用过冲弹簧,观感是「开合时震一下」,已移除); 形变由窗口内弹簧逐帧逼近目标尺寸 / 圆角,锚点取整数且与窗口尺寸无关,卡片宽度取偶数 —— 冒烟逐帧采样窗口矩形与卡片 屏幕矩形,对「中心波动 = 0px、上边缘波动 = 0px、窗口全程冻结」做断言。
  • 光晕:紧急形态的红色呼吸光晕只用 inset 阴影画在卡片内部(窗口比卡片只大 2~4px,向外的 box-shadow 会被裁成硬边); 冒烟有像素断言守着(截图最外圈偏红像素必须为 0)。
  • 事件流:notification:new → renderer/stores/realtime.ts → pushNotificationToIsland() → 主进程 IslandController(决定隐藏 / 胶囊 / 详情)→ 灵动岛渲染进程 src/island/IslandApp.vue。
  • 多条通知的列表形态:卡片高度、能显示几行、要不要「展开更多」由 @classhelper/shared 的 islandListLayout() 统一算出 —— 主进程与渲染进程各算一次同一个数(主进程据此把窗口包围盒长高,渲染进程据此排版); 改 IslandApp.vue 里列表相关 CSS 的尺寸必须同步改那份度量。
  • 点「知道了 / 标为已读」后的收起:状态同步落地(调用方与冒烟立刻看到清空),窗口随即淡出隐藏; 没有跨进程的收回握手(早先自研的「缩回胶囊再淡出」快照动画因触摸屏上会露旧帧,已整体删除)。
  • 本机录入的作业不上岛:服务端是「先广播 homework:new、后回响应」的,因此 renderer/island/bridge.ts 在发请求之前就按内容登记指纹(班级 + 日期 + 标题 + 正文),回执拿到 id 后再补记一次,投递前用 id / 指纹双重判定。
  • 相关文件:src/main/island.ts(控制器 + IPC)、src/preload/island.ts、src/island/{IslandApp.vue,spring.ts,squircle.ts}、 src/renderer/island/bridge.ts。

各形态实拍(由 pnpm verify:desktop 自动截取并归档到 docs/screenshots/island/,并做像素级校验):

形态 截图 尺寸(逻辑 / 图片) 说明
下课后自动弹出详情 下课后自动弹出 424×230 / 848×460 上课期间暂存的通知,下课后自动弹出
上课期间紧急通知 紧急通知立即展开 440×246 / 880×492 卡片内部红色光晕 + 紧急角标,无需点击
非上课时段「新消息」胶囊 新消息胶囊 268×44 / 536×88 直接出现(无入场动画),点击后展开
点击胶囊后的详情 点击后展开 424×230 / 848×460 标题 / 内容 / 时间 + 打开应用 / 标为已读 / 知道了
新作业胶囊 / 叫人卡片 island-7-homework.png / island-8-call.png 424×230 / 456×262 作业与叫人各自的配色与徽标

截图路径可用 ISLAND_SHOTS_DIR 覆盖(默认 .cache/island-shots/)。

4.4 作业(按天查看 · 教室机器录入)

作业以所属日期(Homework.assignDate,本地日期 YYYY-MM-DD)归类,而不是录入时间: 晚上 8 点留的作业算今天(createdAt 是 UTC,直接切片会算成明天)。

能力 客户端(教室机器) Web 管理端
按天查看 日期选择器 + 前 / 后一天 + ?date= 深链 工具栏日期选择器(同一套接口)
有作业的日期高亮 ✅ 日期下带圆点(GET /api/homeworks/days) ✅ 同上
录入作业 ✅ 教室机器直接录(班级账号可 POST /api/homeworks,归属班主任) ✅ 发布时可选所属日期
未交名单 ✅(班级设备) ✅
  • 客户端作业模块不再显示「已完成 / 待完成」:完成状态是老师端与通知中心的概念,教室机器上只做「看作业 + 录作业」 (服务端的完成数据与统计接口保持不变,Web 端照旧可用)。
  • 可点快捷短语(默认 P / 大本 / 小本 / 卷子 / 背诵 / 默写 / 听写 / 预习 / 订正 / 读书, 定义在 @classhelper/shared 的 HOMEWORK_PHRASE_DEFAULTS),点一下追加到标题或内容; 短语在客户端「设置 → 通用」里增删,存在本机配置里。
  • 接口:GET /homeworks?date=YYYY-MM-DD(按天)、GET /homeworks/days?classId=&from=&to=(哪些天有作业)、 POST /homeworks(附 assignDate;班级账号也能调用,用于教室机器录入)。
  • 截止时间功能已下线(接口不再返回 dueAt)。

4.5 叫人(老师点名)

老师在 Web 管理端 → 学生管理 → 叫人(或 通知发布 → 叫人)选中学生,配合快捷短语或自定义消息发送。 学生管理页是管理员专属,而班主任与科任老师都需要叫人,因此教师的叫人入口在「通知发布 → 叫人」。

级别 落库级别 学生端行为
普通叫人 HIGH 课间:先显示「叫人」胶囊,点击展开;上课时段:只进队列、不打断课堂,下课后自动弹出详情(90 秒后收起)
紧急叫人 URGENT 无视上课时段立即展开(与紧急通知同待遇),上课时被收起后仍保留胶囊、可再次点开

两档的卡片都是「请 XXX 同学找 XXX 老师」+ 具体事由,徽标 叫人、按钮「收到」(复用通知已读机制)。

位置 行为
服务端 POST /api/calls(教师 / 管理员)→ urgent: true 落库 URGENT,否则 HIGH + 广播 notification:new(班级房间)与 call:new(定向到被叫学生 / 班级设备)
快捷短语 CALL_QUICK_PHRASES(共享常量,8 条):请到办公室 / 讲台 / 实验室 / 门卫处找我、请带上作业本 / 试卷找我、请到教室门口等我、请马上来一趟;点击选中,再点取消
自定义消息 输入框内容优先于快捷短语(最长 200 字),例如「带上昨天的数学作业到办公室」
客户端 call:new → 灵动岛 kind=call;是否立即展开只由 priority 决定,与普通通知共用同一条排队 / 隐藏 / 下课弹出路径
权限 学生调用被拒 403;教师只能叫自己班级的学生(跨班 / 不在本班返回 400 / 403)

4.6 上课时段策略

「上课时段」由课表实时判定:GET /api/schedules/current?classId= 返回 { inClass, current, next, week, serverTime }(at 参数可覆盖判定时刻,便于联调与测试)。

服务端强制拦截(不是只靠前端提醒):

POST /api/notifications  { classId, title, content, priority: "URGENT" }      → 409 URGENT_DURING_CLASS
POST /api/notifications  { …, priority: "URGENT", confirmDuringClass: true }  → 201
POST /api/notifications  { …, priority: "NORMAL" }(上课时段)                 → 201(不受限)

409 响应体携带 details: { classId, current, next, week, serverTime },前端据此渲染「正在上的课」。 这样即使有人绕过界面直接调接口,也一定会被拦下来。

Web 管理端:教师在通知发布弹窗选择「紧急」并提交时,先查询班级上课状态,命中则弹出全屏二次确认 (packages/web-admin/src/components/UrgentClassWarning.vue):全屏遮罩 + 强警示配色,文案「现在为上课时间段……」+ 正在上的课时段 + 待发布标题;确认按钮带 3 秒倒计时(圆环进度),倒计时结束前不可点击,防止误触; 取消则不发,确认后带 confirmDuringClass: true 重新提交;并发场景(提交瞬间刚好打铃)仍会收到 409,同样弹该警告。

客户端:上课时段非紧急通知自动隐藏、下课后自动弹出;紧急通知无论是否上课都立即展开(见 4.3)。 ClassIsland 侧同一套规则(见 4.8)。

4.7 表格与 ClassIsland 导入

四条导入链路都在服务端完成解析、校验与去重,前端只负责收集文件与展示结果,因此 Web 端、客户端与接口调用者 拿到的是同一套规则与同一份结果统计。

1)成绩表格导入(管理员 / 本班班主任)

界面:Web 管理端 →「成绩录入」→「导入表格」(components/TableImportDialog.vue)。

  1. 下载模板:GET /imports/template?kind=grades&format=xlsx|csv 模板列:学生用户名 / 学生姓名 / 考试名称 / 分数 / 总分 / 课程(CSV 带 BOM,Excel 直接双击不乱码)。
  2. 上传预览:POST /imports/table/preview → 列名、前 20 行、totalRows、建议字段映射与校验问题。
    • 支持 .xlsx / .xls / .csv / .tsv(SheetJS,自动识别分隔符),表格文件上限 8MB; 文件以 base64 放进 JSON,请求体上限设为 12MB 才能让 8MB 文件真正传得进来;超限返回 413 IMPORT_TOO_LARGE。
    • 列名按同义词自动映射(如「学号→学生用户名」「得分→分数」「满分→总分」),也可手动改; 缺必填列不会静默通过,直接给出「缺少必填列「…」」提示。
  3. 确认导入:POST /imports/table/commit,参数 mapping(规范字段 → 列名)+ mode:
    • upsert(默认)已存在则更新;append 已存在则跳过;
    • 成绩重复判定:同班级 + 同学生 + 同考试 + 同课程;学生按「用户名」优先、「姓名」兜底匹配;
    • 返回 { total, inserted, updated, skipped, failed, errors[], warnings[] }, errors[].row 是 Excel 视角的行号(含表头),便于老师改完再导。

2)学生名单导入(管理员)

界面:Web 管理端 →「学生管理」→「导入名单」(同一组件,kind=students)。模板列 用户名 / 姓名 (学生没有账号属性,没有密码列);用户名必须匹配 ^[A-Za-z0-9_.-]{3,32}$;重复按同一用户名 (append 跳过 / upsert 更新姓名与班级);用户名被教师 / 管理员占用时该行进错误行,不会覆盖他人账号。

3)教师名单导入(管理员)

界面:Web 管理端 →「教师管理」→「导入名单」(kind=teachers)。模板列 用户名 / 姓名 / 初始密码 / 角色; 角色取 ADMIN / TEACHER(留空按 TEACHER);与新建教师同一套校验,导入后可直接登录。

4)ClassIsland 时间配置导入(管理员 / 本班班主任)

界面:Web 管理端 →「课表管理」→「导入时间配置」(components/TimeLayoutImportDialog.vue)。 课表页入口只保留一个(「导入 ClassIsland 课程表」):同一份档案 JSON 里的 TimeLayouts 已把节次时间带进来; 「导入时间配置」按钮已下线,组件与接口仍保留给旧脚本调用。

  • 输入:粘贴 JSON 或选择 ClassIsland 导出的 .json 文件;
  • 真实档案形态(Profiles/<档案名>.json,ClassIsland 2.x,字段为 PascalCase):TimeLayouts 是 { "<guid>": { Name, Layouts: [ { StartTime: "08:00:00", EndTime, TimeType } ] } } 的字典, 解析器会把所有时间表的 Layouts 合并后导入;同时兼容顶层数组、{ TimeLayouts: [...] }、 { items | Items | Layouts | TimeLayout: [...] } 与单时间表对象;
  • 时间字段:StartTime/EndTime("08:00:00")、旧版 StartSecond/EndSecond(ISO 字符串或秒数)、 "8:0" / "08:00" 都能识别;
  • 时间点类型(TimeType):0=上课、1=课间、2=分割线、3=行动,与 ClassIsland 一致;
  • 校验:时间无法解析、结束不晚于开始、空配置都会给出逐条原因;时间重叠、分割线无时间等只作 warnings;
  • 写入模式:replace 整体替换该班同名配置;merge 以开始时间为槽位标识合并(同槽位被导入项覆盖, 其它旧节次全部保留,不静默丢数据);
  • 失败回滚:解析有 errors 时返回 400 IMPORT_INVALID / IMPORT_EMPTY,数据库完全不写入。

5)ClassIsland 课程表导入(支持单双周)

界面:Web 管理端 →「课表管理」→「导入 ClassIsland 课程表」(components/ClassPlanImportDialog.vue)。

  • 输入:同一份 ClassIsland 档案 JSON(含 TimeLayouts / ClassPlans / Subjects 三个 Guid 字典);
  • 单双周来源:ClassPlan.TimeRule.WeekCountDiv(0=每周、1=单周、2=双周)+ WeekCountDivTotal(默认 2), 与 ClassIsland 源码一致的判定语义;3 周及以上轮换无法用单双周表达,会给出 warnings 并按每周处理;
  • 节次对齐:ClassPlan.Classes[i] 对应 Layouts 里第 i 个 TimeType === 0 的点(课间 / 分割线不占位);
  • 科目映射:Classes[i].SubjectId → Subjects[<guid>].Name;班级里还没有的科目会自动补建课程 (任课老师取该班班主任),预览里会提前列出「待补建科目」;
  • 星期换算:ClassIsland WeekRule.WeekDay 是 0=周日 … 6=周六,导入时换算成 1=周一 … 7=周日;
  • 写入模式:replace(清空该班课表后写入,事务内完成)/ merge(按 星期 + 开始时间 + 单双周 去重);
  • 接口:POST /api/imports/class-plan/preview(只解析不写库)与 POST /api/imports/class-plan;
  • 单双周字段:Schedule.weekParity = ALL | ODD | EVEN,周视图 / 上课判定按「第 1 周 = 单周」过滤 (weekParityOf(week)),历史数据默认 ALL;
  • 课表来源字段:Schedule.source = manual | classisland。插件全量上报时,服务端只清理「source='classisland' 且本次未再上报」的行(老师在 ClassIsland 里删掉 / 挪动能真正同步下来),老师在小助手这边手排的课一律不删; source 只在 create 时确定,update 一律保持原值。

学期周数(管理员可调):Class.termWeeks(默认 20,上限 30)由管理员在「班级管理 → 编辑班级」设置, 上限与 MAX_TERM_WEEK、weekNumberSchema 三处一致;GET /api/dashboard/term?classId= 返回该班的 maxWeek。

手工覆盖 dist 部署(而非跑安装程序)时,已有数据库需要补列: node packages/server/scripts/apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js"; 安装程序覆盖升级会自动补迁移,仅空库首次启动会执行随包迁移 SQL。

4.8 ClassIsland 联动

课堂里真正「知道现在第几节、在上什么课」的是 ClassIsland。本项目通过一个 ClassIsland 插件把两边接起来(源码在 packages/classisland-plugin/),三个方向各解决一件事:

方向 做什么 老师在哪看
ClassIsland → 班级小助手 上报课表 + 节次时间 + 上课状态(现在上什么 / 下一节 / 本节起止时间),写入本班课表 Web 端「ClassIsland 联动」实时状态卡片
班级小助手 → ClassIsland 老师在 Web 端发一条提醒 → 落库 → 教室机器的插件取回 → ClassIsland 上全屏弹出(可语音朗读),播完回执、不会重复弹 Web 端「ClassIsland 联动 → 下发提醒」
(可选)班级 → ClassIsland 把班级小助手上排好的课表镜像回 ClassIsland:新建一份 班级小助手-<班级名> 档案课表,不动老师原有的课表 同上页面的「开镜像」开关

接入流程(三步)

  1. Web 端签发设备令牌:管理员 / 本班班主任在「ClassIsland 联动」页点「接入新设备」,选择班级并生成令牌 (形如 chci_xxxxxxxx…,明文只显示这一次,服务端只存 sha256);
  2. 教室机器填令牌:在 ClassIsland 的「设置 → 班级小助手联动」里填入后端地址 + 令牌,点「立即上报」;
  3. 验证:页面顶部出现设备卡片与「在线」标记,说明链路通了 —— 之后课表与上课状态自动同步, 老师发的提醒也会在这台机器上弹出来。

通知从哪些入口发都会联动

三个发布入口共用同一份推送实现(服务端 pushToClassIsland),因此不存在「只有某个页面才联动」:

入口 说明
「通知发布」页 与班级通知中心同一条记录,同时推给教室的 ClassIsland
「叫人」 老师点名让某位同学过去,教室大屏也会播报一条
「ClassIsland 联动」页的「下发提醒」 专门发给教室大屏的提醒(支持时长 / 语音 / 优先级)

上课时段策略:与客户端同一套规则

老师在上课时段发通知时,教室里的两个端都遵守同一条规则(否则学生在客户端看不到、却在 ClassIsland 上被打断):

提醒类型 上课时段 下课 / 放学
通知类(kind=notification:通知发布页 / 联动页下发) 暂存不弹(客户端与 ClassIsland 都先收着) 自动补弹
主动通知:紧急提醒、叫人(kind=call) 立刻弹(老师正在等学生) 立刻弹

ClassIsland 侧的暂存由插件实现:订阅课程服务,下课后把暂存的提醒补弹出来;未弹出前不回执,服务端会保留待提醒。

两个方向各需要哪些开关

要同步的内容 需要打开的开关
服务端 → ClassIsland(镜像课表) ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」
ClassIsland → 服务端(上报课表) 插件设置「上报课表到班级小助手」(关着时日志会明说「仅状态(开关已关闭)」);同步是双向对齐的:老师在 ClassIsland 里删掉 / 挪动的节次会一并清理,Web 端手排的课不会被删
提醒下发 ① 教室客户端「通知显示位置」不是「只在 ClassHelper」+ ② 插件「接收班级小助手提醒」

提醒弹在哪个端:由教室客户端自己选

教室机器的 ClassHelper 客户端「设置 → 提醒」里选(选择会同步到班级记录 Class.notificationChannel):

选择 ClassHelper 客户端 ClassIsland
ClassHelper 与 ClassIsland 都弹(默认) 弹窗 + 灵动岛 全屏提醒
只在 ClassHelper 客户端弹 弹窗 + 灵动岛 不推送
只在 ClassIsland 上弹 只进通知中心(不弹窗、不上岛) 全屏提醒

服务端据此决定要不要推 ClassIsland —— 规则只有一条,任何入口都遵守(包括联动页的「下发提醒」, 此时会回一句「该班教室已设置为只在 ClassHelper 客户端显示」)。教师 / 管理员也可以通过 PATCH /api/classes/:id/notification-channel 改,但默认由教室机器自己定。

插件为什么这样设计

  • 不用 IPC,而是走 HTTP 上报:ClassIsland 的跨进程 IPC 数据质量不足以还原课表,插件内部直接读 ILessonsService(课程状态)与 IProfileService(档案课表)最准;与后端之间用「上报即拉取」 (每次上报的返回值里顺带带一条待弹出的提醒),插件因此不需要 WebSocket 依赖,故障面更小 —— 上报间隔本身就是心跳。 提醒是轮询取回的(默认最多 10 秒延迟,设置页「提醒轮询间隔」可调到 5 秒)。
  • 单双周按 ClassIsland 的口径换算:WeekCountDiv/WeekCountDivTotal ⇄ ALL/ODD/EVEN 走 @classhelper/shared 的 weekParityFromDiv / weekDivFromParity(服务端与插件共用同一份语义)。
  • 镜像时重建时间点:ClassIsland 的 ClassPlan.Classes[i] 必须与时间表里第 i 个上课点对齐,因此镜像接口会带上 每条课目的 startTime/endTime;少一个时间点整份课表就会错位,这是插件契约里最容易踩的坑。 镜像进 ClassIsland 的课表还要带课间(每两节之间补一段,最后一节之后不补)。

4.9 数据库管理(仅管理员)

Web 管理端「数据库管理」页(/database,仅 ADMIN 可见,后端同样 requireRole('ADMIN'))把过去要手工完成的 数据库运维全部收进界面:

能力 说明
连接状态检测 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、14 张表行数、连接串(MySQL 密码脱敏)
连接测试 任意目标库的连通性试连(切换前的第一步)
一键切换 SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → prisma generate / db push 建表 → 子进程迁移全部数据 → 改写 .env」,异步任务带步骤进度,失败自动回滚
备份 / 恢复 gzip JSON 快照存 <数据目录>/backups/;手动备份 / 从备份恢复(整库覆盖)/ 删除
定时备份 enabled + 间隔小时数 + 保留份数,存 data/database-settings.json;服务端每 10 分钟检查一次到点任务(进程不运行不补跑)
快捷导入导出 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传)

关键设计:备份、导入导出、跨库迁移共用同一种「JSON 快照」(lib/snapshot.ts,14 张表按拓扑序导出, 恢复时临时关外键检查 —— User.classId 与 Class.teacherId 互相引用),因此任意备份都能恢复回任意一种受支持的数据库。 MySQL 的连接测试 / 建表 / 写入依赖随包内置的 @prisma/adapter-mariadb(已在依赖里,不再是「切换时手动安装」)。

边界与须知:

  • 主库只支持 SQLite 与 MySQL。Redis 是内存键值库,Prisma 不支持它作为主库(无法建表 / 迁移),接口层直接拒绝 (422,e2e 有断言);PostgreSQL 暂未纳入。
  • 切换完成后必须重启服务端(安装版运行 restart.cmd,开发模式重跑 pnpm dev:server)才会连接新库; 切换失败会自动回滚 schema.prisma,.env 只在数据全部迁移成功后才改写;目标库必须为空(有表即拒绝)。
  • 安装包体积因此增大(约 34MB → 62MB):切换需要随包内置 prisma CLI 与 TypeScript 编译器 (Prisma 7 的生成器产出 .ts 源码,切换后要在目标机编译进 dist)。 打包末尾会做一次运行时裁剪(删掉 Prisma 为其它数据库方言准备的查询编译器副本等死载荷, 见 scripts/dist-server.mjs 的 pruneRuntime()),免安装目录 469MB → 335MB。
  • 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致);e2e 只覆盖校验分支 (同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 .env。

4.10 更新检查

交付物是通过 GitHub Releases 分发的(服务端 / 客户端安装包、便携版、插件 .cipx), 因此"有没有新版本"就是问那条 Release:拉 releases/latest(不含草稿与预发布)、 把 tag_name 与本机版本比一下,比本机新就提示并给一个「前往下载」。

端 入口 检查路径
服务端 / Web 管理端 顶栏用户菜单 →「检查更新」 浏览器 → 服务端 GET /api/update/check → GitHub
桌面客户端 启动后自动一次(延迟 5 秒)+「设置 → 关于」里的「检查更新」按钮 主进程 → GitHub(不经后端)

两端的检查路径不同是被约束逼出来的,不是随手选的:

  • Web 端不能直连 GitHub。服务端下发的 CSP(middleware/security.ts)里 connect-src 只放行 'self' 与 WebSocket,浏览器发往 api.github.com 的请求会被直接拦掉。 走服务端转发还有个好处:所有管理员共享同一份缓存(GitHub 匿名限流是 60 次/小时/IP)。
  • 客户端在主进程做。渲染进程的 CSP 与导航防线是安全模型里最要紧的一层, 不让它去连外部域名、解析外部 JSON;主进程本来就在做网络与文件。

"查不到"是设计内的结果,不是错误:机房服务器与教室机器常常只有内网,请求会超时/失败。 此时接口返回 ok: false + 一句人话(「无法连接 GitHub,本机可能没有外网」), 界面按"暂时查不到更新"呈现并给重试入口,而不是弹红字报错。失败结果同样进缓存, 否则断网时每点一次按钮都要白等一个超时。

细节 说明
版本比较 @classhelper/shared 的 compareVersions / isNewerVersion,三端共用一套口径(避免"客户端说有新版、服务端说没有")。数字相同、只有后缀不同时带后缀的算更新 —— 对应 v0.1.0-fix 这种"同版本号的修复发布"
缓存 两端各缓存 30 分钟;?force=1 绕过服务端缓存,但仅管理员生效(否则任意登录用户都能刷爆匿名限流)
权限 任何已登录用户都能查(信息来自公开仓库);force 除外
提示一次就够 客户端启动检查发现新版才推提示;点「忽略此版本」记进 config.json,同一版本不再提示(「关于」页仍可手动查)
版本号单一来源 服务端版本由 lib/version.ts 读 package.json 得到(原先 app.ts 里硬编码了三处 '1.0.0',与 5 份 package.json 一定会漂移)
自动化里不查外网 冒烟模式跳过启动自动检查;verify:e2e / verify:desktop / verify:web 的断言都只验"结构完整 + 能降级",不断言"一定有新版本"(那会把用例绑死在外网可达上)

只提示、不自动下载、不自动安装 —— 安装包仍由人从 Release 页取(SHA256SUMS-<版本>.txt 可核对)。

5. 技术参考

5.1 目录结构

class-helper/
├── 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)
├── .prettierrc.json             # 单引号 / 110 列 / LF / 尾逗号
├── .dockerignore                # 容器构建上下文排除项
├── build/icon.{ico,png}         # 应用图标(由 pnpm icons 生成)
├── scripts/
│   ├── use-database.mjs         # SQLite ⇄ MySQL provider 切换助手(改写 schema.prisma 的 provider)
│   ├── generate-icons.mjs       # 用 Electron 渲染 SVG 生成 PNG/ICO 图标
│   ├── icons/render.cjs         # 图标渲染脚本(CommonJS)
│   ├── dist-server.mjs          # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序)
│   ├── verify-packaged.mjs      # 对打包后 / 已安装的客户端 EXE 复跑冒烟
│   ├── lib/electron-env.mjs     # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件)
│   ├── lib/node-runtime.mjs     # 定位真正的 node.exe(打包与 electron-builder 用)
│   ├── nsis/server-installer.nsi# 安装程序脚本模板(版本号由 dist-server 注入)
│   └── ui-smoke/                # Web 管理端 UI 真实点击回归(Electron 驱动 + 上课时段探针)
├── deploy/                      # 生产部署:Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf
│   ├── install.sh               # Linux 一键安装器(TUI;native/docker 双形态;自带 --check 体检)
│   ├── install-linux.sh         # 兼容壳:转发到 install.sh(旧命令仍可用)
│   ├── classhelper              # 运维命令本体(装到 <安装目录>/bin,软链 /usr/local/bin/classhelper)
│   ├── verify-linux.sh          # Linux 安装/改密/备份/升级/回滚验收(自统计 N/N 项)
│   ├── tools/admin-cli.mjs      # 离线账号工具(改密、列账号、SQLite 一致性快照)
│   ├── profile.d/ logrotate.d/ systemd/   # /etc 下的环境变量、日志轮转、定时备份模板
│   ├── Dockerfile.linux-package # 安装包内那份 Dockerfile(--mode docker 用它构建镜像)
│   ├── classhelper.service      # systemd 单元模板(替换 __NODE__/__DIR__/__USER__/__CONFIG__)
│   ├── package.runtime.json     # 运行时依赖清单(安装包 / 容器 / Linux 部署共用)
│   └── .env.example / .env.sqlite.example
├── docs/
│   ├── linux-deploy.md          # Linux 部署与运维指南(安装/命令/升级回滚/备份/验收/安全清单)
│   ├── production.md            # 生产部署指南(四种形态 + Linux systemd + 运维 + 安全清单)
│   ├── mysql.md                 # MySQL 切换指南
│   ├── winisland-design-tokens.md  # 灵动岛照 WinIsland 提取的设计 token
│   └── screenshots/             # 回归测试自动留档的截图(island / client / web-mobile / classisland)
└── packages/
    ├── shared/                  # 三端共享:类型契约、常量、权限矩阵、工具函数
    │   └── src/{types,constants,permissions,utils,index}.ts
    ├── server/                  # 后端服务
    │   ├── prisma/schema.prisma         # 数据模型(14 个 model)
    │   ├── prisma/migrations/           # 迁移历史(10 个)
    │   ├── prisma/seed.ts               # 种子数据(会清空业务表后重建演示数据)
    │   ├── prisma.config.ts             # Prisma 7 配置(schema / 迁移 / 种子 / 连接串)
    │   ├── prisma/backups/              # 备份快照与定时配置(运行期生成,已 gitignore)
    │   ├── scripts/verify-e2e.mjs       # 后端端到端验收脚本
    │   ├── scripts/verify-classisland.mjs  # 联动链路验收脚本
    │   ├── scripts/apply-column-migrations.cjs  # 旧库补列(手工覆盖 dist 部署时用)
    │   ├── tsconfig.generate.json       # 打包形态编译 Prisma 生成物用
    │   └── src/
    │       ├── app.ts                   # Express 装配(探针 + 限流 + 模块挂载 + 静态托管 + 兜底)
    │       ├── index.ts                 # 启动入口(自检 / 初始化 + HTTP + Socket.IO + 定时备份 + 优雅退出)
    │       ├── config/env.ts            # 环境变量校验(zod)+ 生产配置自检
    │       ├── lib/                     # access(RBAC) / class-account / session / db / db-bootstrap /
    │       │                            #   snapshot(全库 JSON 快照) / web-static / http / jwt / logger /
    │       │                            #   mappers / password / schemas / term / version(读 package.json)
    │       ├── tools/apply-snapshot.ts  # 跨库迁移的子进程入口(编译到 dist/tools/)
    │       ├── middleware/              # auth / error / validate / security(helmet+限流+耗时日志)
    │       ├── realtime/                # socket.ts + bus.ts(事件总线)
    │       └── modules/                 # 15 个功能模块 + registry.ts(模块注册表)
    │           ├── database/            # 数据库管理(状态/备份/导入导出/定时/一键切换,仅管理员)
    │           └── update/              # 更新检查(查 GitHub 最新 Release,服务端唯一出网请求)
    ├── classisland-plugin/      # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace)
    │   ├── manifest.yml                 # 清单(id / version / apiVersion / entranceAssembly)
    │   ├── src/Plugin.cs                # 入口(读配置 → 注册提醒提供方 / 设置页 / 联动服务)
    │   ├── src/Models/PluginSettings.cs
    │   ├── src/Services/BridgeService.cs                    # 上报 / 接收 / 镜像主循环
    │   ├── src/Services/ScheduleMapper.cs                   # 课表与单双周映射
    │   ├── src/Services/ClassPlanWriter.cs                  # 镜像回 ClassIsland(含课间时间点)
    │   ├── src/Services/ClassHelperNotificationProvider.cs  # 把老师发的提醒显示到 ClassIsland 上
    │   ├── src/Views/BridgeSettingsPage.axaml(.cs)          # 「班级小助手联动」设置页(Avalonia)
    │   ├── src/Interop/ClassHelperClient.cs                 # 后端 HTTP 客户端 + 对齐服务端 zod 的 DTO
    │   └── scripts/{build,verify}.mjs   # 构建打包(.cipx)与静态契约校验
    ├── web-admin/               # Web 管理端(Vue 3 + Vite + Element Plus + PWA)
    │   ├── public/                      # manifest.webmanifest / sw.js / 图标(由 pnpm icons 生成)
    │   └── src/{api,stores,router,layouts,views,components,composables,styles}
    └── desktop-client/          # 桌面客户端(Electron + Vue 3)
        ├── electron-builder.yml         # 打包配置(nsis 安装包 + portable 单文件)
        ├── build/icon.ico               # 应用图标(electron-builder 自动使用)
        ├── vite.config.mts              # 渲染进程构建(base: './',hash 路由)
        ├── scripts/{build-main,dev,smoke,dist-win,check-bundle-secrets}.mjs
        └── src/
            ├── main/                    # 主进程:窗口 / 单实例 / 配置持久化(IPC) / 托盘 / 灵动岛 / 冒烟
            ├── preload/                 # contextBridge 安全桥(index.ts + island.ts)
            ├── island/                  # 灵动岛渲染进程(独立透明置顶窗口)
            ├── types/desktop.d.ts       # 主进程 <-> 渲染进程契约
            └── renderer/                # 渲染进程:api / stores / cache / router / layouts / views /
                                         #   components(OnboardingWelcome) / island(桥接) / settings 子页

5.2 技术栈

层 选型 版本(package.json 实际声明)
包管理 pnpm workspace 11.8.0
语言 TypeScript 5.9.3(typescript-eslint 要求 < 6.1,因此未用 7.x)
后端 Node.js + Express ≥ 20.19(本机 24.19) / 5.2
ORM Prisma + driver adapter 7.10(+ @prisma/adapter-libsql,切 MySQL 用 -mariadb)
实时 Socket.IO 4.8
认证 jsonwebtoken + bcryptjs 9.0 / 3.0
校验 zod 4.6
Web 端 Vue + Vite + Element Plus + Pinia + Vue Router + Axios 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20
客户端 Electron + Vue + Element Plus + Pinia + Socket.IO Client + IndexedDB 44.3 / 3.5 / 2.14 / 4.0 / 4.8
插件 .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) net8.0(本机 SDK 8.0.425)
打包 electron-builder(nsis / portable)+ NSIS 26.15
Lint/格式 ESLint 10 + typescript-eslint 8 + eslint-plugin-vue 10;Prettier 3 —

5.3 REST API 一览

统一响应体:{ "success": true, "data": {}, "message": "" }(错误为 success:false + code)。 所有接口前缀 /api,除登录 / 健康检查外均需 Authorization: Bearer <token> (ClassIsland 设备接口用 X-ClassIsland-Token)。

方法 路径 权限 说明
POST /auth/login 公开 登录(教师 / 管理员);role=STUDENT 返回 403(学生只有名单,改用班级登录)
POST /auth/class-login 公开 班级账号登录:班级码 + 班级密码 → classSession 会话
GET /auth/me 登录 当前用户(学生附带班级 / 年级)
PATCH /auth/password 登录 修改自己的密码(班级会话 → 班级密码)
POST /auth/logout 登录 退出(无状态,客户端丢弃 token)
GET /classes 登录 班级列表(按权限收敛)
GET /classes/:id 班级可见 班级详情(学生 / 课程 / 协作教师)
POST /classes 管理员 创建班级(自动生成班级码 = 班级账号,可指定班主任)
PATCH / DELETE /classes/:id 管理员 / 教师 编辑 / 删除班级
PATCH /classes/:id/class-account 管理员 设置 / 重置班级账号(班级码 + 班级密码)
PATCH /classes/:id/head-teacher 管理员 设置 / 更换班主任
GET /classes/:id/classisland-status 班级可见 本班 ClassIsland 联动状态(教室客户端设置页用)
GET /classes/:id/notification-channel 班级可见 读取「通知显示到哪个端」
PATCH /classes/:id/notification-channel 班级账号 / 教师 / 管理员 设置「通知显示到哪个端」(默认由教室机器自己选)
GET / POST /classes/:id/students 班级可见 / ADMIN·TEACHER 学生名单 / 添加学生(已存在账号直接转入)
DELETE /classes/:id/students/:userId ADMIN·TEACHER 移出学生
POST / DELETE /classes/:id/teachers[/:teacherId] ADMIN·TEACHER 分配 / 取消协作教师
GET / POST / PATCH / DELETE /courses 登录(写:班主任 / 管理员) 课程管理
GET /schedules?classId=&week=&dayOfWeek= 登录 课表列表(week 过滤周次范围)
GET /schedules/grid?classId=&week= 登录 周视图(7 列结构,供客户端直接渲染)
GET /schedules/current?classId=&at= 登录 当前上课状态(inClass / current / next;at 为诊断用时间覆盖)
POST / PATCH / DELETE /schedules[/:id] 班主任 / 管理员 课表增删改(广播 schedule:updated)
GET /homeworks?classId=&courseId=&date=&pendingOnly=&keyword= 登录 作业列表(支持按所属日期;学生带完成状态,教师带完成人数)
GET /homeworks/days?classId=&from=&to= 登录 哪些天有作业(日期高亮)
GET /homeworks/:id 班级可见 作业详情
POST /homeworks 教师 / 管理员 / 班级账号 发布作业(附 assignDate;班级账号用于教室机器录入,归属班主任)
PATCH / DELETE /homeworks/:id 教师 / 管理员 修改 / 删除(广播 homework:new / homework:updated)
PATCH /homeworks/:id/status 登录 标记完成 / 取消(广播 homework:status)
GET / PATCH /homeworks/:id/submissions 登录 未交名单:读名单 / 回写全班完成状态
GET /notifications?classId=&priority=&unreadOnly=&keyword= 登录 通知列表(带已读状态)
GET /notifications/unread-count 登录 未读数(红点)
POST /notifications 教师 / 管理员 发布通知(广播 notification:new);上课时段发布紧急通知需 confirmDuringClass
POST /notifications/:id/read、/notifications/read-all 登录 标记已读(班级设备会写全班)
DELETE /notifications/:id 教师 / 管理员 删除通知
POST /calls 教师 / 管理员 叫人(urgent 决定 URGENT / HIGH;广播 notification:new 与 call:new)
GET /grades/my 登录 个人成绩(班级会话 → 全班总览)
GET /grades?classId=&courseId=&userId=&examName= 教师 / 管理员 班级成绩
GET /grades/stats?classId=&courseId=&examName= 教师 / 管理员 等级分布 + 各课程平均得分率
POST /grades、/grades/bulk 班主任 / 管理员 单条 / 批量录入(广播 grade:updated)
PATCH / DELETE /grades/:id 班主任 / 管理员 修改 / 删除成绩
GET /students?classId=&keyword= 管理员 学生名单
POST / PATCH / DELETE /students[/:id] 管理员 学生名单增删改(无密码概念;重置密码接口已下线 → 404)
GET /teachers?keyword= 管理员 教师列表
POST / PATCH / DELETE /teachers[/:id] 管理员 新建 / 编辑 / 删除教师账号
POST /teachers/:id/reset-password 管理员 修改 / 重置教师密码(newPassword 可选,留空 = 默认初始密码)
GET /dashboard/summary / /dashboard/term 登录 仪表盘汇总 / 学期周次
GET /imports/template?kind=&format= 管理员 / 班主任 导入模板下载(csv 走 JSON,xlsx 走二进制)
POST /imports/table/preview 管理员 / 班主任 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射
POST /imports/table/commit 管理员 / 班主任 按字段映射与写入模式导入(成绩 / 学生名单 / 教师名单)
POST /imports/time-layout/preview 管理员 / 本班班主任 解析 ClassIsland 时间配置 JSON(只解析不落库)
GET / POST / DELETE /imports/time-layout[/:id] 管理员 / 本班班主任 时间配置列表 / 导入(replace 覆盖、merge 合并)/ 删除
POST /imports/class-plan/preview、/imports/class-plan 管理员 / 本班班主任 ClassIsland 课程表解析预览 / 导入(支持单双周)
GET / POST / PATCH / DELETE /integrations/devices[/:id] 管理员 / 教师 ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别)
POST /integrations/devices/:id/token 管理员 / 教师 重置设备令牌(旧令牌立即失效,明文只返回一次)
POST /integrations/classisland/notify 管理员 / 教师 下发提醒到该班 ClassIsland 设备(广播 classisland:notification)
GET /database/status 管理员 数据库状态(连接 / 版本 / 体积 / 14 张表行数 / 备份列表 / 定时配置)
POST /database/test-connection 管理员 测试任意目标库连通性(provider 仅 sqlite / mysql)
GET / POST /database/backups 管理员 备份列表 / 立即备份(gzip JSON 快照,存 <数据目录>/backups/)
POST / DELETE /database/backups/:name/restore · /database/backups/:name 管理员 从备份恢复(整库覆盖)/ 删除备份
GET /database/export、/database/sqlite-file 管理员 下载 JSON 快照 / 下载数据库文件(后者仅 SQLite)
POST /database/import 管理员 导入快照(base64,整库覆盖;可在 SQLite/MySQL 之间互迁)
GET / PUT /database/backup-schedule 管理员 定时备份配置(enabled/intervalHours/keepCount)
POST / GET /database/switch、/database/switch/jobs/:id 管理员 一键切换数据库(异步任务:备份→建表→迁数据→改 .env)/ 轮询进度
POST /integrations/classisland/report 设备令牌 插件上报状态 + 课表 + 节次时间(X-ClassIsland-Token,非 JWT)
GET /integrations/classisland/pending 设备令牌 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐)
POST /integrations/classisland/ack 设备令牌 插件确认提醒已弹出(确认后不再补发)
GET /integrations/classisland/class-plan 设备令牌 插件拉取本班课表(开启镜像时)用于写回 ClassIsland
GET /update/check 登录 更新检查(服务端代查 GitHub 最新 Release;?force=1 绕过缓存仅管理员生效)
GET /health 公开 健康检查(含版本号与已挂载模块);另有 /healthz 存活与 /readyz 就绪探针

5.4 WebSocket 事件

连接方式:io(url, { auth: { token } }),握手阶段用 JWT 鉴权。事件名定义在 @classhelper/shared 的 SOCKET_EVENTS(不要手写字符串)。

事件 方向 载荷
connected 服务端 → 客户端 { userId, role, rooms }
notification:new 服务端 → 客户端 NotificationDto
homework:new 服务端 → 客户端 HomeworkDto
homework:updated 服务端 → 客户端 HomeworkDto & { deleted?: boolean }
homework:status 服务端 → 客户端 HomeworkStatusDto & { classId }
grade:updated 服务端 → 客户端 GradeDto
schedule:updated 服务端 → 客户端 { classId, action, schedule? }
class:updated 服务端 → 客户端 { classId, action }
call:new 服务端 → 客户端 叫人消息(定向到 user:{studentId} / 班级设备)
classisland:state 服务端 → 客户端 ClassIslandStateEvent(教室现在上什么课)
classisland:notification 服务端 → 客户端 ClassIslandNotificationEvent(提醒已下发)
class:join / class:leave / ping 客户端 → 服务端 手动订阅班级(服务端二次校验权限)

房间规则(@classhelper/shared 的 SOCKET_ROOMS):class:{classId}、user:{userId}、teacher:{teacherId}、 role:{role}、students、teachers。新增实时事件要同时改三处:服务端 bus/socket、Web 端订阅、客户端订阅 (灵动岛也依赖它)。客户端断线由 Socket.IO 自动重连。

5.5 角色与权限(RBAC)

班级内的角色由「账号角色 + 与该班级的关系」共同决定,前后端共用同一份矩阵 (packages/shared/src/permissions.ts,后端 lib/access.ts 复用,前端用它隐藏入口):

角色 判定方式 可见范围
ADMIN 管理员 User.role = ADMIN 全部班级
班主任 HEAD Class.teacherId === 当前用户 本班
科任老师 SUBJECT ClassTeacher 中存在当前用户 被分配的班级
学生 STUDENT User.classId / 班级账号 自己的班级(只读业务数据)

权限矩阵(PERMISSION_MATRIX,验收脚本按此逐条断言):

操作 管理员 班主任 科任老师
班级创建 / 修改 / 删除 ✅ ❌ ❌
分配班主任与科任老师 ✅ ❌ ❌
学生名单管理(增删 / 导入) ✅ ❌ ❌
教师录入(新建 / 导入名单 / 修改密码) ✅ ❌ ❌
数据库管理(备份 / 导入导出 / 一键切换) ✅ ❌ ❌
课表管理(增删改 / 时间配置导入) ✅ ✅(仅本班) ❌
布置作业 ✅ ✅ ✅
叫人 ✅ ✅ ✅
发布通知 ✅ ✅ ✅
成绩录入 / 修改 / 导入(仅本班) ✅ ✅(仅本班) ❌
班级密码维护(PATCH /classes/:id/class-account) ✅ ❌ ❌

实现要点:

  • src/middleware/auth.ts:JWT 校验后回查数据库,班级 / 角色变更立即生效(班级会话回查 Class 表)。
  • src/lib/access.ts:resolveUserClassRole() 解析班级内角色;assertCanManageClasses / assertCanAssignTeachers / assertCanManageRoster / assertCanManageSchedule / assertCanPublishContent / assertCanManageGrades 在服务层断言(路由只做角色过滤)。
  • resolveClassScope() 收敛列表数据(classId in [...]),单条接口直接断言;越权一律 403。
  • 前端按同一矩阵隐藏入口:教师端侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 数据库管理」, 课表页的增删改按钮仅对「管理员或本班班主任」显示,成绩页的录入 / 删除按钮管理员与班主任可用。
  • 成绩相关写入按「老师端可用 + 不越权」实现(需求 6 / 7 口径):录入 / 修改 / 删除成绩与成绩模板下载 / 预览 / 导入 都是「管理员 + 本班班主任」,学生名单模板与导入仅管理员,班科任老师一律 403 —— 服务层兜底,前端只是按同一矩阵隐藏。

5.6 模块化设计

后端每个功能是一个独立目录,统一契约 ApiModule = { name, basePath, router, enabled? }:

// packages/server/src/modules/registry.ts(当前 15 个模块)
export const apiModules: ApiModule[] = [
  authModule,
  classesModule,
  coursesModule,
  schedulesModule,
  homeworksModule,
  notificationsModule,
  callsModule,
  importsModule,
  integrationsModule, // ← ClassIsland 联动
  gradesModule,
  studentsModule,
  teachersModule,
  dashboardModule,
  databaseModule, // ← 数据库管理(仅管理员)
  updateModule, // ← 更新检查(查 GitHub 最新 Release)
].filter((module) => module.enabled !== false);
  • 新增模块:新建 modules/<name>/(*.schemas.ts 校验、*.service.ts 业务、*.module.ts 路由), 在 registry 里加一行即可,app.ts 无需改动。
  • 移除 / 下线模块:删掉对应行,或把 enabled 置为 false,其接口立即从路由表消失。
  • 实时推送解耦:业务模块只调用 realtime/bus.ts 的 emitToClass/emitToUser,不直接依赖 Socket.IO 实例。
  • 共享契约:所有 DTO 与类型集中在 @classhelper/shared(改完要 pnpm build:shared),三端共用,避免契约漂移。

5.7 数据库与迁移

  • 默认:SQLite(Prisma 7 的 libSQL driver adapter,数据文件 packages/server/prisma/dev.db)。
  • 模型设计为跨库通用(无 enum、无 @db.* 原生类型),切换到 MySQL 只需改 provider + 连接串 (schema.prisma 只有一份,scripts/use-database.mjs 会就地改写它的 provider):
pnpm db:switch:mysql
# 修改 packages/server/.env 的 DATABASE_PROVIDER / DATABASE_URL
pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed

MySQL 驱动适配器 @prisma/adapter-mariadb 已随包内置(数据库管理模块的连接测试 / 迁移需要),无需手动安装。

数据模型共 14 张表:User / Class / ClassTeacher / Enrollment / Course / Schedule / Homework / HomeworkStatus / Notification / NotificationRead / TimeLayout / Grade / IntegrationDevice / ClassIslandPush。 其中 Class.code / Class.passwordHash 是班级账号字段;Class.notificationChannel 决定提醒弹在哪端; Homework.assignDate 是作业的「所属日期」;Schedule.weekParity 是单双周;Schedule.source 是课表来源; ClassIslandPush.kind 区分通知类与叫人。

迁移历史(10 个,按时间顺序):init → add_time_layout → add_class_account → add_schedule_week_parity → add_class_term_weeks → add_classisland_integration → add_class_notification_channel → add_homework_assign_date → add_push_kind → add_schedule_source。

启动时迁移(安装版自动升级):src/lib/db-bootstrap.ts 用一张账本表 _ch_migrations 记录已执行的迁移目录名 —— 全新安装依次执行全部迁移并创建初始管理员;覆盖安装只补跑账本里没有的迁移(已存在的表 / 索引自动跳过), 因此新增表无需用户手动执行 prisma migrate deploy;已是最新则不做任何写操作。日志会明确写出 「全新安装 / 升级安装」、应用了几个迁移、跳过了几个已存在对象。

6. 验收

6.1 验收标准对照

验收项 结果 证据
教师 Web 端发布通知,学生端 5 秒内收到 ✅ verify:e2e:notification:new 实测 30–50ms 到达(要求 < 5 秒)
教师发布作业,学生能查看并标记完成 ✅ homework:new 实时到达,PATCH /homeworks/:id/status 200 且列表回显 completed=true
教师录入成绩,学生能查看个人成绩 ✅ grade:updated 实时到达,/grades/my 返回记录;批量录入与统计接口通过
学生能查看课表,支持按周切换 ✅ /schedules/grid?week=1 返回周视图条目,week 过滤 weekStart ≤ week ≤ weekEnd,单双周按第 1 周 = 单周过滤
断网后客户端可查看缓存数据 ✅ 客户端冒烟:断网时回退到本地缓存 → fromCache=true;离线横幅 + 缓存统计页可用
权限隔离:学生不能访问其他班级数据 ✅ e2e 中 10 余项越权断言全部 403/401(学生跨班 / 跨班作业 / 跨班课表、教师跨班发布、未登录访问…)
上课时段发布紧急通知必须二次确认 ✅ 服务端 409 URGENT_DURING_CLASS(confirmDuringClass 后 201);Web 端全屏警告 + 3 秒倒计时
客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 ✅ verify:desktop 状态断言 + 像素级截图(docs/screenshots/island/)
灵动岛:收回无「方框」闪烁 / 点击任意处收回 ✅ 逐帧采样卡片尺寸恒定(窗口固定包围盒)+ 失焦自动收回
灵动岛「标为已读」同步通知中心 ✅ 真实链路:点击后 read=false → true、未读数减少
多条通知展开为竖排列表 ✅ verify:desktop:按重要程度排序、默认 3 行 + 展开更多(还有 N 条)、卡片高度与 islandListLayout() 一致;放不下时提示 更多请前往应用内操作
多条通知的整批操作 ✅ verify:desktop:知道了 整批关闭且状态同步清空、窗口随后淡出隐藏;标为已读 经真实链路把两条通知都标为已读
本机录入的作业不再上岛 ✅ verify:desktop:按 id 与内容指纹双重判定,投递后灵动岛状态不变;别的作业照常上岛
作业发布也上岛(「新作业」胶囊) ✅ kind=homework + 展开显示作业要求;截止时间功能已下线(接口不再返回 dueAt,e2e 有断言)
灵动岛收起后始终可再次打开 ✅ verify:desktop:收起后胶囊常驻、「收起态点击可再次展开」、「空闲细缝态保持可交互」
触摸屏触摸灵动岛可展开(希沃白板) ⏳ 触摸模式下窗口贴合岛体 + 始终接收输入;verify:desktop 断言「窗口 = 岛体 + 2×阴影留白、interactive=true、退出后恢复固定包围盒」。真机(触摸屏)需人工复验
叫人(老师点名,分紧急 / 普通两级) ✅ verify:e2e 8 项(普通 HIGH / 紧急 URGENT)+ verify:desktop「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」
成绩 / 名单表格导入(xlsx·xls·csv) ✅ 模板下载 + 预览映射 + 重复处理 + 行号级错误:verify:e2e 覆盖 20 余项,verify:web 弹窗实测
ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) ✅ 合法 200、非法 400 IMPORT_INVALID 且原配置仍在、merge 覆盖与保留行为符合预期
安装版覆盖升级自动补迁移 ✅ 真实旧库升级日志:升级安装:已应用 N 个迁移文件,跳过 M 个已存在对象,新表自动建好
学生端主体 = 班级(班级码 + 班级密码登录) ✅ verify:e2e 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原
班级设备代全班操作(已读 · 完成 · 成绩总览) ✅ 标记已读写入全班、教师端 readCount = 班级学生数、completedCount 一致、/grades/my 返回全班成绩
客户端只保留班级登录入口 ✅ verify:desktop:导航「成绩」标题变为「本班成绩」;个人学生登录 403
教师录入(仅管理员) ✅ verify:e2e:教师读 / 建教师 403、管理员建 201 → 编辑 → 重置密码(新密码 200 / 旧密码 401)→ 删除(有职责 409 / 无职责 200);verify:web:管理员真人点「教师管理 → 新建教师 → 保存 → 列表出现 → 删除」
教师名单表格导入(仅管理员) ✅ verify:e2e:教师预览 403、管理员预览 / 提交 200(inserted=1)、导入账号可登录;模板列=用户名 / 姓名 / 初始密码 / 角色
教师修改密码(管理员) ✅ 服务端 POST /teachers/:id/reset-password 收可选 newPassword,界面「修改密码」填了就设成填的值、留空才是默认初始密码(123456)。学生没有账号,POST /students/:id/reset-password 已下线(404,e2e 有负断言)
客户端不提供改密入口(有意为之) ✅ 按用户要求移除:防教室机器误改班级密码把其他机器锁在门外。verify:desktop 断言账号页「修改密码」按钮不存在、只有「退出登录」;班级密码由管理员在 Web 端维护
创建班级仅管理员 + 设置 / 更改班主任 ✅ verify:e2e:教师建班 403;管理员建班可带 teacherId、PATCH /classes/:id/head-teacher 落库生效;verify:web:教师直接访问 /teachers 被挡回仪表盘
授课科目统一(18 科固定目录) ✅ verify:web:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表)
登录页无示例内容 ✅ verify:web(管理端)与 verify:desktop(客户端)都断言登录页正文 / 占位符不含 演示 / 示例 / admin123 / teacher123 / G101
新手引导(首启弹出 + 随时重看) ✅ Web 管理端:首次登录弹出 el-tour 聚焦引导,头像菜单「使用引导」重看(verify:web);客户端:首启六步向导,登录页 / 设置页可重看(verify:desktop)
客户端设置改版(分组侧栏 / 深色模式 / 关于页) ✅ verify:desktop:「侧边栏点击导航」适配「学习 / 设置」分组;「主题切换与侧边栏折叠(真实点击、写回配置)」——html.dark 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染」含 configPath
数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换) ✅ verify:e2e 13 项:状态与 14 表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、Redis 主库 422、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;切换子进程链路真机实测:快照 728 行 → generate → db push → apply-snapshot 逐表一致;verify:web:数据库管理页(仅管理员)状态卡与备份表渲染
更新检查(服务端 / Web 端 / 客户端) ✅ verify:e2e 4 项:未登录 401、管理员结构完整且本机版本取自 package.json、教师可查但 force 仅管理员生效、健康检查暴露版本号与 15 个模块;verify:desktop 4 项:IPC 返回结构正确(无外网时降级 ok=false 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;verify:web:「顶栏用户菜单 → 弹窗给出结论并可关闭」。真机联网实测:GET /api/update/check → ok=true、latestVersion=1.0.0、5 个附件、更新说明 1358 字
其他页面发通知也会联动 ClassIsland ✅ verify:classisland:「通知发布页发的通知也会推送」「叫人也会推送」;真机实测截图 03-from-notify-page
作业按天查看(日期选择器 + 有作业日期高亮) ✅ verify:e2e:assignDate 默认服务器当天、?date= 只返回该天、/homeworks/days 返回有作业日期、非法日期 422
支持在客户端录入作业(含自定义快捷短语) ✅ verify:e2e:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 通用」管理短语
提醒弹在哪个端由客户端自行选择 ✅ 客户端「设置 → 提醒」三选一(verify:desktop:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(verify:classisland:client 时通知与联动页下发都被跳过)
班级小助手能读取 ClassIsland 的课表 ✅ verify:classisland:插件上报课表 → 本班课表新增 / 更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;verify:web:教师在「ClassIsland 联动」页真实签发设备令牌
用户可选择通知是否在 ClassIsland 上显示 ✅ 插件设置页开关「接收班级小助手提醒」(本机总闸)+ Web 端「下发提醒」(标题 / 内容 / 时长 / 优先级 / 语音朗读 / 是否同步通知中心);verify:classisland:提醒落库 → 待提醒 → 回执 → 确认后不补发
能成功打包 Windows EXE ✅ -x64-setup.exe / -x64-portable.exe / win-unpacked/*.exe(见 7. 打包与交付)
提供完整 README(启动、构建、打包、默认账号) ✅ 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题

6.2 最近一次实测

验证 结果
pnpm verify:e2e(后端 + 实时 + RBAC + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查) 190/190 ✅(2026-10-05 实测)
pnpm verify:desktop(客户端冒烟,不弹窗) 105/105 ✅(在线轮)+ 89/89 ✅(二次启动轮),0 失败(2026-10-05 实测)
pnpm verify:web(Web 管理端真实点击回归) 32/33(2026-10-05 实测;未过的 1 项是既有脆弱用例「课表科目为全校统一目录」,见下)
pnpm verify:classisland(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) 42/42 ✅(2026-10-05 实测)
pnpm verify:classisland-plugin(插件静态契约) 68/68 ✅
pnpm typecheck / pnpm lint / pnpm check:icons 全过 ✅(lint 0 error 0 warning)
pnpm build:desktop(含产物凭据门禁 check-bundle-secrets) 通过 ✅(61 个产物文件里无种子凭据字面量)
pnpm verify:packaged --exe …(对打包副本复验) 105/105 ✅ + 89/89 ✅(2026-10-05 实测,0 失败)
裁剪后的服务端运行时(起服务 → 登录查表 → prisma generate → tsc → db push) 通过 ✅(2026-10-05 实测,建出 14 张表;详见 AGENTS.md §8)
插件 dotnet 编译(pnpm build:classisland-plugin) 0 错误 ✅(1 条预期内的 Avalonia AVLN3001 警告)
数据库切换子进程链路(快照 → generate → db push → 写入目标库) ✅ 实测:728 行逐表一致(users / schedules / grades 抽查一致)
pnpm dist:server(服务端安装包 + Web 端) ✅ 打出 ~62MB 安装程序(随包内置切换链路所需的 prisma CLI 与 tsc,并在打包末尾做运行时裁剪)
安装程序完整生命周期(静默安装 → 启动 → 卸载) 通过 ✅(安装到 %LOCALAPPDATA%\Programs,卸载干净)
便捷版(单文件)解包启动 通过 ✅(wrapper + 多个进程,主窗口正常)
Docker / Nginx 部署样例 文件已提供;本机无 Docker 未实测 ⚠️
Linux 一键安装 + classhelper 运维命令(deploy/install.sh / deploy/classhelper / pnpm dist:server:linux) 脚本与文档已交付,本地静态检查通过(bash -n、ESLint 0 error);Linux 实机验收待做 ⚠️(清单见 linux-deploy.md §10,deploy/verify-linux.sh 可自动跑)

6.3 已知的时序 / 环境相关用例

这些用例不是回归,遇到时先看条件,别急着改代码(完整清单见 AGENTS.md 的「已知脆弱用例」表):

  • verify:desktop 的「真实通知链路」「灵动岛标为已读」等 3 项:要求当前不在上课时段。 若种子课表恰有课正在进行,客户端会正确地把普通通知暂存到下课(见 4.6), 这三项就会红(日志里会写 客户端判定上课中=true / 正在上 XXX)。2026-10-02 那次 99/102 即为此因。
  • verify:e2e 的「上课状态接口(at=周一 08:10)」:种子把周一第 1 节限制在第 1 周,且用例把 2026-09-07 当第 1 周 —— .env 的 TERM_START_DATE 对齐到 2026-09-07 即全绿。
  • verify:web 的「课表科目为全校统一目录」:种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组。
  • 灵动岛交互 / 动画 / 截图留档几个用例在 AI 会话里会间歇性失败:置顶透明小窗的 rAF 被限流、形变采样抓不到帧, 属时序敏感;先原样重跑一次再动手改代码。
  • 跑验证脚本大面积报「登录失败 / 401」:先核对本机 teacher1 的密码 —— 本机实测被改成了 123456, 不是种子默认的 teacher123(原因见 AGENTS.md §7 第 51 条)。三套脚本各有各的传参方式:
    脚本 覆盖凭据的方式
    verify:desktop set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop
    verify:packaged 同上(ELECTRON_SMOKE_*)
    verify:web set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web(另有 UI_SMOKE_ADMIN*)
    verify:e2e 不支持环境变量(口令硬编码在脚本里),需先确认本机种子口令或用临时副本(见 AGENTS.md §7 第 51 条)

7. 打包与交付

体积与性能(2026-10-05 优化后实测)

产物 / 指标 优化前 优化后 做了什么
客户端渲染产物 dist/renderer 3.2MB 2.1MB 成绩页图表从 echarts 全量(1.12MB,占 35%)换成内置 HTML/SVG 组件;Element Plus 图标从全量注册 293 个改为按需 42/33 个
客户端主进程产物体积 242KB 242KB 未变(冒烟代码拆分会重复 island/config 单例,esbuild 的 CJS 打包无法代码分割,故保留)
客户端 win-unpacked 372MB 323MB electronLanguages: [zh-CN],只留中文语言包(其余 54 个 locales/*.pak 共 48MB)
客户端 setup.exe / portable.exe 107MB 98MB 同上。安装包有硬底:班级小助手.exe(electron.exe)压缩后本身就有 85~90MB,60MB 不可达
服务端 dist 3.8MB 0.6MB tsconfig.build.json 关掉 declaration/declarationMap/sourceMap(占原体积 82%)
服务端免安装目录 436MB 335MB 打包末尾的运行时裁剪:删掉 Prisma 为 postgresql/cockroachdb/sqlserver 准备的查询编译器副本、*.map、文档与测试夹具(共 2329 项 / 101MB)
服务端安装程序 71MB 62MB 同上
Web 端前端产物 3.3MB 2.1MB 同样是 echarts → 内置 SVG/CSS 图表 + 图标按需
灵动岛主进程常驻开销 每 60ms 2 次系统调用 每 60ms 1 次 光标位置每跳只读一次(命中判定与"跟随鼠标屏幕"共用);窗口不可见时整跳

没有做的事,以及为什么:Element Plus 仍全量引入(按需引入要改所有视图的样式引入方式与 locale 配置, 收益约 200 到 350KB JS,风险不成比例);服务端静态资源不做预压缩;客户端不再删 GPU 回退组件 (dxcompiler / swiftshader / d3dcompiler,共约 37MB 未压缩,教室老显卡与虚拟机靠它们)。 详见 AGENTS.md 的 §5 第 53 至 56 条与 §7 第 23 条。

客户端(Windows EXE)

pnpm dist:dir     # 免安装目录:releases/client/<版本>/免安装/班级小助手.exe
pnpm dist:win     # nsis 安装包 + portable 单文件(需联网下载 NSIS 工具,可用镜像)

打包脚本 packages/desktop-client/scripts/dist-win.mjs 做了几件事以规避常见问题:复用本地已解压的 Electron (--config.electronDist,不重复下载 150MB+);默认走国内镜像(ELECTRON_MIRROR、 ELECTRON_BUILDER_BINARIES_MIRROR,可用环境变量覆盖);构建缓存固定到仓库内 .cache/; 用 --config.directories.output 把输出目录覆盖成 releases/client/<版本>/,打包成功后再把 electron-builder 的原始布局整理成 安装包/、免安装/、构建中间/; 收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,见 8. 常见问题)。

产物结构:免安装/班级小助手.exe(Electron 运行时)+ resources/app.asar (仅含 dist/ 与 package.json,运行时不需要 node_modules)。

把修复交付到「已安装的客户端」:pnpm dist:win 只产出 releases/client/<版本>/。 若学生机上是安装版(例如装在 D:\class\@classhelperdesktop-client),只更新 release 而不同步安装目录, 用户打开的还是旧 resources/app.asar,会出现「改了但界面不生效」。覆盖升级(用户配置在 %APPDATA%,不受影响):

# 1) 先退出客户端(托盘右键退出;有进程残留就精确结束 PID)
# 2) 用新产物覆盖安装目录(不删 Uninstall 等安装器文件,所以不加 /MIR)
robocopy "packages\desktop-client\release\win-unpacked" "D:\class\@classhelperdesktop-client" /E /NFL /NDL /NJH /NJS /R:2 /W:1
# 3) 实测**那个副本**真的带上了修复(packaged=true)
pnpm verify:packaged --exe "D:\class\@classhelperdesktop-client\班级小助手.exe"

最近一次打包实测(产物不入库):

产物 大小 冒烟结果
服务端安装程序(内置 Node + Web 端) ~62MB ✅ 静默安装(升级保留 .env 与数据库)→ 自动建库建号 → 服务就绪
releases/client/<版本>/免安装/班级小助手.exe ~235MB ✅ 打包版冒烟全绿,packaged: true,退出码 0
releases/client/<版本>/安装包/…-portable.exe ~98MB ✅ 打包后冒烟全绿,packaged: true,退出码 0
releases/client/<版本>/安装包/…-setup.exe ~98MB ✅ 构建成功,已嵌入自定义图标
releases/classisland-plugin/<版本>/…cipx ~88KB ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 bin/Release 一致

ClassIsland 插件

pnpm build:classisland-plugin    # 编译(.NET 8,缓存与临时目录自动指到仓库内 .cache/)
pnpm dist:classisland-plugin     # 打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
pnpm verify:classisland-plugin   # 静态契约校验(不需要 .NET,不需要服务端)

编译需要 .NET 8 SDK(判据看 dotnet --list-sdks 有没有输出)。全量编译会有 1 个预期内的警告 AVLN3001(设置页只有 DI 构造函数、无公开无参构造)—— 本插件走编译期 XAML + ClassIsland DI 实例化, 别为了消警告加无参构造函数。部署到教室机器(先退出 ClassIsland 再覆盖 DLL,别覆盖 Settings.json)的步骤 见 AGENTS.md §7 第 12 条与 §8。

8. 常见问题

开发环境的坑请直接看 AGENTS.md §7「本机环境已知坑」 —— 那里是本仓库唯一维护这些内容的 地方(含命令坑、代理与 TLS、Low 完整性标签、makensis 临时目录、ClassIsland 路径与日志编码、AI 会话里跑 Electron 验证等)。这里只列最常被问到的几条:

  1. 客户端装不上 / 便捷版双击没反应 / 应用一闪就没(0x80000003) 现象:安装程序报「拒绝访问」,便捷版双击毫无反应,装上了也启动即退且不写日志。 原因:交付物被打了 Low 完整性标签(本仓库目录树带此标签、可继承,在仓库内构建就会继承)—— 带 Low 标签的 exe 会被 Windows 降级运行,写不进 %LOCALAPPDATA% / %TEMP%;即使装上,Chromium 的 子进程(AppContainer,完整性更低)也读不到这些文件而秒崩。 处置:pnpm dist:win 收尾已自动改成 Medium;手工补救与排查命令见 AGENTS.md §7 第 7 条。 别把安装测试装进仓库目录里(会在那儿又继承 Low,看起来像「装完还是坏的」)。

  2. 文件传输 / 上传报「服务器内部错误:request entity too large」? 表格导入的文件上限是 8MB(请求体 12MB),超限应返回 413 IMPORT_TOO_LARGE 与中文提示。 若看到英文的英文兜底错误,说明该异常没被 middleware/error.ts 映射,属 bug。

  3. pnpm db:deploy 在根目录不存在 根 package.json 只声明了 db:generate / db:migrate / db:seed / db:reset / db:studio。 正确写法:pnpm --filter @classhelper/server db:deploy。

  4. pnpm db:seed 报 Cannot find module '.../@classhelper/shared/dist/index.js' packages/shared 还没构建(它产出 dist,三端与 seed 都依赖)。先 pnpm build:shared。

  5. 后端改了代码不生效? tsx src/index.ts 不热载 —— 用 pnpm dev:server(watch),或重启服务端。 切换数据库之后必须重启才会连新库。

9. 后续可选增强

  1. 代码签名证书(消除 SmartScreen 提示)。
  2. 客户端「提交类操作离线队列」(当前离线为只读,联网后自动同步读取的数据)。
  3. Socket.IO 多实例广播(引入 @socket.io/redis-adapter)与 CI 流水线。
  4. 灵动岛在「跟随鼠标屏幕」之外,支持记住每块屏幕各自的外观偏好。
  5. 触摸版灵动岛在真实触摸屏机器上的人工复验(希沃白板)。
S
Description
ClassHelper -是给中小学班级用的一套信息管理系统
https://lovechengke.github.io/ClassHelper/
Readme
20 MiB
1 Stars 1 Watchers 0 Forks
Languages
TypeScript 45.4%
Vue 27%
JavaScript 17.2%
CSS 4.6%
Shell 3.8%
Other 1.9%