Files
ClassHelper/docs/reference.md
T

180 KiB
Raw Blame History

ClassHelper · 完整参考

2026-10-17 审查修复:默认冒烟改为后台快速检查,视觉验收显式运行;离线缓存按服务端和班级隔离, 通知删除实时同步到通知中心与灵动岛;名单只读权限与写权限分离。完整核对表见审查修复记录。

班级信息管理系统。一个 pnpm monorepo,一份代码产出三个交付物:后端服务、Web 管理端(教师 / 管理员)、 桌面客户端(ClassHelper 班级端 / 教室机器)。

核心链路:

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

本文是 ClassHelper的完整参考:功能行为、接口契约与已知问题都在这里,是功能口径的权威来源。 面向「第一次打开仓库」的展示型介绍在 README;按读者分组的引导式文档在文档站(本目录)。 贡献者需要的工程约定在 AGENTS.md;生产部署在 production.md;切换数据库在 mysql.md。 文中的文件路径均相对仓库根目录(packages/…、scripts/…),只有目录链接是相对本文的。 改动交付形态 / 新增功能后请一并更新本文;条目数由脚本自行统计,不要写死容易过期的总数。

目录

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,含「灵动岛」浮窗、托盘、离线缓存 ClassHelper 班级端 / 教室机器
共享契约 packages/shared 三端共用的类型 / 常量 / 权限矩阵 / 工具函数 上面三端
官网 website/ 纯 HTML / CSS / JS 的静态页面,没有构建步骤 展示与说明(node website/serve.mjs 本地预览)

两处技术选型取舍:

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

2. 交付产物与部署形态

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

这些产物都不入库(releases/ 已 gitignore + eslint ignore)。三个打包脚本直接输出到 releases/<组件>/<版本>/,每个版本目录下按 安装包/(交付物)、构建中间/(可重新生成)分层, 历史版本原样保留。没有免安装目录:组装/解包出来的那一份只是中间物,脚本打完就删。

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

场景 形态 入口
学校机房 / 教师电脑(Windows 单机) Windows 服务端安装程序(内置 Node,双击即用) releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-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 --filter @classhelper/server start

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

启动客户端(ClassHelper 班级端)

# 开发模式:自动构建主进程 + 启动 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 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印: 账号 3 / 在读班级 3 / 学生 15 / 课程 54(每班 18 科)/ 课表 93 / 作业 16 / 通知 13 / 成绩 90 / 调班历史 2 / 已归档届别 1):

角色 凭据 密码 说明
管理员 admin admin123 可访问全部班级、可执行毕业归档 / 学年升级 / 学期周次
教师 teacher1 teacher123 张老师:2026级1班 班主任,兼该班数学
教师 teacher2 teacher123 李老师:2026级2班 班主任,兼 2026级1班 语文
ClassHelper 班级端 班级码 G101 123456 2026级1班(教室机器唯一的登录方式)
ClassHelper 班级端 班级码 G102 123456 2026级2班
ClassHelper 班级端 班级码 G203 123456 2025级3班(不是 G103)
  • 密码由种子脚本硬编码(admin123 / teacher123 / 班级 123456);.env 的 DEFAULT_CLASS_PASSWORD 只决定新建班级时的初始班级密码。
  • 学生不是账号(2026-10-06 起由表结构保证):Student 表里没有 passwordHash / role, 学号(202601…202615)只是名单记录与查询键 —— 拿学号去登录只会得到 401。
  • 任课关系刻意让语文由另一位老师任教,所以班主任在语文上没有编辑权, 正好覆盖「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
  • 演示数据里还有一个已归档的 2023级1班(3 名毕业生),用于验证「毕业归档」页。
  • 教学周由 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 icons 从 build/classhelper.png 派生全部图标(PWA / favicon / 托盘 / 安装包 / 官网 / 界面内品牌标)
pnpm dist:server 打包服务端 + Web 管理端(NSIS 安装程序,内置 Node)
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 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;在 Windows 上不可靠,用 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 个:登录、仪表盘、班级管理、学生管理、教师管理、课表管理、作业发布、通知发布、成绩录入、 客户端管理、数据库管理、页面不存在。其中班级管理 / 学生管理 / 教师管理 / 数据库管理为 meta.roles: ['ADMIN'](直接输网址会被挡回仪表盘,服务端同样拦),客户端管理为 ['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。

实拍(node website/tools/capture-shots.mjs 采集,落在 docs/images/ —— 与 README、官网用的是同一份图):

场景 截图
手机抽屉菜单(叠在「通知发布」页上) 手机抽屉菜单
手机仪表盘(统计卡两列,表格在卡片内横向滚动) 手机仪表盘

手机访问:与电脑同一局域网时用 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,仅管理员),原班主任仍留在协作教师名单
学生录入 「学生管理」:单个新建(登录名自动生成)+导入名单;ClassHelper 班级端主体是班级账号

学生名单接口(/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 三处共用一个开关。

作业看板(ClassHelper 班级端)

默认按科目分卡片(每张卡列该科作业,条目可点开详情),工具栏可切「看板 / 列表」、开关「显示时间」、 拖「字号」(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 分钟;无课时显示「今天的课都上完了 / 今天没有课程安排」
时间轴 左侧时间栏(开始 / 结束)+ 竖向轨道与节点 + 右侧课卡:已结束(置灰)、正在上课(强调色实底 + 进度条 + 距下课倒计时)、下一节(橙色标记 + 还有多久开始)
周视图 保留「节次 × 星期」表格(本周 标签)

实拍(node website/tools/capture-shots.mjs 采集,落在 docs/images/ —— 与 README、官网用的是同一份图):

页面 截图
课表 · 今天(时间轴) 客户端课表时间轴
作业看板 客户端作业看板
通知 客户端通知
成绩 客户端成绩
设置 · 灵动岛 客户端设置灵动岛
设置 · 外观(深色) 客户端设置外观

个性化设置与系统托盘

设置项 区间 / 取值 说明
灵动岛高度 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 管理端顶栏下拉修改 —— 防止教室机器上误改班级密码后其他机器被锁在门外。

系统托盘:关闭主窗口不退出程序,而是隐藏到托盘后台继续接收通知;托盘图标(左键单击 / 双击)恢复主窗口, 右键菜单提供「显示主窗口 / 隐藏到托盘 / 退出 ClassHelper」。退出时统一清理:销毁托盘 → 销毁灵动岛 (含帧循环与定时器)→ 销毁全部窗口,并用 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/dev/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。

各形态实拍(显示的是 docs/images/ —— 与 README、官网同一份,按逻辑像素 1× 采集; pnpm verify:desktop 另有一套逐像素校验过的留档在 docs/screenshots/island/,那是回归产物,不在这页展示):

形态 截图 尺寸(逻辑 px) 说明
下课后自动弹出详情 下课后自动弹出 424×230 上课期间暂存的通知,下课后自动弹出
上课期间紧急通知 紧急通知立即展开 440×246 卡片内部红色光晕 + 紧急角标,无需点击
非上课时段「新消息」胶囊 新消息胶囊 268×44 直接出现(无入场动画),点击后展开
点击胶囊后的详情 点击后展开 424×230 标题 / 内容 / 时间 + 打开应用 / 标为已读 / 知道了
多条通知竖向排列 多条通知 424×303 按重要程度排序,默认显示 3 条 +「展开更多」
新作业胶囊 新作业 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 管理端 → 学生管理 → 叫人(或 通知发布 → 叫人)选中学生,配合快捷短语或自定义消息发送。 学生管理页是管理员专属,而班主任与科任老师都需要叫人,因此教师的叫人入口在「通知发布 → 叫人」。

级别 落库级别 ClassHelper 班级端行为
普通叫人 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)。

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._-]{1,32}$(入库前去掉全部空白并统一大写,所以唯一约束天然大小写无关); 重复按同一学号(append 跳过 / upsert 更新姓名、班级与联系方式); 表格里带「班级」列时按班级名解析(重名会报错让老师改用班级码),否则用弹窗里选的班级。 只写 Student 名单,不创建任何账号。

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;
  • 课表来源字段:统一由管理端维护;历史同步课表在升级时保留并归为 manual。

学期周数(管理员可调):Class.termWeeks(默认 20,上限 30)由管理员在「班级管理 → 编辑班级」设置, 上限统一经 getMaxTermWeeks() 读取全校配置(默认 30、允许 1–60),服务端动态校验;GET /api/dashboard/term?classId= 返回该班的 maxWeek。

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

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

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

能力 说明
连接状态检测 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、23 张表行数、连接串(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,23 张表按拓扑序导出, 恢复时临时关外键检查 —— 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 (这份运行时现在只是中间物,打完安装程序即删)。
  • e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 .env; 真实切换走的子进程链路已完整验证(快照 728 行逐表一致)。

4.10 更新检查

因此"有没有新版本"就是问那条 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 可核对)。

4.11 毕业归档(仅管理员)

按年度把毕业的班级与学生归档,并留档当年转出的学生、以及毕业班级发过的作业与通知。 入口:Web 管理端 →「毕业归档」。

归档 = 打标记 + 只读,不删任何数据:

动作 结果
归档一个届别 该届班级从常规列表隐去(Class.archivedYearId)、在读学生标记为「已毕业」(Student.archivedYearId)
作业与通知 原样留在库里,在「届别详情 → 看作业与通知」里只读浏览
撤销归档 班级与学生回到在读状态;同样不删任何数据(转出的学生保持转出 —— 那是事实,不是归档造成的)
班级端登录 归档班级的教室机器不能再登录(403 CLASS_ARCHIVED),继续登进去只会看到一个只读的壳子

「未毕业而升级的班级不归档」:学年升级在「班级管理 → 班级设置 → 学年升级」里做, 只改 Class.grade(高一 → 高二),班级记录、学生名单、学号、作业与成绩全部沿用。

班级称呼是「XXXX级X班」(入学年份 + 班号),不是「高一(1)班」—— 后者每年都要改一遍, 前者一路跟着这届学生到毕业。称呼由服务端按 enrollmentYear + classIndex 生成,表单里只填这两个数。

调班 / 转出(Web 管理端 →「学生管理」):

  • 调班(单个 / 批量同一接口):只改学生当前班级,学号不变、不创建账号; 原班级 / 新班级 / 操作人 / 时间记入 StudentClassTransfer,可查; 历史作业与成绩保留原归属(它们挂在各自的 classId 上,不随学生走)。
  • 转出:学籍离开本校(不是调班)。班级归属保留作历史,状态置为 transferred, 并写一条 mode='transfer-out' 的历史,归档页据此统计"当年转出的学生"。

4.12 学期周次(可逐周覆盖,仅管理员)

入口:Web 管理端 →「学期周次」。

课表、作业、成绩都按教学周组织,而「开学日期 + 每周七天」的线性推算遇到法定节假日调休、 周末补课、错峰开学就会整体错位(错一周全错)。所以支持逐周指定实际日期:

操作 说明
选配置范围 留空 = 全校默认;也可以挑某个班单独配(该班优先用自己的)
学期开始日期 第 1 教学周的周一;填完点「按开学日期生成」得到一份标准学期
逐周日期区间 用日期范围选择器改任意一周(调休那一周拉长 / 开学前那几天并进第 1 周…)
拉取放假安排 联网获取当年的法定节假日,只给建议、不会自动改数据
保存 校验区间不重叠(重叠会让"现在第几周"出现歧义),然后下发给所有 ClassHelper 班级端
  • 存储:TermWeek(classId = '' 表示全校默认)+ Class.termStartDate(班级)+ AppSetting 的 term.startDate(全校默认的开学日期);
  • 判定:lib/term.ts 的 loadTermContext() 做三级回落 —— 开学日期按「Class.termStartDate → 全校设置 → .env 的 TERM_START_DATE」, 逐周区间按「班级 → 全校默认 → 纯线性」, resolveCurrentWeek(now, context) 优先按区间判定;
  • 开学日期在页面上就能改(2026-10-11 起):原先全校默认那一级只能来自 .env,填错了得动文件 + 重启;现在管理员在「学期周次」页填完保存即可,当前周次立刻按新日期重算,随时可以纠正。
  • 机房/服务器没有外网时,"拉取放假安排"返回 ok:false 是正常结果(不是错误), 失败结果同样进缓存,避免断网点一次要白等一个超时。

4.13 课表分发

教师在管理端维护课表,客户端通过接口与实时事件同步;可从 ClassIsland 档案 JSON 离线导入。

4.14 权限组管理(角色只是默认模板)

入口:Web 管理端 →「权限组管理」。在这之前,账号能干什么完全由角色(ADMIN / TEACHER)决定; 现在多了一层权限组,角色退化成「没有分组时」的默认模板。

账号 生效权限
ADMIN 管理员 按权限组计算;permission:manage 与 system:manage 不可被剥夺(避免把自己锁死)
有分组的账号 所属权限组权限键的并集 —— 因此可以比角色默认更少(用来收回权限)
没有任何分组的账号 使用可编辑的角色默认权限组
CLASS_DEVICE 班级端账号 空集,不受权限组影响;它的能力由班级维度断言决定
  • 生效集合的唯一计算入口是 src/lib/permissions.ts 的 loadEffectivePermissions(), 每请求实时查询、不缓存 —— 改完权限立即生效(代价是每请求多一次分组查询)。
  • 权限键目录(PERMISSION_KEYS / PERMISSION_LABELS / PERMISSION_CATEGORIES)只有一份, 放在 @classhelper/shared:权限页的勾选树与后端的 requirePermission 读的是同一份。
  • 保存权限组时是整体替换权限键(漏传的等于取消勾选,不会留下幽灵权限); builtin 内置组可以改名、可以调键,但不能删除;删掉一个组后原成员回到角色默认模板。
  • 端点:GET /permissions/me(无权限键,两端据此决定入口显不显示)、GET /permissions/catalog、 GET / POST /permissions/groups、PATCH / DELETE /permissions/groups/:id、GET /permissions/users、 PUT /permissions/users/:id/groups(除 /me 外都要求 permission:manage)。
  • 判定规则与完整矩阵见角色与权限;服务端始终是唯一拦截点, 前端只按 GET /permissions/me 隐藏入口,别把它当安全边界。

4.15 客户端更新与远控

「客户端」页显示设备在线状态、版本和最近指令结果,支持关机、重启、注销和锁屏。 下发更新可选立即、放学后或空闲时安装;放学后取指定最早时间与当天最后一节课结束时间中的较晚者。 默认权限组可直接编辑,教师被授予远控或推送更新权限后也可使用对应操作。

4.16 通知按天归档与批量发布

通知多了以后「哪天发了什么」比「什么时候创建的」更重要,所以通知也跟作业一样有了所属日期:

项 说明
assignDate Notification.assignDate 是本地日期(创建时 dayKeyLocal() 写入),不是 createdAt 切 UTC
索引 @@index([classId, assignDate]) 支撑按天 / 区间查询
按天归档 GET /notifications/days(日期 + 条数)、GET /notifications/creators(按发布人)
列表筛选 GET /notifications?date=&from=&to=&createdBy=(与 classId / priority / unreadOnly / keyword 叠加)
批量发布 POST /notifications/bulk(notification:publish)、POST /homeworks/bulk(homework:write)

批量发布逐班判定、绝不整批回滚:

BulkPublishResult { total, succeeded, failed, results: BulkPublishClassResult[] }
BulkPublishClassResult { classId, ok, id?, code?, message?, details? }

某个班没权限(403)或撞上「上课时段只许发紧急通知」只让那一个班失败,其它班照常落库并实时推送; 失败项带可直接展示的中文 message,界面按班列出来。作业批量里每个班可以给 subjectName, 服务端按科目名在该班匹配课程 —— 匹配不上就是那个班失败,不会退化成"挂到班主任名下"。

不要用 toISOString().slice(0, 10) 算"哪一天":它按 UTC 切,晚上 8 点以后发的通知会算到第二天。 统一用 @classhelper/shared 的 dayKeyLocal / isDayKey / shiftDayKey。

4.17 大模型助手与开发者模式 / 关怀模式

大模型助手(Web 管理端 →「AI 助手」)用一句话办事,但助手只解析、不落库:

环节 口径
设置 GET / PUT /ai/settings(ai:manage):开关 / provider / baseUrl / 模型 / 提示词补充
密钥 API Key 用 AES-256-GCM 加密后存 AppSetting(密钥由 JWT_SECRET 经 scrypt 派生),只进不出:GET 只回 hasApiKey 与 keyHint(末 4 位)
解析 POST /ai/interpret(ai:use)→ AiInterpretResult{ action, params, preview, confidence, needConfirm }
动作 action ∈ create_homework / create_notification / call_student / unknown
二次校验 params 由服务端用与手动入口同一份 zod 再校验一遍,不合规直接判 unknown
落库 前端展示 preview(班级 / 科目 / 学生已解析成中文),用户确认后调既有端点发布 —— 权限与上课时段保护都还在那条路径上
provider openai 走 {baseUrl}/chat/completions;mock 是离线确定性解析(自动化验收不联网)
审计 每次解析写一条 AiAudit;GET /ai/audit(ai:manage)可查谁在什么时候让助手做了什么

系统设置(system:manage,管理员)入口在「大模型设置」页底部的两张卡片:

  • GET / PUT /system/settings:developerMode 开启后 500 响应会附带 error.stack (排查"服务端到底报了什么"),默认关闭;
  • GET / PUT /system/autostart:Windows 改 HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClassHelperServer,Linux 走 systemctl enable/disable classhelper;非安装形态(开发机 / 免安装 / 容器)返回 supported:false 并给出原因,不假装成功。安装器 scripts/nsis/server-installer.nsi 已把开机自启做成 组件页上的可选 Section(默认勾选);
  • GET /system/diagnostics:运行诊断(版本 / Node / 平台 / 运行时长 / 数据库 provider / 在线客户端数 / 已缓存安装包数 / 已加载模块数 / 服务端根目录 / 数据目录,不含任何密钥)。

关怀模式与菜单回缩(纯界面偏好,不落服务端):Web 端把「关怀模式」存在 localStorage (classhelper.admin.careMode + careScale,1.0~1.5、默认 1.2),开启时给 <html> 挂 care-mode 并把倍数写进 --ch-font-scale;客户端把 careMode 存本机 config.json,同口径放大界面文字。 侧栏折叠同样是本机偏好。User.careMode 列已经建好,但服务端目前没有读写它 —— 别把它当成"已经有服务端开关"。

4.18 考试成绩:单科排名与总分排名

一次考试(月考 / 期中考…)在本系统里就是一组同名 examName 的成绩。除了单科分数,还能看排名:

能力 在哪 说明
单科排名 「成绩录入」页 / 教室「客户端」的成绩页 按得分率排;并列同名次(1、2、2、4)
总分排名 同上 把这次考试的所有科目相加,按总分排
考试口径开关 「成绩录入」页 → 筛选出某个「考试名」后出现 「所有科目相加,排总分榜」/「只排单科,不合总分」
班级端成绩与排名 教室机器的「成绩」页 由服务端决定开不开(Class.studentGradeQueryEnabled),客户端只读

两条口径写死在服务端(modules/grades),三端共用:

  • 总分是"考试"的属性,不是"分数"的属性 —— 语数外 150 分、音体美 100 分不是一个量纲, 合不合总分由学校决定,所以按「班级 + 考试名」存一条 Exam.countInTotal;
  • 同一学生同一科目只取最高分:老师补录 / 重考会产生多行,不去重的话"补录一条"就变成 "总分多一科",排名直接失真;
  • 总分的科目数会一并返回(subjectCount),便于识别"只录了两科也上榜"的情况。

教室机器能不能看由 Class.studentGradeQueryEnabled 控制(默认开启);关掉后客户端一律 403。 这个开关只有教师 / 管理员能在 Web 端改(2026-10-11 起)—— 原先教室机器自己也能改, 等于把"学校说不给看"变成了一台机器说了算。

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/classhelper.{png,ico}  # **图标源**(设计导出、手工放入);pnpm icons 从它派生其余全部图标
├── scripts/
│   ├── use-database.mjs         # SQLite ⇄ MySQL provider 切换助手(改写 schema.prisma 的 provider)
│   ├── generate-icons.mjs       # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
│   ├── icons/render.cjs         # 把 classhelper.png 重采样成各端图标(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
│   ├── images/                  # 展示图:文档站 / README / 官网共用的同一份(1× 逻辑像素)
│   └── screenshots/             # 回归测试留档的截图(island / client / web-mobile),页面不展示
├── website/                     # 产品官网(纯静态,无构建;node serve.mjs 本地预览)
│   ├── index.html / download.html
│   └── assets/{styles.css,motion.js,main.js,download.js,icon.png,shots/}   # motion.js = beUI 动效 token 与弹簧引擎
└── packages/
    ├── shared/                  # 三端共享:类型契约、常量、权限矩阵、工具函数
    │   └── src/{types,constants,permissions,utils,index}.ts
    ├── server/                  # 后端服务
    │   ├── prisma/schema.prisma         # 数据模型(24 个 model)
    │   ├── prisma/migrations/           # 迁移历史(17 个)
    │   ├── prisma/seed.ts               # 种子数据(会清空业务表后重建演示数据)
    │   ├── prisma.config.ts             # Prisma 7 配置(schema / 迁移 / 种子 / 连接串)
    │   ├── prisma/backups/              # 备份快照与定时配置(运行期生成,已 gitignore)
    │   ├── scripts/verify-e2e.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/                 # 21 个功能模块 + registry.ts(模块注册表)
    │           ├── database/            # 数据库管理(状态/备份/导入导出/定时/一键切换,仅管理员)
    │           └── update/              # 更新检查(查 GitHub 最新 Release,服务端唯一出网请求)
    ├── 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/classhelper.ico         # 应用 / 安装包图标(electron-builder 的 win.icon 显式指向它)
        ├── 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 / 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
打包 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>

方法 路径 权限 说明
POST /auth/login 公开 登录(教师 / 管理员的工号 + 密码);学生不是账号,拿学号登录只会得到 401
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/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=&date=&from=&to=&createdBy= 登录 通知列表(带已读状态;可按所属日期与发布人筛选)
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 /database/status 管理员 数据库状态(连接 / 版本 / 体积 / 23 张表行数 / 备份列表 / 定时配置)
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)/ 轮询进度
GET /update/check 登录 更新检查(服务端代查 GitHub 最新 Release;?force=1 绕过缓存仅管理员生效)
GET /health 公开 健康检查(含版本号与已挂载模块);另有 /healthz 存活与 /readyz 就绪探针
GET /permissions/me 登录 我的生效权限(无权限键;班级端会话返回空集),两端据此隐藏入口
GET /permissions/catalog permission:manage 权限键目录(分类 / 名称,供勾选树用)
GET / POST /permissions/groups permission:manage 权限组列表 / 新建
PATCH / DELETE /permissions/groups/:id permission:manage 改名 / 改描述 / 整体替换权限键;内置组不可删
GET /permissions/users permission:manage 账号列表(带各自所属权限组)
PUT /permissions/users/:id/groups permission:manage 设置账号所属权限组(空数组 = 回落角色默认模板)
POST /clients/heartbeat 登录 客户端心跳(无权限键,班级端也调):返回更新目标 + 一条待执行命令
POST /clients/commands/:id/ack 登录 命令回执(无权限键;body 的 deviceKey 校验归属,幂等)
POST /clients/commands remote:control 下发远控命令(关机 / 重启 / 注销 / 锁屏;自动撤销该机未回执的旧命令)
GET /clients/devices?classId= client:manage 客户端设备列表(在线判定窗口 90 秒)
POST /clients/updates client:update-push 给指定设备下发更新目标版本
DELETE /clients/devices/:id client:manage 移除设备记录
GET /update/client/releases client:manage 已缓存的客户端安装包列表
POST /update/client/fetch client:update-push 服务端代下 GitHub 上的安装包并登记 sha256 + size
POST /update/client/releases client:update-push 手工登记安装包(文件先放进 data/client-releases/<版本>/)
DELETE /update/client/releases/:version client:update-push 删除缓存的安装包
GET /update/client/download/:version 登录 下载安装包(故意不挂权限键,班级端要能下载;版本号白名单防路径穿越)
POST /homeworks/bulk homework:write 跨班批量发布作业(每班可选 subjectName,按科目名在该班匹配)
GET /notifications/days?classId=&from=&to=&createdBy= 登录 哪些天有通知(按天归档,供日期高亮)
GET /notifications/creators?classId=&from=&to=&createdBy=&date= 登录 发布人列表(含每人条数)
POST /notifications/bulk notification:publish 跨班批量发布通知(逐班判定,单班失败不影响整批)
GET / PUT /ai/settings ai:manage 大模型设置(API Key 加密存储、只进不出,只回 hasApiKey / keyHint)
POST /ai/interpret ai:use 助手解析自然语言(只解析不落库,返回 action / params / preview)
GET /ai/audit?limit= ai:manage 助手使用审计(谁在什么时候让助手做了什么)
GET / PUT /system/settings system:manage 系统设置(developerMode:开启后 500 响应带 error.stack)
GET / PUT /system/autostart system:manage 服务端开机自启状态 / 开关(非安装形态 supported:false 并给原因)
GET /system/diagnostics system:manage 运行诊断(版本 / Node / 运行时长 / 在线客户端数 / 数据目录…,不含密钥)

新增的这几组用权限键(permission:manage / client:manage / remote:control / client:update-push / ai:use / ai:manage / system:manage / homework:write / notification:publish)标权限,规则见 §5.5 与 角色与权限;上表其余行写的是角色默认模板下的口径。

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} / 班级设备)
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 该班存在 Course.teacherId === 当前用户 被任课的班级
CLASS_DEVICE 班级端 班级码 + 班级密码登录 本班(代全班读写个人数据)

权限矩阵(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 —— 服务层兜底,前端只是按同一矩阵隐藏。

权限组:角色只是默认模板(完整规则见角色与权限):

  • 上面的矩阵描述的是角色默认组的初始权限;默认组可在权限管理中编辑,在账号没有加入其他权限组时生效。
  • 账号可以加入若干权限组,生效权限 = 这些组权限键的并集,因此可以比角色默认更少 (要收回权限就建一个只勾部分键的组);删掉组后成员回到可编辑的角色默认组。
  • ADMIN 按权限组计算,但 permission:manage / system:manage 不可被剥夺(否则可能把自己锁在外面); CLASS_DEVICE 恒定空集,不受权限组影响。
  • 唯一计算入口是 src/lib/permissions.ts 的 loadEffectivePermissions(),每请求实时查询、不缓存; 路由上的 requirePermission('<key>') 与权限页的勾选树共用 @classhelper/shared 里同一份权限键目录, PERMISSION_MATRIX 与它不再是两套规则(矩阵就是默认模板的说明)。

5.6 模块化设计

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

// packages/server/src/modules/registry.ts(当前 20 个模块)
export const apiModules: ApiModule[] = [
  authModule,
  aiModule, // ← 大模型助手
  classesModule,
  clientsModule, // ← 客户端设备 / 心跳 / 远控
  coursesModule,
  schedulesModule,
  homeworksModule,
  notificationsModule,
  permissionsModule, // ← 权限组
  callsModule,
  importsModule,
  gradesModule,
  studentsModule,
  systemModule, // ← 系统设置(开发者模式 / 开机自启 / 诊断)
  teachersModule,
  archivesModule,
  termModule,
  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 已随包内置(数据库管理模块的连接测试 / 迁移需要),无需手动安装。

数据模型共 24 张表:User(纯账号表)/ Student(名单,不是账号)/ StudentClassTransfer(调班与转出历史)/ ArchivedYear(毕业归档届别)/ Class / TermWeek(学期周次区间)/ Course(= 班级 + 科目 + 教师的任课关系)/ Schedule / Homework / HomeworkStatus / Notification / NotificationRead / TimeLayout / Grade / PermissionGroup / PermissionGroupItem / UserPermissionGroup(权限组)/ AppSetting(全局键值设置)/ ClientDevice / ClientCommand(客户端设备与远控)/ ClientRelease(安装包缓存)/ AiAudit(大模型审计)。 其中 Class.name 是「2026级1班」这种届别称呼;Class.code / Class.passwordHash 是班级端账号字段; Class.studentGradeQueryEnabled 是班级端查成绩的开关; Student.studentNo 是学生的唯一标识与查询键;Grade.levelType / level 是等级口径与等级; Homework.assignDate 是作业的「所属日期」;Notification.assignDate 是通知的「所属日期」(本地日期, 配 @@index([classId, assignDate]) 支撑按天查询);User.careMode 是关怀模式的预留列(服务端暂未读写); Schedule.weekParity 是单双周;Schedule.source 是课表来源。

备份 / 导出导入共用 src/lib/snapshot.ts 的 SNAPSHOT_TABLES,按拓扑序导出 21 张 (TermWeek 不在其中 —— 学期周次不进快照,换库后按开学日期重新生成)。

迁移历史(17 个,按时间顺序):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 → add_student_table → add_student_grade_query → add_grade_level → add_classhelper_heartbeat → add_archives → add_term_weeks → add_permission_groups_and_clients (最后一个只加表 / 加列:8 张新表 + Notification.assignDate 与索引 + User.careMode,无数据搬运)。

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

5.8 动效层

两端的动画与过渡统一走一套自研的动效语言,移植自 beUI 的 lib/ease.ts (beUI 的组件本身是 React,无法直接用于本项目的 Vue 两端,但它的动效语言与框架无关)。

  • 令牌单一来源:packages/shared/src/motion.ts。三组缓动曲线(EASE_OUT / EASE_IN_OUT / EASE_DRAWER)与六组具名弹簧(SPRING_PRESS / SWAP / PANEL / LAYOUT / MOUSE / GLIDE)。 弹簧由数值积分求出落定时间,再生成 CSS linear() 缓动字面量 —— 于是弹簧可以纯 CSS 驱动, 不必给每个组件挂一个 rAF 循环去逐帧改样式。
  • 运行时注入:两端的 main.ts 在 app.mount() 之前把令牌写进 :root 的自定义属性 (--ch-ease-* / --ch-spring-* / --ch-dur-*)。CSS 里只引用变量,不抄数值 —— 抄了必漂。
  • 两端各一份组合式函数:packages/{web-admin/src,desktop-client/src/renderer}/composables/motion.ts (useReducedMotion / useHoverCapable / useCountUp / useStaggerIn)。与 ScoreBarChart.vue 一样,两端各一份是仓库既有做法。

落地到界面上的动效:

位置 动效
Element Plus 全站过渡 时长 300/200ms → 200/140ms,缓动换成 EASE_OUT(改写 --el-transition-* 变量,不动组件源码)
弹窗 / 抽屉 / 浮层 / 消息条 面板走 SPRING_PANEL / SPRING_SWAP 弹簧;退出一律比入场快(用户已经决定关掉它了)
按钮 按下 90ms 直落(跟手)、抬起走 SPRING_PRESS 弹回
侧栏激活项 单个"底片"在菜单项之间滑过去(SPRING_LAYOUT),取代"每项各画一块底色"
页面内容 .page > * 按块错峰浮现(30ms 一档,封顶 180ms)
统计数字 0 → 实际值滚动(useCountUp)
主题切换(两端) View Transition API:从点击位置扩散的圆整页换肤
灵动岛 刻意只有单调缓动(曲线取自同一套令牌,但不用弹簧):岛的形变由主进程缓动窗口尺寸,任何过冲都会让"开合"看起来在震动(冒烟逐帧断言)。悬停微交互关在 @media (hover: hover) 里,避免触摸屏上"幽灵 hover"粘住按钮
降低动态效果 全站 prefers-reduced-motion 降级(加载指示旋除外 —— 停掉会让界面看着像卡死)

三条别踩的约定(详见 AGENTS.md §5 第 61 条): 路由层不要包 <transition mode="out-in">;Vue 的 <transition> 一律显式给 :duration; position: fixed 的整屏层与"自己量尺寸"的元素不要叠位移类动画。

5.9 主题(浅色 / 深色)

两端都有深色主题,机制完全一致:在 <html> 上挂 .dark 类,由各自 styles/index.css 的 html.dark 块覆盖语义色令牌,Element Plus 的 theme-chalk/dark/css-vars.css 提供 --el-* 的深色默认。 两端的 main.ts 都在 app.mount() 之前落主题,深色用户首帧才不会看到"先白后黑"闪一下。

  • 语义色令牌:--ch-surface / --ch-surface-alt / --ch-surface-sunken / --ch-surface-hover / --ch-text / --ch-text-secondary / --ch-text-muted / --ch-text-placeholder。 组件里一律引用这些,不要写死颜色;html.dark 只覆盖颜色,不动圆角与动效令牌 —— 两套主题的几何必须完全一致,否则切换时会看到布局跳一下。
  • 默认跟随系统(prefers-color-scheme,且系统改了就跟着变);用户一旦手动点过切换按钮, 就以用户的选择为准(系统的偏好是默认值,不是命令)。
  • 切换入口:客户端在顶栏([data-test="theme-toggle"]),Web 管理端同样在顶栏。 两边都走 useThemeReveal()(见 5.8)——从点击位置扩散的圆整页换肤。
  • 刻意不随主题的区域:登录页的深色渐变底、桌面端的启动过渡页与全屏作业看板、 灵动岛自身、Web 管理端的侧栏(本来就是深色)、以及深色底上的白色文字。
  • Web 端还会同步 <meta name="theme-color">,并设 color-scheme 让原生控件 (滚动条、日期选择器弹层、自动填充底色)也跟着走 —— 只改 CSS 变量的话它们会仍是刺眼的浅色。

⚠️ 改令牌时只改值。别对样式表跑"把 #303133 全量替换成 var(--ch-text)"这类批量替换 —— 它会把令牌定义本身也换掉(--ch-text: #303133 → --ch-text: var(--ch-text)), 自定义属性一旦自引用就整条失效、解析成空串,全站文字颜色静默回落成继承色。

6. 验收

6.1 验收标准对照

验收项 结果 证据
教师 Web 端发布通知,ClassHelper 班级端 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 个已存在对象,新表自动建好
ClassHelper 班级端主体 = 班级(班级码 + 班级密码登录) ✅ 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 项:状态与全部数据表行数、教师全端点 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 仅管理员生效、健康检查暴露版本号与模块清单(含 update 模块);verify:desktop 4 项:IPC 返回结构正确(无外网时降级 ok=false 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;verify:web:「顶栏用户菜单 → 弹窗给出结论并可关闭」
作业按天查看(日期选择器 + 有作业日期高亮) ✅ verify:e2e:assignDate 默认服务器当天、?date= 只返回该天、/homeworks/days 返回有作业日期、非法日期 422
支持在客户端录入作业(含自定义快捷短语) ✅ verify:e2e:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 通用」管理短语
能成功打包 Windows EXE ✅ -x64-setup.exe / -x64-portable.exe(见 7. 打包与交付)
提供完整 README(启动、构建、打包、默认账号) ✅ 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题

6.2 怎么自己跑一遍

上面那张表的证据全部来自仓库里的验收脚本 —— 五套脚本怎么跑、覆盖什么、哪些用例是脆弱的、 失败了怎么判断是不是真回归,见验收与测试。条目数由脚本自行统计并打印, 不要在文档里写死容易过期的总数。

7. 打包与交付

体积与性能(优化后)

产物 / 指标 优化前 优化后 做了什么
客户端渲染产物 dist/renderer 3.2MB 2.1MB 成绩页图表从 echarts 全量(1.12MB,占 35%)换成内置 HTML/SVG 组件;Element Plus 图标从全量注册 293 个改为按需 42/33 个
客户端主进程产物体积 242KB 242KB 未变(冒烟代码拆分会重复 island/config 单例,esbuild 的 CJS 打包无法代码分割,故保留)
客户端解包目录(中间物,打完即删) 372MB 323MB electronLanguages: [zh-CN],只留中文语言包(其余 54 个 locales/*.pak 共 48MB)
客户端 setup.exe / portable.exe 107MB 98MB 同上。安装包有硬底:ClassHelper.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 条。

客户端(Windows 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 的原始布局整理成 安装包/、构建中间/,并把 win-unpacked/(约 323MB 的中间物) 删掉。

只有两个交付物,都落在 releases/client/<版本>/安装包/:-x64-setup.exe(NSIS 安装程序) 与 -x64-portable.exe(自解压单文件,双击即用)。解包目录 win-unpacked/ 只是生成这两个 exe 的 中间产物 —— 便携版是拿它自己做成的自解压包,所以它必须活到 portable 这一步之后才能删, tidyArtifacts() 就排在最后。

把修复交付到「已安装的客户端」:pnpm dist:win 只产出 releases/client/<版本>/ 里的两个安装包。 若学生机上是安装版(例如装在 %LOCALAPPDATA%\Programs\ClassHelper),只更新 release 而不同步安装目录, 用户打开的还是旧 resources/app.asar,会出现「改了但界面不生效」。两个可行做法:

# 做法一(推荐):直接跑新安装包覆盖升级。用户配置在 %APPDATA%,不受影响。
#   静默:releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe /S
#   再对**装好那个副本**跑一次冒烟,确认真的带上了修复(packaged=true)
pnpm verify:packaged --exe "%LOCALAPPDATA%\Programs\ClassHelper\ClassHelper.exe"

# 做法二:要按文件覆盖(例如只换 app.asar),先让 portable 自解压出一份再拷
#   便携版双击后会解到 %TEMP% 下的一个随机目录,从那里 robocopy 即可。

注意路径变了:dist-win.mjs 不再在 releases 里留解包目录, 所以 packages\desktop-client\release\win-unpacked 这个旧路径已经不存在了。

各产物的体积(产物不入库):

产物 大小
服务端安装程序(内置 Node + Web 端) ~62MB
releases/client/<版本>/安装包/…-portable.exe ~99MB
releases/client/<版本>/安装包/…-setup.exe ~99MB

官网(website/)

产品介绍页,纯静态、没有构建步骤,也不进 pnpm workspace:

node website/serve.mjs        # http://127.0.0.1:5180,只是本地预览用

发布时把 website/ 整个目录丢给任意静态托管 / Nginx 即可(index.html 是唯一入口,无路由)。

动效层(assets/motion.js)的参数逐条取自 beUI 的开源 token—— 曲线(EASE_OUT = cubic-bezier(0.16, 1, 0.3, 1) 等)、弹簧(SPRING_PRESS 500/30/0.6、SPRING_LAYOUT 360/32/0.6、 SPRING_GLIDE 700/50/0.5 ……)、时长口径,以及各个组件源码里的具体写法。没有引 React 那套依赖, 是自己写的一个小引擎(Spring 类 + 一个自续 rAF 循环 + duration/bounce 反解刚度的求解器)。

页面上的动效 对着 beUI 的哪个组件写的
标题逐字上浮(位移用弹簧、透明度 0.7s、去模糊 0.9s、逐字 0.03s 错开) motion/text-animation 的 TextReveal
首屏那个灵动岛 blocks/dynamic-island
胶囊文案交换(scale 0.94 + 模糊 8px,0.2s) motion/action-swap 的 blur 变体
铃铛从铰链上摆一下 motion guide「Semantic icon motion」
顶栏悬停胶囊滑移 motion/shared-layout-bg
顶部 2px 阅读进度条 motion/scroll-animation 的 ScrollProgressBar
按钮按压 0.93 / 悬停 1.02 motion/button
验收数字滚动(0–9 竖排滚轮) motion/number 的 NumberTicker
上课时段滑块的把手 motion/range-slider
复制按钮文案滚上来 motion/action-swap 的 roll 变体
实机图库的分段控件(指示器滑移) motion/tabs 的 layoutId 指示器
切分组换图(旧的走得比新的快,±4px) motion guide「Content swap」
点缩略图放大(从原位置长成大图) motion/image-viewer 的 morphing-lightbox

灵动岛保持产品的几何(连续圆角 squircle、真实尺寸 胶囊 268×44 / 展开 424×230 / 叫人 456×262), 换成 beUI 的动效语言:壳用 Apple 的 duration 0.8s + bounce 0.2、内容层 bounce 0.35, 圆角是常数 24(由 min(圆角, w/2, h/2) 钳制,胶囊态自动成为满圆角),内容进出带 scale + 位移 + 模糊。 少动效下只保留透明度:位移、缩放、模糊、回弹全部去掉。

版式照 ClassIsland 官网 写(它是教室大屏那套东西,读者是同一批人, 两边看起来像一套更省事):

版式 来自 ClassIsland 的哪一处
分幕标题(居中大标题 + 中间一段渐变填色) .headline-feature + .gradient-base
特性单元:#彩色标签 → 粗体短标题 → 一句说明 #主界面 / #提醒 … 那一组
首屏左文右块、文字收成一条窄列 .introduction-col(flex-basis: 480px)
首屏逐项错峰入场(.72s EASE_OUT、延迟 150/200/250ms) .intro-seq + --intro-delay
末尾一整块收尾 CTA 「更多功能留给您自行探索!」那一屏

有意思的是 ClassIsland 用的曲线 cubic-bezier(.16, 1, .3, 1) 和 beUI 的 EASE_OUT 是同一条, 所以两套参考并不打架。

标题与引文用的是楷体(优先加载霞鹜文楷 Screen,失败回落到系统 KaiTi / STKaiti), 正文用产品自己那套 UI 字体。字体是 CDN 引入的,断网时会自动回落,不影响阅读。

采集实机图

官网上那一栏实机截图不是画的示意,是脚本真的把程序跑起来截的:Web / 手机走 website/tools/capture-shots.mjs,客户端页面与灵动岛走客户端冒烟。

展示图统一放在 docs/images/ —— 文档站、README 与官网吃的是同一份文件, 重新采集的完整命令(含目录与清理)写在验收与测试的「截图留档」一节。

两个环境变量在 smoke.ts 里是默认值为洋红的开关:冒烟平时故意用洋红主题色,把验证实例的岛 与真实客户端区分开(见 AGENTS.md §5 第 38 条),采集留档图时才换成产品默认外观。

采集脚本会把窗口真的弹在屏幕上(和 verify:web / verify:desktop 一样),跑的时候别动键鼠。 客户端留档会临时停掉 CSS 动画再截图:窗口被遮挡时 Chromium 冻结动画,页面入场动画的起始帧是 opacity: 0,不停掉的话截出来是整片空白(DOM 里文本齐全,只有图是空的)。 capture-shots.mjs 自带 UnknownVizError 重试与空内容跳过,偶发失败不会毁掉整轮。

8. 常见问题

开发与构建环境的坑不在本文维护 —— 统一记在仓库根的 AGENTS.md §7(命令坑、代理与 TLS、 makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列最常被问到的几条:

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

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

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

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

9. 后续可选增强

  1. 代码签名证书(消除 SmartScreen 提示)。
  2. 客户端「提交类操作离线队列」(当前离线为只读,联网后自动同步读取的数据)。
  3. Socket.IO 多实例广播(引入 @socket.io/redis-adapter)与 CI 流水线。
  4. 灵动岛在「跟随鼠标屏幕」之外,支持记住每块屏幕各自的外观偏好。
  5. 触摸版灵动岛在真实触摸屏机器上的人工复验(希沃白板)。

默认权限组与客户端维护(2026-10-16)

管理员和教师各有一个默认权限组;未分配其他组时使用对应默认组,编辑后立即生效。 默认组可以改名、增减权限,但不能删除。分配多个组时取并集。 管理员始终保留 permission:manage 和 system:manage,防止失去管理入口。 班级端不参与账号权限组,班级与科目范围的检查保持生效。

「客户端」页统一显示设备心跳、版本、是否接受自动更新、最近指令结果和安装时机。 更新先从本服务器下载并校验 SHA256,再按所选时机安装:

  • 立即更新:下载完成后安装并重启客户端,会中断当前客户端界面。
  • 放学后更新:按服务器当天日期,等待所选最早时间与本班最后一节课结束时间的较晚者;上课中继续等待。
  • 空闲时更新:不上课且主窗口隐藏满五分钟后安装。

离线设备上线后补领更新;取消下发后,已下载的安装包不再自动安装。 客户端关闭自动更新时,任何下发策略都不会强制安装。 远控仍由管理端确认下发;客户端收到后立即执行白名单动作,并按系统退出码回执。

联动插件及相关接口已下线;升级迁移会删除旧联动设备和推送记录,保留业务课表、通知及离线 JSON 导入。 服务端兼容旧客户端领取修复包;第一次升级仍按旧版空闲条件安装,新策略需修复版客户端。

2026-10-17 课表与组件机制对照

上游现场参照:ClassPlan 模型、 LessonsService 的启用解析、 组件文档。复刻能力与语义,不引入 .NET 或已退役插件。

需求 实现入口 验收入口
日课表、1–9 周轮换、四种时间点、显式位置外键 时间表与课表编辑页、class-plans 模块 packages/server/scripts/verify-v14.ts
换课/临时层/预定/群组,原表不变 schedules 与 class-plan-groups 同上
本机多行、多方案、组件独立设置与触摸拖动 设置→灵动岛→编辑岛内组件;托盘同名入口 verify:desktop 的组件布局用例
容器、规则隐藏、四档字体与外观覆盖 右侧按内容/外观/高级分栏;行设置和整体外观独立 同上 + 显式视觉回归
通知/作业/叫人组件 主要且允许通知的行内,共用岛体和原提醒操作 原有固定模式回归 + 组件布局回归
换服务端重置退避与令牌 config-events / client-agent 本地假服务端心跳用例
即时远控与回执 clients / client-agent 冒烟假执行器;不调用系统命令
下载与安装阶段进度 客户端管理页/桌面关于页 进度 GET、ADMIN 房间事件、IPC
集体成绩与转出过滤 grades / lib/students 临时数据库隐私与范围负断言
自动学年升级与可配周数 学期周次页 dry-run、快照与事务幂等
自启动按钮与日期归档 系统设置页/通知日期选择器 注入系统适配器、Web 回归
四种内置岗位组与只读名单 权限组页/学生名单页 跨年级、跨部 403

天气组件本轮不做:没有可离线使用的数据源,验收不依赖外网。 自定义组件栏位于灵动岛内部,提醒内容也在同一个岛体内展开,不再嵌套另一张带底色的岛卡片。 可在「设置 → 灵动岛」选择经典或自定义灵动岛,常驻内容已并入自定义模式,不再有独立开关。 常态内容与无通知展开内容分别编辑,并随方案一起导入导出。紧急通知立即展开,课中普通通知暂存,下课后展开;点击铃铛可主动查看。 没有待处理消息时点击岛体,展示自定义的空闲展开页;处理完消息后回到常态内容。通知正文、排序与已读操作共用经典岛组件。 铃铛在右侧预留固定空间,收到消息时内容不横移;默认组件按垂直中心对齐,时间使用等宽数字。 常驻外观重新按 ClassIsland 源码对齐:单行基准高 40 px、左右留白 12 px、四档字号 14/16/18/20、时钟使用强调字号 18 px;默认缩放 1×、圆角 8 px、背景不透明度 0.5、行间距 5 px。 窗口设置沿用「指针移入淡化」与「反转指针移入淡化」;普通背景淡化至 5% 并穿透点击,移出恢复。铃铛/展开按钮、编辑模式、已经展开的内容及触摸操作短暂宽限期保持可交互。 界面缩放范围 0.75–2.5×,圆角 0–20 px,背景不透明度 0–1;整体外观可一键恢复 ClassIsland 默认值。 自定义岛使用 ClassIsland 半透明横条外观,内容与外壳同步过渡;应用内动画开关直接决定是否播放,系统减少动态效果不再额外关闭已开启的动画。 开合只改变岛壳,原生窗口保持固定画布,避免窗口重排、闪烁和两端尺寸防抖互相追赶。隐藏窗口直接落到目标尺寸。 布局按内容自然尺寸、屏幕可用空间和缩放设置自动适配;长文本限制宽度并省略,同一行的时间、分隔线与文字保持成组对齐,左右及底部停靠保持对应锚点。 通知列表和空闲展开页均不出现滚动条;较多消息按可用高度展示,完整长正文及剩余消息可通过「查看完整内容」进入应用查看,底部操作始终保留。 编辑器左侧按用途添加组件、中央预览岛内排列、右侧只显示当前对象的设置;方案操作收在顶部菜单。 课程时间位于课程名称右侧并居中对齐,显示完整的起止时间;可在组件设置中关闭「显示课程时间」。 经典与自定义两种布局均在上课前 60 秒弹出准备提醒:先播放 ClassIsland 五段斜切强调遮罩,再显示秒级倒计时、课程及右侧时间范围。 上课时提示 5 秒;下课时先提示,再预告下一节课程。最后一节课结束后只提示课间状态,不展示不存在的下一节课程。 课表提醒独立于通知队列,刷新同一课表不重复开始,移除课程自动取消;已经打开的通知详情与紧急消息优先。提醒只在本机展示,不自动启用音效和语音。 主设置页保留字号、位置、透明度、动画与提醒选项,自定义布局和组件内容在编辑器中调整。 迁移保留旧 Schedule 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课, 迁移选择同一时段 ID 最小的行,全部原行仍保留;旧数据未记录的群组、临时层和时间点名称无法恢复。

实施选择、问题根因与实测结果见 实施记录。

2026-10-17 课程状态与管理后台导航

课程右侧信息与本节进度参考 LessonControlExpanded。新增课中距下课、末段秒数倒数及独立提醒设置;主窗口中的自定义编辑页保留组件拖动与方案管理。

管理后台结构参考 art-design-pro 的 art-sidebar-menu、SidebarSubmenu、art-breadcrumb 与 art-header-bar;采用 Vue 3 / Element Plus 原有技术栈,重新实现分组菜单、面包屑、页签和搜索,业务接口和权限校验保持原有入口。