Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
227117ccb3 | ||
|
|
3236d3ad83 | ||
|
|
0b9a321bc9 |
No files matched your search
@@ -432,7 +432,9 @@ pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版
|
|||||||
git add -A && git commit -m "chore(release): <版本>" && git push origin master
|
git add -A && git commit -m "chore(release): <版本>" && git push origin master
|
||||||
|
|
||||||
# ⑦ 开 Release(gh release create 会创建并推送 tag,从而触发 Linux 包的 CI;见下)
|
# ⑦ 开 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<版本> \
|
gh release upload v<版本> \
|
||||||
releases/client/<版本>/安装包/*.exe \
|
releases/client/<版本>/安装包/*.exe \
|
||||||
releases/classisland-plugin/<版本>/安装包/*.cipx \
|
releases/classisland-plugin/<版本>/安装包/*.cipx \
|
||||||
@@ -478,6 +480,36 @@ gh workflow run release-linux-server.yml -f tag=v<版本> -f attach=true
|
|||||||
**已发布的版本号不要复用**:客户端把它记进「忽略此版本」,两端各有 30 分钟缓存 ——
|
**已发布的版本号不要复用**:客户端把它记进「忽略此版本」,两端各有 30 分钟缓存 ——
|
||||||
同号换内容会让「检查更新」永久失灵。要修就 `pnpm version:bump patch` 发新号。
|
同号换内容会让「检查更新」永久失灵。要修就 `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` 或
|
**不用做任何事。** 推 master 时只要 `website/**`、`docs/**`、`scripts/build-pages.mjs` 或
|
||||||
@@ -785,6 +817,31 @@ term / dashboard / database / update / permissions / clients / system`。
|
|||||||
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
|
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
|
||||||
完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。
|
完整性标签(§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`
|
43. **Web 端新手引导(`el-tour`)在自动化里的判据有三条坑**(2026-10-02 实测,`verify:web`
|
||||||
「首次登录展示新手引导且可跳过」踩过):
|
「首次登录展示新手引导且可跳过」踩过):
|
||||||
① **外壳先出现、内容晚一拍** —— `document.querySelector('.el-tour')` 先于步骤卡片渲染成立,
|
① **外壳先出现、内容晚一拍** —— `document.querySelector('.el-tour')` 先于步骤卡片渲染成立,
|
||||||
@@ -1067,6 +1124,17 @@ term / dashboard / database / update / permissions / clients / system`。
|
|||||||
CentOS 7 是 2.17 —— 装上去的表现为"服务起来就退出、日志什么都没有"。安装器检测 glibc,
|
CentOS 7 是 2.17 —— 装上去的表现为"服务起来就退出、日志什么都没有"。安装器检测 glibc,
|
||||||
低于 2.28 时自动改用 unofficial-builds 的 `-glibc-217` 变体。CentOS 7 仍属尽力而为。
|
低于 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 ] && 可能失败的命令` 会
|
两条写这类脚本时的通用坑(本仓库踩过):**`set -e` 下 `[ cond ] && 可能失败的命令` 会
|
||||||
在条件为假时……其实不会退出**(bash 对 `&&` 列表里非最后一条失败是豁免的),但
|
在条件为假时……其实不会退出**(bash 对 `&&` 列表里非最后一条失败是豁免的),但
|
||||||
`A && B` 里 B 失败、以及**用 `$(函数)` 捕获输出时把日志一起捕获**(所以安装器用全局
|
`A && B` 里 B 失败、以及**用 `$(函数)` 捕获输出时把日志一起捕获**(所以安装器用全局
|
||||||
@@ -1307,6 +1375,50 @@ term / dashboard / database / update / permissions / clients / system`。
|
|||||||
`app.relaunch()`;**冒烟模式绝不重启**(会杀掉验证实例)。
|
`app.relaunch()`;**冒烟模式绝不重启**(会杀掉验证实例)。
|
||||||
自愈模块在 `packages/desktop-client/src/main/crash-guard.ts`,限流与 `isIdle` 判据别放宽。
|
自愈模块在 `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. 代码风格
|
## 6. 代码风格
|
||||||
|
|
||||||
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
||||||
|
|||||||
@@ -49,9 +49,17 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单
|
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单
|
||||||
- [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
|
- [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
|
||||||
- [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级
|
- [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级
|
||||||
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计
|
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计;单科排名自动出,可选「各科相加排总分榜」
|
||||||
- [x] ClassIsland 联动:签发设备令牌、下发提醒、把课表镜像回教室大屏
|
- [x] ClassIsland 联动:签发设备令牌、下发提醒、把课表镜像回教室大屏
|
||||||
- [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换
|
- [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换
|
||||||
|
- [x] 权限组:39 个权限键,建组分配账号 —— 没分组的按角色默认生效,分组后只按组的并集算
|
||||||
|
- [x] 客户端(合并页):一个班一张卡片,大屏与灵动岛两条链路一起看;勾选可批量下发课表 / 更新,点进去做逐台操作
|
||||||
|
- [x] 课表同步按需:点「下发课表」把课表写进教室并停用冲突的旧课表,点「获取课表」把教室的课表取回来 —— 没有"一直同步"
|
||||||
|
- [x] 客户端设备:每台教室机器的版本、在线状态与最后心跳;一键推送更新,可下发关机 / 重启 / 注销 / 锁屏
|
||||||
|
- [x] 大模型助手:一句话解析成待确认的作业 / 通知 / 叫人,点确认才落库
|
||||||
|
- [x] 通知与作业批量发布:勾多个班一次发完,个别班失败不影响其余班级
|
||||||
|
- [x] 系统设置:开发者模式、运行诊断、开机自启开关(仅安装形态)
|
||||||
|
- [x] 关怀模式:字号与控件整体放大
|
||||||
- [x] 手机小屏适配,可「添加到主屏幕」当应用用
|
- [x] 手机小屏适配,可「添加到主屏幕」当应用用
|
||||||
|
|
||||||
### 教室里的桌面客户端
|
### 教室里的桌面客户端
|
||||||
@@ -60,9 +68,10 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
- [x] 课表:「今天」时间轴(大时钟 + 正在上 / 下一节 + 距下课倒计时)与周视图
|
- [x] 课表:「今天」时间轴(大时钟 + 正在上 / 下一节 + 距下课倒计时)与周视图
|
||||||
- [x] 作业看板:按科目分卡、可全屏铺满、字号可调,也能切回列表
|
- [x] 作业看板:按科目分卡、可全屏铺满、字号可调,也能切回列表
|
||||||
- [x] 通知中心:未读红点、优先级标签、点击自动标为已读
|
- [x] 通知中心:未读红点、优先级标签、点击自动标为已读
|
||||||
- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布
|
- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布;按考试看单科排名,老师开启后还有总分排名
|
||||||
- [x] 教室机器可直接录入作业、勾选未交名单
|
- [x] 教室机器可直接录入作业、勾选未交名单
|
||||||
- [x] 离线优先:断网回退 IndexedDB 缓存,恢复连接后自动同步
|
- [x] 离线优先:断网回退 IndexedDB 缓存,恢复连接后自动同步
|
||||||
|
- [x] 断网期间错过的通知,重新上线时补弹一次;渲染进程崩了会安静地自己重建窗口
|
||||||
- [x] 浅色 / 深色主题,跟随系统
|
- [x] 浅色 / 深色主题,跟随系统
|
||||||
|
|
||||||
### 灵动岛(桌面通知浮窗)
|
### 灵动岛(桌面通知浮窗)
|
||||||
@@ -87,6 +96,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
- [x] Linux 一键安装 + `classhelper` 运维命令(systemd 托管,升级 / 回滚 / 备份)
|
- [x] Linux 一键安装 + `classhelper` 运维命令(systemd 托管,升级 / 回滚 / 备份)
|
||||||
- [x] Docker Compose(MySQL 版与 SQLite 版各一份)
|
- [x] Docker Compose(MySQL 版与 SQLite 版各一份)
|
||||||
- [x] SQLite 与 MySQL 一键切换,备份与跨库迁移共用同一份 JSON 快照
|
- [x] SQLite 与 MySQL 一键切换,备份与跨库迁移共用同一份 JSON 快照
|
||||||
|
- [x] 客户端批量更新不依赖外网:安装包由服务端下载缓存,教室机从服务端取,校验 sha256 后空闲时自动装
|
||||||
|
|
||||||
## 软件截图
|
## 软件截图
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-1.3.0}
|
image: classhelper-server:${APP_VERSION:-1.3.1}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-1.3.0}
|
image: classhelper-server:${APP_VERSION:-1.3.1}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -540,6 +540,12 @@ resolve_package_url() {
|
|||||||
sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz"
|
sudo bash install.sh --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz"
|
||||||
fi
|
fi
|
||||||
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}"
|
ver="${tag#v}"
|
||||||
# 注意这里用 GITHUB_REPO_URL 而不是 GITHUB_RELEASES_PAGE:后者本身已经以 /releases 结尾,
|
# 注意这里用 GITHUB_REPO_URL 而不是 GITHUB_RELEASES_PAGE:后者本身已经以 /releases 结尾,
|
||||||
# 再拼一次就变成 .../releases/releases/download/... (必然 404,实测踩到)。
|
# 再拼一次就变成 .../releases/releases/download/... (必然 404,实测踩到)。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "classhelper-server-runtime",
|
"name": "classhelper-server-runtime",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
||||||
|
|||||||
+25
-1
@@ -19,6 +19,21 @@ description: 改版本号、跑验收、打安装包、发 GitHub Release、发
|
|||||||
| 要给别人一份新安装包 | 只在自己机器上跑 |
|
| 要给别人一份新安装包 | 只在自己机器上跑 |
|
||||||
| 插件单独发补丁(只动插件第 4 段版本号) | — |
|
| 插件单独发补丁(只动插件第 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 处 / 12 个文件)
|
||||||
|
|
||||||
同一个版本号分散在 14 个落点上,**手工改必然漏一处**,而漏掉的那处不会报错:
|
同一个版本号分散在 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/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/
|
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
|
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
|
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 一变会自动重跑。
|
||||||
|
> 1.3.0 实测踩过:tag 指的那个提交里打包脚本有个 bug,CI 红了、Release 上少一个 Linux 包,
|
||||||
|
> 而本地 `pnpm dist:server` 一直是绿的(Windows 侧走另一条分支)。
|
||||||
|
>
|
||||||
|
> 另外,本页第 8 步的「官网与文档站」是**另一条**流水线(master 推送触发)——
|
||||||
|
> 它绿不代表 Linux 包出来了,推完 tag 要去 `gh run list --limit 5` 看一眼。
|
||||||
|
|
||||||
### 8. 官网与文档站
|
### 8. 官网与文档站
|
||||||
|
|
||||||
**不用做任何事。** 推送 master 时只要 `website/**`、`docs/**`、`scripts/build-pages.mjs` 或
|
**不用做任何事。** 推送 master 时只要 `website/**`、`docs/**`、`scripts/build-pages.mjs` 或
|
||||||
|
|||||||
+59
-7
@@ -47,9 +47,10 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
|||||||
- [4.12 学期周次](#412-学期周次可逐周覆盖仅管理员)
|
- [4.12 学期周次](#412-学期周次可逐周覆盖仅管理员)
|
||||||
- [4.13 课表同步方向](#413-课表同步方向服务端--教室)
|
- [4.13 课表同步方向](#413-课表同步方向服务端--教室)
|
||||||
- [4.14 权限组管理(角色只是默认模板)](#414-权限组管理角色只是默认模板)
|
- [4.14 权限组管理(角色只是默认模板)](#414-权限组管理角色只是默认模板)
|
||||||
- [4.15 客户端设备统一更新与远控](#415-客户端设备统一更新与远控)
|
- [4.15 客户端(合并页):统一更新与远控](#415-客户端合并了classisland-联动客户端设备统一更新与远控)
|
||||||
- [4.16 通知按天归档与批量发布](#416-通知按天归档与批量发布)
|
- [4.16 通知按天归档与批量发布](#416-通知按天归档与批量发布)
|
||||||
- [4.17 大模型助手与开发者模式 / 关怀模式](#417-大模型助手与开发者模式--关怀模式)
|
- [4.17 大模型助手与开发者模式 / 关怀模式](#417-大模型助手与开发者模式--关怀模式)
|
||||||
|
- [4.18 考试成绩:单科排名与总分排名](#418-考试成绩单科排名与总分排名)
|
||||||
- [5. 技术参考](#5-技术参考)
|
- [5. 技术参考](#5-技术参考)
|
||||||
- [5.1 目录结构](#51-目录结构)
|
- [5.1 目录结构](#51-目录结构)
|
||||||
- [5.2 技术栈](#52-技术栈)
|
- [5.2 技术栈](#52-技术栈)
|
||||||
@@ -698,9 +699,23 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
|
|||||||
|
|
||||||
| 方向 | 做什么 | 老师在哪看 |
|
| 方向 | 做什么 | 老师在哪看 |
|
||||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- |
|
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- |
|
||||||
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止时间),写入本班课表 | Web 端「ClassIsland 联动」实时状态卡片 |
|
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止时间),写入本班课表 | 「客户端」页的班级卡片与详情 |
|
||||||
| ClassHelper → ClassIsland | 老师在 Web 端发一条**提醒** → 落库 → 教室机器的插件取回 → ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不会重复弹 | Web 端「ClassIsland 联动 → 下发提醒」 |
|
| ClassHelper → ClassIsland | 老师在 Web 端发一条**提醒** → 落库 → 教室机器的插件取回 → ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不会重复弹 | 「客户端」页 → 「下发提醒」 |
|
||||||
| (可选)班级 → ClassIsland | 把 ClassHelper上排好的课表**镜像**回 ClassIsland:新建一份 `ClassHelper-<班级名>` 档案课表,不动老师原有的课表 | 同上页面的「开镜像」开关 |
|
| ClassHelper → ClassIsland(课表) | 把本班课表**下发**到教室:写一份 `ClassHelper-<班级名>` 档案课表,并**停用**同一天同一单双周相位下冲突的旧课表(只停用、不删除) | 「客户端」页 → 「下发课表」 |
|
||||||
|
|
||||||
|
#### 课表同步:两个方向都「按需」
|
||||||
|
|
||||||
|
**没有"一直同步"的开关**,两个方向都只在你点按钮时发生一次:
|
||||||
|
|
||||||
|
| 动作 | 从哪点 | 插件做什么 |
|
||||||
|
| ------------------------ | ------------------------------------- | ---------------------------------------------------------------- |
|
||||||
|
| **下发课表**(→ 教室) | 「客户端」页的卡片 / 详情里的「下发课表」 | 拉本班课表 → 写进 ClassIsland → **停用**冲突的旧课表(强制替换) |
|
||||||
|
| **获取课表**(→ 服务端) | 同上的「获取课表」 | 把 ClassIsland 里**启用的**课表合并进本班课表 |
|
||||||
|
|
||||||
|
> **为什么不做自动同步**:插件原先默认每 15 秒上报一次、每次上报都带全量课表、上报完还顺手镜像一遍 ——
|
||||||
|
> 教室的课表每 15 秒被重写、服务端也每 15 秒 merge + 清理一次幽灵行,老师看到的是「课表被反复删改」。
|
||||||
|
> 现在定时上报**只报运行状态**(当前上什么课),课表永远只在点击时双向走一次。
|
||||||
|
> 「强制替换」也**只停用不删除**:冲突的课表内容原样留在 ClassIsland 档案里,随时可以重新启用。
|
||||||
|
|
||||||
#### 接入流程(三步)
|
#### 接入流程(三步)
|
||||||
|
|
||||||
@@ -875,9 +890,14 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
|
|||||||
| 拉取放假安排 | 联网获取当年的法定节假日,**只给建议、不会自动改数据** |
|
| 拉取放假安排 | 联网获取当年的法定节假日,**只给建议、不会自动改数据** |
|
||||||
| 保存 | 校验区间不重叠(重叠会让"现在第几周"出现歧义),然后**下发给所有 ClassHelper 班级端** |
|
| 保存 | 校验区间不重叠(重叠会让"现在第几周"出现歧义),然后**下发给所有 ClassHelper 班级端** |
|
||||||
|
|
||||||
- 存储:`TermWeek`(`classId = ''` 表示全校默认)+ `Class.termStartDate`;
|
- 存储:`TermWeek`(`classId = ''` 表示全校默认)+ `Class.termStartDate`(班级)+ `AppSetting`
|
||||||
- 判定:`lib/term.ts` 的 `loadTermContext()` 做「班级覆盖 → 全校默认 → 纯线性」三级回落,
|
的 `term.startDate`(全校默认的开学日期);
|
||||||
|
- 判定:`lib/term.ts` 的 `loadTermContext()` 做三级回落 ——
|
||||||
|
开学日期按「`Class.termStartDate` → 全校设置 → `.env` 的 `TERM_START_DATE`」,
|
||||||
|
逐周区间按「班级 → 全校默认 → 纯线性」,
|
||||||
`resolveCurrentWeek(now, context)` 优先按区间判定;
|
`resolveCurrentWeek(now, context)` 优先按区间判定;
|
||||||
|
- **开学日期在页面上就能改**(2026-10-11 起):原先全校默认那一级只能来自 `.env`,填错了得动文件 +
|
||||||
|
重启;现在管理员在「学期周次」页填完保存即可,**当前周次立刻按新日期重算**,随时可以纠正。
|
||||||
- 机房/服务器**没有外网时,"拉取放假安排"返回 `ok:false` 是正常结果**(不是错误),
|
- 机房/服务器**没有外网时,"拉取放假安排"返回 `ok:false` 是正常结果**(不是错误),
|
||||||
失败结果同样进缓存,避免断网点一次要白等一个超时。
|
失败结果同样进缓存,避免断网点一次要白等一个超时。
|
||||||
|
|
||||||
@@ -922,7 +942,16 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
|
|||||||
- 判定规则与完整矩阵见[角色与权限](dev/permissions.md);**服务端始终是唯一拦截点**,
|
- 判定规则与完整矩阵见[角色与权限](dev/permissions.md);**服务端始终是唯一拦截点**,
|
||||||
前端只按 `GET /permissions/me` 隐藏入口,别把它当安全边界。
|
前端只按 `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 秒**,一次请求把两件事带回:
|
每台教室机器的心跳间隔 `CLIENT_HEARTBEAT_INTERVAL_MS` = **30 秒**,一次请求把两件事带回:
|
||||||
「要不要更新 / 装哪个版本」与「有没有待执行的远控命令」(`POST /clients/heartbeat`,**不挂权限键**)。
|
「要不要更新 / 装哪个版本」与「有没有待执行的远控命令」(`POST /clients/heartbeat`,**不挂权限键**)。
|
||||||
@@ -1007,6 +1036,29 @@ BulkPublishClassResult { classId, ok, id?, code?, message?, details? }
|
|||||||
侧栏折叠同样是本机偏好。`User.careMode` 列已经建好,但**服务端目前没有读写它** ——
|
侧栏折叠同样是本机偏好。`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. 技术参考
|
||||||
|
|
||||||
### 5.1 目录结构
|
### 5.1 目录结构
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "class-helper",
|
"name": "class-helper",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||||
"packageManager": "pnpm@11.8.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ description: |-
|
|||||||
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
|
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
|
||||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||||
url: https://github.com/LoveChengke/ClassHelper
|
url: https://github.com/LoveChengke/ClassHelper
|
||||||
version: 1.3.0.0
|
version: 1.3.1.0
|
||||||
apiVersion: 2.0.0.0
|
apiVersion: 2.0.0.0
|
||||||
author: ClassHelper
|
author: ClassHelper
|
||||||
@@ -31,6 +31,7 @@ const clientSource = read('src/Interop/ClassHelperClient.cs');
|
|||||||
const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs');
|
const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs');
|
||||||
const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml');
|
const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml');
|
||||||
const mapperSource = read('src/Services/ScheduleMapper.cs');
|
const mapperSource = read('src/Services/ScheduleMapper.cs');
|
||||||
|
const writerSource = read('src/Services/ClassPlanWriter.cs');
|
||||||
const settingsSource = read('src/Models/PluginSettings.cs');
|
const settingsSource = read('src/Models/PluginSettings.cs');
|
||||||
const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts');
|
const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts');
|
||||||
const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts');
|
const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts');
|
||||||
@@ -255,15 +256,24 @@ check(
|
|||||||
);
|
);
|
||||||
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
|
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
|
||||||
|
|
||||||
// 3.10 课表同步方向(2026-10-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
// 3.10 课表同步方向(2026-10-11 定稿):**两个方向都只由 Web 端的一次性动作触发**。
|
||||||
// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。
|
// 2026-10-06 曾经把它做成"服务端 → 教室自动下发 + 本机两个开关",但默认 15 秒一轮的
|
||||||
|
// 自动上传 + 自动镜像会让服务端与教室里的课表每 15 秒互相覆盖一次,老师看到的是
|
||||||
|
// "课表被反复删改"(2026-10-11 实测)。现在本机没有任何"一直同步"的开关:
|
||||||
|
// · 教室 → 服务端:`scheduleRequested` 一次性待办(Web 端「从教室机器获取课表」)
|
||||||
|
// · 服务端 → 教室:`mirrorRequested` 一次性待办(Web 端「下发课表到教室」,强制替换)
|
||||||
|
// 这几条盯着新契约,别删。
|
||||||
check(
|
check(
|
||||||
'默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)',
|
'课表两个方向都改为按需:本机不再有「自动回传 / 自动镜像」开关',
|
||||||
/_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource),
|
!/_uploadSchedule/.test(settingsSource) && !/_mirrorSchedule/.test(settingsSource),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)',
|
'定时上报只报运行状态,课表仅在服务端显式请求时上传',
|
||||||
/scheduleRequested/.test(clientSource),
|
/var uploadSchedule = forceSchedule;/.test(bridgeSource),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'响应 DTO 里有 scheduleRequested / mirrorRequested(两个方向的一次性待办)',
|
||||||
|
/scheduleRequested/.test(clientSource) && /mirrorRequested/.test(clientSource),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
|
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
|
||||||
@@ -271,8 +281,13 @@ check(
|
|||||||
/forceSchedule:\s*true/.test(bridgeSource),
|
/forceSchedule:\s*true/.test(bridgeSource),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)',
|
'只有收到 mirrorRequested 才镜像(不再跟着每一次上报走)',
|
||||||
/forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource),
|
/MirrorRequested\s*==\s*true/.test(bridgeSource),
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
'镜像写入是「强制替换」(停用冲突的旧课表,而不是只留一条警告)',
|
||||||
|
/Apply\(profile, plan, force: true\)/.test(bridgeSource) &&
|
||||||
|
/IsEnabled = false/.test(writerSource),
|
||||||
);
|
);
|
||||||
check(
|
check(
|
||||||
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
|
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
|
||||||
|
|||||||
@@ -88,6 +88,15 @@ public class ReportSettingsDto
|
|||||||
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
|
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
|
[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
|
public class ReportResultDto
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ public class PluginSettings : ObservableObject
|
|||||||
private string _deviceKey = "";
|
private string _deviceKey = "";
|
||||||
private bool _autoReport = true;
|
private bool _autoReport = true;
|
||||||
private int _reportIntervalSeconds = 60;
|
private int _reportIntervalSeconds = 60;
|
||||||
// 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
// 课表同步(2026-10-11 起):**两个方向都只由 Web 端的一次性动作触发** ——
|
||||||
// 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。
|
// 「从教室机器获取课表」与「下发课表到教室」,都走服务端的一个待办标记。
|
||||||
// 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。
|
// 原先这里有两个开关(自动回传 / 自动镜像),默认 15 秒一轮,结果是服务端与教室里
|
||||||
private bool _uploadSchedule = false;
|
// 的课表每 15 秒互相覆盖一次,老师看到的就是"课表被反复删改"。
|
||||||
private bool _mirrorSchedule = true;
|
// 定时上报现在**只报运行状态**(当前上什么课),不带课表。
|
||||||
private bool _receiveNotifications = true;
|
private bool _receiveNotifications = true;
|
||||||
private int _notificationDurationSeconds = 8;
|
private int _notificationDurationSeconds = 8;
|
||||||
private int _notificationPollSeconds = 10;
|
private int _notificationPollSeconds = 10;
|
||||||
@@ -76,33 +76,6 @@ public class PluginSettings : ObservableObject
|
|||||||
set => SetProperty(ref _reportIntervalSeconds, Math.Clamp(value, 15, 3600));
|
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-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
|
||||||
///
|
|
||||||
/// **默认开启**:这是现在课表同步的主方向。
|
|
||||||
/// </summary>
|
|
||||||
public bool MirrorSchedule
|
|
||||||
{
|
|
||||||
get => _mirrorSchedule;
|
|
||||||
set => SetProperty(ref _mirrorSchedule, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
|
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
|
||||||
/// </summary>
|
/// </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)
|
public void RequestReport(string reason)
|
||||||
{
|
{
|
||||||
_ = ReportAsync(reason);
|
_ = ReportAsync(reason, includeSchedule: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -251,17 +257,14 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
|
|
||||||
var entryCount = 0;
|
var entryCount = 0;
|
||||||
var layoutCount = 0;
|
var layoutCount = 0;
|
||||||
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
|
// 课表**只在服务端显式请求时**上传:没有"本机自动回传"这条路径了
|
||||||
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
|
// (原先由本机 `UploadSchedule` 开关决定,默认开 + 定时 15 秒 = 服务端课表被反复 merge)。
|
||||||
// forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
|
// forceSchedule:服务端点了「从教室机器获取课表」,这是一次人工请求。
|
||||||
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
|
var uploadSchedule = forceSchedule;
|
||||||
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
|
|
||||||
|
|
||||||
if (includeSchedule && !uploadSchedule)
|
if (includeSchedule && !uploadSchedule)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation("ClassHelper 联动:本次只上报运行状态;课表由 Web 端点「从教室机器获取课表」时才会拉取");
|
||||||
"ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
|
|
||||||
"需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeSchedule && uploadSchedule && profile is not null)
|
if (includeSchedule && uploadSchedule && profile is not null)
|
||||||
@@ -337,8 +340,12 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
|
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_settings.MirrorSchedule)
|
// 只有服务端点了「下发课表到教室」才镜像。原先跟着**每一次上报**走(本机 MirrorSchedule
|
||||||
|
// 开关 + 默认 15 秒周期),教室的课表被反复重写 —— 与服务端那边的 merge/清理叠在一起,
|
||||||
|
// 就是老师反馈的"课表一直在变"。现在是一次性待办,拉取成功由服务端清标记。
|
||||||
|
if (data.Settings?.MirrorRequested == true)
|
||||||
{
|
{
|
||||||
|
_logger.LogInformation("ClassHelper 联动:服务端请求下发课表,正在拉取并强制替换教室课表…");
|
||||||
await MirrorScheduleAsync().ConfigureAwait(false);
|
await MirrorScheduleAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,7 +456,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
// **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。
|
// **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。
|
||||||
var outcome = await Dispatcher.UIThread.InvokeAsync(() =>
|
var outcome = await Dispatcher.UIThread.InvokeAsync(() =>
|
||||||
{
|
{
|
||||||
var applied = ClassPlanWriter.Apply(profile, plan);
|
var applied = ClassPlanWriter.Apply(profile, plan, force: true);
|
||||||
if (!applied.Applied)
|
if (!applied.Applied)
|
||||||
{
|
{
|
||||||
return applied;
|
return applied;
|
||||||
@@ -507,7 +514,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
/* ---------------------------------------------------------------- 内部实现 */
|
/* ---------------------------------------------------------------- 内部实现 */
|
||||||
|
|
||||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||||
private const string PluginVersion = "1.3.0.0";
|
private const string PluginVersion = "1.3.1.0";
|
||||||
|
|
||||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||||
private static string SafeAppVersion()
|
private static string SafeAppVersion()
|
||||||
|
|||||||
@@ -41,7 +41,13 @@ public static class ClassPlanWriter
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 应用服务端下发的课表。
|
/// 应用服务端下发的课表。
|
||||||
/// </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();
|
var result = new ApplyResult();
|
||||||
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
|
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
|
||||||
@@ -132,7 +138,9 @@ public static class ClassPlanWriter
|
|||||||
profile.ClassPlans[planId] = plan;
|
profile.ClassPlans[planId] = plan;
|
||||||
result.PlanNames.Add(planName);
|
result.PlanNames.Add(planName);
|
||||||
|
|
||||||
// 冲突提示:同一星期几 + 同一单双周相位下老师还开着别的课表
|
// 冲突处理:同一星期几 + 同一单双周相位下老师还开着别的课表。
|
||||||
|
// ClassIsland 在同一个时间点上只会用一份**启用**的课表,所以"强制更换"就是把冲突的
|
||||||
|
// 那份停用 —— 教室大屏上立刻显示成 ClassHelper 下发的这一份。
|
||||||
var conflicts = profile.ClassPlans
|
var conflicts = profile.ClassPlans
|
||||||
.Where(pair => pair.Key != planId
|
.Where(pair => pair.Key != planId
|
||||||
&& pair.Value.IsEnabled
|
&& pair.Value.IsEnabled
|
||||||
@@ -140,13 +148,22 @@ public static class ClassPlanWriter
|
|||||||
&& pair.Value.TimeRule.WeekDay == group.WeekDay
|
&& pair.Value.TimeRule.WeekDay == group.WeekDay
|
||||||
&& pair.Value.TimeRule.WeekCountDivTotal == group.WeekCountDivTotal
|
&& pair.Value.TimeRule.WeekCountDivTotal == group.WeekCountDivTotal
|
||||||
&& pair.Value.TimeRule.WeekCountDiv == group.WeekCountDiv)
|
&& pair.Value.TimeRule.WeekCountDiv == group.WeekCountDiv)
|
||||||
.Select(pair => pair.Value.Name)
|
|
||||||
.ToList();
|
.ToList();
|
||||||
if (conflicts.Count > 0)
|
if (conflicts.Count > 0)
|
||||||
{
|
{
|
||||||
|
var names = string.Join("、", conflicts.Select(pair => pair.Value.Name));
|
||||||
|
if (force)
|
||||||
|
{
|
||||||
|
foreach (var pair in conflicts) pair.Value.IsEnabled = false;
|
||||||
result.Warnings.Add(
|
result.Warnings.Add(
|
||||||
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
|
$"星期 {group.WeekDay} 原有的课表({names})已停用,教室显示改以 ClassHelper 下发的课表为准" +
|
||||||
"两者重叠时以 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 ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||||
<Button Content="检查连接" Command="{Binding TestCommand}" 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 PullCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||||
<Button Content="重建档案课表" Command="{Binding MirrorCommand}"
|
<Button Content="重建档案课表" Command="{Binding MirrorCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||||
IsEnabled="{Binding Settings.MirrorSchedule}" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- 状态 -->
|
<!-- 状态 -->
|
||||||
@@ -50,33 +49,13 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- 同步 -->
|
<!-- 同步 -->
|
||||||
<TextBlock Margin="0 24 0 0" Text="同步内容"
|
<TextBlock Margin="0 24 0 0" Text="课表同步"
|
||||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||||
Text="课表以「合并」方式写入 ClassHelper:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
Text="课表的两个方向都是按需的,由 Web 端「ClassIsland 联动」页的两个按钮触发,本机不再提供「一直同步」的开关:「从教室机器获取课表」把 ClassIsland 里启用的课表合并进 ClassHelper;「下发课表到教室」把 ClassHelper 排好的课表写回 ClassIsland,并把同一天同一单双周相位下冲突的旧课表停用(只停用、不删除,可在 ClassIsland 的课表面板里重新启用)。" />
|
||||||
|
|
||||||
<fa:SettingsExpander Header="自动上报课表到 ClassHelper"
|
|
||||||
Description="把 ClassIsland 里启用的课表**自动**同步给 ClassHelper。默认关闭:课表以 ClassHelper 为准,自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,与本开关无关。">
|
|
||||||
<fa:SettingsExpander.IconSource>
|
|
||||||
<ci:FluentIconSource Glyph="" />
|
|
||||||
</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="" />
|
|
||||||
</fa:SettingsExpander.IconSource>
|
|
||||||
<fa:SettingsExpander.Footer>
|
|
||||||
<ToggleSwitch IsChecked="{Binding Settings.MirrorSchedule, Mode=TwoWay}" />
|
|
||||||
</fa:SettingsExpander.Footer>
|
|
||||||
</fa:SettingsExpander>
|
|
||||||
|
|
||||||
<fa:SettingsExpander Header="自动上报"
|
<fa:SettingsExpander Header="自动上报"
|
||||||
Description="应用启动、课表变化以及每隔一段时间各上报一次;关闭后只能用上面的「立即上报」手动触发。">
|
Description="应用启动、课程事件以及每隔一段时间各上报一次**运行状态**(现在上什么课、ClassIsland 版本等)。课表不在这条路径上 —— 它只由 Web 端「从教室机器获取课表」按需拉取。关闭后只能用上面的「立即上报」手动触发。">
|
||||||
<fa:SettingsExpander.IconSource>
|
<fa:SettingsExpander.IconSource>
|
||||||
<ci:FluentIconSource Glyph="" />
|
<ci:FluentIconSource Glyph="" />
|
||||||
</fa:SettingsExpander.IconSource>
|
</fa:SettingsExpander.IconSource>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/desktop-client",
|
"name": "@classhelper/desktop-client",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||||
"author": "Class Helper",
|
"author": "Class Helper",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
type ClassDto,
|
type ClassDto,
|
||||||
type ClassIslandNotificationChannel,
|
type ClassIslandNotificationChannel,
|
||||||
type CourseDto,
|
type CourseDto,
|
||||||
|
type ExamDto,
|
||||||
|
type ExamRankingDto,
|
||||||
type HomeworkDaysDto,
|
type HomeworkDaysDto,
|
||||||
type DashboardSummary,
|
type DashboardSummary,
|
||||||
type GradeDto,
|
type GradeDto,
|
||||||
@@ -136,6 +138,15 @@ export const gradeApi = {
|
|||||||
*/
|
*/
|
||||||
studentDetail: (studentNo: string): Promise<StudentGradeDetailDto> =>
|
studentDetail: (studentNo: string): Promise<StudentGradeDetailDto> =>
|
||||||
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`),
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const rect = active.getBoundingClientRect();
|
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 = {
|
indicator.value = {
|
||||||
x: rect.left - boxRect.left,
|
x: rect.left - boxRect.left,
|
||||||
y: rect.top - boxRect.top,
|
y: rect.top - boxRect.top,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
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;
|
let asideObserver: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分组(`.el-sub-menu`)展开 / 收起的观察器。
|
||||||
|
*
|
||||||
|
* 为什么 `asideObserver` 不够:收起分组改的是它内部 `<ul>` 的**高度**,`.aside` 与 `.menu`
|
||||||
|
* 这两个盒子的尺寸都不变(栏宽没动、滚动容器也还是那么高),于是 ResizeObserver 收不到回调,
|
||||||
|
* 底片就停在刚才那个子项的位置上(见 `syncIndicator()` 里那段注释)。
|
||||||
|
* 直接观察每个分组的 `<ul>`,它的高度一动就重新量。
|
||||||
|
*/
|
||||||
|
let submenuObserver: ResizeObserver | null = null;
|
||||||
let indicatorFrame = 0;
|
let indicatorFrame = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -205,6 +230,15 @@ onMounted(() => {
|
|||||||
if (aside && typeof ResizeObserver !== 'undefined') {
|
if (aside && typeof ResizeObserver !== 'undefined') {
|
||||||
asideObserver = new ResizeObserver(scheduleIndicatorSync);
|
asideObserver = new ResizeObserver(scheduleIndicatorSync);
|
||||||
asideObserver.observe(aside);
|
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;
|
menuScroller = null;
|
||||||
asideObserver?.disconnect();
|
asideObserver?.disconnect();
|
||||||
asideObserver = null;
|
asideObserver = null;
|
||||||
|
submenuObserver?.disconnect();
|
||||||
|
submenuObserver = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
|
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
formatDate,
|
formatDate,
|
||||||
gradeLevel,
|
gradeLevel,
|
||||||
gradePercent,
|
gradePercent,
|
||||||
|
type ExamDto,
|
||||||
|
type ExamRankingDto,
|
||||||
type GradeDto,
|
type GradeDto,
|
||||||
type StudentGradeDetailDto,
|
type StudentGradeDetailDto,
|
||||||
} from '@classhelper/shared';
|
} 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 {
|
function onGradeEvent(): void {
|
||||||
void loadGrades();
|
void loadGrades();
|
||||||
|
void loadExams();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRecovered(): void {
|
function onRecovered(): void {
|
||||||
void loadGrades();
|
void loadGrades();
|
||||||
|
void loadExams();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadGrades();
|
await loadGrades();
|
||||||
|
await loadExams();
|
||||||
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
|
||||||
appStore.onServerRecovered(onRecovered);
|
appStore.onServerRecovered(onRecovered);
|
||||||
});
|
});
|
||||||
@@ -195,6 +250,87 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-card>
|
</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-row :gutter="12">
|
||||||
<el-col :xs="24" :md="8">
|
<el-col :xs="24" :md="8">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -305,4 +441,14 @@ onUnmounted(() => {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ranking-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -167,9 +167,7 @@ async function resetPhrases(): Promise<void> {
|
|||||||
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
|
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
|
||||||
*/
|
*/
|
||||||
const autoLaunch = ref(false);
|
const autoLaunch = ref(false);
|
||||||
const autoUpdate = ref(true);
|
|
||||||
const savingLaunch = ref(false);
|
const savingLaunch = ref(false);
|
||||||
const savingUpdate = ref(false);
|
|
||||||
|
|
||||||
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
|
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
|
||||||
const agent = ref<ClientAgentStatus | null>(null);
|
const agent = ref<ClientAgentStatus | null>(null);
|
||||||
@@ -202,7 +200,6 @@ const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
|
|||||||
async function loadLaunchSettings(): Promise<void> {
|
async function loadLaunchSettings(): Promise<void> {
|
||||||
if (!window.desktop?.getConfig) return;
|
if (!window.desktop?.getConfig) return;
|
||||||
const config = await window.desktop.getConfig();
|
const config = await window.desktop.getConfig();
|
||||||
autoUpdate.value = config.autoUpdate !== false;
|
|
||||||
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
|
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
|
||||||
const info = await window.desktop.getAppInfo?.().catch(() => null);
|
const info = await window.desktop.getAppInfo?.().catch(() => null);
|
||||||
if (info?.appVersion) appVersion.value = info.appVersion;
|
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):显示调试页面与详细日志,仅排查问题时打开。
|
* 开发者模式(需求 10):显示调试页面与详细日志,仅排查问题时打开。
|
||||||
@@ -448,13 +400,15 @@ onBeforeUnmount(() => {
|
|||||||
<strong>不上课且窗口已隐藏 5 分钟后</strong>
|
<strong>不上课且窗口已隐藏 5 分钟后</strong>
|
||||||
进行,下载随时可能发生。
|
进行,下载随时可能发生。
|
||||||
</p>
|
</p>
|
||||||
<p class="text-muted">服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。</p>
|
<!--
|
||||||
|
这里**刻意没有开关**(需求:「客户端无权选择是否要自动更新」)。
|
||||||
|
教室机器是统一的投放终端,版本一致性由管理员在服务端「客户端设备」页决定;
|
||||||
|
留一个本地开关只会让"为什么这台还没升"变成无解的排查题。
|
||||||
|
客户端配置里的 `autoUpdate` 仍然照读(`autoUpdate !== false` 才自动装),
|
||||||
|
因为服务端下发的是一台机器的目标版本,不是"允许不允许更新"。
|
||||||
|
-->
|
||||||
|
<p class="text-muted">更新由管理员在服务端统一推送,教室机器不提供本地开关。</p>
|
||||||
</div>
|
</div>
|
||||||
<el-switch
|
|
||||||
:model-value="autoUpdate"
|
|
||||||
:loading="savingUpdate"
|
|
||||||
@update:model-value="toggleAutoUpdate"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar mt-12">
|
<div class="toolbar mt-12">
|
||||||
<el-button @click="openLogDir">打开日志目录</el-button>
|
<el-button @click="openLogDir">打开日志目录</el-button>
|
||||||
@@ -464,23 +418,13 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<el-card shadow="never" class="mt-12">
|
<el-card shadow="never" class="mt-12">
|
||||||
<template #header><span>显示与调试</span></template>
|
<template #header><span>显示与调试</span></template>
|
||||||
<div class="switch-row">
|
<!--
|
||||||
<div class="switch-main">
|
关怀模式**只保留在 Web 管理端**(需求:「客户端无需关怀模式」)。
|
||||||
<span>关怀模式</span>
|
客户端是投在教室大屏上的固定界面,字号由「外观」页的看板/岛设置管,
|
||||||
<p class="text-muted">
|
再来一套全站放大只会和那些设置互相打架,所以这里不再提供开关。
|
||||||
放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。
|
`stores/ui.ts` 与 `styles/index.css` 里的那套管道暂时保留(配置里还有这个字段,
|
||||||
</p>
|
删掉会让老 config.json 反序列化时多一条归一化分支),但界面上不再暴露。
|
||||||
<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>
|
|
||||||
<div class="switch-row">
|
<div class="switch-row">
|
||||||
<div class="switch-main">
|
<div class="switch-main">
|
||||||
<span>开发者模式</span>
|
<span>开发者模式</span>
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ const classIslandStatus = ref<{
|
|||||||
classIslandVersion: string | null;
|
classIslandVersion: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 班级端成绩查询开关
|
/* ------------------------------------------------------------ 班级端成绩查询状态(只读)
|
||||||
*
|
*
|
||||||
* 「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」——
|
* 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关,
|
||||||
* 教室机器在这里决定,值存在班级上;Web 端「班级管理」里也能改同一个开关。
|
* 这台机器只负责把状态显示出来(需求:「成绩查询由服务端控制,客户端只读」)。
|
||||||
|
* 曾经这里有个开关 —— 教室机器能自己开成绩查询,等于把"学校说不给看"变成了一台机器说了算。
|
||||||
*/
|
*/
|
||||||
const gradeQueryEnabled = ref(true);
|
const gradeQueryEnabled = ref(true);
|
||||||
const gradeQuerySaving = ref(false);
|
|
||||||
|
|
||||||
async function loadGradeQuery(): Promise<void> {
|
async function loadGradeQuery(): Promise<void> {
|
||||||
const classId = auth.user?.classId;
|
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 classIslandStatusText = computed(() => {
|
||||||
const status = classIslandStatus.value;
|
const status = classIslandStatus.value;
|
||||||
if (!status || !status.connected) return '未接入';
|
if (!status || !status.connected) return '未接入';
|
||||||
@@ -154,21 +141,24 @@ onMounted(async () => {
|
|||||||
</p>
|
</p>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 成绩查询:让教室机器能按学号代学生查本人明细 -->
|
<!-- 成绩查询:让教室机器能按学号代学生查本人明细。
|
||||||
|
这里**只读**(需求:「成绩查询由服务端控制,客户端只读」)——教室机器是投放终端,
|
||||||
|
开不开成绩查询是学校口径,不该由某台机器本地改。 -->
|
||||||
<el-card shadow="never" class="mt-16">
|
<el-card shadow="never" class="mt-16">
|
||||||
<template #header><span>成绩查询</span></template>
|
<template #header><span>成绩查询</span></template>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
打开后,可以在「成绩」页按<b>学号</b>查到本班学生的成绩明细(含等级)。
|
打开后,可以在「成绩」页看到本班成绩与排名。学生本身没有账号、不能登录,
|
||||||
学生本身没有账号、不能登录,查成绩就是由这台教室机器代做。
|
查成绩就是由这台教室机器代做。关闭后本机查成绩会被服务端拒绝,教师端不受影响。
|
||||||
关闭后本机查成绩会被服务端拒绝,教师端不受影响。
|
|
||||||
</p>
|
</p>
|
||||||
<el-switch
|
<p class="text-muted">
|
||||||
:model-value="gradeQueryEnabled"
|
本开关由管理员在服务端「班级管理」和「客户端设备」里统一设置,教室机器不提供本地开关。
|
||||||
:loading="gradeQuerySaving"
|
</p>
|
||||||
active-text="允许按学号查询成绩明细"
|
<el-tag :type="gradeQueryEnabled ? 'success' : 'info'" effect="plain">
|
||||||
inactive-text="已关闭"
|
{{ gradeQueryEnabled ? '当前:已允许查看成绩与排名' : '当前:已由管理员关闭' }}
|
||||||
@change="(value: string | number | boolean) => applyGradeQuery(value === true)"
|
</el-tag>
|
||||||
/>
|
<div class="toolbar mt-8">
|
||||||
|
<el-button size="small" :icon="'Refresh'" @click="loadGradeQuery">刷新状态</el-button>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/server",
|
"name": "@classhelper/server",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||||
"type": "module",
|
"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");
|
||||||
+16
@@ -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;
|
||||||
@@ -241,6 +241,7 @@ model Class {
|
|||||||
homeworks Homework[]
|
homeworks Homework[]
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
grades Grade[]
|
grades Grade[]
|
||||||
|
exams Exam[]
|
||||||
timeLayouts TimeLayout[]
|
timeLayouts TimeLayout[]
|
||||||
integrationDevices IntegrationDevice[]
|
integrationDevices IntegrationDevice[]
|
||||||
classIslandPushes ClassIslandPush[]
|
classIslandPushes ClassIslandPush[]
|
||||||
@@ -465,11 +466,23 @@ model IntegrationDevice {
|
|||||||
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
|
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
|
||||||
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
|
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
|
||||||
syncScheduleToServer Boolean @default(false)
|
syncScheduleToServer Boolean @default(false)
|
||||||
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
|
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。
|
||||||
|
///
|
||||||
|
/// **2026-10-11 起含义收窄为"允许下发"**:不再是"自动同步的主方向" —— 插件以前每 15 秒
|
||||||
|
/// 上报一次、每次上报完都顺手镜像一遍(`MirrorSchedule` 开着时),教室的课表被反复重写;
|
||||||
|
/// 现在镜像只由 `requestMirrorSchedule` 这个一次性待办触发,这里只表示"这台设备接不接受下发"。
|
||||||
mirrorScheduleToClassIsland Boolean @default(true)
|
mirrorScheduleToClassIsland Boolean @default(true)
|
||||||
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
|
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
|
||||||
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
|
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
|
||||||
requestScheduleReport Boolean @default(false)
|
requestScheduleReport Boolean @default(false)
|
||||||
|
/// 待办的「立即下发一次课表」请求:Web 端点「下发课表到教室」时置位,
|
||||||
|
/// 插件在下一次心跳看到它就去拉本班课表并**强制替换**写进 ClassIsland,
|
||||||
|
/// 拉取成功即由服务端清除标记(见 `pullClassPlanForDevice`)。
|
||||||
|
///
|
||||||
|
/// 为什么要一次性待办而不是"开关":教室机常常离线,一个实时下发的长连接握手不值得,
|
||||||
|
/// 而后台定时镜像会与老师在教室本地做的调整互相打架(2026-10-11 实测:
|
||||||
|
/// 插件每 15 秒重写一遍课表,服务端那边看起来就是课表被反复删改)。
|
||||||
|
requestMirrorSchedule Boolean @default(false)
|
||||||
classIslandVersion String?
|
classIslandVersion String?
|
||||||
pluginVersion String?
|
pluginVersion String?
|
||||||
/// 最后一次**带运行状态**的上报时间
|
/// 最后一次**带运行状态**的上报时间
|
||||||
@@ -696,3 +709,27 @@ model AiAudit {
|
|||||||
|
|
||||||
@@index([userId, createdAt])
|
@@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])
|
||||||
|
}
|
||||||
@@ -19,6 +19,15 @@ export const SETTING_KEYS = {
|
|||||||
aiApiKey: 'ai.apiKey',
|
aiApiKey: 'ai.apiKey',
|
||||||
/** 追加到系统提示词末尾的学校自定义要求(可空) */
|
/** 追加到系统提示词末尾的学校自定义要求(可空) */
|
||||||
aiSystemPromptExtra: 'ai.systemPromptExtra',
|
aiSystemPromptExtra: 'ai.systemPromptExtra',
|
||||||
|
/**
|
||||||
|
* 全校学期开始日期(`YYYY-MM-DD`,本地日期)。
|
||||||
|
*
|
||||||
|
* 需求:「服务端可设置学期开始日期,以保证当前周次正确,管理员也可以随时纠正」。
|
||||||
|
* 原先它只能来自 `.env` 的 `TERM_START_DATE`(改一次要动文件 + 重启),
|
||||||
|
* 于是"开学日期填错了"这种事只能靠运维;现在管理员在「学期周次」页直接改,立即生效。
|
||||||
|
* 班级自己的 `Class.termStartDate` 仍然优先(见 `lib/term.ts` 的三级回落)。
|
||||||
|
*/
|
||||||
|
termStartDate: 'term.startDate',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
|
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
|
import { SETTING_KEYS, getSetting } from './settings.js';
|
||||||
|
|
||||||
export { MAX_TERM_WEEK };
|
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 不传/空串表示**只看全校默认**(管理员在「学期周次」页编辑默认口径时就是这种调用)
|
* @param classId 不传/空串表示**只看全校默认**(管理员在「学期周次」页编辑默认口径时就是这种调用)
|
||||||
*/
|
*/
|
||||||
export async function loadTermContext(classId?: string | null): Promise<TermWeekContext> {
|
export async function loadTermContext(classId?: string | null): Promise<TermWeekContext> {
|
||||||
const globalWeeks = await prisma.termWeek.findMany({
|
const [globalWeeks, settingStart] = await Promise.all([
|
||||||
|
prisma.termWeek.findMany({
|
||||||
where: { classId: '' },
|
where: { classId: '' },
|
||||||
orderBy: { weekNumber: 'asc' },
|
orderBy: { weekNumber: 'asc' },
|
||||||
select: { weekNumber: true, startDate: true, endDate: true },
|
select: { weekNumber: true, startDate: true, endDate: true },
|
||||||
});
|
}),
|
||||||
|
getSetting(SETTING_KEYS.termStartDate),
|
||||||
|
]);
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
// 全校默认没有"班级记录"可以存开学日期,因此**第 1 周的起始日就是它**
|
// 全校默认没有"班级记录"可以存开学日期,因此按「显式设置 → 第 1 周的起始日 → .env」回落
|
||||||
// (管理员点「按开学日期生成」时,第 1 周就是从那天开始的)
|
// (管理员点「按开学日期生成」时,第 1 周就是从那天开始的)
|
||||||
const firstGlobal = globalWeeks.find((item) => item.weekNumber === 1)?.startDate;
|
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([
|
const [cls, classWeeks] = await Promise.all([
|
||||||
@@ -73,7 +83,7 @@ export async function loadTermContext(classId?: string | null): Promise<TermWeek
|
|||||||
// 班级的逐周区间若覆盖了第 1 周,开学日期就以它为准(管理员改周次时最直观)
|
// 班级的逐周区间若覆盖了第 1 周,开学日期就以它为准(管理员改周次时最直观)
|
||||||
const firstWeekStart = weeks.find((item) => item.weekNumber === 1)?.startDate;
|
const firstWeekStart = weeks.find((item) => item.weekNumber === 1)?.startDate;
|
||||||
return {
|
return {
|
||||||
termStartDate: cls?.termStartDate || firstWeekStart || env.termStartDate,
|
termStartDate: cls?.termStartDate || firstWeekStart || settingStart || env.termStartDate,
|
||||||
weeks,
|
weeks,
|
||||||
maxWeek: cls?.termWeeks && cls.termWeeks > 0 ? cls.termWeeks : MAX_TERM_WEEK,
|
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(
|
export async function updateStudentGradeQuery(
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
@@ -603,8 +606,8 @@ export async function updateStudentGradeQuery(
|
|||||||
input: UpdateStudentGradeQueryInput,
|
input: UpdateStudentGradeQueryInput,
|
||||||
): Promise<{ studentGradeQueryEnabled: boolean }> {
|
): Promise<{ studentGradeQueryEnabled: boolean }> {
|
||||||
await assertClassAccess(user, classId);
|
await assertClassAccess(user, classId);
|
||||||
if (!isClassDevice(user) && !isStaff(user)) {
|
if (!isStaff(user)) {
|
||||||
throw ApiError.forbidden('只有 ClassHelper 班级端或教师/管理员可以修改成绩查询开关');
|
throw ApiError.forbidden('成绩查询开关由教师/管理员在 Web 端设置,班级端只读');
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.class.update({
|
await prisma.class.update({
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { defineModule } from '../module.types.js';
|
|||||||
import {
|
import {
|
||||||
bulkCreateGradeSchema,
|
bulkCreateGradeSchema,
|
||||||
createGradeSchema,
|
createGradeSchema,
|
||||||
|
examListQuerySchema,
|
||||||
|
examRankingQuerySchema,
|
||||||
listGradesQuerySchema,
|
listGradesQuerySchema,
|
||||||
|
setExamSchema,
|
||||||
statsQuerySchema,
|
statsQuerySchema,
|
||||||
studentGradeParamSchema,
|
studentGradeParamSchema,
|
||||||
studentGradeQuerySchema,
|
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= - 成绩列表(教师) */
|
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
|
|||||||
@@ -98,3 +98,23 @@ export type CreateGradeInput = z.infer<typeof createGradeSchema>;
|
|||||||
export type UpdateGradeInput = z.infer<typeof updateGradeSchema>;
|
export type UpdateGradeInput = z.infer<typeof updateGradeSchema>;
|
||||||
export type BulkCreateGradeInput = z.infer<typeof bulkCreateGradeSchema>;
|
export type BulkCreateGradeInput = z.infer<typeof bulkCreateGradeSchema>;
|
||||||
export type UpdateGradeLevelsInput = z.infer<typeof updateGradeLevelsSchema>;
|
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,
|
gradeLevel,
|
||||||
gradePercent,
|
gradePercent,
|
||||||
normalizeStudentNo,
|
normalizeStudentNo,
|
||||||
|
type ExamDto,
|
||||||
|
type ExamRankingDto,
|
||||||
|
type ExamSubjectRanking,
|
||||||
type GradeDto,
|
type GradeDto,
|
||||||
type GradeLevelType,
|
type GradeLevelType,
|
||||||
type GradeStats,
|
type GradeStats,
|
||||||
@@ -14,12 +17,14 @@ import {
|
|||||||
assertClassAccess,
|
assertClassAccess,
|
||||||
classScopeWhere,
|
classScopeWhere,
|
||||||
isClassDevice,
|
isClassDevice,
|
||||||
|
isStaff,
|
||||||
resolveClassScope,
|
resolveClassScope,
|
||||||
resolveUserClassRole,
|
resolveUserClassRole,
|
||||||
} from '../../lib/access.js';
|
} from '../../lib/access.js';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
|
import { logger } from '../../lib/logger.js';
|
||||||
import { toGradeDto, toStudentBrief } from '../../lib/mappers.js';
|
import { toGradeDto, toStudentBrief } from '../../lib/mappers.js';
|
||||||
import { parseOptionalDate } from '../../lib/schemas.js';
|
import { parseOptionalDate } from '../../lib/schemas.js';
|
||||||
import { resolvePersonalIds } from '../../lib/session.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) throw ApiError.badRequest('课程不存在');
|
||||||
if (course.classId !== classId) 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;
|
enabled: boolean;
|
||||||
syncScheduleToServer: boolean;
|
syncScheduleToServer: boolean;
|
||||||
mirrorScheduleToClassIsland: boolean;
|
mirrorScheduleToClassIsland: boolean;
|
||||||
|
/** 是否有一次待办的「下发课表到教室」(Web 端点按钮时置位,拉取成功即清除) */
|
||||||
|
mirrorRequested: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 插件侧路由统一挂这个中间件:解析设备令牌并回查设备记录 */
|
/** 插件侧路由统一挂这个中间件:解析设备令牌并回查设备记录 */
|
||||||
@@ -61,6 +63,7 @@ export function authenticateDevice(): RequestHandler {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
syncScheduleToServer: true,
|
syncScheduleToServer: true,
|
||||||
mirrorScheduleToClassIsland: true,
|
mirrorScheduleToClassIsland: true,
|
||||||
|
requestMirrorSchedule: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!record) throw ApiError.unauthorized('设备令牌无效,请在「ClassIsland 联动」里重新生成');
|
if (!record) throw ApiError.unauthorized('设备令牌无效,请在「ClassIsland 联动」里重新生成');
|
||||||
@@ -72,6 +75,7 @@ export function authenticateDevice(): RequestHandler {
|
|||||||
enabled: record.enabled,
|
enabled: record.enabled,
|
||||||
syncScheduleToServer: record.syncScheduleToServer,
|
syncScheduleToServer: record.syncScheduleToServer,
|
||||||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||||||
|
mirrorRequested: record.requestMirrorSchedule === true,
|
||||||
};
|
};
|
||||||
req.device = context;
|
req.device = context;
|
||||||
next();
|
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(
|
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',
|
'/devices/:id/token',
|
||||||
requirePermission('device:manage'),
|
requirePermission('device:manage'),
|
||||||
validate({ params: deviceIdParamSchema }),
|
validate({ params: deviceIdParamSchema }),
|
||||||
|
|||||||
@@ -238,10 +238,12 @@ async function findDeviceOrFail(id: string): Promise<{
|
|||||||
classId: string;
|
classId: string;
|
||||||
name: string;
|
name: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** 「下发课表到教室」要按它判断这台设备接不接受下发(见 requestMirrorSchedule) */
|
||||||
|
mirrorScheduleToClassIsland: boolean;
|
||||||
}> {
|
}> {
|
||||||
const record = await prisma.integrationDevice.findUnique({
|
const record = await prisma.integrationDevice.findUnique({
|
||||||
where: { id },
|
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('设备不存在或已被删除');
|
if (!record) throw ApiError.notFound('设备不存在或已被删除');
|
||||||
return record;
|
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,
|
user: TokenPayload,
|
||||||
deviceId: string,
|
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> {
|
): Promise<IntegrationDeviceTokenDto> {
|
||||||
const current = await findDeviceOrFail(deviceId);
|
const current = await findDeviceOrFail(deviceId);
|
||||||
await assertCanManageSchedule(user, current.classId);
|
await assertCanManageSchedule(user, current.classId);
|
||||||
@@ -649,6 +684,9 @@ export async function applyReport(
|
|||||||
// 注意它**独立于** syncScheduleToServer —— 后者是"允许自动回传",现在默认关闭,
|
// 注意它**独立于** syncScheduleToServer —— 后者是"允许自动回传",现在默认关闭,
|
||||||
// 但管理员显式点"获取课表"时仍然应当能取到。
|
// 但管理员显式点"获取课表"时仍然应当能取到。
|
||||||
scheduleRequested: scheduleRequested,
|
scheduleRequested: scheduleRequested,
|
||||||
|
// 「下发一次课表到教室」的待办(反方向)。插件看到就去拉本班课表并强制替换,
|
||||||
|
// 拉取成功时由 `pullClassPlanForDevice` 清除标记 —— 与上面那条对称。
|
||||||
|
mirrorRequested: device.mirrorRequested,
|
||||||
},
|
},
|
||||||
pendingNotification: pending,
|
pendingNotification: pending,
|
||||||
};
|
};
|
||||||
@@ -755,6 +793,17 @@ export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
|
|||||||
const termContext = await loadTermContext(device.classId);
|
const termContext = await loadTermContext(device.classId);
|
||||||
const week = resolveCurrentWeek(now, termContext);
|
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({
|
const classRecord = await prisma.class.findUnique({
|
||||||
where: { id: device.classId },
|
where: { id: device.classId },
|
||||||
select: { id: true, name: true, termWeeks: true, termStartDate: true },
|
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 type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { logger } from '../../lib/logger.js';
|
import { logger } from '../../lib/logger.js';
|
||||||
import { MAX_TERM_WEEK, loadTermContext, resolveCurrentWeek } from '../../lib/term.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 { emitToClass } from '../../realtime/bus.js';
|
||||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||||
import { notifyScheduleChanged } from '../integrations/integrations.service.js';
|
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(
|
logger.info(
|
||||||
`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`,
|
`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`,
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-9
@@ -1,11 +1,13 @@
|
|||||||
import type { PermissionKey } from '@classhelper/shared';
|
import type { PermissionKey } from '@classhelper/shared';
|
||||||
import type { TokenPayload } from '../lib/jwt.js';
|
import type { TokenPayload } from '../lib/jwt.js';
|
||||||
|
import type { DeviceContext } from '../modules/integrations/device-auth.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扩展 Express 的 Request 类型。
|
* 扩展 Express 的 Request 类型。
|
||||||
* - auth:认证中间件解析出的当前用户 + 生效权限集合
|
* - auth:认证中间件解析出的当前用户 + 生效权限集合
|
||||||
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
|
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
|
||||||
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
|
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
|
||||||
|
* - device:ClassIsland 插件上报时解析出的设备上下文
|
||||||
*/
|
*/
|
||||||
declare global {
|
declare global {
|
||||||
namespace Express {
|
namespace Express {
|
||||||
@@ -18,15 +20,14 @@ declare global {
|
|||||||
};
|
};
|
||||||
validatedQuery?: unknown;
|
validatedQuery?: unknown;
|
||||||
validatedParams?: unknown;
|
validatedParams?: unknown;
|
||||||
/** ClassIsland 联动插件上报时解析出的设备上下文(见 modules/integrations/device-auth.ts) */
|
/**
|
||||||
device?: {
|
* ClassIsland 联动插件上报时解析出的设备上下文(见 modules/integrations/device-auth.ts)。
|
||||||
deviceId: string;
|
*
|
||||||
classId: string;
|
* **引用那个接口本身,不要在这里再抄一遍字段** —— 原先这里是手抄的结构,
|
||||||
name: string;
|
* 往 `DeviceContext` 加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;`
|
||||||
enabled: boolean;
|
* 上("Property 'x' is missing … but required"),看不出真正原因是这处重复声明。
|
||||||
syncScheduleToServer: boolean;
|
*/
|
||||||
mirrorScheduleToClassIsland: boolean;
|
device?: DeviceContext;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/shared",
|
"name": "@classhelper/shared",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -910,6 +910,66 @@ export interface GradeDto {
|
|||||||
className?: string | null;
|
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 {
|
export interface CreateGradeRequest {
|
||||||
classId: string;
|
classId: string;
|
||||||
courseId?: string | null;
|
courseId?: string | null;
|
||||||
@@ -1473,9 +1533,15 @@ export interface ClassIslandReportResult {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/**
|
/**
|
||||||
* 服务端请求插件**立刻上报一次课表**(Web 端点了「从教室机器获取课表」)。
|
* 服务端请求插件**立刻上报一次课表**(Web 端点了「从教室机器获取课表」)。
|
||||||
* 独立于 `syncScheduleToServer`:那个开关管的是"要不要自动回传"。
|
* 两个课表方向都只由这类一次性待办触发,本机没有"自动回传 / 自动镜像"的开关。
|
||||||
*/
|
*/
|
||||||
scheduleRequested?: boolean;
|
scheduleRequested?: boolean;
|
||||||
|
/**
|
||||||
|
* 服务端请求插件**下发一次课表到教室**(Web 端点了「下发课表到教室」)。
|
||||||
|
* 插件看到就去拉本班课表并**强制替换**写进 ClassIsland(停用同一天同一单双周相位下
|
||||||
|
* 冲突的旧课表,只停用不删除);拉取成功时由服务端清除这个待办。
|
||||||
|
*/
|
||||||
|
mirrorRequested?: boolean;
|
||||||
};
|
};
|
||||||
/** 尚未在 ClassIsland 上确认过的一条提醒(插件可立即弹出) */
|
/** 尚未在 ClassIsland 上确认过的一条提醒(插件可立即弹出) */
|
||||||
pendingNotification?: ClassIslandPushNotification | null;
|
pendingNotification?: ClassIslandPushNotification | null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/web-admin",
|
"name": "@classhelper/web-admin",
|
||||||
"version": "1.3.0",
|
"version": "1.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
type DatabaseConnectionTestDto,
|
type DatabaseConnectionTestDto,
|
||||||
type DatabaseStatusDto,
|
type DatabaseStatusDto,
|
||||||
type DatabaseSwitchJobDto,
|
type DatabaseSwitchJobDto,
|
||||||
|
type ExamDto,
|
||||||
|
type ExamRankingDto,
|
||||||
type GradeDto,
|
type GradeDto,
|
||||||
type GradeLevelType,
|
type GradeLevelType,
|
||||||
type GradeStats,
|
type GradeStats,
|
||||||
@@ -298,6 +300,14 @@ export const gradeApi = {
|
|||||||
/** 按**学号**查某个学生的成绩明细(教师端"按学号查成绩") */
|
/** 按**学号**查某个学生的成绩明细(教师端"按学号查成绩") */
|
||||||
studentDetail: (studentNo: string, classId?: string): Promise<StudentGradeDetailDto> =>
|
studentDetail: (studentNo: string, classId?: string): Promise<StudentGradeDetailDto> =>
|
||||||
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`, classId ? { classId } : undefined),
|
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}`),
|
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 }> =>
|
requestSchedule: (id: string): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> =>
|
||||||
api.post(`${API_PATHS.integrations}/devices/${id}/request-schedule`, {}),
|
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 上全屏弹出) */
|
/** 把一条提醒下发到该班的 ClassIsland 设备(ClassIsland 上全屏弹出) */
|
||||||
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
|
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
|
||||||
api.post(`${API_PATHS.integrations}/classisland/notify`, payload),
|
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;
|
||||||
|
}
|
||||||
@@ -115,22 +115,24 @@ const menuItems: MenuItem[] = [
|
|||||||
roles: ['ADMIN', 'TEACHER'],
|
roles: ['ADMIN', 'TEACHER'],
|
||||||
permission: 'ai:use',
|
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',
|
path: '/clients',
|
||||||
title: '客户端设备',
|
title: '客户端',
|
||||||
icon: 'Monitor',
|
icon: 'Monitor',
|
||||||
// 与「权限组」同样按**生效权限**门控:教师默认没有 client:manage(见 shared/permissions.ts
|
// 与「权限组」同样按**生效权限**门控:教师默认没有 client:manage(见 shared/permissions.ts
|
||||||
// 的默认模板只给 ADMIN),所以这里不能只写 roles —— 被权限组授予后教师也该看见。
|
// 的默认模板只给 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'],
|
roles: ['ADMIN', 'TEACHER'],
|
||||||
permission: 'client:manage',
|
permission: 'classisland:notify',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/ai-settings',
|
path: '/ai-settings',
|
||||||
|
|||||||
@@ -88,8 +88,10 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: 'integrations',
|
path: 'integrations',
|
||||||
name: 'integrations',
|
name: 'integrations',
|
||||||
component: () => import('@/views/IntegrationsView.vue'),
|
component: () => import('@/views/IntegrationsView.vue'),
|
||||||
// 与后端 requireRole('ADMIN','TEACHER') 对齐:这一页含"班级端在线状态",教师要看自己的班
|
// **已从侧栏摘掉**(2026-10-11 合并进「客户端」):设备管理与课表下发都搬到那一页了,
|
||||||
meta: { title: 'ClassHelper 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
// 这里只保留少数还没搬过去的东西(提醒下发、联动步骤说明),从「客户端 → 详情」进。
|
||||||
|
// 路由留着而不是删掉,是为了不把功能一起丢掉;`meta.hidden` 让侧栏不显示它。
|
||||||
|
meta: { title: 'ClassIsland 联动(旧)', icon: 'Connection', roles: ['ADMIN', 'TEACHER'], hidden: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'ai-settings',
|
path: 'ai-settings',
|
||||||
@@ -116,12 +118,24 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{
|
{
|
||||||
path: 'clients',
|
path: 'clients',
|
||||||
name: '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'),
|
component: () => import('@/views/ClientsView.vue'),
|
||||||
// 这一页同样**按生效权限**门控:远控关机、统一下发更新都是高危操作,
|
// **已从侧栏摘掉**(2026-10-11 合并进「客户端」):设备清单、远控、下发更新都在新页里,
|
||||||
// 后端三个接口分别要 client:manage / client:update-push / remote:control,
|
// 这里只留新版还没搬过去的部分(客户端安装包缓存管理 —— 不先把包拉下来,就没有版本可下发)。
|
||||||
// 但教师默认三个都没有(见 shared/permissions.ts 的默认模板),所以这里用
|
// 从「客户端 → 详情 → 安装包与设备记录」进。
|
||||||
// 最基础的 client:manage 当入口门禁,页面内的按钮再各自按权限显示/禁用。
|
meta: { title: '安装包与设备记录', icon: 'Monitor', permission: 'client:manage', hidden: true },
|
||||||
meta: { title: '客户端设备', icon: 'Monitor', permission: 'client:manage' },
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type AiInterpretResult,
|
type AiInterpretResult,
|
||||||
type ApiErrorResponse,
|
type ApiErrorResponse,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
|
type ClassPeriod,
|
||||||
type CourseDto,
|
type CourseDto,
|
||||||
type NotificationPriority,
|
type NotificationPriority,
|
||||||
type StudentDto,
|
type StudentDto,
|
||||||
@@ -17,6 +18,8 @@ import {
|
|||||||
import { aiApi, callApi, classApi, courseApi, homeworkApi, notificationApi } from '@/api';
|
import { aiApi, callApi, classApi, courseApi, homeworkApi, notificationApi } from '@/api';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useResponsive } from '@/composables/useResponsive';
|
import { useResponsive } from '@/composables/useResponsive';
|
||||||
|
import { findClassInSession } from '@/composables/useClassInSession';
|
||||||
|
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 助手:**一句话 → 待确认的草稿 → 老师确认后才落库**。
|
* AI 助手:**一句话 → 待确认的草稿 → 老师确认后才落库**。
|
||||||
@@ -54,6 +57,20 @@ const result = ref<AiInterpretResult | null>(null);
|
|||||||
const publishError = ref('');
|
const publishError = ref('');
|
||||||
const publishing = ref(false);
|
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[]>([]);
|
const classes = ref<ClassDto[]>([]);
|
||||||
@@ -247,16 +264,52 @@ async function confirmPublish(): Promise<void> {
|
|||||||
const valid = await formRef.value?.validate().catch(() => false);
|
const valid = await formRef.value?.validate().catch(() => false);
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
|
const action = current.action;
|
||||||
|
|
||||||
|
// 上课时段的紧急通知:先查上课状态,命中就弹全屏二次确认(与手动发布页完全同一条判据)
|
||||||
|
if (action === 'create_notification' && form.priority === 'URGENT') {
|
||||||
publishing.value = true;
|
publishing.value = true;
|
||||||
publishError.value = '';
|
|
||||||
try {
|
try {
|
||||||
await publishOnce(current.action, false);
|
const hit = await findClassInSession([form.classId]);
|
||||||
finishSuccess(current.action);
|
if (hit) {
|
||||||
} catch (error) {
|
urgentWarning.period = hit.period;
|
||||||
await handlePublishError(error, current.action);
|
urgentWarning.title = form.title.trim();
|
||||||
|
urgentWarning.visible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
publishing.value = false;
|
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(action, confirmDuringClass);
|
||||||
|
finishSuccess(action);
|
||||||
|
} catch (error) {
|
||||||
|
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 推送都在服务端) */
|
/** 真正落库:复用既有的发布端点(权限断言 / 上课保护 / ClassIsland 推送都在服务端) */
|
||||||
@@ -555,6 +608,15 @@ function goSettings(): void {
|
|||||||
</template>
|
</template>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 上课时段发布紧急通知的全屏二次确认(3 秒倒计时)——与手动发布页同一个组件 -->
|
||||||
|
<UrgentClassWarning
|
||||||
|
:visible="urgentWarning.visible"
|
||||||
|
:period="urgentWarning.period"
|
||||||
|
:pending-title="urgentWarning.title"
|
||||||
|
@cancel="onUrgentCancelled"
|
||||||
|
@confirm="onUrgentConfirmed"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,731 @@
|
|||||||
|
<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 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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} 台`);
|
||||||
|
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="操作" 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))]);
|
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> {
|
async function loadClasses(): Promise<void> {
|
||||||
classes.value = await classApi.list();
|
classes.value = await classApi.list();
|
||||||
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
|
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
|
||||||
@@ -448,6 +490,26 @@ onUnmounted(() => {
|
|||||||
班主任若不教这一科,同样改不了这一科(不指定科目的内容归班主任与管理员)。
|
班主任若不教这一科,同样改不了这一科(不指定科目的内容归班主任与管理员)。
|
||||||
</el-alert>
|
</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-row :gutter="12">
|
||||||
<el-col :xs="24" :md="6">
|
<el-col :xs="24" :md="6">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
@@ -773,4 +835,13 @@ onUnmounted(() => {
|
|||||||
padding-left: 90px;
|
padding-left: 90px;
|
||||||
margin-top: -6px;
|
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>
|
</style>
|
||||||
@@ -19,8 +19,9 @@ import {
|
|||||||
type NotificationPriority,
|
type NotificationPriority,
|
||||||
type StudentDto,
|
type StudentDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { callApi, classApi, notificationApi, scheduleApi } from '@/api';
|
import { callApi, classApi, notificationApi } from '@/api';
|
||||||
import { useRealtimeStore } from '@/stores/realtime';
|
import { useRealtimeStore } from '@/stores/realtime';
|
||||||
|
import { findClassInSession } from '@/composables/useClassInSession';
|
||||||
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
|
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
|
||||||
|
|
||||||
const realtime = useRealtimeStore();
|
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> {
|
async function submitForm(): Promise<void> {
|
||||||
const valid = await formRef.value?.validate().catch(() => false);
|
const valid = await formRef.value?.validate().catch(() => false);
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ const MENU_ITEMS = [
|
|||||||
{ label: '作业发布', path: '/homeworks' },
|
{ label: '作业发布', path: '/homeworks' },
|
||||||
{ label: '通知发布', path: '/notifications' },
|
{ label: '通知发布', path: '/notifications' },
|
||||||
{ label: '成绩录入', path: '/grades' },
|
{ label: '成绩录入', path: '/grades' },
|
||||||
{ label: 'ClassIsland 联动', path: '/integrations' },
|
// 2026-10-11 起「ClassIsland 联动」与「客户端设备」合并成这一项;
|
||||||
|
// 门控是 `classisland:notify`(默认 ADMIN + TEACHER),所以教师也看得见。
|
||||||
|
{ label: '客户端', path: '/clients' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
|
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
|
||||||
@@ -1116,18 +1118,72 @@ async function main() {
|
|||||||
: `原因=${removedUnified?.reason ?? '-'}`,
|
: `原因=${removedUnified?.reason ?? '-'}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7.85 ClassIsland 联动页:班主任为本班签发设备令牌 → 页面下发提醒表单可用 → 收尾删除设备
|
// 7.84 「客户端」页(合并了原来的「ClassIsland 联动」与「客户端设备」):
|
||||||
// (这条链路是"老师在 Web 端发提醒 → 教室里 ClassIsland 全屏弹出"的入口,必须真实点得动)
|
// 侧栏点得进、班级卡片渲染出来、两个批量按钮在
|
||||||
const integrationUi = await win.webContents.executeJavaScript(`(async () => {
|
const hubUi = await win.webContents.executeJavaScript(`(async () => {
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
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();
|
menu.click();
|
||||||
const deadline = Date.now() + 6000;
|
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);
|
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);
|
await sleep(900);
|
||||||
|
|
||||||
const buttons = Array.from(document.querySelectorAll('button')).map((node) => (node.textContent ?? '').trim());
|
const buttons = Array.from(document.querySelectorAll('button')).map((node) => (node.textContent ?? '').trim());
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
|
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
|
||||||
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
|
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
|
||||||
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
|
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
|
||||||
<meta name="ch-version" content="1.3.0" />
|
<meta name="ch-version" content="1.3.1" />
|
||||||
<link rel="icon" href="assets/icon.png" />
|
<link rel="icon" href="assets/icon.png" />
|
||||||
<link rel="apple-touch-icon" href="assets/icon.png" />
|
<link rel="apple-touch-icon" href="assets/icon.png" />
|
||||||
|
|
||||||
|
|||||||
+47
-5
@@ -44,7 +44,7 @@
|
|||||||
<a class="brand" href="#top" data-press="0.96">
|
<a class="brand" href="#top" data-press="0.96">
|
||||||
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
|
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
|
||||||
<span class="brand-name">ClassHelper</span>
|
<span class="brand-name">ClassHelper</span>
|
||||||
<span class="brand-ver">1.3.0</span>
|
<span class="brand-ver">1.3.1</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="topnav" aria-label="页内导航">
|
<nav class="topnav" aria-label="页内导航">
|
||||||
<span class="nav-pill" aria-hidden="true"></span>
|
<span class="nav-pill" aria-hidden="true"></span>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
|
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
|
||||||
<li>v1.3.0</li>
|
<li>v1.3.1</li>
|
||||||
<li>Windows 10/11</li>
|
<li>Windows 10/11</li>
|
||||||
<li>Linux · Docker</li>
|
<li>Linux · Docker</li>
|
||||||
<li>触摸屏可用</li>
|
<li>触摸屏可用</li>
|
||||||
@@ -464,9 +464,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="feature-block">
|
<div class="feature-block">
|
||||||
<p class="feature-tag">#成绩</p>
|
<p class="feature-tag">#成绩</p>
|
||||||
<h4 class="feature-title">单条、批量,或者整张表导进来</h4>
|
<h4 class="feature-title">单条、批量,或者整张表导进来,还带排名</h4>
|
||||||
<p class="feature-body">
|
<p class="feature-body">
|
||||||
重复判定按「同班 + 同学生 + 同考试 + 同课程」,导入报错精确到 Excel 行号,改完再导。
|
重复判定按「同班 + 同学生 + 同考试 + 同课程」,导入报错精确到 Excel 行号。单科排名自动出;这次考试要不要把各科相加排总分榜,你说了算。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-block">
|
<div class="feature-block">
|
||||||
@@ -476,6 +476,34 @@
|
|||||||
SQLite 与 MySQL 互相搬数据,中途失败会自动回滚 —— 现有的库不会被动到。
|
SQLite 与 MySQL 互相搬数据,中途失败会自动回滚 —— 现有的库不会被动到。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -501,6 +529,13 @@
|
|||||||
无边框透明置顶的小窗,通知、作业、叫人在里面展开成卡片;鼠标穿透,不占任务栏。
|
无边框透明置顶的小窗,通知、作业、叫人在里面展开成卡片;鼠标穿透,不占任务栏。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="feature-block">
|
||||||
<p class="feature-tag">#离线缓存</p>
|
<p class="feature-tag">#离线缓存</p>
|
||||||
<h4 class="feature-title">断网的时候接着看</h4>
|
<h4 class="feature-title">断网的时候接着看</h4>
|
||||||
@@ -515,6 +550,13 @@
|
|||||||
高度、宽度、圆角、字号、停靠位置、视觉风格都能调;越界的值在主进程统一夹紧。
|
高度、宽度、圆角、字号、停靠位置、视觉风格都能调;越界的值在主进程统一夹紧。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="feature-block">
|
||||||
|
<p class="feature-tag">#自愈</p>
|
||||||
|
<h4 class="feature-title">崩了会自己回来</h4>
|
||||||
|
<p class="feature-body">
|
||||||
|
渲染进程挂了先安静地重建窗口;断网期间错过的通知,重新上线时补弹一次。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -704,7 +746,7 @@
|
|||||||
ClassHelper
|
ClassHelper
|
||||||
<span>Class Helper</span>
|
<span>Class Helper</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="foot-ver">版本 1.3.0 · 源码公开 · 装在你自己的机器上</p>
|
<p class="foot-ver">版本 1.3.1 · 源码公开 · 装在你自己的机器上</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="foot-nav" aria-label="相关链接">
|
<nav class="foot-nav" aria-label="相关链接">
|
||||||
|
|||||||
Reference in new issue
Block a user