4 Commits
Author SHA1 Message Date
LoveChengke b7cdc7c9ea fix(clients,client,web): 远控指令的两个修复 —— 执行失败不再谎报成功 + 「上次指令」让"点了没反应"可见(1.3.2)
背景:用户报「下发锁屏等等指令不生效」。先把服务端那条链路单独验了一遍
(模拟客户端:登记 → 下发 → 心跳带回 → 设备列表可见 → 回执 → 清除),**整条是通的**;
真正的问题在两处。

客户端:执行失败会谎报成功
- `spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在下一个 tick 发一个异步 `error` 事件 ——
  只包 `try/catch` 抓不到,代码一路走到 `return { ok: true }`,回执写着「已下达系统命令」,
  Web 端看起来一切正常而机器纹丝不动
- 现在等一个 200ms 的窗口确认没有立刻报错,失败如实回执(`client-agent.ts` 的 `executeCommand`)

Web 端:点了没生效,界面上什么都不说
- 一条命令被**取消 / 过期**之后就从 `pendingCommand` 里消失,页面留白 ——
  而最常见的两种"没反应"恰恰是「本机没人确认(客户端 60 秒自动按取消)」与「下达时机器离线」
- `GET /clients/devices` 增加 `lastCommand`(最近一条,不论状态);
  「客户端」页的设备表多一列「上次指令」,由 `commandStateText()` 翻成一句人话
  (已执行 / 本机用户取消 / 已过期 —— 下达时机器不在线 / 待执行)
- 下发成功的提示补上「60 秒内没人点确认就按取消处理」

文档
- AGENTS.md §5 第 69 条记下这两条,并写明排查同类反馈的顺序:**先看那台机器的客户端版本是否 ≥ 1.3.0**
  (远控命令的处理 `client-agent.ts` 是 1.3.0 才有的,旧客户端连心跳都不发)
- docs/reference.md 4.15 的表格补上「二次确认」的准确口径与新增的「上次指令」列

验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿;
后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、
Web 真实点击 **34/34**、客户端冒烟 **90/90**;
远控链路另用模拟客户端跑通,并确认新增的「上次指令」在待执行与已回执两种状态下都显示正确。
2026-10-11 17:00:11 +08:00
LoveChengke 227117ccb3 fix(integrations,grades,clients,web,client,plugin): 课表同步改为按需(修「课表被反复删改」)+ 客户端页合并 + 成绩排名(1.3.1)
线上问题的根因
- ClassIsland 插件默认每 15 秒上报一次,每次都带全量课表,上报完还顺手把课表镜像回教室
  (本机 `UploadSchedule` / `MirrorSchedule` 两个开关默认都是开)。于是教室的课表每 15 秒被重写、
  服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。
- 现在两个方向都只由 Web 端的一次性待办触发,插件侧那两个开关整体删除;
  定时上报只报**运行状态**(当前上什么课),不带课表。

课表同步(#3)
- 新增 `IntegrationDevice.requestMirrorSchedule` + `POST /integrations/devices/:id/mirror-schedule`
  (「下发课表」),与既有的 `request-schedule`(「获取课表」)对称;拉取成功即清标记
- 插件 `ClassPlanWriter.Apply(..., force: true)`:停用同一天同一单双周相位下冲突的旧课表
  —— **只停用不删除**,内容原样留在档案里,老师随时能重新启用
- 插件设置页删掉「自动上报课表」「镜像回 ClassIsland」两个开关,改为一句说明

「客户端」页(#4)
- 把「ClassIsland 联动」与「客户端设备」合成一页 `ClientHubView.vue`:一个班一张卡片,
  两条链路状态放一起,勾选可批量下发课表 / 更新,点卡片进详情做逐台操作
- 「下发提醒」也搬了过来;入口权限用 `classisland:notify`(默认 ADMIN + TEACHER),
  页内按钮再各自按 device:manage / remote:control / client:update-push 显示或禁用
- 两个旧页面 `meta.hidden: true` **保留路由**(安装包缓存管理、联动步骤说明仍在),
  从详情抽屉里给链接 —— 不删功能

成绩(#5/#6/#7)
- 新增 `Exam`(班级 + 考试名 + `countInTotal`)与迁移;`GET/PUT /api/grades/exams`、
  `GET /api/grades/ranking`(两条都**不挂权限键**,班级端的有效权限是空集,边界在 service 里)
- 排名口径写在服务端:**同一学生同一科目只取最高分**(补录 / 重考会多行,不去重等于"总分多一科")、
  并列同名次(1、2、2、4)
- 「成绩录入」页加「考试口径」开关(按班级 + 考试名记住);客户端「成绩」页新增总分榜 + 单科榜
- `updateStudentGradeQuery` 改成只有教师 / 管理员能改 —— 原先班级端自己也能改
- 客户端的成绩查询开关改为只读(只显示服务端下发的状态)

其他(#2/#5/#8/#9)
- 全校学期开始日期改成 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改、立刻重算周次
  (原先这一级只能来自 `.env`)
- 大模型助手发紧急通知补上「上课时段 3 秒全屏警告」:判据抽成 `composables/useClassInSession.ts`,
  与手动发布页共用
- 客户端去掉「关怀模式」「自动更新」两个开关(教室机器是统一投放的终端)
- 修掉「选中设置二级菜单后收起分组、高亮块仍留在原地」:底片只在路由 / 滚动 / 缩放时重测,
   而收起分组动不了 `.aside` 的盒子 —— 补上"子项被 overflow:hidden 裁掉"的判据 + 观察分组 `<ul>` 高度
- `src/types/express.d.ts` 里 `req.device` 改成引用 `DeviceContext`:原先手抄了一份结构,
  往接口加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;` 上,看不出真正原因

验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿;
后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、
Web 真实点击 **34/34**(含新增的「客户端」页用例)、客户端冒烟 **90/90**。
2026-10-11 16:21:38 +08:00
LoveChengke 3236d3ad83 fix(deploy): install.sh --version 1.3.0 下载 404 —— 拼地址时 tag 少了 v 前缀
Release 的 tag 是 `v1.3.0`,而 `--version` 的用法一向不带 v(用法提示与文档都写 `--version 1.1.2`),
GitHub 的 `/releases/download/<tag>/` 又是**按 tag 精确匹配**的(`releases/tags/1.3.0` 直接 404),
于是 install.sh 直接把 `VERSION` 拿去拼出来的地址必然 404:

    [classhelper] 下载:.../releases/download/1.3.0/classhelper-server-linux-x64-1.3.0.tar.gz
    curl: (22) The requested URL returned error: 404

同一个功能上,`classhelper` 的 `resolve_download()` 一直是 `tag="v${want#v}"`,install.sh 漏了这句 ——
所以 `classhelper upgrade --version 1.3.0` 正常、`bash install.sh --version 1.3.0` 却 404,
两处只在 `deploy/` 内部对不齐。现在两边都归一化。

`VERSION` 本身**保持不带 v**:`tag` 是另一个局部变量,装完写进安装目录的 VERSION 文件用的仍是不带 v
的裸版本号,别把它一起改了。

实测(把 install.sh 里的 `resolve_package_url()` 原样抽出来单独跑,不手抄):

| VERSION 传参  | 修复前                            | 修复后                               |
| ------------- | --------------------------------- | ------------------------------------ |
| `1.3.0`       | `.../download/1.3.0/...` → 404    | `.../download/v1.3.0/...`            |
| `v1.3.0`      | `.../download/v1.3.0/...`         | `.../download/v1.3.0/...`            |
| 留空(latest) | `.../download/v1.3.0/...`         | `.../download/v1.3.0/...`            |

`bash -n` 四个脚本(install.sh / classhelper / verify-linux.sh / install-linux.sh)全过。
本机 curl 到 github.com 超时(见 §7 第 1 条),所以**真正下载要走服务器实测**。

AGENTS.md §5 第 59 条补 ⑦ 记下这条约定。
2026-10-11 14:28:07 +08:00
LoveChengke 0b9a321bc9 docs(release,website,readme): 1.3.0 的更新日志、官网功能块与 README 清单,并把「每次发版四件套」写进 AGENTS.md
- `releases/ReleaseNotes-1.3.0.md`:1.3.0 的 Release 正文草稿(一句话定位 / 七个分节 /
  ⚠️ 升级须知 / 验收 / 产物表)。验收表只写本轮真跑过的静态门禁,运行时验收没跑就直说没跑
- `website/index.html`:老师与管理员加 #权限 / #教室机器 / #大模型助手 / #批量发布 四块,
  教室机器与学生机加 #自愈 一块(11 → 16 块,实测渲染正常)
- `README.md`:功能清单补上权限组、客户端设备、大模型助手、批量发布、系统设置、关怀模式、
  崩溃自愈与离线补弹,以及「客户端更新不依赖外网」
- `AGENTS.md` §4.4 新增「每次发版必须一起交付的四样东西」(更新日志 / Linux 包 / 文档 / 官网与 README),
  并记下「CI 按 tag 的 ref 检出源码」这个坑 —— 发完版再改构建脚本时**光推 master 不够**,
  必须把 tag 挪到修复后的提交;第 ⑦ 步的示例也改成用 `--notes-file`
- `docs/dev/release.md`:同一份四件套清单(面向人)+ 第 7 步改用 `--notes-file` + tag 那个坑

`pnpm docs:check` 0 warning 0 error。
2026-10-11 14:23:29 +08:00
65 changed files with 2314 additions and 437 deletions

No files matched your search

+125 -1
View File
@@ -432,7 +432,9 @@ pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版
git add -A && git commit -m "chore(release): <版本>" && git push origin master
# ⑦ 开 Release(gh release create 会创建并推送 tag,从而触发 Linux 包的 CI;见下)
gh release create v<版本> --title "v<版本>" --generate-notes
# 正文用手写的更新日志,别只留 --generate-notes 的自动列表(见「每次发版必须一起交付的四样东西」)
gh release create v<版本> --title "ClassHelper v<版本>(<一句话定位>)" \
--notes-file releases/ReleaseNotes-<版本>.md
gh release upload v<版本> \
releases/client/<版本>/安装包/*.exe \
releases/classisland-plugin/<版本>/安装包/*.cipx \
@@ -478,6 +480,36 @@ gh workflow run release-linux-server.yml -f tag=v<版本> -f attach=true
**已发布的版本号不要复用**:客户端把它记进「忽略此版本」,两端各有 30 分钟缓存 ——
同号换内容会让「检查更新」永久失灵。要修就 `pnpm version:bump patch` 发新号。
#### 每次发版必须一起交付的四样东西
`pnpm dist:all` 只产出安装包。**每次版本迭代都要把这四样一起做掉**,少一样都算「发到一半」:
| # | 交付物 | 落在哪 | 怎么做 |
| -- | ------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| ① | **更新日志** | GitHub Release 的正文 | 手写,草稿放 `releases/ReleaseNotes-<版本>.md`(与它描述的产物同一层),用 `gh release create/edit --notes-file` 发布。**不要**只挂 `--generate-notes` 的自动列表 |
| ② | **Linux 服务端包** | Release 上的 `classhelper-server-linux-x64-<版本>.tar.gz` | tag 推上去由 `release-linux-server.yml` 构建;**推完必须去 Actions 确认没红** |
| ③ | **文档** | `docs/reference.md`(功能说明 + 验收对照表)与相关章节 | 新增功能补对应小节;改交付形态一并改 §2 / §7 与 `docs/dev/*` |
| ④ | **官网与 README** | `website/index.html`(功能块)、`website/download.html`(兜底版本号)、`README.md`(功能清单) | 新功能补进功能块 / 清单;页面里的版本号由 `pnpm version:bump` 覆盖,不用手写 |
更新日志的骨架照 1.2.0 / 1.3.0 那两份:**一句话定位 → 分节讲功能 → ⚠️ 升级须知(含自动应用的迁移)→
验收表 → 产物表**。验收表里**只写真跑过的数字**,没跑就直说没跑,别照抄上一版的。
**② 有个很容易漏的坑:CI 是按 tag 的 ref 检出源码的**(`actions/checkout` 的
`ref: ${{ steps.ref.outputs.tag }}`)。所以发完版又发现构建脚本(或任何影响出包的代码)要修时,
**光推 master 不够** —— CI 仍会拿旧 tag 的源码跑,必须把 tag 挪到修复后的提交:
```bash
git tag -f v<版本> <修复后的提交> && git push --force origin v<版本>
```
tag 一变会自动重跑。v1.3.0 实测踩过:tag 指的那个提交里 `dist-server.mjs` 有个 bug
(见 §5 第 59 条 ④),CI 红了、Release 上少一个 Linux 包,而本地完全看不出来 ——
Windows 侧走的是另一条分支,`pnpm dist:server` 一直绿。
**推完 tag 去 `gh run list --limit 5` 看一眼。** `发布官网与文档站`(master 推送触发)与
`发布 Linux 服务端安装包`(tag 推送触发)是两条独立流水线,**前者绿不代表后者出来了**;
失败用 `gh run view <id>` 定位到具体是哪一步。
#### 官网与文档站怎么发布
**不用做任何事。** 推 master 时只要 `website/**`、`docs/**`、`scripts/build-pages.mjs` 或
@@ -785,6 +817,31 @@ term / dashboard / database / update / permissions / clients / system`。
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。
**还有第三件:安装目录缺文件 —— 症状是"窗口开着但全黑"。**(2026-10-11 实测)
Electron 的渲染进程要读 `dxil.dll`(`dxcompiler.dll` 的依赖)、`vk_swiftshader.dll` + `vulkan-1.dll`
(软件渲染回退)、`ffmpeg.dll`、`resources/elevate.exe` 这几份**主进程之外的**文件,
少任何一个都会让渲染进程起来就崩,表现是 `loadFile` 抛 `ERR_FAILED (-2) ... index.html` +
`Renderer process crashed`,而**主进程日志里一切正常**(托盘、灵动岛、更新检查全都照常打印)——
于是用户看到的就是"打开一个纯黑窗口,标题栏还是 ClassHelper"。
**注意 `installCrashGuard()` 排在 `await loadRenderer()` 之后**,所以这条崩溃路径上
连"主窗口渲染进程异常退出"那行都不会打,日志里只有一句 Node 的 UnhandledPromiseRejection。
排查顺序(都是实测走通过的):
1. 先确认是不是"黑屏"而不是"没窗口":`tasklist` 里有 `ClassHelper.exe` 但没有可见窗口时,
是另一回事(多为 `--hidden` / 托盘);有窗口且全黑才是缺文件。
2. **别信"我看了一眼目录",要按文件清单比对**:用 7-Zip 直接列安装包里的条目
(`7za l <setup.exe>`,bin 在 `.cache/electron-builder/7zip@*/bin/7za.exe`),
与安装目录 `find <dir> -type f` 的结果做差集。本次差集正好是上面那 5 个文件。
3. 用 `sha256sum <安装目录>/ClassHelper.exe <.cache 里解出来的>/ClassHelper.exe` 确认
装的就是当前构建(本次一致),排除"装的是旧版"。
4. 想复现/验证修复,直接把这 5 个文件从安装包载荷补回安装目录即可(`7za x` 解出来就是)。
**别拿便携版的 `%TEMP%` 解包目录当对照** —— 它是自解压出来的,文件数与安装版可能不同,
但只要两边的 `resources/app.asar` 哈希一致,就说明差的是 Electron 运行时文件而不是业务代码。
为什么值得单独记一条:这次的第一嫌疑(完整性标签、ACL、Chromium 沙箱、`--no-sandbox`、
用户配置)**全部试过且全部无关**,真正的原因是安装目录少文件;而没有上面这套比对方法,
很容易一路查到 `dist-server.mjs`、`electron-builder.yml` 甚至怀疑产物本身。
43. **Web 端新手引导(`el-tour`)在自动化里的判据有三条坑**(2026-10-02 实测,`verify:web`
「首次登录展示新手引导且可跳过」踩过):
① **外壳先出现、内容晚一拍** —— `document.querySelector('.el-tour')` 先于步骤卡片渲染成立,
@@ -1067,6 +1124,17 @@ term / dashboard / database / update / permissions / clients / system`。
CentOS 7 是 2.17 —— 装上去的表现为"服务起来就退出、日志什么都没有"。安装器检测 glibc,
低于 2.28 时自动改用 unofficial-builds 的 `-glibc-217` 变体。CentOS 7 仍属尽力而为。
⑦ **拼下载地址时,tag 必须补 `v` 前缀 —— `--version` 的用法是不带 v 的。**
Release 的 tag 是 `v1.3.0`,而安装/升级参数一律写成 `--version 1.3.0`;GitHub 的
`/releases/download/<tag>/` 是**按 tag 精确匹配**的(`gh api .../releases/tags/1.3.0` 直接 404),
所以中间少一步归一化就会拼出 `.../download/1.3.0/...` → **curl 22 / HTTP 404**。
两处拼地址的地方**必须都写** `tag="v${tag#v}"`:`classhelper` 的 `resolve_download()` 一直有这句,
而 `install.sh` 的 `resolve_package_url()` 2026-10-11 之前是直接 `local tag="$VERSION"` ——
于是同一个版本,`classhelper upgrade --version 1.3.0` 正常、`install.sh --version 1.3.0` 却 404,
只在 `deploy/` 内部对不齐,光看代码很难注意到。新增拼地址的地方照抄这句,别各写一套。
(注意:`VERSION` 本身**保持不带 v** —— `tag` 是另一个局部变量,装完写进安装目录的 VERSION 文件
仍是不带 v 的裸版本号,别把它一起改了。)
两条写这类脚本时的通用坑(本仓库踩过):**`set -e` 下 `[ cond ] && 可能失败的命令` 会
在条件为假时……其实不会退出**(bash 对 `&&` 列表里非最后一条失败是豁免的),但
`A && B` 里 B 失败、以及**用 `$(函数)` 捕获输出时把日志一起捕获**(所以安装器用全局
@@ -1252,6 +1320,18 @@ term / dashboard / database / update / permissions / clients / system`。
然后 `POST /api/clients/commands/:id/ack`(body 带 `deviceKey`,服务端据此校验归属,重复回执幂等)。
同一台机器上**同一条命令只弹一次**(内存去重),回执失败只暂存重试、不重复弹窗。
**`executeCommand()` 必须显式监听子进程的 `error` 事件**(2026-10-11 修):
`spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在 next tick 发一个异步 `error` 事件 ——
只包 `try/catch` 会一路走到 `return { ok: true }`,回执写着"已下达系统命令",机器却纹丝不动。
现在等 200ms 的窗口确认没立刻报错,失败如实回执。
**「点了没反应」必须看得出来**:命令被取消 / 过期后就从 `pendingCommand` 里消失,Web 端原本一片空白,
而最常见的两种失败恰恰是"本机没人确认(60 秒自动取消)"与"下达时机器离线"。
所以 `GET /clients/devices` 一并返回 `lastCommand`(最近一条,不论状态),
「客户端」页把它翻成一句人话(`ClientHubView` 的 `commandStateText`)。
排查这类反馈时先看这一列,再看那台机器的**客户端版本**是否 ≥ 1.3.0 ——
远控命令的处理(`client-agent.ts`)是 1.3.0 才有的,旧客户端连心跳都不发。
70. **客户端安装包由服务端下载并缓存,教室机不直连 GitHub。** 原因有两条:Web 端 CSP
(`connect-src 'self' ws wss …`)不允许浏览器直连 GitHub,而教室机常常根本没有外网。
所以 `POST /api/update/client/fetch` 由**服务端**下载到 `<serverRoot>/../data/client-releases/<版本>/`
@@ -1307,6 +1387,50 @@ term / dashboard / database / update / permissions / clients / system`。
`app.relaunch()`;**冒烟模式绝不重启**(会杀掉验证实例)。
自愈模块在 `packages/desktop-client/src/main/crash-guard.ts`,限流与 `isIdle` 判据别放宽。
78. **课表同步只有两个「一次性动作」,没有"一直同步"的开关。**(2026-10-11 定稿)
教室 → 服务端走 `IntegrationDevice.requestScheduleReport`(Web 端「获取课表」),
服务端 → 教室走新增的 `requestMirrorSchedule`(「下发课表」)—— 两者都是**待办标记**:
Web 端置位,插件在下一次心跳看到就做一次,做完由服务端清标记。
插件侧的 `UploadSchedule` / `MirrorSchedule` 两个本机开关**已删除**:定时上报现在只报
**运行状态**(当前上什么课),课表只在 `forceSchedule` 时才上传。
**为什么改**:原先默认 `ReportIntervalSeconds = 15` + 两个开关都开,等于教室课表每 15 秒被重写一次、
服务端每 15 秒 merge + 清理一次幽灵行 —— 老师看到的是"课表被反复删改"(线上实测)。
配套:`pullClassPlanForDevice` 拉取成功即清 `requestMirrorSchedule`;插件的
`ClassPlanWriter.Apply(…, force: true)` 会**停用**同一天同一单双周相位下冲突的旧课表
(只停用不删除,别改成删除)。契约锁在 `verify:classisland-plugin` 的两条断言里。
79. **「客户端」页 = 「ClassIsland 联动」+「客户端设备」合并成的一页。**(2026-10-11)
路由 `/clients`(`ClientHubView.vue`):一个班一张卡片,两条链路的状态放一起,
勾选可批量下发课表 / 更新,点卡片进详情。入口权限用 **`classisland:notify`**
(默认 ADMIN + TEACHER)而不是 `client:manage`(只给 ADMIN)—— 这一页同时承载
「向教室下发提醒」,老师要用;用后者会把教师整个挡在门外。
页内按钮再各自按 `device:manage` / `remote:control` / `client:update-push` 显示或禁用。
两个旧页面**还留着路由但已从侧栏摘掉**(`meta.hidden: true`,`AdminLayout` 的 `menuItems` 是手写列表):
`IntegrationsView` 留提醒下发与联动步骤,`ClientsView` 留客户端安装包缓存管理
(不先把包拉下来就没有版本可下发),从详情抽屉里的两条链接进。
**别把这两页删掉或把它们的路由一起摘了**,会丢功能。
80. **考试成绩的"总分"是考试的属性,不是分数的属性。** `Exam`(班级 + 考试名 + `countInTotal`)
单独一张表,与 `Grade` 只按名字弱关联(不建外键:成绩删光设置还在,补录时口径不丢)。
排名口径一律写在服务端 `modules/grades` 的 `getExamRanking`:**同一学生同一科目只取最高分**
(老师补录 / 重考会产生多行,不去重等于"总分多一科")、并列同名次(1、2、2、4)。
客户端「成绩」页的排名与"这次算不算总分"都只是**显示**,两个开关都不在客户端。
路由 `/grades/exams`(GET/PUT)与 `/grades/ranking`(GET)**故意不挂权限键** ——
班级端的有效权限是空集,挂 `grade:read` 会把教室机器挡在门外;边界在 service 里
(`assertClassAccess` + `canQueryStudentGrades`),与按学号查明细同一条。
81. **成绩查询开关(`Class.studentGradeQueryEnabled`)只有教师 / 管理员能改。**
原先班级端自己也能改(`updateStudentGradeQuery` 里的 `isClassDevice` 分支),
等于把"学校说不给看"变成一台机器说了算;现在班级端只读(客户端那个开关也撤掉了,
只显示状态)。**注意别拿"通知显示位置(`Class.notificationChannel`)也是班级端自己改"来类比** ——
那个确实是现场偏好,两者不再是一套模式。
82. **全校学期开始日期是 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改。**
原先这一级只能来自 `.env` 的 `TERM_START_DATE`,填错了得动文件 + 重启。
现在 `loadTermContext()` 的三级回落是「`Class.termStartDate` → 全校设置 → `.env`」,
逐周区间仍是「班级 → 全校默认 → 纯线性」。改完 `resolveCurrentWeek` 立刻按新日期算。
## 6. 代码风格
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
+13 -3
View File
@@ -49,9 +49,17 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单
- [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
- [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计;单科排名自动出,可选「各科相加排总分榜」
- [x] ClassIsland 联动:签发设备令牌、下发提醒、把课表镜像回教室大屏
- [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换
- [x] 权限组:39 个权限键,建组分配账号 —— 没分组的按角色默认生效,分组后只按组的并集算
- [x] 客户端(合并页):一个班一张卡片,大屏与灵动岛两条链路一起看;勾选可批量下发课表 / 更新,点进去做逐台操作
- [x] 课表同步按需:点「下发课表」把课表写进教室并停用冲突的旧课表,点「获取课表」把教室的课表取回来 —— 没有"一直同步"
- [x] 客户端设备:每台教室机器的版本、在线状态与最后心跳;一键推送更新,可下发关机 / 重启 / 注销 / 锁屏
- [x] 大模型助手:一句话解析成待确认的作业 / 通知 / 叫人,点确认才落库
- [x] 通知与作业批量发布:勾多个班一次发完,个别班失败不影响其余班级
- [x] 系统设置:开发者模式、运行诊断、开机自启开关(仅安装形态)
- [x] 关怀模式:字号与控件整体放大
- [x] 手机小屏适配,可「添加到主屏幕」当应用用
### 教室里的桌面客户端
@@ -60,9 +68,10 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] 课表:「今天」时间轴(大时钟 + 正在上 / 下一节 + 距下课倒计时)与周视图
- [x] 作业看板:按科目分卡、可全屏铺满、字号可调,也能切回列表
- [x] 通知中心:未读红点、优先级标签、点击自动标为已读
- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布
- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布;按考试看单科排名,老师开启后还有总分排名
- [x] 教室机器可直接录入作业、勾选未交名单
- [x] 离线优先:断网回退 IndexedDB 缓存,恢复连接后自动同步
- [x] 断网期间错过的通知,重新上线时补弹一次;渲染进程崩了会安静地自己重建窗口
- [x] 浅色 / 深色主题,跟随系统
### 灵动岛(桌面通知浮窗)
@@ -87,6 +96,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] Linux 一键安装 + `classhelper` 运维命令(systemd 托管,升级 / 回滚 / 备份)
- [x] Docker Compose(MySQL 版与 SQLite 版各一份)
- [x] SQLite 与 MySQL 一键切换,备份与跨库迁移共用同一份 JSON 快照
- [x] 客户端批量更新不依赖外网:安装包由服务端下载缓存,教室机从服务端取,校验 sha256 后空闲时自动装
## 软件截图
@@ -214,7 +224,7 @@ pnpm dev # 后端 4000 + Web 端 5
| 教师 | `teacher1` | `teacher123` |
| ClassHelper 班级端(班级) | 班级码 `G101` | `123456` |
- [AGENTS.md](AGENTS.md) —— 工程约定与本机踩坑,**改代码前先读它**
- [AGENTS.md](AGENTS.md) —— 工程约定与开发环境注意事项,**改代码前先读它**
- [docs/reference.md](docs/reference.md) —— 完整参考(功能行为、接口、验收记录)
- [docs/dev/architecture.md](docs/dev/architecture.md) —— 架构总览
+1 -1
View File
@@ -23,7 +23,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
image: classhelper-server:${APP_VERSION:-1.3.0}
image: classhelper-server:${APP_VERSION:-1.3.2}
restart: unless-stopped
environment:
NODE_ENV: production
+1 -1
View File
@@ -60,7 +60,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
image: classhelper-server:${APP_VERSION:-1.3.0}
image: classhelper-server:${APP_VERSION:-1.3.2}
restart: unless-stopped
environment:
NODE_ENV: production
+6
View File
@@ -540,6 +540,12 @@ resolve_package_url() {
sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz"
fi
fi
# tag 一律补上 v 前缀:Release 的 tag 是 v1.3.0,而 --version 的用法是 `--version 1.3.0`(不带 v)——
# 直接拿 VERSION 去拼就成了 .../download/1.3.0/...,GitHub 按 tag 精确匹配,必然 404
# (2026-10-11 实测:`install.sh --version 1.3.0` 下载 404,而 `classhelper upgrade --version 1.3.0` 正常
# —— 因为 classhelper 的 resolve_download 一直有这句,install.sh 漏了)。
# 传 `v1.3.0`、`1.3.0`、或者不传(走 latest)三种写法都落在这里。
tag="v${tag#v}"
ver="${tag#v}"
# 注意这里用 GITHUB_REPO_URL 而不是 GITHUB_RELEASES_PAGE:后者本身已经以 /releases 结尾,
# 再拼一次就变成 .../releases/releases/download/... (必然 404,实测踩到)。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "classhelper-server-runtime",
"version": "1.3.0",
"version": "1.3.2",
"private": true,
"type": "module",
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
+1 -1
View File
@@ -139,7 +139,7 @@ export const apiModules: ApiModule[] = [
## 几条贯穿全局的硬约定
这些都是踩过坑之后固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
这些都是长期演进中固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
| 约定 | 为什么 |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
+5 -2
View File
@@ -112,14 +112,17 @@ pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需
## 部署到教室机器
把编出来的 DLL 覆盖到这台机器的 ClassIsland 数据目录(默认 `%USERPROFILE%\ClassIsland\data`,
便携版则是安装目录下的 `data\`)。ClassIsland **运行时会锁住 DLL,必须先完全退出它**:
```powershell
# 1) 完全退出 ClassIsland(它会锁住 DLL)
Get-Process ClassIsland.Desktop | Stop-Process -Force
# 2) 覆盖插件 DLL(**不要覆盖 Settings.json**,那里存着服务器地址与设备令牌)
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll `
D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
"$env:USERPROFILE\ClassIsland\data\Plugins\classhelper.classisland.bridge\" -Force
# 3) 重新启动
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
Start-Process "$env:USERPROFILE\ClassIsland\ClassIsland.exe"
```
改配置要在 **ClassIsland 退出时**改:运行中改会被内存里的副本随时回写覆盖。
+1 -1
View File
@@ -141,7 +141,7 @@ add_permission_groups_and_clients
## 开发时的模型改动
1. 改 `schema.prisma`;
2. 在 `prisma/migrations/` 下加一个迁移目录(本机用
2. 在 `prisma/migrations/` 下加一个迁移目录(用
`pnpm --filter @classhelper/server db:deploy` 应用,**别用 `pnpm db:migrate`** ——
它在 Windows 上不可靠)。要改已有表结构而不只是加列时,可以先用
`pnpm exec prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`
+4 -4
View File
@@ -48,8 +48,8 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
## 仓库里另外两份文档
- **[完整参考](../reference.md)** —— 产品说明书 + 验收对照表(原 README 全文),
功能行为的**权威来源**;仓库首页那份 README 是精简后的门面页,细节都在这里。
- **[完整参考](../reference.md)** —— 产品说明书(原 README 全文),功能行为的**权威来源**;
仓库首页那份 README 是精简后的门面页,细节都在这里。
- **[`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md)** ——
写给 AI 编码代理的工程约定与本机踩坑记录,**改代码前必读**。
里面每一条硬约定都对应一次真实的翻车,比代码注释更值得先看一遍。
工程约定与开发环境注意事项,**改代码前必读**。里面每一条硬约定都对应一处容易踩空的地方,
比代码注释更值得先看一遍。
+25 -1
View File
@@ -19,6 +19,21 @@ description: 改版本号、跑验收、打安装包、发 GitHub Release、发
| 要给别人一份新安装包 | 只在自己机器上跑 |
| 插件单独发补丁(只动插件第 4 段版本号) | — |
## 每次发版必须一起交付的四样东西
打安装包只是其中一件。**少一样都算「发到一半」**:
| # | 交付物 | 落在哪 | 怎么做 |
| -- | ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ |
| ① | **更新日志** | GitHub Release 的正文 | 手写,草稿放 `releases/ReleaseNotes-<版本>.md`,用 `--notes-file` 发布。别只挂自动生成的列表 |
| ② | **Linux 服务端包** | Release 上的 `classhelper-server-linux-x64-<版本>.tar.gz` | tag 推上去由 CI 构建;**推完去 Actions 确认没红**(见第 7 步) |
| ③ | **文档** | `docs/reference.md`(功能说明 + 验收对照表)与相关章节 | 新增功能补对应小节;改交付形态一并改第 2 章 / 第 7 章 |
| ④ | **官网与 README** | `website/index.html`、`website/download.html`、`README.md` | 新功能补进官网功能块与 README 功能清单;版本号由脚本覆盖 |
更新日志的骨架(照 1.2.0 / 1.3.0 那两份写):
**一句话定位 → 分节讲功能 → ⚠️ 升级须知(含会自动应用的迁移)→ 验收表 → 产物表**。
验收表里**只写真跑过的数字**,没跑就直说没跑,不要照抄上一版的。
## 版本号写在哪(14 处 / 12 个文件)
同一个版本号分散在 14 个落点上,**手工改必然漏一处**,而漏掉的那处不会报错:
@@ -143,7 +158,7 @@ cp "releases/client/$V/安装包/"*-x64-portable.exe ".cache/release-staging/Cla
cp "releases/server/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-server-setup.exe"
cp "releases/classisland-plugin/$V/安装包/ClassHelper.ClassIslandPlugin.cipx" .cache/release-staging/
gh release create v$V --title "v$V" --generate-notes
gh release create v$V --title "ClassHelper v$V(<一句话定位>)" --notes-file "releases/ReleaseNotes-$V.md"
gh release upload v$V .cache/release-staging/* "releases/SHA256SUMS-$V.txt" --clobber
```
@@ -173,6 +188,15 @@ gh release upload v$V .cache/release-staging/* "releases/SHA256SUMS-$V.txt" --cl
gh workflow run release-linux-server.yml -f tag=v1.1.3 -f attach=true
```
> **CI 是按 tag 的 ref 检出源码的。** 发完版又发现构建脚本(或任何影响出包的代码)要修时,
> **光推 master 不够** —— CI 仍会拿旧 tag 的源码跑,得把 tag 挪到修复后的提交:
> `git tag -f v<版本> <提交> && git push --force origin v<版本>`,tag 一变会自动重跑。
> 别拿「Windows 侧绿不绿」当判据:`pnpm dist:server` 走的是另一条分支,
> Linux 那一步红的时候本地完全看不出来。
>
> 另外,本页第 8 步的「官网与文档站」是**另一条**流水线(master 推送触发)——
> 它绿不代表 Linux 包出来了,推完 tag 要去 `gh run list --limit 5` 看一眼。
### 8. 官网与文档站
**不用做任何事。** 推送 master 时只要 `website/**`、`docs/**`、`scripts/build-pages.mjs` 或
+11 -10
View File
@@ -53,21 +53,22 @@ pnpm build && pnpm verify:web
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 |
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 |
| `verify:desktop`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 |
| 岛交互 / 岛动画 / 岛截图留档 | **AI 会话里会间歇性失败**:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
| 岛交互 / 岛动画 / 岛截图留档 | 窗口被遮挡或跑在远程会话里时会间歇性失败:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
## 大面积报「登录失败 / 401」时
先核对本机 `teacher1` 的密码 —— 本机实测可能被改成了 `123456`,不是种子默认的 `teacher123`
(原因是「修改密码」弹窗留空会落到默认初始密码)。**处理方式是用环境变量传实际凭据,不要去改数据。**
先确认演示账号的**当前**口令。种子脚本硬编码的是 `teacher123`,但「修改密码」弹窗留空会落到
默认初始密码(`DEFAULT_TEACHER_PASSWORD`,默认 `123456`),任何一次误操作都会把它改掉 ——
而**两个演示账号不一定一起漂**,所以不能简单地全局替换。
两个演示账号的口令**不一定一起漂**,所以不能简单地全局替换。
**处理方式是用环境变量传实际凭据,不要去改数据。**
| 脚本 | 覆盖凭据的方式 |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `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` | **不支持环境变量**,需用临时副本替换(且副本必须放在 `packages/server/scripts/` 里,否则 `import 'socket.io-client'` 会失败) |
| 脚本 | 覆盖凭据的方式 |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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` | **不支持环境变量**(口令硬编码在脚本里),需自己用改过口令的临时副本跑;副本必须放在 `packages/server/scripts/` 里,否则 `import 'socket.io-client'` 会失败 |
## 冒烟会真的动你的屏幕
+1 -1
View File
@@ -1,6 +1,6 @@
# WinIsland 视觉设计系统(Design Token 规格)
来源:`D:\classhelper\.cache\winisland-ref\WinIsland-master`(只读 checkout)。
来源:[WinIsland](https://github.com/WinIslandProject/WinIsland) 的源码(`WinIsland-master`,只读 checkout)。
所有数值均取自代码常量,标注了「常量名 = 值 @ 文件」。未在代码中找到的项明确写「未找到」。
关键前提:**岛内所有几何尺寸都乘以 `compact_scale`(默认 1.0)或 `expanded_scale`(默认 1.0)**,
+2 -20
View File
@@ -7,24 +7,6 @@ description: 装不上、连不上、改了不生效——最常被问到的那
## 安装与启动
### 客户端装不上 / 便捷版双击没反应 / 应用一闪就没了
**现象**:安装程序报「拒绝访问」,便捷版双击毫无反应,装上了也启动即退且不写日志
(退出码 `0x80000003`)。
**原因**:交付物被打了 **Low 完整性标签**。带 Low 标签的 exe 会被 Windows 降级运行:
写不进 `%LOCALAPPDATA%` / `%TEMP%`;即使装上,Chromium 的子进程(完整性更低)也读不到这些文件而秒崩。
**处置**:从 [Releases](https://github.com/LoveChengke/classhelper/releases) 下载的官方包不受影响
(打包脚本收尾会自动修正标签)。自己构建时如果碰到,用:
```cmd
icacls <产物目录> /setintegritylevel Medium /T /C
```
判据是 `icacls <exe> | findstr Mandatory` 里出现 `Low`。
另外**别把安装测试装进仓库目录里** —— 在那里又会继承 Low。
### 双击客户端"点了没反应"
**关窗只是隐藏到托盘,进程还在。** 再双击 EXE 会被单实例锁秒退(看起来像没反应),
@@ -148,8 +130,8 @@ Linux 上跑 `sudo classhelper doctor` 有专门一条断言盯着这个软链
## 还没解决?
- 开发环境的坑(命令、代理与 TLS、完整性标签、ClassIsland 路径与日志编码……)
都记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里;
- **开发与构建环境**的问题(命令、代理与 TLS、ClassIsland 路径与日志编码……)
记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里;
- 功能行为的权威说明在[完整参考](reference.md);
- 都不是的话,去 [Issues](https://github.com/LoveChengke/classhelper/issues) 提一个,
附上版本号(Web 端顶栏 / 客户端「设置 → 关于」里都有)和日志。
+1 -3
View File
@@ -23,9 +23,7 @@ description: 在教室机器上安装 ClassHelper桌面客户端,用班级码
pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的解包目录)
```
> **装不上 / 双击没反应 / 一闪就没**(报 `0x80000003`)通常不是产物坏了,而是交付物被打了
> Low 完整性标签。打包脚本收尾已自动修正;从 Release 下载的包不受影响。
> 另外:关窗是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘退出。
> **双击没反应?** 关窗只是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘右键退出。
## 2. 登录
+1 -1
View File
@@ -30,7 +30,7 @@ ClassHelper不是一个单机软件,它由四部分组成,**必须先有服
- **看界面**:本页文档里各处嵌的都是实机截图,比如[灵动岛](../app/island.md)、[课表](../app/schedules.md)。
- **本地跑一套演示数据**:装好 Node 20.19+ 与 pnpm 之后,
`pnpm install && pnpm build:shared && pnpm db:seed && pnpm dev` 会在本机拉起一套带演示班级的后端与
`pnpm install && pnpm build:shared && pnpm db:seed && pnpm dev` 会在本地拉起一套带演示班级的后端与
Web 管理端(详见[安装服务端](server.md#从源码跑开发模式))。
## 装完之后呢
+1 -1
View File
@@ -64,7 +64,7 @@ ldd --version | head -1
| 教室机器 / 老师电脑也要用 | 客户端与管理端填服务器的**局域网 IP**(如 `http://192.168.1.10:4000`),防火墙放行 4000 |
| 公网访问 | 前面加 Nginx / Caddy 反代并启用 HTTPS,见[生产部署指南](../management/production.md) |
> **别用代理**。本机自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`;
> **别用代理**。自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`;
> 教室机器上如果开了系统代理,填服务端地址后可能拿到代理返回的 404。插件里对本地 / 内网地址做了强制直连,
> 但浏览器与客户端本身不会绕过。
+3 -3
View File
@@ -23,8 +23,8 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
```
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`,
> 不是 `$1` —— 脚本收不到它(实测踩到:本意是体检,结果走进了交互式安装)。
> 先落盘再执行没这个坑,也不会因为下载中断而执行到半个脚本。
> 不是 `$1` —— 脚本收不到它(想体检却会走进交互式安装)。
> 先落盘再执行没这个问题,也不会因为下载中断而执行到半个脚本。
以下是"已有安装包 / 内网离线"的用法:
@@ -75,7 +75,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
`pnpm dist:server:linux` 在非 Linux 上也**能跑**:脚本会用 npm 的 `--os=linux --cpu=x64`
按目标平台解析可选依赖,所以 `@libsql/linux-x64-gnu` 这些"按平台分发"的包会被正确装进来。
适合本地快速打一个包去验证安装与运维流程。已核对过的结论:
适合本地快速打一个包去验证安装与运维流程。逐项核对过的结论:
| 项 | 结论 |
| --- | --- |
+9 -9
View File
@@ -2,12 +2,12 @@
本文档覆盖三种上线形态,按适用场景选择:
| 形态 | 适用场景 | 产物 | 本机验证情况 |
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已实测(163 项端到端) |
| 形态 | 适用场景 | 产物 | 验证状态 |
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已验证(静默安装 / 启停 / 卸载 / UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,尚未实机验证 |
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 实机验收清单见 [linux-deploy.md](linux-deploy.md) §10 |
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已验证(端到端验收通过) |
---
@@ -101,8 +101,8 @@ uninstall-service.cmd :: 删除计划任务
## 二、Docker + MySQL(形态 B,云部署推荐)
> 本机没有 Docker,因此这套文件未在本机实测;结构经过与已验证的产物对照(依赖清单、
> 目录布局、迁移方式与服务端完全一致)。
> 这套文件与已验证的产物结构一致(依赖清单、目录布局、迁移方式与服务端完全相同),
> 但**尚未在真实 Docker 环境跑过**。第一次上线请先在测试机走一遍。
```bash
cp deploy/.env.example deploy/.env
@@ -392,7 +392,7 @@ pnpm --filter @classhelper/server start # 或 node packages/server/dist/ind
## 八、性能与容量参考
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 62 项接口与实时推送全部达标。
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),接口与实时推送均已通过端到端验收。
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,
当前为单实例广播;如需多实例请参考 `packages/server/src/realtime/socket.ts` 引入 `@socket.io/redis-adapter`)。
- 静态资源已设置 immutable 长缓存 + gzip;Web 管理端为 SPA,首屏包体约 900 KB(gzip 约 290 KB),
+110 -105
View File
@@ -19,13 +19,12 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
└──── 老师下发的提醒 ◄────────┘
```
> 本文是 ClassHelper的**完整参考**:功能行为、接口契约、验收证据与已知问题都在这里,是功能口径的权威来源。
> 本文是 ClassHelper的**完整参考**:功能行为、接口契约与已知问题都在这里,是功能口径的权威来源。
> 面向「第一次打开仓库」的展示型介绍在 [README](https://github.com/LoveChengke/classhelper/blob/master/README.md);按读者分组的引导式文档在文档站(本目录)。
> 工程约定(改代码前必读)与本机踩坑在 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md);生产部署在
> 贡献者需要的工程约定在 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md);生产部署在
> [production.md](management/production.md);切换数据库在 [mysql.md](management/mysql.md)。
> 文中的**文件路径均相对仓库根目录**(`packages/…`、`scripts/…`),只有目录链接是相对本文的。
> **改动交付形态 / 新增功能后请一并更新本文**;文中计数均标注实测日期,条目数由脚本自行统计,
> 不要写死容易过期的总数。
> **改动交付形态 / 新增功能后请一并更新本文**;条目数由脚本自行统计,不要写死容易过期的总数。
## 目录
@@ -47,9 +46,10 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
- [4.12 学期周次](#412-学期周次可逐周覆盖仅管理员)
- [4.13 课表同步方向](#413-课表同步方向服务端--教室)
- [4.14 权限组管理(角色只是默认模板)](#414-权限组管理角色只是默认模板)
- [4.15 客户端设备统一更新与远控](#415-客户端设备统一更新与远控)
- [4.15 客户端(合并页):统一更新与远控](#415-客户端合并了classisland-联动客户端设备统一更新与远控)
- [4.16 通知按天归档与批量发布](#416-通知按天归档与批量发布)
- [4.17 大模型助手与开发者模式 / 关怀模式](#417-大模型助手与开发者模式--关怀模式)
- [4.18 考试成绩:单科排名与总分排名](#418-考试成绩单科排名与总分排名)
- [5. 技术参考](#5-技术参考)
- [5.1 目录结构](#51-目录结构)
- [5.2 技术栈](#52-技术栈)
@@ -74,9 +74,9 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#,装在教室的 ClassIsland 上(上报课表与上课状态、弹提醒) | 教室机器(独立于 pnpm workspace) |
| 官网 | `website/` | 纯 HTML / CSS / JS 的静态页面,**没有构建步骤** | 展示与说明(`node website/serve.mjs` 本地预览) |
**设计取舍(与原始提示词的两处偏差,已与用户确认)**:
**两处技术选型取舍**:
1. 桌面客户端采用 **Electron + Vue 3**(而非 Avalonia/FluentAvalonia)——需求正文 90% 的内容
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。
@@ -94,8 +94,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
> 这些产物**都不入库**(`releases/` 已 gitignore + eslint ignore)。三个打包脚本**直接输出**到
> `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`(交付物)、`构建中间/`(可重新生成)分层,
> 历史版本原样保留。**没有免安装目录**:组装/解包出来的那一份只是中间物,脚本打完就删 ——
> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §4。
> 历史版本原样保留。**没有免安装目录**:组装/解包出来的那一份只是中间物,脚本打完就删。
四种部署形态按场景选(Linux 完整步骤见 [docs/management/linux-deploy.md](management/linux-deploy.md),
其余见 [docs/management/production.md](management/production.md)):
@@ -118,7 +117,7 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
> 参数接在最后即可,例如先只体检(不需要 root、不改动系统):把末尾换成
> `sudo bash /tmp/classhelper-install.sh --check`。
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`
> 而不是 `$1`,脚本收不到(实测踩到:想体检却走进了交互式安装)。
> 而不是 `$1`,脚本收不到(想体检却会走进交互式安装)。
## 3. 快速开始
@@ -166,7 +165,8 @@ pnpm build:desktop
pnpm verify:desktop
```
> Electron 二进制**不会**随 `pnpm install` 自动下载,缺失时先手动装一次(见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7 第 5 条)。
> Electron 二进制**不会**随 `pnpm install` 自动下载,缺失时先手动装一次
> (见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7 第 5 条)。
客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,输入**班级码 + 班级密码**
(种子数据:`G101` / `123456`)即可进入班级。想验证「断网可查看缓存」:登录并浏览若干页面后停掉后端
@@ -183,7 +183,7 @@ pnpm verify:desktop
### 默认账号与种子数据
`pnpm db:seed` 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印,**2026-10-06 实测**:
`pnpm db:seed` 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印:
账号 3 / 在读班级 3 / 学生 15 / 课程 54(每班 18 科)/ 课表 93 / 作业 16 / 通知 13 / 成绩 90 /
调班历史 2 / 已归档届别 1):
@@ -232,8 +232,8 @@ pnpm verify:desktop
| `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: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) |
@@ -698,9 +698,23 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
| 方向 | 做什么 | 老师在哪看 |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- |
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止时间),写入本班课表 | Web 端「ClassIsland 联动」实时状态卡片 |
| ClassHelper → ClassIsland | 老师在 Web 端发一条**提醒** → 落库 → 教室机器的插件取回 → ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不会重复弹 | Web 端「ClassIsland 联动 → 下发提醒」 |
| (可选)班级 → ClassIsland | 把 ClassHelper上排好的课表**镜像**回 ClassIsland:新建一份 `ClassHelper-<班级名>` 档案课表,不动老师原有的课表 | 同上页面的「开镜像」开关 |
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止时间),写入本班课表 | 「客户端」页的班级卡片与详情 |
| ClassHelper → ClassIsland | 老师在 Web 端发一条**提醒** → 落库 → 教室机器的插件取回 → ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不会重复弹 | 「客户端」页 → 「下发提醒」 |
| ClassHelper → ClassIsland(课表) | 把本班课表**下发**到教室:写一份 `ClassHelper-<班级名>` 档案课表,并**停用**同一天同一单双周相位下冲突的旧课表(只停用、不删除) | 「客户端」页 → 「下发课表」 |
#### 课表同步:两个方向都「按需」
**没有"一直同步"的开关**,两个方向都只在你点按钮时发生一次:
| 动作 | 从哪点 | 插件做什么 |
| ------------------------ | ------------------------------------- | ---------------------------------------------------------------- |
| **下发课表**(→ 教室) | 「客户端」页的卡片 / 详情里的「下发课表」 | 拉本班课表 → 写进 ClassIsland → **停用**冲突的旧课表(强制替换) |
| **获取课表**(→ 服务端) | 同上的「获取课表」 | 把 ClassIsland 里**启用的**课表合并进本班课表 |
> **为什么不做自动同步**:插件原先默认每 15 秒上报一次、每次上报都带全量课表、上报完还顺手镜像一遍 ——
> 教室的课表每 15 秒被重写、服务端也每 15 秒 merge + 清理一次幽灵行,老师看到的是「课表被反复删改」。
> 现在定时上报**只报运行状态**(当前上什么课),课表永远只在点击时双向走一次。
> 「强制替换」也**只停用不删除**:冲突的课表内容原样留在 ClassIsland 档案里,随时可以重新启用。
#### 接入流程(三步)
@@ -794,8 +808,8 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
> 打包末尾会做一次**运行时裁剪**(删掉 Prisma 为其它数据库方言准备的查询编译器副本等死载荷,
> 见 `scripts/dist-server.mjs` 的 `pruneRuntime()`),组装出来的运行时 469MB → 335MB
> (这份运行时现在只是中间物,打完安装程序即删)。
> - 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致);e2e 只覆盖校验分支
> (同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`。
> - e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`;
> 真实切换走的子进程链路已完整验证(快照 728 行逐表一致)。
### 4.10 更新检查
@@ -875,9 +889,14 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
| 拉取放假安排 | 联网获取当年的法定节假日,**只给建议、不会自动改数据** |
| 保存 | 校验区间不重叠(重叠会让"现在第几周"出现歧义),然后**下发给所有 ClassHelper 班级端** |
- 存储:`TermWeek`(`classId = ''` 表示全校默认)+ `Class.termStartDate`;
- 判定:`lib/term.ts` 的 `loadTermContext()` 做「班级覆盖 → 全校默认 → 纯线性」三级回落,
- 存储:`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` 是正常结果**(不是错误),
失败结果同样进缓存,避免断网点一次要白等一个超时。
@@ -922,7 +941,16 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
- 判定规则与完整矩阵见[角色与权限](dev/permissions.md);**服务端始终是唯一拦截点**,
前端只按 `GET /permissions/me` 隐藏入口,别把它当安全边界。
### 4.15 客户端设备统一更新与远控
### 4.15 客户端(合并了「ClassIsland 联动」+「客户端设备」,统一更新与远控)
入口:Web 管理端 →「客户端」。**一个班一张卡片**,卡片上同时看两条链路的状态
(ClassIsland 插件 = 教室大屏;ClassHelper 客户端 = 灵动岛),勾选可**批量下发课表 / 更新**,
点卡片进详情做逐台操作(签发令牌、重置、下发 / 获取课表、远控、移除记录)。
> 2026-10-11 合并:原先分成「ClassIsland 联动」与「客户端设备」两页,讲的却是同一个教室的两半 ——
> 要给一个班做一件事,得先想清楚该进哪一页,批量操作更是无从下手。
> 入口权限用 `classisland:notify`(默认 ADMIN + TEACHER,教师要用「下发提醒」),
> 页内的设备管理 / 远控 / 下发更新各自再要 `device:manage` / `remote:control` / `client:update-push`。
每台教室机器的心跳间隔 `CLIENT_HEARTBEAT_INTERVAL_MS` = **30 秒**,一次请求把两件事带回:
「要不要更新 / 装哪个版本」与「有没有待执行的远控命令」(`POST /clients/heartbeat`,**不挂权限键**)。
@@ -934,7 +962,8 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
| 命令种类 | 关机 / 重启 / 注销 / 锁屏(`CLIENT_COMMAND_KINDS` + `CLIENT_COMMAND_LABELS`) |
| 新命令取代旧命令 | 同一台机器未回执的旧命令会被自动撤销(先关机后重启不会排队执行) |
| 回执 | `POST /clients/commands/:id/ack`,body 里的 `deviceKey` 既是身份也是护栏(班级端令牌全班共用),且**幂等** |
| 二次确认 | 客户端执行前必须在界面上二次确认;回执里带成功 / 失败原因 |
| 二次确认 | 客户端执行前必须在界面上二次确认(**默认选中「取消」**,**60 秒未确认按取消处理**);回执里带成功 / 失败原因 |
| 上次指令 | 「客户端」页的详情表显示最近一条命令的结果(已执行 / 本机取消 / 已过期 / 待执行)——排"点了没反应"先看它 |
**安装包由服务端中继下发**(教室机器常常没有外网,Web 端的 CSP 也不允许直连 GitHub):
@@ -1007,6 +1036,29 @@ BulkPublishClassResult { classId, ok, id?, code?, message?, details? }
侧栏折叠同样是本机偏好。`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 目录结构
@@ -1111,14 +1163,14 @@ class-helper/
| --------- | -------------------------------------------------------------------- | ---------------------------------------------------------- |
| 包管理 | pnpm workspace | 11.8.0 |
| 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) |
| 后端 | Node.js + Express | ≥ 20.19(本机 24.19) / 5.2 |
| 后端 | 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 |
| 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0(本机 SDK 8.0.425) |
| 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0 |
| 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 |
| Lint/格式 | ESLint 10 + typescript-eslint 8 + eslint-plugin-vue 10;Prettier 3 | — |
@@ -1415,7 +1467,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| 灵动岛 | **刻意只有单调缓动**(曲线取自同一套令牌,但不用弹簧):岛的形变由主进程缓动窗口尺寸,任何过冲都会让"开合"看起来在震动(冒烟逐帧断言)。悬停微交互关在 `@media (hover: hover)` 里,避免触摸屏上"幽灵 hover"粘住按钮 |
| 降低动态效果 | 全站 `prefers-reduced-motion` 降级(**加载指示旋除外** —— 停掉会让界面看着像卡死) |
三条**别踩**的约定(都对应实测过的坑,详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §5 第 61 条):
三条**别踩**的约定(详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §5 第 61 条):
路由层**不要**包 `<transition mode="out-in">`;Vue 的 `<transition>` 一律显式给 `:duration`;
`position: fixed` 的整屏层与"自己量尺寸"的元素不要叠位移类动画。
@@ -1440,7 +1492,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
> ⚠️ 改令牌时**只改值**。别对样式表跑"把 `#303133` 全量替换成 `var(--ch-text)`"这类批量替换 ——
> 它会把令牌**定义本身**也换掉(`--ch-text: #303133` → `--ch-text: var(--ch-text)`),
> 自定义属性一旦自引用就整条失效、解析成空串,全站文字颜色静默回落成继承色(实测踩过)。
> 自定义属性一旦自引用就整条失效、解析成空串,全站文字颜色静默回落成继承色。
## 6. 验收
@@ -1448,7 +1500,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| 验收项 | 结果 | 证据 |
| ------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 教师 Web 端发布通知,ClassHelper 班级端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` 实测 30–50ms 到达(要求 < 5 秒) |
| 教师 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 周 = 单周过滤 |
@@ -1465,7 +1517,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:收起后胶囊常驻、「收起态点击可再次展开」、「空闲细缝态保持可交互」 |
| **触摸屏触摸灵动岛可展开(希沃白板)** | ⏳ | 触摸模式下窗口贴合岛体 + 始终接收输入;`verify:desktop` 断言「窗口 = 岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(触摸屏)需人工复验** |
| 叫人(老师点名,分紧急 / 普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 |
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗实测 |
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗走通 |
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍在、merge 覆盖与保留行为符合预期 |
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 N 个迁移文件,跳过 M 个已存在对象`,新表自动建好 |
| ClassHelper 班级端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 |
@@ -1480,9 +1532,9 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| **登录页无示例内容** | ✅ | `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`:「顶栏用户菜单 → 弹窗给出结论并可关闭」。**真机联网实测**:`GET /api/update/check` → `ok=true`、`latestVersion=1.0.0`、5 个附件、更新说明 1358 字 |
| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;2026-09-27 在真机(ClassIsland 2.1.0.1 + 本插件)上实测过,当时的抓图留在 `docs/screenshots/classisland/` |
| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `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`:「顶栏用户菜单 → 弹窗给出结论并可关闭」 |
| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;抓图留在 `docs/screenshots/classisland/` |
| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 |
| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 通用」管理短语 |
| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 提醒」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:`client` 时通知与联动页下发都被跳过) |
@@ -1491,53 +1543,15 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe`(见 [7. 打包与交付](#7-打包与交付)) |
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题 |
### 6.2 最近一次实测
### 6.2 怎么自己跑一遍
| 验证 | 结果 |
| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `pnpm verify:e2e`(后端 + 实时 + RBAC + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查) | **190/190** ✅(2026-10-05 实测) |
| `pnpm verify:desktop`(客户端冒烟,不弹窗) | **107/107** ✅ + **89/89** ✅(2026-10-06 实测,0 失败) |
| `pnpm verify:web`(Web 管理端真实点击回归) | **32/33**(2026-10-06 实测;未过的 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](management/linux-deploy.md) §10,`deploy/verify-linux.sh` 可自动跑)。2026-10-06 用交叉构建出了 1.1.2 的包并核对过内容(sha256 自洽、顶层目录与 `--strip-components=1` 对得上、`.cross-built` 标记在位、包内是新图标),但**没在 Linux 上跑过** —— 正式发布仍走 CI |
### 6.3 已知的时序 / 环境相关用例
这些用例**不是回归**,遇到时先看条件,别急着改代码(完整清单见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 的「已知脆弱用例」表):
- **`verify:desktop` 的「真实通知链路」「灵动岛标为已读」等 3 项**:要求**当前不在上课时段**。
若种子课表恰有课正在进行,客户端会**正确地**把普通通知暂存到下课(见 [4.6](#46-上课时段策略)),
这三项就会红(日志里会写 `客户端判定上课中=true` / `正在上 XXX`)。2026-10-02 那次 99/102 即为此因,
2026-10-06 09:27(种子里的「政治 08:55–09:40」正在上)那次 104/107 同因。
- **`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 条) |
上面那张表的证据全部来自仓库里的验收脚本 —— 五套脚本怎么跑、覆盖什么、哪些用例是脆弱的、
失败了怎么判断是不是真回归,见[验收与测试](dev/testing.md)。条目数由脚本自行统计并打印,
不要在文档里写死容易过期的总数。
## 7. 打包与交付
### 体积与性能(2026-10-05 优化后实测)
### 体积与性能(优化后)
| 产物 / 指标 | 优化前 | 优化后 | 做了什么 |
| ----------------------------------- | -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
@@ -1554,7 +1568,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
> 没有做的事,以及为什么:Element Plus 仍全量引入(按需引入要改所有视图的样式引入方式与 locale 配置,
> 收益约 200 到 350KB JS,风险不成比例);服务端静态资源不做预压缩;客户端不再删 GPU 回退组件
> (dxcompiler / swiftshader / d3dcompiler,共约 37MB 未压缩,教室老显卡与虚拟机靠它们)。
> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 的 §5 第 53 至 56 条与 §7 第 23 条。
> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 的 §5 第 53 至 56 条。
### 客户端(Windows EXE)
@@ -1567,8 +1581,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
`ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`;
用 `--config.directories.output` 把输出目录覆盖成 `releases/client/<版本>/`,打包成功后再把
electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并把 `win-unpacked/`(约 323MB 的中间物)
删掉;收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,
见 [8. 常见问题](#8-常见问题))。
删掉。
**只有两个交付物**,都落在 `releases/client/<版本>/安装包/`:`-x64-setup.exe`(NSIS 安装程序)
与 `-x64-portable.exe`(自解压单文件,双击即用)。解包目录 `win-unpacked/` 只是生成这两个 exe 的
@@ -1576,14 +1589,14 @@ electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并
`tidyArtifacts()` 就排在最后。
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/` 里的两个安装包。
> 若学生机上是**安装版**(例如装在 `D:\class\@classhelperdesktop-client`),只更新 release 而不同步安装目录,
> 若学生机上是**安装版**(例如装在 `%LOCALAPPDATA%\Programs\ClassHelper`),只更新 release 而不同步安装目录,
> 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。两个可行做法:
>
> ```powershell
> # 做法一(推荐):直接跑新安装包覆盖升级。用户配置在 %APPDATA%,不受影响。
> # 静默:releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe /S
> # 再实测**装好那个副本**真的带上了修复(packaged=true)
> pnpm verify:packaged --exe "D:\class\@classhelperdesktop-client\ClassHelper.exe"
> # 再对**装好那个副本**跑一次冒烟,确认真的带上了修复(packaged=true)
> pnpm verify:packaged --exe "%LOCALAPPDATA%\Programs\ClassHelper\ClassHelper.exe"
>
> # 做法二:要按文件覆盖(例如只换 app.asar),先让 portable 自解压出一份再拷
> # 便携版双击后会解到 %TEMP% 下的一个随机目录,从那里 robocopy 即可。
@@ -1592,14 +1605,14 @@ electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并
> 注意路径变了:`dist-win.mjs` 不再在 releases 里留解包目录,
> 所以 `packages\desktop-client\release\win-unpacked` 这个旧路径已经不存在了。
最近一次打包实测(产物不入库):
各产物的体积(产物不入库):
| 产物 | 大小 | 冒烟结果 |
| ------------------------------------------------- | ------ | -------------------------------------------------------------------- |
| 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 |
| `releases/client/<版本>/安装包/…-portable.exe` | ~99MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 |
| `releases/client/<版本>/安装包/…-setup.exe` | ~99MB | ✅ 构建成功,已嵌入自定义图标 |
| `releases/classisland-plugin/<版本>/…cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 |
| 产物 | 大小 |
| ---------------------------------------------- | ----- |
| 服务端安装程序(内置 Node + Web 端) | ~62MB |
| `releases/client/<版本>/安装包/…-portable.exe` | ~99MB |
| `releases/client/<版本>/安装包/…-setup.exe` | ~99MB |
| `releases/classisland-plugin/<版本>/…cipx` | ~88KB |
### ClassIsland 插件
@@ -1675,8 +1688,8 @@ node website/serve.mjs # http://127.0.0.1:5180,只是本地预览用
展示图统一放在 `docs/images/` —— 文档站、README 与官网吃的是**同一份文件**,
重新采集的完整命令(含目录与清理)写在[验收与测试](dev/testing.md)的「截图留档」一节。
两个环境变量在 `smoke.ts` 里是**默认值为洋红**的开关:冒烟平时故意用洋红主题色把验证实例的岛
和用户自己的岛区分开(见 AGENTS.md §5 第 38 条),采集留档图时才换成产品默认外观。
两个环境变量在 `smoke.ts` 里是**默认值为洋红**的开关:冒烟平时故意用洋红主题色,把验证实例的岛
与真实客户端区分开(见 AGENTS.md §5 第 38 条),采集留档图时才换成产品默认外观。
> 采集脚本会把窗口**真的弹在屏幕上**(和 `verify:web` / `verify:desktop` 一样),跑的时候别动键鼠。
> 客户端留档会**临时停掉 CSS 动画**再截图:窗口被遮挡时 Chromium 冻结动画,页面入场动画的起始帧是
@@ -1685,30 +1698,22 @@ node website/serve.mjs # http://127.0.0.1:5180,只是本地预览用
## 8. 常见问题
**开发环境的坑请直接看 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7「本机环境已知坑」** —— 那里是本仓库唯一维护这些内容的
地方(含命令坑、代理与 TLS、Low 完整性标签、makensis 临时目录、ClassIsland 路径与日志编码、AI 会话里跑
Electron 验证等)。这里只列最常被问到的几条:
**开发与构建环境的坑不在本文维护** —— 统一记在仓库根的
[AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7(命令坑、代理与 TLS、
makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列最常被问到的几条:
1. **客户端装不上 / 便捷版双击没反应 / 应用一闪就没(0x80000003)**
现象:安装程序报「拒绝访问」,便捷版双击毫无反应,装上了也启动即退且不写日志。
原因:**交付物被打了 Low 完整性标签**(本仓库目录树带此标签、可继承,在仓库内构建就会继承)——
带 Low 标签的 exe 会被 Windows 降级运行,写不进 `%LOCALAPPDATA%` / `%TEMP%`;即使装上,Chromium 的
子进程(AppContainer,完整性更低)也读不到这些文件而秒崩。
处置:`pnpm dist:win` 收尾已自动改成 Medium;手工补救与排查命令见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7 第 7 条。
**别把安装测试装进仓库目录里**(会在那儿又继承 Low,看起来像「装完还是坏的」)。
2. **文件传输 / 上传报「服务器内部错误:request entity too large」?**
1. **文件传输 / 上传报「服务器内部错误:request entity too large」?**
表格导入的文件上限是 **8MB**(请求体 12MB),超限应返回 413 `IMPORT_TOO_LARGE` 与中文提示。
若看到英文的英文兜底错误,说明该异常没被 `middleware/error.ts` 映射,属 bug。
3. **`pnpm db:deploy` 在根目录不存在**
2. **`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'`**
3. **`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),或重启服务端。
4. **后端改了代码不生效?** `tsx src/index.ts` 不热载 —— 用 `pnpm dev:server`(watch),或重启服务端。
切换数据库之后**必须重启**才会连新库。
## 9. 后续可选增强
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "class-helper",
"version": "1.3.0",
"version": "1.3.2",
"private": true,
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0",
+1 -1
View File
@@ -14,6 +14,6 @@ description: |-
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper
version: 1.3.0.0
version: 1.3.2.0
apiVersion: 2.0.0.0
author: ClassHelper
+23 -8
View File
@@ -31,6 +31,7 @@ const clientSource = read('src/Interop/ClassHelperClient.cs');
const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs');
const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml');
const mapperSource = read('src/Services/ScheduleMapper.cs');
const writerSource = read('src/Services/ClassPlanWriter.cs');
const settingsSource = read('src/Models/PluginSettings.cs');
const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts');
const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts');
@@ -255,15 +256,24 @@ check(
);
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
// 3.10 课表同步方向(2026-10-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。
// 3.10 课表同步方向(2026-10-11 定稿):**两个方向都只由 Web 端的一次性动作触发**。
// 2026-10-06 曾经把它做成"服务端 → 教室自动下发 + 本机两个开关",但默认 15 秒一轮的
// 自动上传 + 自动镜像会让服务端与教室里的课表每 15 秒互相覆盖一次,老师看到的是
// "课表被反复删改"(2026-10-11 实测)。现在本机没有任何"一直同步"的开关:
// · 教室 → 服务端:`scheduleRequested` 一次性待办(Web 端「从教室机器获取课表」)
// · 服务端 → 教室:`mirrorRequested` 一次性待办(Web 端「下发课表到教室」,强制替换)
// 这几条盯着新契约,别删。
check(
'默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)',
/_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource),
'课表两个方向都改为按需:本机不再有「自动回传 / 自动镜像」开关',
!/_uploadSchedule/.test(settingsSource) && !/_mirrorSchedule/.test(settingsSource),
);
check(
'响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)',
/scheduleRequested/.test(clientSource),
'定时上报只报运行状态,课表仅在服务端显式请求时上传',
/var uploadSchedule = forceSchedule;/.test(bridgeSource),
);
check(
'响应 DTO 里有 scheduleRequested / mirrorRequested(两个方向的一次性待办)',
/scheduleRequested/.test(clientSource) && /mirrorRequested/.test(clientSource),
);
check(
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
@@ -271,8 +281,13 @@ check(
/forceSchedule:\s*true/.test(bridgeSource),
);
check(
'服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)',
/forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource),
'只有收到 mirrorRequested 才镜像(不再跟着每一次上报走)',
/MirrorRequested\s*==\s*true/.test(bridgeSource),
);
check(
'镜像写入是「强制替换」(停用冲突的旧课表,而不是只留一条警告)',
/Apply\(profile, plan, force: true\)/.test(bridgeSource) &&
/IsEnabled = false/.test(writerSource),
);
check(
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
@@ -88,6 +88,15 @@ public class ReportSettingsDto
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
/// </summary>
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
/// <summary>
/// 服务端请求**下发一次课表到教室**(老师在 Web 端点了「下发课表到教室」)。
///
/// 与 <see cref="ScheduleRequested"/> 反方向,同样是一次性待办:看到就拉本班课表并
/// **强制替换**写进 ClassIsland;拉取成功时由服务端清除标记。
/// 没有"后台定时镜像"那条路径了 —— 那会让教室课表被反复重写(见 BridgeService 的说明)。
/// </summary>
[JsonPropertyName("mirrorRequested")] public bool MirrorRequested { get; set; }
}
public class ReportResultDto
@@ -17,11 +17,11 @@ public class PluginSettings : ObservableObject
private string _deviceKey = "";
private bool _autoReport = true;
private int _reportIntervalSeconds = 60;
// 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
// 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。
// 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。
private bool _uploadSchedule = false;
private bool _mirrorSchedule = true;
// 课表同步(2026-10-11 起):**两个方向都只由 Web 端的一次性动作触发** ——
// 「从教室机器获取课表」与「下发课表到教室」,都走服务端的一个待办标记。
// 原先这里有两个开关(自动回传 / 自动镜像),默认 15 秒一轮,结果是服务端与教室里
// 的课表每 15 秒互相覆盖一次,老师看到的就是"课表被反复删改"。
// 定时上报现在**只报运行状态**(当前上什么课),不带课表。
private bool _receiveNotifications = true;
private int _notificationDurationSeconds = 8;
private int _notificationPollSeconds = 10;
@@ -76,33 +76,6 @@ public class PluginSettings : ObservableObject
set => SetProperty(ref _reportIntervalSeconds, Math.Clamp(value, 15, 3600));
}
/// <summary>
/// 是否把 ClassIsland 的课表**自动**同步到 ClassHelper。
///
/// **默认关闭**:课表以 ClassHelper为准,教室这边改完课表不会自己传上去
/// (否则老师刚在 ClassHelper上排好的课会被教室的旧课表悄悄覆盖)。
/// 要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,
/// 与本开关无关(见 <c>ReportSettingsDto.ScheduleRequested</c>)。
/// 关闭后仍会上报"当前上什么课"的状态。
/// </summary>
public bool UploadSchedule
{
get => _uploadSchedule;
set => SetProperty(ref _uploadSchedule, value);
}
/// <summary>
/// 是否把 ClassHelper上排好的课表镜像回 ClassIsland
/// (新建一份名为「ClassHelper-&lt;班级名&gt;」的档案课表,不覆盖老师原有的课表)。
///
/// **默认开启**:这是现在课表同步的主方向。
/// </summary>
public bool MirrorSchedule
{
get => _mirrorSchedule;
set => SetProperty(ref _mirrorSchedule, value);
}
/// <summary>
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
/// </summary>
@@ -206,10 +206,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
}
}
/// <summary>发起一次上报(事件驱动:已有上报在跑时直接丢弃本次,避免事件风暴打出并发请求)。</summary>
/// <summary>
/// 发起一次**仅状态**的上报(定时器与课程事件都走这里)。
///
/// 刻意不带课表:课表只在服务端显式点「从教室机器获取课表」时上传(见 <see cref="ReportAsync"/>
/// 的 <c>forceSchedule</c>)。原先每次上报都带全量课表、默认 15 秒一次,服务端于是每 15 秒
/// merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"(2026-10-11 实测)。
/// </summary>
public void RequestReport(string reason)
{
_ = ReportAsync(reason);
_ = ReportAsync(reason, includeSchedule: false);
}
/// <summary>
@@ -251,17 +257,14 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
var entryCount = 0;
var layoutCount = 0;
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
// forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
// 课表**只在服务端显式请求时**上传:没有"本机自动回传"这条路径了
// (原先由本机 `UploadSchedule` 开关决定,默认开 + 定时 15 秒 = 服务端课表被反复 merge)。
// forceSchedule:服务端点了「从教室机器获取课表」,这是一次人工请求。
var uploadSchedule = forceSchedule;
if (includeSchedule && !uploadSchedule)
{
_logger.LogInformation(
"ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
"需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
_logger.LogInformation("ClassHelper 联动:本次只上报运行状态;课表由 Web 端点「从教室机器获取课表」时才会拉取");
}
if (includeSchedule && uploadSchedule && profile is not null)
@@ -337,8 +340,12 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
}
if (_settings.MirrorSchedule)
// 只有服务端点了「下发课表到教室」才镜像。原先跟着**每一次上报**走(本机 MirrorSchedule
// 开关 + 默认 15 秒周期),教室的课表被反复重写 —— 与服务端那边的 merge/清理叠在一起,
// 就是老师反馈的"课表一直在变"。现在是一次性待办,拉取成功由服务端清标记。
if (data.Settings?.MirrorRequested == true)
{
_logger.LogInformation("ClassHelper 联动:服务端请求下发课表,正在拉取并强制替换教室课表…");
await MirrorScheduleAsync().ConfigureAwait(false);
}
@@ -449,7 +456,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
// **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。
var outcome = await Dispatcher.UIThread.InvokeAsync(() =>
{
var applied = ClassPlanWriter.Apply(profile, plan);
var applied = ClassPlanWriter.Apply(profile, plan, force: true);
if (!applied.Applied)
{
return applied;
@@ -507,7 +514,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.3.0.0";
private const string PluginVersion = "1.3.2.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion()
@@ -41,7 +41,13 @@ public static class ClassPlanWriter
/// <summary>
/// 应用服务端下发的课表。
/// </summary>
public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source)
/// <param name="force">
/// 「强制更换课表」:同一星期几 + 同一单双周相位下老师还开着别的课表时,**停用**它们,
/// 让教室大屏立刻改成以 ClassHelper 下发的这份为准。
/// 由 Web 端的「下发课表到教室」按钮触发(服务端那个一次性待办)。
/// **只停用不删除** —— 课表内容原样留在档案里,老师随时能在 ClassIsland 里重新启用。
/// </param>
public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source, bool force = false)
{
var result = new ApplyResult();
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
@@ -132,7 +138,9 @@ public static class ClassPlanWriter
profile.ClassPlans[planId] = plan;
result.PlanNames.Add(planName);
// 冲突提示:同一星期几 + 同一单双周相位下老师还开着别的课表
// 冲突处理:同一星期几 + 同一单双周相位下老师还开着别的课表。
// ClassIsland 在同一个时间点上只会用一份**启用**的课表,所以"强制更换"就是把冲突的
// 那份停用 —— 教室大屏上立刻显示成 ClassHelper 下发的这一份。
var conflicts = profile.ClassPlans
.Where(pair => pair.Key != planId
&& pair.Value.IsEnabled
@@ -140,13 +148,22 @@ public static class ClassPlanWriter
&& pair.Value.TimeRule.WeekDay == group.WeekDay
&& pair.Value.TimeRule.WeekCountDivTotal == group.WeekCountDivTotal
&& pair.Value.TimeRule.WeekCountDiv == group.WeekCountDiv)
.Select(pair => pair.Value.Name)
.ToList();
if (conflicts.Count > 0)
{
result.Warnings.Add(
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
"两者重叠时以 ClassHelper同步的课表为准");
var names = string.Join("、", conflicts.Select(pair => pair.Value.Name));
if (force)
{
foreach (var pair in conflicts) pair.Value.IsEnabled = false;
result.Warnings.Add(
$"星期 {group.WeekDay} 原有的课表({names})已停用,教室显示改以 ClassHelper 下发的课表为准" +
"(只停用、未删除,可在 ClassIsland 的课表面板里重新启用)");
}
else
{
result.Warnings.Add(
$"星期 {group.WeekDay} 已有启用的课表({names}),两者重叠时以 ClassHelper 同步的课表为准");
}
}
}
@@ -35,8 +35,7 @@
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="检查连接" Command="{Binding TestCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="拉取待提醒" Command="{Binding PullCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="重建档案课表" Command="{Binding MirrorCommand}"
IsEnabled="{Binding Settings.MirrorSchedule}" />
<Button Content="重建档案课表" Command="{Binding MirrorCommand}" IsEnabled="{Binding !Bridge.Busy}" />
</StackPanel>
<!-- 状态 -->
@@ -50,33 +49,13 @@
</Border>
<!-- 同步 -->
<TextBlock Margin="0 24 0 0" Text="同步内容"
<TextBlock Margin="0 24 0 0" Text="课表同步"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="课表以「合并」方式写入 ClassHelper:同一星期、同一时间的课目会被更新,不会产生重复行。" />
<fa:SettingsExpander Header="自动上报课表到 ClassHelper"
Description="把 ClassIsland 里启用的课表**自动**同步给 ClassHelper。默认关闭:课表以 ClassHelper 为准,自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,与本开关无关。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8A5;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.UploadSchedule, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
Description="反方向同步:把 ClassHelper上排好的课表写回 ClassIsland,会新建一份以「ClassHelper-」开头的档案课表,不会修改老师原有的课表。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8F1;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.MirrorSchedule, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
Text="课表的两个方向都是按需的,由 Web 端「ClassIsland 联动」页的两个按钮触发,本机不再提供「一直同步」的开关:「从教室机器获取课表」把 ClassIsland 里启用的课表合并进 ClassHelper;「下发课表到教室」把 ClassHelper 排好的课表写回 ClassIsland,并把同一天同一单双周相位下冲突的旧课表停用(只停用、不删除,可在 ClassIsland 的课表面板里重新启用)。" />
<fa:SettingsExpander Header="自动上报"
Description="应用启动、课表变化以及每隔一段时间各上报一次;关闭后只能用上面的「立即上报」手动触发。">
Description="应用启动、课程事件以及每隔一段时间各上报一次**运行状态**(现在上什么课、ClassIsland 版本等)。课表不在这条路径上 —— 它只由 Web 端「从教室机器获取课表」按需拉取。关闭后只能用上面的「立即上报」手动触发。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE895;" />
</fa:SettingsExpander.IconSource>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "1.3.0",
"version": "1.3.2",
"private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
@@ -317,7 +317,22 @@ async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; r
try {
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
const child = spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true });
/**
* **必须监听 `error` 事件**:`spawn` 找不到可执行文件(或没权限)时不是抛异常,而是在
* next tick 上发一个 `error` 事件 —— 只包 `try/catch` 的话,这种情况会一路走到
* `return { ok: true }`,回执写着"已下达系统命令",Web 端看起来一切正常,机器却纹丝不动。
* 等一个很短的窗口(200ms)确认没有立刻报错;真执行成功时子进程本来也要跑一会儿。
*/
const failure = await new Promise<string | null>((resolve) => {
child.once('error', (error) => resolve(error.message));
setTimeout(() => resolve(null), 200);
});
child.unref();
if (failure) {
logger.warn(`远控命令「${CLIENT_COMMAND_LABELS[kind] ?? kind}」无法执行:${failure}`);
return { ok: false, result: failure };
}
return { ok: true, result: '已下达系统命令' };
} catch (error) {
return { ok: false, result: error instanceof Error ? error.message : String(error) };
@@ -4,6 +4,8 @@ import {
type ClassDto,
type ClassIslandNotificationChannel,
type CourseDto,
type ExamDto,
type ExamRankingDto,
type HomeworkDaysDto,
type DashboardSummary,
type GradeDto,
@@ -136,6 +138,15 @@ export const gradeApi = {
*/
studentDetail: (studentNo: string): Promise<StudentGradeDetailDto> =>
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`),
/** 本班出现过的考试(含"这次考试算不算总分") */
exams: (classId: string): Promise<ExamDto[]> => api.get(`${API_PATHS.grades}/exams`, { classId }),
/**
* 一次考试的单科榜与总分榜。
* 总分榜只在老师把这次考试设为"所有科目相加排名"时才有内容;
* 整条通道与按学号查明细共用同一个按班开关(关闭时服务端 403)。
*/
ranking: (classId: string, examName: string): Promise<ExamRankingDto> =>
api.get(`${API_PATHS.grades}/ranking`, { classId, examName }),
};
/**
@@ -125,12 +125,27 @@ function syncIndicator(): void {
return;
}
const rect = active.getBoundingClientRect();
/**
* 「看得见」不等于「宽高 > 0」。
*
* 分组(`.el-sub-menu`)收起时,Element Plus 是把它那个 `<ul>` 的**高度**动画到 0,
* 再配 `overflow: hidden` 把内容裁掉 —— 子项**自身的 rect 仍在原位**(布局压根没变)。
* 不补这一条,底片就会停在那个已经被裁掉的子项上:用户看到的是"设置分组收起来了,
* 但选择块还留在那儿"(2026-10-11 反馈,图一那个空的蓝块就是它)。
* 判据取"子项是否落在父 `<ul>` 的可见高度里",收起动画过程中也成立(逐帧为假)。
*/
const listRect = active.parentElement?.getBoundingClientRect();
const insideList =
!listRect ||
(listRect.height > 0.5 && rect.top >= listRect.top - 0.5 && rect.bottom <= listRect.bottom + 0.5);
indicator.value = {
x: rect.left - boxRect.left,
y: rect.top - boxRect.top,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0,
visible: rect.width > 0 && rect.height > 0 && insideList,
};
}
@@ -142,6 +157,16 @@ const indicatorStyle = computed(() => ({
}));
let asideObserver: ResizeObserver | null = null;
/**
* 分组(`.el-sub-menu`)展开 / 收起的观察器。
*
* 为什么 `asideObserver` 不够:收起分组改的是它内部 `<ul>` 的**高度**,`.aside` 与 `.menu`
* 这两个盒子的尺寸都不变(栏宽没动、滚动容器也还是那么高),于是 ResizeObserver 收不到回调,
* 底片就停在刚才那个子项的位置上(见 `syncIndicator()` 里那段注释)。
* 直接观察每个分组的 `<ul>`,它的高度一动就重新量。
*/
let submenuObserver: ResizeObserver | null = null;
let indicatorFrame = 0;
/**
@@ -205,6 +230,15 @@ onMounted(() => {
if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync);
asideObserver.observe(aside);
// 分组的展开 / 收起(见 submenuObserver 的注释);等一拍再挂,确保 el-menu 已经把
// 各分组的 <ul> 渲染出来 —— onMounted 这一刻子组件的菜单项可能还没落位
void nextTick(() => {
submenuObserver = new ResizeObserver(scheduleIndicatorSync);
document
.querySelectorAll<HTMLElement>('.aside.ch-nav .el-sub-menu > .el-menu')
.forEach((el) => submenuObserver?.observe(el));
});
}
});
@@ -217,6 +251,8 @@ onUnmounted(() => {
menuScroller = null;
asideObserver?.disconnect();
asideObserver = null;
submenuObserver?.disconnect();
submenuObserver = null;
});
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
@@ -6,6 +6,8 @@ import {
formatDate,
gradeLevel,
gradePercent,
type ExamDto,
type ExamRankingDto,
type GradeDto,
type StudentGradeDetailDto,
} from '@classhelper/shared';
@@ -114,16 +116,69 @@ async function loadGrades(): Promise<void> {
}
}
/* ------------------------------------------------------------ 考试排名
*
* 需求:「服务端选择成绩查询可用时,客户端可以直接查看全部,并且自动排名;
* 单次考试可选是否把所有科目相加以总分排名,客户端也能看到总分排名和个别单科的排名」。
*
* 排名**一律由服务端算**(`GET /api/grades/ranking`):并列名次(1、2、2、4)、
* "同一学生同一科补录过多条只取最高分"这些口径写在服务端,三端才不会各算一套。
* 是否开放由班级上的 `studentGradeQueryEnabled` 决定(关闭时服务端 403),
* 而"这次考试算不算总分"由老师在 Web 端定(`Exam.countInTotal`)——
* 客户端只负责显示,两个开关都不在这里。
*/
const examList = ref<ExamDto[]>([]);
const selectedExam = ref('');
const ranking = ref<ExamRankingDto | null>(null);
const rankingLoading = ref(false);
const rankingError = ref('');
async function loadExams(): Promise<void> {
const classId = auth.user?.classId;
if (!classId) return;
try {
examList.value = await gradeApi.exams(classId);
if (!selectedExam.value && examList.value.length > 0) {
// 默认看最近一次:列表按名字排,取最后一条通常就是最新那场考试
selectedExam.value = examList.value[examList.value.length - 1]?.name ?? '';
}
await loadRanking();
} catch (error) {
rankingError.value = error instanceof Error ? error.message : '获取考试列表失败';
}
}
async function loadRanking(): Promise<void> {
const classId = auth.user?.classId;
if (!classId || !selectedExam.value) {
ranking.value = null;
return;
}
rankingLoading.value = true;
rankingError.value = '';
try {
ranking.value = await gradeApi.ranking(classId, selectedExam.value);
} catch (error) {
ranking.value = null;
rankingError.value = error instanceof Error ? error.message : '获取排名失败';
} finally {
rankingLoading.value = false;
}
}
function onGradeEvent(): void {
void loadGrades();
void loadExams();
}
function onRecovered(): void {
void loadGrades();
void loadExams();
}
onMounted(async () => {
await loadGrades();
await loadExams();
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.onServerRecovered(onRecovered);
});
@@ -195,6 +250,87 @@ onUnmounted(() => {
</template>
</el-card>
<!-- 考试排名:总分榜(老师把这次考试设成"所有科目相加"时才有)+ 单科榜。
排名与"算不算总分"都由服务端说了算,这里只显示。 -->
<el-card v-if="auth.isClassSession" v-loading="rankingLoading" shadow="never" class="mt-16">
<template #header>
<div class="ranking-header">
<span>考试排名</span>
<el-select
v-model="selectedExam"
placeholder="选择考试"
size="small"
style="width: 200px"
:disabled="examList.length === 0"
@change="loadRanking"
>
<el-option
v-for="item in examList"
:key="item.name"
:label="`${item.name}(${item.gradeCount} 条)`"
:value="item.name"
/>
</el-select>
<el-button size="small" :icon="'Refresh'" @click="loadRanking">刷新</el-button>
</div>
</template>
<el-alert v-if="rankingError" type="warning" :closable="false" show-icon :title="rankingError" />
<el-empty
v-else-if="examList.length === 0"
description="还没有成绩记录,先在 Web 端录入或导入"
:image-size="60"
/>
<template v-else-if="ranking">
<el-alert v-if="!ranking.countInTotal" type="info" :closable="false" show-icon class="mb-12">
<template #title>这次考试没有开启总分排名</template>
<template #default>
语数外与音体美的分数量纲不同,合不合总分由老师决定 —— 需要总分榜请在 Web 端「成绩录入」
页把该次考试的「考试口径」打开。
</template>
</el-alert>
<el-table v-if="ranking.countInTotal" :data="ranking.total" size="small" max-height="320">
<el-table-column label="总分排名" width="96">
<template #default="{ row }">
<el-tag :type="row.rank <= 3 ? 'danger' : 'info'" effect="plain" size="small">
{{ row.rank }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="studentNo" label="学号" width="110" />
<el-table-column prop="studentName" label="姓名" width="110" />
<el-table-column label="总分" width="90">
<template #default="{ row }">{{ row.score }}</template>
</el-table-column>
<el-table-column label="科目数" width="90">
<template #default="{ row }">{{ row.subjectCount }}</template>
</el-table-column>
</el-table>
<el-divider v-if="ranking.countInTotal" content-position="left">单科排名</el-divider>
<el-tabs v-if="ranking.subjects.length > 0" type="border-card">
<el-tab-pane
v-for="subject in ranking.subjects"
:key="subject.courseName"
:label="subject.courseName"
>
<el-table :data="subject.rows" size="small" max-height="300">
<el-table-column label="名次" width="80">
<template #default="{ row }">{{ row.rank }}</template>
</el-table-column>
<el-table-column prop="studentNo" label="学号" width="110" />
<el-table-column prop="studentName" label="姓名" width="110" />
<el-table-column label="得分" width="120">
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<el-empty v-else description="这次考试还没有单科成绩" :image-size="60" />
</template>
</el-card>
<el-row :gutter="12">
<el-col :xs="24" :md="8">
<div class="stat-card">
@@ -305,4 +441,14 @@ onUnmounted(() => {
gap: 6px;
margin-top: 8px;
}
.ranking-header {
display: flex;
align-items: center;
gap: 8px;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
@@ -167,9 +167,7 @@ async function resetPhrases(): Promise<void> {
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
*/
const autoLaunch = ref(false);
const autoUpdate = ref(true);
const savingLaunch = ref(false);
const savingUpdate = ref(false);
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
const agent = ref<ClientAgentStatus | null>(null);
@@ -202,7 +200,6 @@ const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
async function loadLaunchSettings(): Promise<void> {
if (!window.desktop?.getConfig) return;
const config = await window.desktop.getConfig();
autoUpdate.value = config.autoUpdate !== false;
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
const info = await window.desktop.getAppInfo?.().catch(() => null);
if (info?.appVersion) appVersion.value = info.appVersion;
@@ -227,52 +224,7 @@ async function toggleAutoLaunch(value: string | number | boolean): Promise<void>
}
}
async function toggleAutoUpdate(value: string | number | boolean): Promise<void> {
if (!window.desktop?.saveConfig) return;
const wanted = value === true;
autoUpdate.value = wanted;
savingUpdate.value = true;
try {
// 注意:必须传纯布尔值 —— 跨 contextBridge 传响应式对象会在进入 preload 前就被结构化克隆拒绝
const saved = await window.desktop.saveConfig({ autoUpdate: wanted });
autoUpdate.value = saved.autoUpdate !== false;
ElMessage.success(wanted ? '已开启自动更新' : '已关闭自动更新(仍可在「关于」页手动更新)');
} catch (error) {
autoUpdate.value = !wanted;
ElMessage.error(error instanceof Error ? error.message : '保存自动更新设置失败');
} finally {
savingUpdate.value = false;
}
}
/* ------------------------------------------------------------ 关怀模式 / 开发者模式 */
/**
* 关怀模式(需求 14):放大**正文与控件**字号。
*
* 这里只挂 `<html>.care-mode` 类并落盘,放大规则全在 `styles/index.css` 末尾那个集中覆盖块里
* (`--ch-font-scale` + 少数几个常用类),不在 JS 里逐个元素改字号。
*
* 开关直接读 ui store 的状态,**不用本地 ref 复制一份**:侧栏、路由守卫、主题页读的都是它,
* 复制就会出现"两个开关各说各话"。`ui.init()` 在首屏渲染前已从主进程配置恢复过。
*
* 关于"与助手联动":开启关怀模式会放大 AI 助手的按钮 —— 这条联动**只存在于 Web 管理端**,
* 本客户端目前没有助手入口,所以这里不放大任何按钮,也不为了"看起来对称"去造一个入口。
*/
const savingCareMode = ref(false);
async function toggleCareMode(value: string | number | boolean): Promise<void> {
const wanted = value === true;
savingCareMode.value = true;
try {
await ui.setCareMode(wanted);
ElMessage.success(wanted ? '已开启关怀模式:正文与控件字号已放大' : '已关闭关怀模式');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存关怀模式设置失败');
} finally {
savingCareMode.value = false;
}
}
/* ------------------------------------------------------------ 开发者模式 */
/**
* 开发者模式(需求 10):显示调试页面与详细日志,仅排查问题时打开。
@@ -448,13 +400,15 @@ onBeforeUnmount(() => {
<strong>不上课且窗口已隐藏 5 分钟后</strong>
进行,下载随时可能发生。
</p>
<p class="text-muted">服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。</p>
<!--
这里**刻意没有开关**(需求:「客户端无权选择是否要自动更新」)。
教室机器是统一的投放终端,版本一致性由管理员在服务端「客户端设备」页决定;
留一个本地开关只会让"为什么这台还没升"变成无解的排查题。
客户端配置里的 `autoUpdate` 仍然照读(`autoUpdate !== false` 才自动装),
因为服务端下发的是一台机器的目标版本,不是"允许不允许更新"。
-->
<p class="text-muted">更新由管理员在服务端统一推送,教室机器不提供本地开关。</p>
</div>
<el-switch
:model-value="autoUpdate"
:loading="savingUpdate"
@update:model-value="toggleAutoUpdate"
/>
</div>
<div class="toolbar mt-12">
<el-button @click="openLogDir">打开日志目录</el-button>
@@ -464,23 +418,13 @@ onBeforeUnmount(() => {
<el-card shadow="never" class="mt-12">
<template #header><span>显示与调试</span></template>
<div class="switch-row">
<div class="switch-main">
<span>关怀模式</span>
<p class="text-muted">
放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。
</p>
<p class="text-muted">
「开启后同时放大 AI 助手按钮」这一条只在 Web 管理端生效 —— 本客户端目前没有助手入口。
</p>
</div>
<el-switch
:model-value="ui.careMode"
:loading="savingCareMode"
data-test="care-mode-switch"
@update:model-value="toggleCareMode"
/>
</div>
<!--
关怀模式**只保留在 Web 管理端**(需求:「客户端无需关怀模式」)。
客户端是投在教室大屏上的固定界面,字号由「外观」页的看板/岛设置管,
再来一套全站放大只会和那些设置互相打架,所以这里不再提供开关。
`stores/ui.ts` 与 `styles/index.css` 里的那套管道暂时保留(配置里还有这个字段,
删掉会让老 config.json 反序列化时多一条归一化分支),但界面上不再暴露。
-->
<div class="switch-row">
<div class="switch-main">
<span>开发者模式</span>
@@ -37,13 +37,13 @@ const classIslandStatus = ref<{
classIslandVersion: string | null;
} | null>(null);
/* ------------------------------------------------------------ 班级端成绩查询开关
/* ------------------------------------------------------------ 班级端成绩查询状态(只读)
*
* 「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」——
* 教室机器在这里决定,值存在班级上;Web 端「班级管理」里也能改同一个开关。
* 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关,
* 这台机器只负责把状态显示出来(需求:「成绩查询由服务端控制,客户端只读」)。
* 曾经这里有个开关 —— 教室机器能自己开成绩查询,等于把"学校说不给看"变成了一台机器说了算。
*/
const gradeQueryEnabled = ref(true);
const gradeQuerySaving = ref(false);
async function loadGradeQuery(): Promise<void> {
const classId = auth.user?.classId;
@@ -56,19 +56,6 @@ async function loadGradeQuery(): Promise<void> {
}
}
async function applyGradeQuery(value: boolean): Promise<void> {
const classId = auth.user?.classId;
if (!classId) return;
gradeQuerySaving.value = true;
try {
const result = await classGradeQueryApi.set(classId, value);
gradeQueryEnabled.value = result.studentGradeQueryEnabled;
ElMessage.success(value ? '已允许按学号查询成绩明细' : '已关闭成绩查询');
} finally {
gradeQuerySaving.value = false;
}
}
const classIslandStatusText = computed(() => {
const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入';
@@ -154,21 +141,24 @@ onMounted(async () => {
</p>
</el-card>
<!-- 成绩查询:让教室机器能按学号代学生查本人明细 -->
<!-- 成绩查询:让教室机器能按学号代学生查本人明细。
这里**只读**(需求:「成绩查询由服务端控制,客户端只读」)——教室机器是投放终端,
开不开成绩查询是学校口径,不该由某台机器本地改。 -->
<el-card shadow="never" class="mt-16">
<template #header><span>成绩查询</span></template>
<p class="text-muted">
打开后,可以在「成绩」页按<b>学号</b>查到本班学生的成绩明细(含等级)。
学生本身没有账号、不能登录,查成绩就是由这台教室机器代做。
关闭后本机查成绩会被服务端拒绝,教师端不受影响。
打开后,可以在「成绩」页看到本班成绩与排名。学生本身没有账号、不能登录,
查成绩就是由这台教室机器代做。关闭后本机查成绩会被服务端拒绝,教师端不受影响。
</p>
<el-switch
:model-value="gradeQueryEnabled"
:loading="gradeQuerySaving"
active-text="允许按学号查询成绩明细"
inactive-text="已关闭"
@change="(value: string | number | boolean) => applyGradeQuery(value === true)"
/>
<p class="text-muted">
本开关由管理员在服务端「班级管理」和「客户端设备」里统一设置,教室机器不提供本地开关。
</p>
<el-tag :type="gradeQueryEnabled ? 'success' : 'info'" effect="plain">
{{ gradeQueryEnabled ? '当前:已允许查看成绩与排名' : '当前:已由管理员关闭' }}
</el-tag>
<div class="toolbar mt-8">
<el-button size="small" :icon="'Refresh'" @click="loadGradeQuery">刷新状态</el-button>
</div>
</el-card>
</div>
</template>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
"version": "1.3.0",
"version": "1.3.2",
"private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
@@ -0,0 +1,30 @@
-- 成绩:按考试记住「是否把所有科目相加以总分排名」
--
-- 需求:「单次考试(如月考,期中考),在导入成绩时可选是否将所有科目相加以总分排名,
-- 客户端也可查看到总分排名和个别单科的排名」。
--
-- `Grade.examName` 一直是自由文本,服务端光看它不知道这次考试算不算总分;而客户端要显示
-- 总分榜就必须知道,所以按「班级 + 考试名」单独记一条 `Exam`。
--
-- 与 `Grade` 是**弱关联**:两边只按名字匹配,不建外键。成绩删光不会连带删掉设置,
-- 补录同一场考试时设置还在。
--
-- 注意:本仓库的 SQLite / MySQL 双库约束是「只用 String / Int / Float / Boolean / DateTime」,
-- 因此这里没有 enum、没有 @db.*。
-- CreateTable
CREATE TABLE "Exam" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"countInTotal" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Exam_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Exam_classId_name_key" ON "Exam"("classId", "name");
-- CreateIndex
CREATE INDEX "Exam_classId_idx" ON "Exam"("classId");
@@ -0,0 +1,16 @@
-- 课表下发改成「按按钮才发生」
--
-- 需求:「ClassIsland 联动改为下发或获取课表,获取课表时点击获取才会获取,而不是一直获取」。
--
-- 起因(2026-10-11 实测):插件每 `ReportIntervalSeconds`(默认 15)秒上报一次,且
-- 本机 `UploadSchedule` 开着时**每次都带全量课表**、`MirrorSchedule` 开着时**每次上报完
-- 还顺手镜像一遍**。于是教室的课表每 15 秒被重写一次,服务端这边也每 15 秒
-- merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。
--
-- 现在两个方向都只由一次性待办触发:
-- · 教室 → 服务端:`requestScheduleReport`(已有,「从教室机器获取课表」按钮)
-- · 服务端 → 教室:`requestMirrorSchedule`(本次新增,「下发课表到教室」按钮)
-- `mirrorScheduleToClassIsland` 的含义同时收窄为「这台设备接不接受下发」,不再是自动同步开关。
-- AlterTable
ALTER TABLE "IntegrationDevice" ADD COLUMN "requestMirrorSchedule" BOOLEAN NOT NULL DEFAULT false;
+38 -1
View File
@@ -241,6 +241,7 @@ model Class {
homeworks Homework[]
notifications Notification[]
grades Grade[]
exams Exam[]
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
@@ -465,11 +466,23 @@ model IntegrationDevice {
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
syncScheduleToServer Boolean @default(false)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。
///
/// **2026-10-11 起含义收窄为"允许下发"**:不再是"自动同步的主方向" —— 插件以前每 15 秒
/// 上报一次、每次上报完都顺手镜像一遍(`MirrorSchedule` 开着时),教室的课表被反复重写;
/// 现在镜像只由 `requestMirrorSchedule` 这个一次性待办触发,这里只表示"这台设备接不接受下发"。
mirrorScheduleToClassIsland Boolean @default(true)
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
requestScheduleReport Boolean @default(false)
/// 待办的「立即下发一次课表」请求:Web 端点「下发课表到教室」时置位,
/// 插件在下一次心跳看到它就去拉本班课表并**强制替换**写进 ClassIsland,
/// 拉取成功即由服务端清除标记(见 `pullClassPlanForDevice`)。
///
/// 为什么要一次性待办而不是"开关":教室机常常离线,一个实时下发的长连接握手不值得,
/// 而后台定时镜像会与老师在教室本地做的调整互相打架(2026-10-11 实测:
/// 插件每 15 秒重写一遍课表,服务端那边看起来就是课表被反复删改)。
requestMirrorSchedule Boolean @default(false)
classIslandVersion String?
pluginVersion String?
/// 最后一次**带运行状态**的上报时间
@@ -696,3 +709,27 @@ model AiAudit {
@@index([userId, createdAt])
}
/// 一次考试(月考 / 期中考…)在本班的设置。
///
/// 需求:「单次考试在导入成绩时可选是否将所有科目相加以总分排名,客户端也能看到总分排名」。
/// `Grade.examName` 是自由文本,服务端光看它不知道这次考试算不算总分,而客户端要显示总分榜
/// 就必须知道 —— 于是按「班级 + 考试名」记一条。
///
/// 与 `Grade` 是**弱关联**(两边只按名字匹配,不建外键):成绩删光也不会连带删掉设置,
/// 补录同一场考试时设置还在;反过来留一条没有成绩的设置是无害的。
model Exam {
id String @id @default(cuid())
classId String
/// 考试名,与 `Grade.examName` 同口径(如「第一次月考」)
name String
/// 是否把所有科目(本次考试已录入的)相加做总分排名
countInTotal Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@unique([classId, name])
@@index([classId])
}
+9
View File
@@ -19,6 +19,15 @@ export const SETTING_KEYS = {
aiApiKey: 'ai.apiKey',
/** 追加到系统提示词末尾的学校自定义要求(可空) */
aiSystemPromptExtra: 'ai.systemPromptExtra',
/**
* 全校学期开始日期(`YYYY-MM-DD`,本地日期)。
*
* 需求:「服务端可设置学期开始日期,以保证当前周次正确,管理员也可以随时纠正」。
* 原先它只能来自 `.env` 的 `TERM_START_DATE`(改一次要动文件 + 重启),
* 于是"开学日期填错了"这种事只能靠运维;现在管理员在「学期周次」页直接改,立即生效。
* 班级自己的 `Class.termStartDate` 仍然优先(见 `lib/term.ts` 的三级回落)。
*/
termStartDate: 'term.startDate',
} as const;
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
+19 -9
View File
@@ -5,6 +5,7 @@ import {
} from '@classhelper/shared';
import { env } from '../config/env.js';
import { prisma } from './db.js';
import { SETTING_KEYS, getSetting } from './settings.js';
export { MAX_TERM_WEEK };
@@ -40,21 +41,30 @@ export function resolveCurrentWeek(now: Date = new Date(), context?: TermWeekCon
* 取出某个班的周次口径。
*
* 优先级:班级自己的逐周区间 → 全校默认的逐周区间 → 纯线性推算。
* 开学日期同理:`Class.termStartDate` 优先,没配就用全局 `TERM_START_DATE`。
* 开学日期同理:`Class.termStartDate` → 全校设置(`term.startDate`,管理员在「学期周次」页改)
* → `.env` 的 `TERM_START_DATE` 兜底。中间那一级是 2026-10-11 加的:原先只有 `.env`,
* 管理员改一次开学日期得动文件 + 重启,填错了只能靠运维。
*
* @param classId 不传/空串表示**只看全校默认**(管理员在「学期周次」页编辑默认口径时就是这种调用)
*/
export async function loadTermContext(classId?: string | null): Promise<TermWeekContext> {
const globalWeeks = await prisma.termWeek.findMany({
where: { classId: '' },
orderBy: { weekNumber: 'asc' },
select: { weekNumber: true, startDate: true, endDate: true },
});
const [globalWeeks, settingStart] = await Promise.all([
prisma.termWeek.findMany({
where: { classId: '' },
orderBy: { weekNumber: 'asc' },
select: { weekNumber: true, startDate: true, endDate: true },
}),
getSetting(SETTING_KEYS.termStartDate),
]);
if (!classId) {
// 全校默认没有"班级记录"可以存开学日期,因此**第 1 周的起始日就是它**
// 全校默认没有"班级记录"可以存开学日期,因此按「显式设置 → 第 1 周的起始日 → .env」回落
// (管理员点「按开学日期生成」时,第 1 周就是从那天开始的)
const firstGlobal = globalWeeks.find((item) => item.weekNumber === 1)?.startDate;
return { termStartDate: firstGlobal || env.termStartDate, weeks: globalWeeks, maxWeek: MAX_TERM_WEEK };
return {
termStartDate: settingStart || firstGlobal || env.termStartDate,
weeks: globalWeeks,
maxWeek: MAX_TERM_WEEK,
};
}
const [cls, classWeeks] = await Promise.all([
@@ -73,7 +83,7 @@ export async function loadTermContext(classId?: string | null): Promise<TermWeek
// 班级的逐周区间若覆盖了第 1 周,开学日期就以它为准(管理员改周次时最直观)
const firstWeekStart = weeks.find((item) => item.weekNumber === 1)?.startDate;
return {
termStartDate: cls?.termStartDate || firstWeekStart || env.termStartDate,
termStartDate: cls?.termStartDate || firstWeekStart || settingStart || env.termStartDate,
weeks,
maxWeek: cls?.termWeeks && cls.termWeeks > 0 ? cls.termWeeks : MAX_TERM_WEEK,
};
@@ -592,10 +592,13 @@ export async function getStudentGradeQuery(
}
/**
* 设置「是否允许 ClassHelper 班级端按学号查询本班学生成绩明细」。
* 设置「是否允许 ClassHelper 班级端查看本班成绩与排名」。
*
* 与通知显示位置同一套模式:**班级端自己改**(教室里老师最清楚要不要给学生看),
* 教师 / 管理员在 Web 端也能改。关闭后班级端查成绩一律 403,教师端不受影响。
* **只有教师 / 管理员能改**(需求:「成绩查询由服务端控制,客户端只读」)。
* 原先班级端自己也能改这个开关 —— 教室机器能给自己开成绩查询,等于把
* "学校说不给看"变成了一台机器说了算;而与它同一套模式的「通知显示位置」
* (`Class.notificationChannel`)**仍然由班级端自己改**,那个确实是现场偏好,
* 两者别再按"同一个模式"去理解。关闭后班级端查成绩一律 403,教师端不受影响。
*/
export async function updateStudentGradeQuery(
user: TokenPayload,
@@ -603,8 +606,8 @@ export async function updateStudentGradeQuery(
input: UpdateStudentGradeQueryInput,
): Promise<{ studentGradeQueryEnabled: boolean }> {
await assertClassAccess(user, classId);
if (!isClassDevice(user) && !isStaff(user)) {
throw ApiError.forbidden('只有 ClassHelper 班级端或教师/管理员可以修改成绩查询开关');
if (!isStaff(user)) {
throw ApiError.forbidden('成绩查询开关由教师/管理员在 Web 端设置,班级端只读');
}
await prisma.class.update({
@@ -209,9 +209,22 @@ export async function listDevices(options: { classId?: string } = {}): Promise<C
if (!pendingByDevice.has(command.deviceId)) pendingByDevice.set(command.deviceId, command);
}
// 「最近一条命令」:待执行的那条在取消 / 超时之后就从 pendingCommand 里消失了,
// Web 端于是看不出"我刚点的锁屏到底怎么了"。取最近 500 条(够覆盖一次批量下发)按设备分组。
const recent = (await prisma.clientCommand.findMany({
where: { deviceId: { in: rows.map((row) => row.id) } },
orderBy: { createdAt: 'desc' },
take: 500,
})) as unknown as CommandRow[];
const lastByDevice = new Map<string, CommandRow>();
for (const command of recent) {
if (!lastByDevice.has(command.deviceId)) lastByDevice.set(command.deviceId, command);
}
return rows.map((row) => {
const heartbeat = row.lastHeartbeatAt;
const pending = pendingByDevice.get(row.id);
const last = lastByDevice.get(row.id);
return {
id: row.id,
classId: row.classId,
@@ -228,6 +241,7 @@ export async function listDevices(options: { classId?: string } = {}): Promise<C
stateNote: row.stateNote,
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
pendingCommand: pending ? toCommandDto(pending) : null,
lastCommand: last ? toCommandDto(last) : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
@@ -7,7 +7,10 @@ import { defineModule } from '../module.types.js';
import {
bulkCreateGradeSchema,
createGradeSchema,
examListQuerySchema,
examRankingQuerySchema,
listGradesQuerySchema,
setExamSchema,
statsQuerySchema,
studentGradeParamSchema,
studentGradeQuerySchema,
@@ -65,6 +68,54 @@ router.get(
},
);
/**
* GET /api/grades/exams?classId= - 某班出现过的考试(含"是否计入总分")
*
* **刻意不挂权限键**:ClassHelper 班级端要用它列出可选考试(班级端的有效权限是空集,
* 挂 `grade:read` 会把教室机器挡在门外)。边界在 service 里:`assertClassAccess` + 班级开关。
*/
router.get(
'/exams',
validate({ query: examListQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await gradeService.listExams(user, classId), '获取考试列表成功');
},
);
/** PUT /api/grades/exams - 设置"这次考试是否把所有科目相加做总分排名"(教师/管理员) */
router.put(
'/exams',
requirePermission('grade:write'),
validate({ body: setExamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const body = req.body as { classId: string; name: string; countInTotal: boolean };
sendOk(
res,
await gradeService.setExamCountInTotal(user, body.classId, body.name, body.countInTotal),
body.countInTotal ? '已设为:这次考试按总分排名' : '已设为:这次考试不排总分',
);
},
);
/**
* GET /api/grades/ranking?classId=&examName= - 一次考试的单科榜与总分榜
*
* 同样不挂权限键(班级端要看),边界在 service 里 —— 与按学号查明细共用
* `canQueryStudentGrades`:班级端受按班开关控制,教师端不受影响。
*/
router.get(
'/ranking',
validate({ query: examRankingQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
const { classId, examName } = validatedQuery<{ classId: string; examName: string }>(req);
sendOk(res, await gradeService.getExamRanking(user, classId, examName), '获取考试排名成功');
},
);
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
router.get(
'/',
@@ -98,3 +98,23 @@ export type CreateGradeInput = z.infer<typeof createGradeSchema>;
export type UpdateGradeInput = z.infer<typeof updateGradeSchema>;
export type BulkCreateGradeInput = z.infer<typeof bulkCreateGradeSchema>;
export type UpdateGradeLevelsInput = z.infer<typeof updateGradeLevelsSchema>;
/* ---------------------------------------------------------------- 考试与排名 */
/** GET /api/grades/exams?classId= */
export const examListQuerySchema = z.object({
classId: z.string().min(1),
});
/** PUT /api/grades/exams —— 设置"这次考试是否把所有科目相加做总分排名" */
export const setExamSchema = z.object({
classId: z.string().min(1),
name: z.string().trim().min(1, '考试名不能为空').max(60, '考试名最多 60 个字'),
countInTotal: z.boolean(),
});
/** GET /api/grades/ranking?classId=&examName= */
export const examRankingQuerySchema = z.object({
classId: z.string().min(1),
examName: z.string().trim().min(1, '请指定考试名').max(60),
});
@@ -4,6 +4,9 @@ import {
gradeLevel,
gradePercent,
normalizeStudentNo,
type ExamDto,
type ExamRankingDto,
type ExamSubjectRanking,
type GradeDto,
type GradeLevelType,
type GradeStats,
@@ -14,12 +17,14 @@ import {
assertClassAccess,
classScopeWhere,
isClassDevice,
isStaff,
resolveClassScope,
resolveUserClassRole,
} from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { toGradeDto, toStudentBrief } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js';
import { resolvePersonalIds } from '../../lib/session.js';
@@ -473,3 +478,190 @@ async function assertCourseInClass(courseId: string, classId: string): Promise<v
if (!course) throw ApiError.badRequest('课程不存在');
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
}
/* ---------------------------------------------------------------- 考试与排名 */
/**
* 列出某班出现过的考试名(`Grade.examName` 的去重),并带上"是否计入总分"。
*
* 以**成绩行**为准而不是以 `Exam` 表为准:老师可能先导了成绩才开始管口径,
* 只列 `Exam` 会让刚导进去的那次考试在界面上根本找不到。
*/
export async function listExams(user: TokenPayload, classId: string): Promise<ExamDto[]> {
await assertClassAccess(user, classId);
const [groups, exams] = await Promise.all([
prisma.grade.groupBy({ by: ['examName'], where: { classId }, _count: { _all: true } }),
prisma.exam.findMany({
where: { classId },
select: { id: true, name: true, countInTotal: true },
}),
]);
const byName = new Map(exams.map((item) => [item.name, item]));
return groups
.map((group) => {
const row = byName.get(group.examName);
return {
id: row?.id ?? '',
classId,
name: group.examName,
countInTotal: row?.countInTotal ?? false,
gradeCount: group._count._all,
};
})
.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'));
}
/**
* 设置「这次考试要不要把所有科目相加做总分排名」。仅教师 / 管理员。
*
* 为什么要单独一个开关:语数外的 150 分和音体美的 100 分不是一个量纲,
* "这次考试要不要合总分"是学校的口径,程序不能替它默认决定 ——
* 而客户端要显示总分榜就必须知道,所以按「班级 + 考试名」存一条(`Exam` 表)。
*/
export async function setExamCountInTotal(
user: TokenPayload,
classId: string,
name: string,
countInTotal: boolean,
): Promise<ExamDto> {
await assertClassAccess(user, classId);
if (!isStaff(user)) throw ApiError.forbidden('只有教师/管理员可以设置考试口径');
const row = await prisma.exam.upsert({
where: { classId_name: { classId, name } },
create: { classId, name, countInTotal },
update: { countInTotal },
});
const gradeCount = await prisma.grade.count({ where: { classId, examName: name } });
logger.info(`考试口径:班级=${classId} 考试=${name} 计入总分=${countInTotal} 由 ${user.name} 设置`);
return { id: row.id, classId, name, countInTotal, gradeCount };
}
/** 并列名次:分数相同的取同一名次,下一个名次跳过(1、2、2、4) */
function assignRanks<T extends { score: number }>(
rows: T[],
rankOf: (row: T) => number,
): Array<T & { rank: number }> {
const sorted = [...rows].sort((a, b) => rankOf(b) - rankOf(a));
let lastScore: number | null = null;
let lastRank = 0;
return sorted.map((row, index) => {
const score = rankOf(row);
const rank = lastScore !== null && score === lastScore ? lastRank : index + 1;
lastScore = score;
lastRank = rank;
return { ...row, rank };
});
}
/**
* 一次考试的排名(单科 + 总分)。
*
* 单科榜按 `courseId` 分组;成绩行里 `courseId` 可能为 null(没挂科目的成绩),
* 那种归到「未关联课程」一栏,不并进任何科目。
*
* 总分榜只在 `Exam.countInTotal` 为 true 时才算 —— 见 `setExamCountInTotal` 的说明。
* 合计时**按科目去重**:同一个学生同一个科目在一次考试里有多行(老师补录过)时只取最高分,
* 否则"补录一条"会变成"总分多一科",排名直接失真。
*
* 班级端受 `Class.studentGradeQueryEnabled` 控制(与按学号查明细同一条边界)。
*/
export async function getExamRanking(
user: TokenPayload,
classId: string,
examName: string,
): Promise<ExamRankingDto> {
await assertClassAccess(user, classId);
const classRecord = await prisma.class.findUnique({
where: { id: classId },
select: { name: true, studentGradeQueryEnabled: true },
});
if (!classRecord) throw ApiError.notFound('班级不存在');
const classRole = await resolveUserClassRole(user, classId);
const allowed = canQueryStudentGrades({
classRole,
// 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理
gradeQueryEnabled: classRecord.studentGradeQueryEnabled ?? true,
});
if (!allowed) throw ApiError.forbidden('本班未开放成绩查询,请联系老师开启');
const [grades, exam] = await Promise.all([
prisma.grade.findMany({
where: { classId, examName },
include,
orderBy: [{ publishedAt: 'desc' }],
}),
prisma.exam.findUnique({
where: { classId_name: { classId, name: examName } },
select: { countInTotal: true },
}),
]);
// 同一个学生同一科目只留最高分(补录 / 重考的场景)
const best = new Map<string, GradeDto>();
for (const grade of grades.map(toGradeDto)) {
const key = `${grade.studentId}|${grade.courseId ?? ''}`;
const current = best.get(key);
if (!current || grade.score > current.score) best.set(key, grade);
}
const unique = [...best.values()];
const byCourse = new Map<string, { courseId: string | null; courseName: string; rows: GradeDto[] }>();
for (const grade of unique) {
const key = grade.courseId ?? '__none__';
const entry = byCourse.get(key) ?? {
courseId: grade.courseId,
courseName: grade.course?.name ?? '未关联课程',
rows: [],
};
entry.rows.push(grade);
byCourse.set(key, entry);
}
const subjects: ExamSubjectRanking[] = [...byCourse.values()]
.map((entry) => ({
courseId: entry.courseId,
courseName: entry.courseName,
rows: assignRanks(entry.rows, (row) => gradePercent(row.score, row.totalScore)).map((row) => ({
studentId: row.studentId,
studentNo: row.student?.studentNo ?? '',
studentName: row.student?.name ?? '',
score: row.score,
totalScore: row.totalScore,
rank: row.rank,
})),
}))
.sort((a, b) => a.courseName.localeCompare(b.courseName, 'zh-Hans-CN'));
const countInTotal = exam?.countInTotal === true;
let total: ExamRankingDto['total'] = [];
if (countInTotal) {
const sum = new Map<
string,
{ studentId: string; studentNo: string; studentName: string; score: number; subjectCount: number }
>();
for (const grade of unique) {
const entry = sum.get(grade.studentId) ?? {
studentId: grade.studentId,
studentNo: grade.student?.studentNo ?? '',
studentName: grade.student?.name ?? '',
score: 0,
subjectCount: 0,
};
entry.score += grade.score;
entry.subjectCount += 1;
sum.set(grade.studentId, entry);
}
total = assignRanks([...sum.values()], (row) => row.score);
}
return {
classId,
className: classRecord.name,
examName,
countInTotal,
subjects,
total,
};
}
@@ -42,6 +42,8 @@ export interface DeviceContext {
enabled: boolean;
syncScheduleToServer: boolean;
mirrorScheduleToClassIsland: boolean;
/** 是否有一次待办的「下发课表到教室」(Web 端点按钮时置位,拉取成功即清除) */
mirrorRequested: boolean;
}
/** 插件侧路由统一挂这个中间件:解析设备令牌并回查设备记录 */
@@ -61,6 +63,7 @@ export function authenticateDevice(): RequestHandler {
enabled: true,
syncScheduleToServer: true,
mirrorScheduleToClassIsland: true,
requestMirrorSchedule: true,
},
});
if (!record) throw ApiError.unauthorized('设备令牌无效,请在「ClassIsland 联动」里重新生成');
@@ -72,6 +75,7 @@ export function authenticateDevice(): RequestHandler {
enabled: record.enabled,
syncScheduleToServer: record.syncScheduleToServer,
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
mirrorRequested: record.requestMirrorSchedule === true,
};
req.device = context;
next();
@@ -183,8 +183,28 @@ router.post(
},
);
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
/**
* POST /api/integrations/devices/:id/mirror-schedule - 「下发一次课表到教室」
*
* 与「获取课表」反方向、同样是一次性动作。插件在下一次心跳看到待办就去拉本班课表,
* 并**强制替换**写进 ClassIsland(覆盖老师手排的冲突课表)。
*
* 为什么要做成按钮而不是后台一直同步:插件原先每 15 秒上报一次、每次上报完还顺手镜像一遍,
* 教室课表被反复重写,服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的是
* "课表被反复删改"(2026-10-11 实测)。
*/
router.post(
'/devices/:id/mirror-schedule',
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const result = await integrationService.requestMirrorSchedule(getAuthUser(req), id);
sendOk(res, result, result.nextHeartbeatHint);
},
);
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */router.post(
'/devices/:id/token',
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
@@ -238,10 +238,12 @@ async function findDeviceOrFail(id: string): Promise<{
classId: string;
name: string;
enabled: boolean;
/** 「下发课表到教室」要按它判断这台设备接不接受下发(见 requestMirrorSchedule) */
mirrorScheduleToClassIsland: boolean;
}> {
const record = await prisma.integrationDevice.findUnique({
where: { id },
select: { id: true, classId: true, name: true, enabled: true },
select: { id: true, classId: true, name: true, enabled: true, mirrorScheduleToClassIsland: true },
});
if (!record) throw ApiError.notFound('设备不存在或已被删除');
return record;
@@ -305,10 +307,43 @@ export async function requestScheduleReport(
};
}
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
export async function resetDeviceToken(
/**
* 「**下发一次课表到教室**」的待办(Web 端手动动作)。
*
* 与 `requestScheduleReport` 对称:服务端没有反向通道去催插件,所以只置一个标记,
* 插件在下一次心跳看到 `settings.mirrorRequested = true` 就去拉本班课表、
* **强制替换**写进 ClassIsland(见插件侧 `ClassPlanWriter` 的 force 语义)。
*
* 为什么要改成手动(2026-10-11):插件原先每 15 秒上报一次、每次上报完还顺手镜像一遍
* (插件侧的 `MirrorSchedule` 开关),教室课表被反复重写、服务端也每 15 秒 merge + 清理
* 一次幽灵行 —— 老师看到的是"课表被反复删改"。两个方向现在都只由点击触发。
*/
export async function requestMirrorSchedule(
user: TokenPayload,
deviceId: string,
): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> {
const current = await findDeviceOrFail(deviceId);
await assertCanManageSchedule(user, current.classId);
if (!current.enabled) throw ApiError.badRequest('该设备已停用,请先启用后再下发课表');
if (!current.mirrorScheduleToClassIsland) {
throw ApiError.badRequest('这台设备没有开启「允许下发课表」,请先在设备设置里打开');
}
await prisma.integrationDevice.update({
where: { id: deviceId },
data: { requestMirrorSchedule: true },
});
logger.info(`已请求向设备下发课表:${current.name}(classId=${current.classId})由 ${user.name} 发起`);
return {
requested: true,
deviceName: current.name,
nextHeartbeatHint: '教室机器会在下一次心跳(约 1 分钟内)拉取并强制替换教室课表',
};
}
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
export async function resetDeviceToken(
user: TokenPayload, deviceId: string,
): Promise<IntegrationDeviceTokenDto> {
const current = await findDeviceOrFail(deviceId);
await assertCanManageSchedule(user, current.classId);
@@ -649,6 +684,9 @@ export async function applyReport(
// 注意它**独立于** syncScheduleToServer —— 后者是"允许自动回传",现在默认关闭,
// 但管理员显式点"获取课表"时仍然应当能取到。
scheduleRequested: scheduleRequested,
// 「下发一次课表到教室」的待办(反方向)。插件看到就去拉本班课表并强制替换,
// 拉取成功时由 `pullClassPlanForDevice` 清除标记 —— 与上面那条对称。
mirrorRequested: device.mirrorRequested,
},
pendingNotification: pending,
};
@@ -755,6 +793,17 @@ export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
const termContext = await loadTermContext(device.classId);
const week = resolveCurrentWeek(now, termContext);
// 「下发一次课表」的待办到这里就算完成:插件已经来拉了,拿到就会写进 ClassIsland。
// **必须清掉** —— 不清的话每次心跳都会重新下发一遍,又变回"一直下发",
// 而这正是老师反映"课表被反复删改"的源头。
if (device.mirrorRequested) {
await prisma.integrationDevice.update({
where: { id: device.deviceId },
data: { requestMirrorSchedule: false },
});
logger.info(`课表下发待办已完成:${device.name}(classId=${device.classId})`);
}
const classRecord = await prisma.class.findUnique({
where: { id: device.classId },
select: { id: true, name: true, termWeeks: true, termStartDate: true },
@@ -11,6 +11,7 @@ import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { MAX_TERM_WEEK, loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
import { SETTING_KEYS, setSetting } from '../../lib/settings.js';
import { emitToClass } from '../../realtime/bus.js';
import { SOCKET_EVENTS } from '@classhelper/shared';
import { notifyScheduleChanged } from '../integrations/integrations.service.js';
@@ -110,6 +111,13 @@ export async function updateTermWeeks(user: TokenPayload, input: UpdateTermWeeks
: []),
]);
// 全校默认没有"班级记录"可以挂开学日期,写进全局设置(`term.startDate`)。
// 需求:「服务端可设置学期开始日期,以保证当前周次正确,管理员也可以随时纠正」——
// 原先这一级只能来自 `.env`,改一次要动文件 + 重启。
if (input.termStartDate && !scope) {
await setSetting(SETTING_KEYS.termStartDate, input.termStartDate);
}
logger.info(
`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`,
);
+10 -9
View File
@@ -1,11 +1,13 @@
import type { PermissionKey } from '@classhelper/shared';
import type { TokenPayload } from '../lib/jwt.js';
import type { DeviceContext } from '../modules/integrations/device-auth.js';
/**
* 扩展 Express 的 Request 类型。
* - auth:认证中间件解析出的当前用户 + 生效权限集合
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
* - device:ClassIsland 插件上报时解析出的设备上下文
*/
declare global {
namespace Express {
@@ -18,15 +20,14 @@ declare global {
};
validatedQuery?: unknown;
validatedParams?: unknown;
/** ClassIsland 联动插件上报时解析出的设备上下文(见 modules/integrations/device-auth.ts) */
device?: {
deviceId: string;
classId: string;
name: string;
enabled: boolean;
syncScheduleToServer: boolean;
mirrorScheduleToClassIsland: boolean;
};
/**
* ClassIsland 联动插件上报时解析出的设备上下文(见 modules/integrations/device-auth.ts)。
*
* **引用那个接口本身,不要在这里再抄一遍字段** —— 原先这里是手抄的结构,
* 往 `DeviceContext` 加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;`
* 上("Property 'x' is missing … but required"),看不出真正原因是这处重复声明。
*/
device?: DeviceContext;
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
"version": "1.3.0",
"version": "1.3.2",
"private": true,
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
"type": "module",
+75 -1
View File
@@ -910,6 +910,66 @@ export interface GradeDto {
className?: string | null;
}
/**
* 一次考试(月考 / 期中考…)在本班的设置。
*
* 需求:「单次考试在导入成绩时可选是否将所有科目相加以总分排名,客户端也能看到总分排名」。
* `Grade.examName` 是自由文本,服务端光看它不知道这次考试算不算总分,所以单独记一条。
*/
export interface ExamDto {
id: string;
classId: string;
name: string;
/** 是否把所有科目(本次考试已录入的)相加做总分排名 */
countInTotal: boolean;
/** 本次考试已录入的成绩条数 —— 老师据此判断"要不要算总分" */
gradeCount: number;
}
/** 排名表里的一行(单科或总分) */
export interface ExamRankRow {
studentId: string;
studentNo: string;
studentName: string;
/** 得分(总分榜里是各科之和) */
score: number;
/** 这一科的满分;总分榜里是各科满分之和(仅供参考) */
totalScore: number;
/** 名次,从 1 开始;并列同名次(1、2、2、4) */
rank: number;
}
/** 单科排名 */
export interface ExamSubjectRanking {
courseId: string | null;
courseName: string;
rows: ExamRankRow[];
}
/**
* 一次考试的排名结果(单科 + 总分)。
*
* 总分榜只在 `countInTotal` 为 true 时才有内容 —— 语数外与音体美的分数量纲不同,
* 加不加是学校的口径,不能由程序默认替它决定。
*/
export interface ExamRankingDto {
classId: string;
className: string;
examName: string;
countInTotal: boolean;
subjects: ExamSubjectRanking[];
/** 总分榜(同一学生各科相加);countInTotal 为 false 时是空数组 */
total: Array<{
studentId: string;
studentNo: string;
studentName: string;
score: number;
/** 参与相加的科目数,便于识别"只录了两科也上榜"的情况 */
subjectCount: number;
rank: number;
}>;
}
export interface CreateGradeRequest {
classId: string;
courseId?: string | null;
@@ -1473,9 +1533,15 @@ export interface ClassIslandReportResult {
enabled: boolean;
/**
* 服务端请求插件**立刻上报一次课表**(Web 端点了「从教室机器获取课表」)。
* 独立于 `syncScheduleToServer`:那个开关管的是"要不要自动回传"。
* 两个课表方向都只由这类一次性待办触发,本机没有"自动回传 / 自动镜像"的开关。
*/
scheduleRequested?: boolean;
/**
* 服务端请求插件**下发一次课表到教室**(Web 端点了「下发课表到教室」)。
* 插件看到就去拉本班课表并**强制替换**写进 ClassIsland(停用同一天同一单双周相位下
* 冲突的旧课表,只停用不删除);拉取成功时由服务端清除这个待办。
*/
mirrorRequested?: boolean;
};
/** 尚未在 ClassIsland 上确认过的一条提醒(插件可立即弹出) */
pendingNotification?: ClassIslandPushNotification | null;
@@ -1835,6 +1901,14 @@ export interface ClientDeviceDto {
online: boolean;
/** 该设备当前待执行(未回执、未撤销、未过期)的命令;没有则 null */
pendingCommand: ClientCommandDto | null;
/**
* **最近一条**远控命令(不管什么状态),用于回答「我刚点了锁屏,怎么没反应?」。
*
* 待执行的那条在取消 / 超时之后就从 `pendingCommand` 里消失了,Web 端于是一片空白 ——
* 而最常见的两种失败恰恰是"本机没人确认(60 秒自动取消)"和"设备当时离线"。
* 把结果摆出来,老师才知道该去哪儿补上那一下确认。
*/
lastCommand?: ClientCommandDto | null;
createdAt: string;
updatedAt: string;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/web-admin",
"version": "1.3.0",
"version": "1.3.2",
"private": true,
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
+19
View File
@@ -42,6 +42,8 @@ import {
type DatabaseConnectionTestDto,
type DatabaseStatusDto,
type DatabaseSwitchJobDto,
type ExamDto,
type ExamRankingDto,
type GradeDto,
type GradeLevelType,
type GradeStats,
@@ -298,6 +300,14 @@ export const gradeApi = {
/** 按**学号**查某个学生的成绩明细(教师端"按学号查成绩") */
studentDetail: (studentNo: string, classId?: string): Promise<StudentGradeDetailDto> =>
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`, classId ? { classId } : undefined),
/** 某班出现过的考试(含"是否计入总分") */
listExams: (classId: string): Promise<ExamDto[]> => api.get(`${API_PATHS.grades}/exams`, { classId }),
/** 设置"这次考试是否把所有科目相加做总分排名" */
setExam: (payload: { classId: string; name: string; countInTotal: boolean }): Promise<ExamDto> =>
api.put(`${API_PATHS.grades}/exams`, payload),
/** 一次考试的单科榜与总分榜 */
ranking: (params: { classId: string; examName: string }): Promise<ExamRankingDto> =>
api.get(`${API_PATHS.grades}/ranking`, params),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.grades}/${id}`),
};
@@ -513,6 +523,15 @@ export const integrationApi = {
*/
requestSchedule: (id: string): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> =>
api.post(`${API_PATHS.integrations}/devices/${id}/request-schedule`, {}),
/**
* 「**下发一次课表到教室**」:置一个待办,插件在下一次心跳拉取本班课表并写进 ClassIsland,
* 同时把同一天同一单双周相位下冲突的旧课表**停用**(只停用、不删除)。
*
* 与 `requestSchedule` 反方向、同样是一次性动作 —— 课表同步不再有"一直同步"的开关
* (那会让教室课表被反复重写,见 AGENTS.md §5 第 59 条与插件侧说明)。
*/
mirrorSchedule: (id: string): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> =>
api.post(`${API_PATHS.integrations}/devices/${id}/mirror-schedule`, {}),
/** 把一条提醒下发到该班的 ClassIsland 设备(ClassIsland 上全屏弹出) */
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
api.post(`${API_PATHS.integrations}/classisland/notify`, payload),
@@ -0,0 +1,26 @@
import type { ClassPeriod } from '@classhelper/shared';
import { scheduleApi } from '@/api';
/**
* 「这几个班里有没有正在上课的」——全站共用的同一条判据。
*
* 为什么抽出来:上课时段发**紧急通知**要先弹全屏二次确认(`UrgentClassWarning`,3 秒倒计时),
* 而「发通知」这件事在本项目里有**两个入口** —— 通知发布页,以及大模型助手确认后走同一批端点。
* 判据各写一份的结果是:助手那条路少了一次检查,老师在上课时用助手发紧急通知就**没有任何警告**
* (2026-10-11 用户实测反馈),而手动那条路有。凡是"发布前要问一句"的入口都必须用它。
*
* 逐班查询 `GET /api/schedules/current?classId=...`,任一班级正在上课即返回命中的那个。
* 查询失败按"没在上课"处理(不能让一次网络抖动把发布整条路堵死),由服务端的
* 409 `URGENT_DURING_CLASS` 兜底再问一次。
*/
export async function findClassInSession(
classIds: string[],
): Promise<{ classId: string; period: ClassPeriod | null } | null> {
const results = await Promise.all(
classIds.map(async (classId) => {
const status = await scheduleApi.classStatus(classId).catch(() => null);
return status?.inClass ? { classId, period: status.current } : null;
}),
);
return results.find((item) => item !== null) ?? null;
}
+12 -10
View File
@@ -115,22 +115,24 @@ const menuItems: MenuItem[] = [
roles: ['ADMIN', 'TEACHER'],
permission: 'ai:use',
},
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
{
path: '/permissions',
title: '权限组',
icon: 'Lock',
roles: ['ADMIN', 'TEACHER'],
permission: 'permission:manage',
},
{
path: '/clients',
title: '客户端设备',
title: '客户端',
icon: 'Monitor',
// 与「权限组」同样按**生效权限**门控:教师默认没有 client:manage(见 shared/permissions.ts
// 的默认模板只给 ADMIN),所以这里不能只写 roles —— 被权限组授予后教师也该看见。
//
// 2026-10-11 起这一项**合并了原来的「ClassIsland 联动」**:两页讲的是同一件事的两半
// (同一个教室里,插件管大屏、桌面端管灵动岛),分开只会让人"先想清楚该进哪一页",
// 批量下发更是无从下手。页内按 device:manage / client:update-push / remote:control
// 分别显示或禁用按钮,与后端三个接口的权限一一对应。
// 门控用 `classisland:notify` 而不是 `client:manage`:这一页同时承载「向教室下发提醒」,
// 而那个权限默认给 ADMIN + TEACHER(`client:manage` 只给 ADMIN)。用后者会把教师
// 挡在门外,等于把「联动页发提醒」这条老师日常要用的入口一并收走。
// 页内的设备管理 / 远控 / 下发更新仍各自要 device:manage / remote:control /
// client:update-push,与后端的三个接口一一对应。
roles: ['ADMIN', 'TEACHER'],
permission: 'client:manage',
permission: 'classisland:notify',
},
{
path: '/ai-settings',
+21 -7
View File
@@ -88,8 +88,10 @@ const routes: RouteRecordRaw[] = [
path: 'integrations',
name: 'integrations',
component: () => import('@/views/IntegrationsView.vue'),
// 与后端 requireRole('ADMIN','TEACHER') 对齐:这一页含"班级端在线状态",教师要看自己的班
meta: { title: 'ClassHelper 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
// **已从侧栏摘掉**(2026-10-11 合并进「客户端」):设备管理与课表下发都搬到那一页了,
// 这里只保留少数还没搬过去的东西(提醒下发、联动步骤说明),从「客户端 → 详情」进。
// 路由留着而不是删掉,是为了不把功能一起丢掉;`meta.hidden` 让侧栏不显示它。
meta: { title: 'ClassIsland 联动(旧)', icon: 'Connection', roles: ['ADMIN', 'TEACHER'], hidden: true },
},
{
path: 'ai-settings',
@@ -116,12 +118,24 @@ const routes: RouteRecordRaw[] = [
{
path: 'clients',
name: 'clients',
component: () => import('@/views/ClientHubView.vue'),
// 「客户端」= 原来的「ClassIsland 联动」+「客户端设备」合并后的一页(2026-10-11):
// 一个班一张卡片,两条链路的状态放在一起,勾选可批量下发课表 / 更新,点卡片进详情。
//
// 门控与侧栏菜单保持一致:`classisland:notify`(默认 ADMIN + TEACHER)——
// 这一页同时承载「向教室下发提醒」,教师要用;设备管理类按钮在页内按
// device:manage / client:update-push / remote:control 各自显示或禁用,
// 与后端三个接口的权限一一对应(前端隐藏不等于权限)。
meta: { title: '客户端', icon: 'Monitor', permission: 'classisland:notify' },
},
{
path: 'client-devices',
name: 'client-devices',
component: () => import('@/views/ClientsView.vue'),
// 这一页同样**按生效权限**门控:远控关机、统一下发更新都是高危操作,
// 后端三个接口分别要 client:manage / client:update-push / remote:control,
// 但教师默认三个都没有(见 shared/permissions.ts 的默认模板),所以这里用
// 最基础的 client:manage 当入口门禁,页面内的按钮再各自按权限显示/禁用。
meta: { title: '客户端设备', icon: 'Monitor', permission: 'client:manage' },
// **已从侧栏摘掉**(2026-10-11 合并进「客户端」):设备清单、远控、下发更新都在新页里,
// 这里只留新版还没搬过去的部分(客户端安装包缓存管理 —— 不先把包拉下来,就没有版本可下发)。
// 从「客户端 → 详情 → 安装包与设备记录」进。
meta: { title: '安装包与设备记录', icon: 'Monitor', permission: 'client:manage', hidden: true },
},
],
},
+65 -3
View File
@@ -10,6 +10,7 @@ import {
type AiInterpretResult,
type ApiErrorResponse,
type ClassDto,
type ClassPeriod,
type CourseDto,
type NotificationPriority,
type StudentDto,
@@ -17,6 +18,8 @@ import {
import { aiApi, callApi, classApi, courseApi, homeworkApi, notificationApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useResponsive } from '@/composables/useResponsive';
import { findClassInSession } from '@/composables/useClassInSession';
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
/**
* AI 助手:**一句话 → 待确认的草稿 → 老师确认后才落库**。
@@ -54,6 +57,20 @@ const result = ref<AiInterpretResult | null>(null);
const publishError = ref('');
const publishing = ref(false);
/**
* 上课时段发布紧急通知的全屏二次确认(3 秒倒计时)。
*
* **这里曾经缺失**:助手发布走的是既有端点,服务端的 409 `URGENT_DURING_CLASS` 兜底还在,
* 但「手动发布页」在提交前会先查一遍上课状态、命中就弹这个全屏警告,而助手这条路径直接提交 ——
* 于是老师在上课时用助手发紧急通知**一次警告都没有**(2026-10-11 用户实测反馈)。
* 判据与手动页共用 `composables/useClassInSession.ts`,两边必须同时命中才谈得上"一致"。
*/
const urgentWarning = reactive({
visible: false,
period: null as ClassPeriod | null,
title: '',
});
/* -------------------------------------------------------------- 表单 */
const classes = ref<ClassDto[]>([]);
@@ -247,18 +264,54 @@ async function confirmPublish(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const action = current.action;
// 上课时段的紧急通知:先查上课状态,命中就弹全屏二次确认(与手动发布页完全同一条判据)
if (action === 'create_notification' && form.priority === 'URGENT') {
publishing.value = true;
try {
const hit = await findClassInSession([form.classId]);
if (hit) {
urgentWarning.period = hit.period;
urgentWarning.title = form.title.trim();
urgentWarning.visible = true;
return;
}
} finally {
publishing.value = false;
}
}
await runPublish(action, false);
}
/** 真正提交并把结果落到结果卡片上(成功 / 失败两条路都收口在这里) */
async function runPublish(action: AiInterpretResult['action'], confirmDuringClass: boolean): Promise<void> {
publishing.value = true;
publishError.value = '';
try {
await publishOnce(current.action, false);
finishSuccess(current.action);
await publishOnce(action, confirmDuringClass);
finishSuccess(action);
} catch (error) {
await handlePublishError(error, current.action);
await handlePublishError(error, action);
} finally {
publishing.value = false;
}
}
/** 全屏警告里点了「仍然发布」 */
async function onUrgentConfirmed(): Promise<void> {
urgentWarning.visible = false;
const current = result.value;
if (!current || current.action === 'unknown') return;
await runPublish(current.action, true);
}
function onUrgentCancelled(): void {
urgentWarning.visible = false;
publishError.value = '已取消:正在上课,紧急通知需要二次确认';
}
/** 真正落库:复用既有的发布端点(权限断言 / 上课保护 / ClassIsland 推送都在服务端) */
async function publishOnce(action: AiInterpretResult['action'], confirmDuringClass: boolean): Promise<void> {
if (action === 'create_homework') {
@@ -555,6 +608,15 @@ function goSettings(): void {
</template>
</el-card>
</template>
<!-- 上课时段发布紧急通知的全屏二次确认(3 秒倒计时)——与手动发布页同一个组件 -->
<UrgentClassWarning
:visible="urgentWarning.visible"
:period="urgentWarning.period"
:pending-title="urgentWarning.title"
@cancel="onUrgentCancelled"
@confirm="onUrgentConfirmed"
/>
</div>
</template>
@@ -0,0 +1,762 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
CLIENT_COMMAND_KINDS,
CLIENT_COMMAND_LABELS,
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
SOCKET_EVENTS,
relativeTime,
type ClassDto,
type ClassHelperStatusDto,
type ClientCommandDto,
type ClientCommandKind,
type ClientDeviceDto,
type IntegrationDeviceDto,
type NotificationPriority,
} from '@classhelper/shared';
import { classApi, clientApi, clientReleaseApi, integrationApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
/**
* 「客户端」——**把原来的「ClassHelper 联动」与「客户端设备」两页合成一页**。
*
* 为什么合并:这两页讲的是同一件事的两半 —— 同一个教室里,ClassIsland 插件负责大屏、
* ClassHelper 桌面端负责灵动岛,老师要做的动作(下发课表、推送更新、看看这台机器还在不在线)
* 也都跨两者。分成两页的结果是"要给一个班做一件事,得先想清楚该进哪一页",
* 批量操作更是无从下手(一页只有插件设备、一页只有桌面端)。
*
* 结构:
* 1. **班级卡片**(一屏看全):每张卡把该班两条链路的状态放在一起,可勾选;
* 2. **批量动作**:勾几台就下发几台(课表 / 客户端更新);
* 3. **点卡片进详情**:该班的设备清单与逐台操作(签发令牌、重置、远控……)。
*
* 权限:入口要 `client:manage`(与旧的两页最基础的那个门禁一致),页内按钮再各自按
* `device:manage` / `client:update-push` / `remote:control` 显示或禁用 —— 后端同样是这么拦的。
*/
const auth = useAuthStore();
const router = useRouter();
const realtime = useRealtimeStore();
const canManageDevices = computed(() => auth.can('device:manage'));
const canNotify = computed(() => auth.can('classisland:notify'));
const canPushUpdate = computed(() => auth.can('client:update-push'));
const canRemote = computed(() => auth.can('remote:control'));
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const integrations = ref<IntegrationDeviceDto[]>([]);
const clients = ref<ClientDeviceDto[]>([]);
const statuses = ref<ClassHelperStatusDto[]>([]);
/** 服务端已缓存的客户端安装包版本(批量下发更新时选目标版本) */
const releaseVersions = ref<string[]>([]);
const targetVersion = ref('');
/** 一张卡 = 一个班 */
interface ClassCard {
classId: string;
className: string;
grade: string;
/** 该班的 ClassIsland 插件设备(可能多台:讲台机 + 备用机) */
devices: IntegrationDeviceDto[];
/** 该班的 ClassHelper 桌面端 */
clients: ClientDeviceDto[];
/** 班级端在线状态(服务端 `classisland/status`) */
status: ClassHelperStatusDto | null;
}
const cards = computed<ClassCard[]>(() =>
classes.value.map((item) => {
const status = statuses.value.find((entry) => entry.classId === item.id) ?? null;
return {
classId: item.id,
className: item.name,
grade: item.grade,
devices: integrations.value.filter((entry) => entry.classId === item.id),
clients: clients.value.filter((entry) => entry.classId === item.id),
status,
};
}),
);
/** 勾选的班级(批量动作按班级走:一台机器都没有的班会跳过并说明原因) */
const selected = ref<string[]>([]);
const allSelected = computed({
get: () => cards.value.length > 0 && selected.value.length === cards.value.length,
set: (value: boolean) => {
selected.value = value ? cards.value.map((item) => item.classId) : [];
},
});
async function loadAll(): Promise<void> {
loading.value = true;
try {
const [classList, deviceList, clientList, statusList, releases] = await Promise.all([
classApi.list(),
integrationApi.listDevices().catch(() => []),
clientApi.devices().catch(() => []),
integrationApi.classHelperStatus().catch(() => null),
clientReleaseApi.list().catch(() => []),
]);
classes.value = classList;
integrations.value = deviceList;
clients.value = clientList;
statuses.value = statusList?.classes ?? [];
releaseVersions.value = releases
.map((item) => item.version)
.filter((version, index, all) => version && all.indexOf(version) === index);
if (!targetVersion.value && releaseVersions.value.length > 0) {
targetVersion.value = releaseVersions.value[0] ?? '';
}
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 批量动作 */
const batchBusy = ref(false);
/** 批量下发课表:给每个勾选班级的**每一台** ClassIsland 设备置一次待办 */
async function batchMirrorSchedule(): Promise<void> {
const picked = cards.value.filter((item) => selected.value.includes(item.classId));
const devices = picked.flatMap((item) => item.devices.filter((device) => device.enabled));
if (devices.length === 0) {
ElMessage.warning('勾选的班级里没有可下发的 ClassIsland 设备(未接入或已停用)');
return;
}
await ElMessageBox.confirm(
`向 ${devices.length} 台设备下发课表?教室的课表会被 ClassHelper 的课表**强制替换**:` +
'同一天同一单双周相位下冲突的旧课表会被**停用**(只停用、不删除,可在 ClassIsland 里重新启用)。',
'下发课表到教室',
{ type: 'warning', confirmButtonText: '下发', cancelButtonText: '取消' },
).catch(() => 'cancel');
batchBusy.value = true;
try {
const results = await Promise.allSettled(
devices.map((device) => integrationApi.mirrorSchedule(device.id)),
);
const ok = results.filter((item) => item.status === 'fulfilled').length;
ElMessage.success(`已向 ${ok}/${devices.length} 台设备下发,教室会在下一次心跳(约 1 分钟内)拉取`);
await loadAll();
} finally {
batchBusy.value = false;
}
}
/** 批量下发客户端更新:按班级下发,服务端会给这些班的每台桌面端置目标版本 */
async function batchPushUpdate(): Promise<void> {
if (selected.value.length === 0) {
ElMessage.warning('请先勾选班级');
return;
}
const hint = targetVersion.value ? `目标版本 v${targetVersion.value}` : '服务端缓存的最新版本';
await ElMessageBox.confirm(
`向 ${selected.value.length} 个班的 ClassHelper 客户端下发更新(${hint})?\n` +
'客户端会在**不上课且主窗口隐藏满 5 分钟**后才安装,下载随时可能发生。',
'下发客户端更新',
{ type: 'warning', confirmButtonText: '下发', cancelButtonText: '取消' },
).catch(() => 'cancel');
batchBusy.value = true;
try {
const result = await clientApi.pushUpdate({
classIds: selected.value,
version: targetVersion.value || null,
});
ElMessage.success(`已下发:成功 ${result.succeeded} / 共 ${result.total} 台`);
await loadAll();
} finally {
batchBusy.value = false;
}
}
/* ------------------------------------------------------------ 详情抽屉 */
const detailVisible = ref(false);
const detailClassId = ref('');
const detail = computed(() => cards.value.find((item) => item.classId === detailClassId.value) ?? null);
function openDetail(classId: string): void {
detailClassId.value = classId;
detailVisible.value = true;
}
const createForm = reactive({ name: '' });
const creating = ref(false);
/** 为当前详情班级签发一台新的 ClassIsland 设备(令牌只在返回值里出现一次) */
async function createDevice(): Promise<void> {
const current = detail.value;
if (!current) return;
creating.value = true;
try {
const created = await integrationApi.createDevice({
classId: current.classId,
name: createForm.name.trim() || undefined,
});
await ElMessageBox.alert(
`设备令牌(只显示这一次,请立刻填进教室的 ClassIsland 插件设置里):\n\n${created.token ?? ''}`,
'设备令牌',
{ confirmButtonText: '我已保存' },
).catch(() => undefined);
createForm.name = '';
await loadAll();
} finally {
creating.value = false;
}
}
async function toggleDevice(device: IntegrationDeviceDto): Promise<void> {
await integrationApi.updateDevice(device.id, { enabled: !device.enabled });
ElMessage.success(device.enabled ? '已停用该设备' : '已启用该设备');
await loadAll();
}
async function allowMirror(device: IntegrationDeviceDto): Promise<void> {
await integrationApi.updateDevice(device.id, {
mirrorScheduleToClassIsland: !device.mirrorScheduleToClassIsland,
});
ElMessage.success(device.mirrorScheduleToClassIsland ? '已禁止向该设备下发课表' : '已允许向该设备下发课表');
await loadAll();
}
async function mirrorOne(device: IntegrationDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`把 ClassHelper 的课表下发到「${device.name}」?教室里同一天同一单双周相位下冲突的旧课表会被停用。`,
'下发课表',
{ type: 'warning', confirmButtonText: '下发', cancelButtonText: '取消' },
).catch(() => 'cancel');
const result = await integrationApi.mirrorSchedule(device.id);
ElMessage.success(result.nextHeartbeatHint);
await loadAll();
}
async function pullOne(device: IntegrationDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`从「${device.name}」获取课表?教室机器的课表会**合并进本班课表**,可能覆盖老师手排的课。`,
'从教室机器获取课表',
{ type: 'warning', confirmButtonText: '获取', cancelButtonText: '取消' },
).catch(() => 'cancel');
const result = await integrationApi.requestSchedule(device.id);
ElMessage.success(result.nextHeartbeatHint);
await loadAll();
}
async function resetToken(device: IntegrationDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`重置「${device.name}」的令牌?旧令牌会立刻失效,教室的插件需要重新填写。`,
'重置令牌',
{ type: 'warning', confirmButtonText: '重置', cancelButtonText: '取消' },
).catch(() => 'cancel');
const result = await integrationApi.resetToken(device.id);
await ElMessageBox.alert(`新令牌(只显示这一次):\n\n${result.token ?? ''}`, '设备令牌', {
confirmButtonText: '我已保存',
}).catch(() => undefined);
await loadAll();
}
async function removeDevice(device: IntegrationDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`删除「${device.name}」?删除后它无法再上报课表或接收提醒(历史提醒记录保留)。`,
'删除设备',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
).catch(() => 'cancel');
await integrationApi.removeDevice(device.id);
ElMessage.success('设备已删除');
await loadAll();
}
async function pushUpdateToClass(): Promise<void> {
const current = detail.value;
if (!current) return;
const result = await clientApi.pushUpdate({
classIds: [current.classId],
version: targetVersion.value || null,
});
ElMessage.success(`已下发:成功 ${result.succeeded} / 共 ${result.total} 台`);
await loadAll();
}
/** 命令种类的中文名。包一层是因为表格插槽里的 `row` 是 any,直接索引常量表过不了类型检查 */
function commandLabel(command: ClientCommandDto): string {
return CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
}
/**
* 一条远控命令"现在究竟怎么样了"。
*
* 待执行的那条会在**取消 / 超时**之后从 `pendingCommand` 里消失,界面上不留痕迹 ——
* 而最常见的两种"点了没反应"恰恰是:本机没人确认(客户端 60 秒自动按取消)与设备当时离线。
* 服务端把最近一条也返回(`lastCommand`),这里把它翻成一句人话。
*/
function commandStateText(command: ClientCommandDto): string { if (command.result) return command.result;
if (command.ackedAt) return '已执行';
if (command.expiresAt && Date.parse(command.expiresAt) < Date.now()) {
return '已过期 —— 下达时机器不在线(命令有效期 10 分钟)';
}
return '待执行(等这台机器的下一次心跳)';
}
async function sendCommand(kind: ClientCommandKind): Promise<void> { const current = detail.value;
if (!current || current.clients.length === 0) return;
const label = CLIENT_COMMAND_LABELS[kind];
await ElMessageBox.confirm(
`向「${current.className}」的 ${current.clients.length} 台机器下发「${label}」?\n` +
'机器上会弹出**默认选中「取消」**的对话框,60 秒未确认按取消处理。',
`下发${label}`,
{ type: 'warning', confirmButtonText: `确认${label}`, cancelButtonText: '取消' },
).catch(() => 'cancel');
const result = await clientApi.commands({
deviceIds: current.clients.map((item) => item.id),
kind,
note: `由 ${auth.user?.name ?? '管理员'} 下达`,
});
ElMessage.success(
`已下发:成功 ${result.succeeded} / 共 ${result.total} 台 —— ` +
'机器上会弹出默认选中「取消」的确认框,60 秒内没人点「确认执行」就按取消处理(详情表里能看到结果)',
);
await loadAll();
}
async function removeClient(device: ClientDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`移除「${device.name}」的设备记录?只删记录,不影响那台机器本身(它下次心跳会重新登记)。`,
'移除设备记录',
{ type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' },
).catch(() => 'cancel');
await clientApi.removeDevice(device.id);
ElMessage.success('已移除');
await loadAll();
}
/* ------------------------------------------------------------ 下发提醒(教室大屏全屏弹出)
*
* 这一块是从「ClassIsland 联动」页搬过来的 —— 两页合并时不能只搬设备管理:
* 「向教室下发提醒」是老师日常要用的入口(权限 `classisland:notify` 默认给教师),
* 只留设备清单的话,教师会平白少一个功能。
*/
const notifyVisible = ref(false);
const notifySending = ref(false);
const notifyForm = reactive({
classId: '',
title: '',
content: '',
durationSeconds: 8,
speech: false,
priority: 'NORMAL' as NotificationPriority,
});
function openNotify(classId: string): void {
notifyForm.classId = classId;
notifyForm.title = '';
notifyForm.content = '';
notifyForm.durationSeconds = 8;
notifyForm.speech = false;
notifyForm.priority = 'NORMAL';
notifyVisible.value = true;
}
async function submitNotify(): Promise<void> {
if (!notifyForm.title.trim()) {
ElMessage.warning('请填写标题');
return;
}
notifySending.value = true;
try {
const result = await integrationApi.notify({
classId: notifyForm.classId,
title: notifyForm.title.trim(),
content: notifyForm.content,
durationSeconds: notifyForm.durationSeconds,
speech: notifyForm.speech,
priority: notifyForm.priority,
});
if (result.skipped === 'channel-client') {
ElMessage.warning('该班教室选择了「只在 ClassHelper 客户端显示」,本次没有推到 ClassIsland');
} else {
ElMessage.success(`已下发到 ${result.delivered}/${result.targetCount} 台教室设备`);
}
notifyVisible.value = false;
await loadAll();
} finally {
notifySending.value = false;
}
}
/* ------------------------------------------------------------ 生命周期 */
function refresh(): void {
void loadAll();
}
onMounted(() => {
void loadAll();
// 设备在线状态、客户端版本都是"心跳推进"的,跟着实时事件刷新即可
realtime.on(SOCKET_EVENTS.classislandState, refresh);
realtime.on(SOCKET_EVENTS.classUpdated, refresh);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.classislandState, refresh);
realtime.off(SOCKET_EVENTS.classUpdated, refresh);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">客户端</h2>
<p class="page-subtitle">
一个班的两条链路:ClassIsland 插件(教室大屏)与 ClassHelper 客户端(灵动岛)。
勾选班级可批量下发课表 / 更新,点卡片进详情做逐台操作
</p>
</div>
<div class="toolbar">
<el-select
v-if="canPushUpdate"
v-model="targetVersion"
placeholder="最新版本"
clearable
style="width: 150px"
>
<el-option v-for="item in releaseVersions" :key="item" :label="`v${item}`" :value="item" />
</el-select>
<el-button
v-if="canManageDevices"
type="warning"
:icon="'Upload'"
:loading="batchBusy"
:disabled="selected.length === 0"
@click="batchMirrorSchedule"
>
批量下发课表
</el-button>
<el-button
v-if="canPushUpdate"
type="primary"
:icon="'Refresh'"
:loading="batchBusy"
:disabled="selected.length === 0"
@click="batchPushUpdate"
>
批量下发更新
</el-button>
<el-button :icon="'Refresh'" :loading="loading" @click="loadAll">刷新</el-button>
</div>
</div>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
<template #title>课表的两个方向都是「按需」的</template>
<template #default>
<b>下发课表</b>:把 ClassHelper 排好的课表写进教室,并停用同一天同一单双周相位下冲突的旧课表(只停用、不删除)。
<b>获取课表</b>:把教室机器里启用的课表合并进本班课表。两个动作都只在你点按钮时发生 ——
教室与这里不会互相自动覆盖。
</template>
</el-alert>
<div class="select-bar">
<el-checkbox v-model="allSelected" :indeterminate="selected.length > 0 && !allSelected">
全选({{ selected.length }}/{{ cards.length }})
</el-checkbox>
</div>
<div v-loading="loading" class="card-grid">
<div v-for="card in cards" :key="card.classId" class="class-card">
<div class="card-head">
<el-checkbox v-model="selected" :value="card.classId" />
<span class="card-title">{{ card.className }}</span>
<el-tag size="small" effect="plain">{{ card.grade }}</el-tag>
<el-tag
size="small"
:type="card.status?.online || card.clients.some((item) => item.online) ? 'success' : 'info'"
effect="plain"
>
{{ card.status?.online || card.clients.some((item) => item.online) ? '在线' : '离线' }}
</el-tag>
</div>
<div class="card-row">
<span class="row-label">ClassIsland</span>
<template v-if="card.devices.length > 0">
<span class="row-value">
{{ card.devices.length }} 台 ·
{{ card.devices.some((item) => item.online) ? '在线' : '离线' }} ·
{{ card.devices[0]?.currentSubject ?? '当前无课' }}
</span>
</template>
<span v-else class="row-value muted">未接入</span>
</div>
<div class="card-row">
<span class="row-label">客户端</span>
<template v-if="card.clients.length > 0">
<span class="row-value">
{{ card.clients.length }} 台 ·
{{ card.clients.some((item) => item.online) ? '在线' : '离线' }} ·
v{{ card.clients[0]?.clientVersion ?? '?' }}
</span>
</template>
<span v-else class="row-value muted">还没有机器上报</span>
</div>
<div class="card-foot">
<el-button link type="primary" @click="openDetail(card.classId)">详情</el-button>
</div>
</div>
<el-empty v-if="!loading && cards.length === 0" description="还没有班级" />
</div>
<!-- 详情:该班的两条链路与逐台操作 -->
<el-drawer v-model="detailVisible" :title="detail ? `${detail.className} · 详情` : '详情'" size="60%">
<template v-if="detail">
<div class="detail-section">
<div class="section-head">
<span>ClassIsland 教室端({{ detail.devices.length }} 台)</span>
<div v-if="canManageDevices || canNotify" class="section-actions">
<el-button v-if="canNotify" size="small" type="primary" @click="openNotify(detail.classId)">
下发提醒
</el-button>
<el-input v-model="createForm.name" placeholder="设备名(默认取机器名)" size="small" style="width: 180px" />
<el-button size="small" type="primary" :loading="creating" @click="createDevice">
新增设备
</el-button>
</div>
</div>
<el-table :data="detail.devices" size="small" empty-text="该班还没有 ClassIsland 设备">
<el-table-column prop="name" label="设备" min-width="140" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.online ? 'success' : 'info'" effect="plain">
{{ row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" width="130">
<template #default="{ row }">CI {{ row.classIslandVersion ?? '-' }} / 插件 {{ row.pluginVersion ?? '-' }}</template>
</el-table-column>
<el-table-column label="最后心跳" width="120">
<template #default="{ row }">{{ row.lastHeartbeatAt ? relativeTime(row.lastHeartbeatAt) : '从未' }}</template>
</el-table-column>
<el-table-column label="允许下发" width="90">
<template #default="{ row }">
<el-switch
:model-value="row.mirrorScheduleToClassIsland"
:disabled="!canManageDevices"
size="small"
@update:model-value="() => allowMirror(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!canManageDevices" @click="mirrorOne(row)">下发课表</el-button>
<el-button link type="warning" :disabled="!canManageDevices" @click="pullOne(row)">获取课表</el-button>
<el-button link :disabled="!canManageDevices" @click="toggleDevice(row)">
{{ row.enabled ? '停用' : '启用' }}
</el-button>
<el-button link :disabled="!canManageDevices" @click="resetToken(row)">重置令牌</el-button>
<el-button link type="danger" :disabled="!canManageDevices" @click="removeDevice(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="detail-section">
<div class="section-head">
<span>ClassHelper 客户端({{ detail.clients.length }} 台)</span>
<div v-if="canPushUpdate" class="section-actions">
<el-button size="small" type="primary" :loading="batchBusy" @click="pushUpdateToClass">
给本班下发更新
</el-button>
</div>
</div>
<el-table :data="detail.clients" size="small" empty-text="该班还没有客户端上报过">
<el-table-column prop="name" label="设备" min-width="140" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.online ? 'success' : 'info'" effect="plain">
{{ row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" width="110">
<template #default="{ row }">
v{{ row.clientVersion }}
<el-tag v-if="row.updateTargetVersion" size="small" type="warning" effect="plain">
→ v{{ row.updateTargetVersion }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后心跳" width="120">
<template #default="{ row }">
{{ row.lastHeartbeatAt ? relativeTime(row.lastHeartbeatAt) : '从未' }}
</template>
</el-table-column>
<el-table-column prop="stateNote" label="状态说明" min-width="140" />
<el-table-column label="上次指令" min-width="200">
<template #default="{ row }">
<span v-if="!row.lastCommand" class="row-value muted">—</span>
<span v-else>
{{ commandLabel(row.lastCommand) }} · {{ commandStateText(row.lastCommand) }}
</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" :disabled="!canManageDevices" @click="removeClient(row)">
移除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="canRemote" class="remote-bar">
<span class="row-label">远程控制</span>
<el-button
v-for="kind in CLIENT_COMMAND_KINDS"
:key="kind"
size="small"
:disabled="detail.clients.length === 0"
@click="sendCommand(kind)"
>
{{ CLIENT_COMMAND_LABELS[kind] }}
</el-button>
</div>
</div>
<!--
两页旧版入口:合并过来的是**常用动作**,还有两处没搬 ——
「提醒下发 / 联动步骤说明」与「客户端安装包缓存管理」(不先把包拉下来就没有版本可下发)。
路由还留着(侧栏已隐藏),别的地方找不到它们,就在这里给一条路。
-->
<div class="detail-section legacy-links">
<el-button link type="primary" @click="router.push('/integrations')">
提醒下发与联动步骤 →
</el-button>
<el-button link type="primary" @click="router.push('/client-devices')">
安装包与设备记录 →
</el-button>
</div>
</template>
</el-drawer>
<!-- 下发提醒到教室大屏(ClassIsland 全屏弹出)——从「ClassIsland 联动」页搬过来 -->
<el-dialog v-model="notifyVisible" title="下发提醒到 ClassIsland" width="520px">
<el-form label-width="96px">
<el-form-item label="目标班级">
<el-input :model-value="cards.find((item) => item.classId === notifyForm.classId)?.className" disabled />
</el-form-item>
<el-form-item label="标题">
<el-input v-model="notifyForm.title" maxlength="60" show-word-limit placeholder="例如:放学前交作业" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="notifyForm.content" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
<el-form-item label="显示时长">
<el-input-number v-model="notifyForm.durationSeconds" :min="1" :max="120" /> 秒
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="notifyForm.priority" style="width: 160px">
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="语音朗读">
<el-switch v-model="notifyForm.speech" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="notifyVisible = false">取消</el-button>
<el-button type="primary" :loading="notifySending" @click="submitNotify">立即下发</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.select-bar {
margin-bottom: 8px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
min-height: 120px;
}
.class-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--ch-radius-lg);
padding: 12px 14px;
background: var(--ch-surface);
}
.card-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.card-title {
font-weight: 600;
}
.card-row {
display: flex;
gap: 8px;
font-size: var(--el-font-size-small);
line-height: 1.8;
}
.row-label {
flex: 0 0 76px;
color: var(--el-text-color-secondary);
}
.row-value.muted {
color: var(--el-text-color-placeholder);
}
.card-foot {
margin-top: 8px;
text-align: right;
}
.detail-section + .detail-section {
margin-top: 20px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
font-weight: 600;
}
.section-actions {
display: flex;
gap: 8px;
}
.remote-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
</style>
@@ -64,6 +64,48 @@ const averagePercent = computed(() => {
const examOptions = computed(() => [...new Set(grades.value.map((item) => item.examName))]);
/* ------------------------------------------------------------ 考试口径(计入总分)
* 需求:「单次考试(如月考、期中考),在导入成绩时可选是否将所有科目相加以总分排名,
* 客户端也可查看到总分排名和个别单科的排名」。
*
* 语数外 150 分、音体美 100 分,量纲不一样,"这次考试要不要合总分"是学校口径 ——
* 所以按「班级 + 考试名」存一条(服务端 `Exam` 表),客户端据它决定显不显示总分榜。
* 开关就摆在导入按钮旁边:老师导完一次考试,顺手就能把口径定下来。
*/
const examCountInTotal = ref(false);
const examSaving = ref(false);
async function loadExamFlag(): Promise<void> {
if (!filter.classId || !filter.examName) {
examCountInTotal.value = false;
return;
}
const list = await gradeApi.listExams(filter.classId).catch(() => []);
examCountInTotal.value = list.find((item) => item.name === filter.examName)?.countInTotal === true;
}
async function setExamCountInTotal(value: boolean): Promise<void> {
if (!filter.classId || !filter.examName) return;
examSaving.value = true;
try {
const saved = await gradeApi.setExam({
classId: filter.classId,
name: filter.examName,
countInTotal: value,
});
examCountInTotal.value = saved.countInTotal;
} catch {
// 失败时回读真实值,避免开关停在一个假状态上(拦截器已经弹过原因)
await loadExamFlag();
} finally {
examSaving.value = false;
}
}
// 换了班级或考试就重新读一次口径(加载成绩的 watch 只管列表)
watch(() => [filter.classId, filter.examName], () => void loadExamFlag(), { immediate: true });
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
@@ -448,6 +490,26 @@ onUnmounted(() => {
班主任若不教这一科,同样改不了这一科(不指定科目的内容归班主任与管理员)。
</el-alert>
<!-- 考试口径:这次考试要不要把所有科目相加做总分排名(按「班级 + 考试名」记,
客户端据此显示总分榜)。导入成绩时顺手就能定下来。 -->
<el-card v-if="canManageGrades && filter.classId && filter.examName" shadow="never" class="mt-12">
<div class="toolbar">
<span class="exam-label">考试口径</span>
<el-tag effect="plain">{{ filter.examName }}</el-tag>
<el-switch
:model-value="examCountInTotal"
:loading="examSaving"
data-test="exam-count-in-total"
active-text="所有科目相加,排总分榜"
inactive-text="只排单科,不合总分"
@change="(value: string | number | boolean) => setExamCountInTotal(value === true)"
/>
<span class="exam-hint">
语数外 150 分、音体美 100 分不是一个量纲,合不合总分由这里决定;客户端会按这个设置显示总分榜
</span>
</div>
</el-card>
<el-row :gutter="12">
<el-col :xs="24" :md="6">
<div class="stat-card">
@@ -773,4 +835,13 @@ onUnmounted(() => {
padding-left: 90px;
margin-top: -6px;
}
.exam-label {
font-weight: 600;
}
.exam-hint {
color: var(--el-text-color-secondary);
font-size: var(--el-font-size-extra-small);
}
</style>
@@ -19,8 +19,9 @@ import {
type NotificationPriority,
type StudentDto,
} from '@classhelper/shared';
import { callApi, classApi, notificationApi, scheduleApi } from '@/api';
import { callApi, classApi, notificationApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
import { findClassInSession } from '@/composables/useClassInSession';
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
const realtime = useRealtimeStore();
@@ -227,20 +228,9 @@ async function openCreate(): Promise<void> {
}
/**
* 上课时段检查:多班发布时逐班查询(GET /api/schedules/current?classId=...),
* 任一班级正在上课就需要二次确认;返回命中的班级与时段。
* 上课时段检查见 `composables/useClassInSession.ts`(大模型助手那条发布路径也用它,
* 判据只留一份,别在这里再写一遍)。
*/
async function findClassInSession(
classIds: string[],
): Promise<{ classId: string; period: ClassPeriod | null } | null> {
const results = await Promise.all(
classIds.map(async (classId) => {
const status = await scheduleApi.classStatus(classId).catch(() => null);
return status?.inClass ? { classId, period: status.current } : null;
}),
);
return results.find((item) => item !== null) ?? null;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
+63 -7
View File
@@ -40,7 +40,9 @@ const MENU_ITEMS = [
{ label: '作业发布', path: '/homeworks' },
{ label: '通知发布', path: '/notifications' },
{ label: '成绩录入', path: '/grades' },
{ label: 'ClassIsland 联动', path: '/integrations' },
// 2026-10-11 起「ClassIsland 联动」与「客户端设备」合并成这一项;
// 门控是 `classisland:notify`(默认 ADMIN + TEACHER),所以教师也看得见。
{ label: '客户端', path: '/clients' },
];
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
@@ -1116,18 +1118,72 @@ async function main() {
: `原因=${removedUnified?.reason ?? '-'}`,
);
// 7.85 ClassIsland 联动页:班主任为本班签发设备令牌 → 页面下发提醒表单可用 → 收尾删除设备
// (这条链路是"老师在 Web 端发提醒 → 教室里 ClassIsland 全屏弹出"的入口,必须真实点得动)
const integrationUi = await win.webContents.executeJavaScript(`(async () => {
// 7.84 「客户端」页(合并了原来的「ClassIsland 联动」与「客户端设备」):
// 侧栏点得进、班级卡片渲染出来、两个批量按钮在
const hubUi = await win.webContents.executeJavaScript(`(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
(node.textContent ?? '').trim().startsWith('ClassIsland 联动'),
(node.textContent ?? '').trim().startsWith('客户端'),
);
if (!menu) return { ok: false, reason: '教师端看不到「ClassIsland 联动」入口' };
if (!menu) return { ok: false, reason: '教师端看不到「客户端」入口' };
menu.click();
const deadline = Date.now() + 6000;
while (Date.now() < deadline && location.pathname !== '/clients') await sleep(80);
if (location.pathname !== '/clients') return { ok: false, reason: '点击后没有跳到 /clients' };
await sleep(1200);
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
const buttons = Array.from(document.querySelectorAll('button')).map((node) => (node.textContent ?? '').trim());
const cards = document.querySelectorAll('.class-card').length;
// 详情抽屉:点第一张卡的「详情」,抽屉里应有两条链路的表格
const detail = Array.from(document.querySelectorAll('.class-card .card-foot button')).find((node) =>
(node.textContent ?? '').trim() === '详情',
);
if (detail) {
detail.click();
await sleep(900);
}
const drawer = document.querySelector('.el-drawer');
const drawerText = (drawer?.textContent ?? '').trim();
const close = document.querySelector('.el-drawer__close-btn');
if (close) close.click();
await sleep(400);
return {
ok: true,
title,
cards,
hasBatchMirror: buttons.includes('批量下发课表'),
hasBatchUpdate: buttons.includes('批量下发更新'),
hasNotify: buttons.includes('下发提醒'),
drawerOpened: Boolean(drawer),
drawerHasBothSides: drawerText.includes('ClassIsland 教室端') && drawerText.includes('ClassHelper 客户端'),
path: location.pathname,
};
})()`);
record(
'「客户端」页(合并 ClassIsland 联动 + 客户端设备):卡片渲染 + 批量下发 + 详情抽屉',
Boolean(hubUi?.ok) &&
hubUi?.title === '客户端' &&
hubUi?.cards > 0 &&
hubUi?.hasBatchMirror === true &&
hubUi?.drawerOpened === true &&
hubUi?.drawerHasBothSides === true,
`path=${hubUi?.path ?? '-'} 标题=${hubUi?.title ?? '-'} 卡片=${hubUi?.cards ?? 0} ` +
`批量下发课表=${hubUi?.hasBatchMirror ? '有' : '无'} 抽屉=${hubUi?.drawerOpened ? '开' : '未开'} ` +
`两端清单都在=${hubUi?.drawerHasBothSides ? '是' : '否'}` +
`${hubUi?.reason ? ` 原因=${hubUi.reason}` : ''}`,
);
// 7.85 ClassIsland 联动页:班主任为本班签发设备令牌 → 页面下发提醒表单可用 → 收尾删除设备
// (这条链路是"老师在 Web 端发提醒 → 教室里 ClassIsland 全屏弹出"的入口,必须真实点得动)
// 这一页 2026-10-11 起**从侧栏摘掉**(常用动作合并进「客户端」),路由还在,
// 所以这里用 history 直接过去,后面几条断言仍然盯着这页本身。
const integrationUi = await win.webContents.executeJavaScript(`(async () => {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
history.pushState({}, '', '/integrations');
window.dispatchEvent(new PopStateEvent('popstate'));
const deadline = Date.now() + 6000;
while (Date.now() < deadline && location.pathname !== '/integrations') await sleep(80);
if (location.pathname !== '/integrations') return { ok: false, reason: '点击后没有跳到 /integrations' };
if (location.pathname !== '/integrations') return { ok: false, reason: '没能跳到 /integrations(旧版联动页)' };
await sleep(900);
const buttons = Array.from(document.querySelectorAll('button')).map((node) => (node.textContent ?? '').trim());
+1 -1
View File
@@ -18,7 +18,7 @@
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
<meta name="ch-version" content="1.3.0" />
<meta name="ch-version" content="1.3.2" />
<link rel="icon" href="assets/icon.png" />
<link rel="apple-touch-icon" href="assets/icon.png" />
+47 -5
View File
@@ -44,7 +44,7 @@
<a class="brand" href="#top" data-press="0.96">
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
<span class="brand-name">ClassHelper</span>
<span class="brand-ver">1.3.0</span>
<span class="brand-ver">1.3.2</span>
</a>
<nav class="topnav" aria-label="页内导航">
<span class="nav-pill" aria-hidden="true"></span>
@@ -93,7 +93,7 @@
</div>
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
<li>v1.3.0</li>
<li>v1.3.2</li>
<li>Windows 10/11</li>
<li>Linux · Docker</li>
<li>触摸屏可用</li>
@@ -464,9 +464,9 @@
</div>
<div class="feature-block">
<p class="feature-tag">#成绩</p>
<h4 class="feature-title">单条、批量,或者整张表导进来</h4>
<h4 class="feature-title">单条、批量,或者整张表导进来,还带排名</h4>
<p class="feature-body">
重复判定按「同班 + 同学生 + 同考试 + 同课程」,导入报错精确到 Excel 行号,改完再导。
重复判定按「同班 + 同学生 + 同考试 + 同课程」,导入报错精确到 Excel 行号。单科排名自动出;这次考试要不要把各科相加排总分榜,你说了算。
</p>
</div>
<div class="feature-block">
@@ -476,6 +476,34 @@
SQLite 与 MySQL 互相搬数据,中途失败会自动回滚 —— 现有的库不会被动到。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#权限</p>
<h4 class="feature-title">谁能做什么,点着勾</h4>
<p class="feature-body">
建权限组、把账号分进去。没分组的按角色默认生效,分组之后只按组的并集算 —— 既能放权,也能收权。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#教室机器</p>
<h4 class="feature-title">一个班一张卡,两条链路一起管</h4>
<p class="feature-body">
大屏(ClassIsland)和灵动岛(客户端)的状态放在同一张卡上,勾几个班就能批量下发课表或更新;想下发或取回课表,点一下才发生 —— 教室和这边不会互相自动覆盖。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#大模型助手</p>
<h4 class="feature-title">说一句话就发出去</h4>
<p class="feature-body">
它只把话解析成待确认的卡片,你点了才落库 —— 权限、上课时段、ClassIsland 推送还是原来那套。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#批量发布</p>
<h4 class="feature-title">勾几个班,一次发完</h4>
<p class="feature-body">
通知与作业都能批量。个别班失败不影响其余班级,哪个班为什么没发出去会逐班写清楚。
</p>
</div>
</div>
</div>
@@ -501,6 +529,13 @@
无边框透明置顶的小窗,通知、作业、叫人在里面展开成卡片;鼠标穿透,不占任务栏。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#成绩排名</p>
<h4 class="feature-title">教室里就能看排名</h4>
<p class="feature-body">
单科榜按得分率排、并列同名次;老师把这次考试设成「各科相加」后,还多一张总分榜。开不开由服务端定,教室里只读。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#离线缓存</p>
<h4 class="feature-title">断网的时候接着看</h4>
@@ -515,6 +550,13 @@
高度、宽度、圆角、字号、停靠位置、视觉风格都能调;越界的值在主进程统一夹紧。
</p>
</div>
<div class="feature-block">
<p class="feature-tag">#自愈</p>
<h4 class="feature-title">崩了会自己回来</h4>
<p class="feature-body">
渲染进程挂了先安静地重建窗口;断网期间错过的通知,重新上线时补弹一次。
</p>
</div>
</div>
</div>
</div>
@@ -704,7 +746,7 @@
ClassHelper
<span>Class Helper</span>
</p>
<p class="foot-ver">版本 1.3.0 · 源码公开 · 装在你自己的机器上</p>
<p class="foot-ver">版本 1.3.2 · 源码公开 · 装在你自己的机器上</p>
</div>
</div>
<nav class="foot-nav" aria-label="相关链接">