diff --git a/AGENTS.md b/AGENTS.md index ecec305..d0e3dd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -817,6 +817,31 @@ term / dashboard / database / update / permissions / clients / system`。 单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。 完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。 + **还有第三件:安装目录缺文件 —— 症状是"窗口开着但全黑"。**(2026-10-11 实测) + Electron 的渲染进程要读 `dxil.dll`(`dxcompiler.dll` 的依赖)、`vk_swiftshader.dll` + `vulkan-1.dll` + (软件渲染回退)、`ffmpeg.dll`、`resources/elevate.exe` 这几份**主进程之外的**文件, + 少任何一个都会让渲染进程起来就崩,表现是 `loadFile` 抛 `ERR_FAILED (-2) ... index.html` + + `Renderer process crashed`,而**主进程日志里一切正常**(托盘、灵动岛、更新检查全都照常打印)—— + 于是用户看到的就是"打开一个纯黑窗口,标题栏还是 ClassHelper"。 + **注意 `installCrashGuard()` 排在 `await loadRenderer()` 之后**,所以这条崩溃路径上 + 连"主窗口渲染进程异常退出"那行都不会打,日志里只有一句 Node 的 UnhandledPromiseRejection。 + + 排查顺序(都是实测走通过的): + 1. 先确认是不是"黑屏"而不是"没窗口":`tasklist` 里有 `ClassHelper.exe` 但没有可见窗口时, + 是另一回事(多为 `--hidden` / 托盘);有窗口且全黑才是缺文件。 + 2. **别信"我看了一眼目录",要按文件清单比对**:用 7-Zip 直接列安装包里的条目 + (`7za l `,bin 在 `.cache/electron-builder/7zip@*/bin/7za.exe`), + 与安装目录 `find -type f` 的结果做差集。本次差集正好是上面那 5 个文件。 + 3. 用 `sha256sum <安装目录>/ClassHelper.exe <.cache 里解出来的>/ClassHelper.exe` 确认 + 装的就是当前构建(本次一致),排除"装的是旧版"。 + 4. 想复现/验证修复,直接把这 5 个文件从安装包载荷补回安装目录即可(`7za x` 解出来就是)。 + **别拿便携版的 `%TEMP%` 解包目录当对照** —— 它是自解压出来的,文件数与安装版可能不同, + 但只要两边的 `resources/app.asar` 哈希一致,就说明差的是 Electron 运行时文件而不是业务代码。 + + 为什么值得单独记一条:这次的第一嫌疑(完整性标签、ACL、Chromium 沙箱、`--no-sandbox`、 + 用户配置)**全部试过且全部无关**,真正的原因是安装目录少文件;而没有上面这套比对方法, + 很容易一路查到 `dist-server.mjs`、`electron-builder.yml` 甚至怀疑产物本身。 + 43. **Web 端新手引导(`el-tour`)在自动化里的判据有三条坑**(2026-10-02 实测,`verify:web` 「首次登录展示新手引导且可跳过」踩过): ① **外壳先出现、内容晚一拍** —— `document.querySelector('.el-tour')` 先于步骤卡片渲染成立, @@ -1350,6 +1375,50 @@ term / dashboard / database / update / permissions / clients / system`。 `app.relaunch()`;**冒烟模式绝不重启**(会杀掉验证实例)。 自愈模块在 `packages/desktop-client/src/main/crash-guard.ts`,限流与 `isIdle` 判据别放宽。 +78. **课表同步只有两个「一次性动作」,没有"一直同步"的开关。**(2026-10-11 定稿) + 教室 → 服务端走 `IntegrationDevice.requestScheduleReport`(Web 端「获取课表」), + 服务端 → 教室走新增的 `requestMirrorSchedule`(「下发课表」)—— 两者都是**待办标记**: + Web 端置位,插件在下一次心跳看到就做一次,做完由服务端清标记。 + 插件侧的 `UploadSchedule` / `MirrorSchedule` 两个本机开关**已删除**:定时上报现在只报 + **运行状态**(当前上什么课),课表只在 `forceSchedule` 时才上传。 + **为什么改**:原先默认 `ReportIntervalSeconds = 15` + 两个开关都开,等于教室课表每 15 秒被重写一次、 + 服务端每 15 秒 merge + 清理一次幽灵行 —— 老师看到的是"课表被反复删改"(线上实测)。 + 配套:`pullClassPlanForDevice` 拉取成功即清 `requestMirrorSchedule`;插件的 + `ClassPlanWriter.Apply(…, force: true)` 会**停用**同一天同一单双周相位下冲突的旧课表 + (只停用不删除,别改成删除)。契约锁在 `verify:classisland-plugin` 的两条断言里。 + +79. **「客户端」页 = 「ClassIsland 联动」+「客户端设备」合并成的一页。**(2026-10-11) + 路由 `/clients`(`ClientHubView.vue`):一个班一张卡片,两条链路的状态放一起, + 勾选可批量下发课表 / 更新,点卡片进详情。入口权限用 **`classisland:notify`** + (默认 ADMIN + TEACHER)而不是 `client:manage`(只给 ADMIN)—— 这一页同时承载 + 「向教室下发提醒」,老师要用;用后者会把教师整个挡在门外。 + 页内按钮再各自按 `device:manage` / `remote:control` / `client:update-push` 显示或禁用。 + 两个旧页面**还留着路由但已从侧栏摘掉**(`meta.hidden: true`,`AdminLayout` 的 `menuItems` 是手写列表): + `IntegrationsView` 留提醒下发与联动步骤,`ClientsView` 留客户端安装包缓存管理 + (不先把包拉下来就没有版本可下发),从详情抽屉里的两条链接进。 + **别把这两页删掉或把它们的路由一起摘了**,会丢功能。 + +80. **考试成绩的"总分"是考试的属性,不是分数的属性。** `Exam`(班级 + 考试名 + `countInTotal`) + 单独一张表,与 `Grade` 只按名字弱关联(不建外键:成绩删光设置还在,补录时口径不丢)。 + 排名口径一律写在服务端 `modules/grades` 的 `getExamRanking`:**同一学生同一科目只取最高分** + (老师补录 / 重考会产生多行,不去重等于"总分多一科")、并列同名次(1、2、2、4)。 + 客户端「成绩」页的排名与"这次算不算总分"都只是**显示**,两个开关都不在客户端。 + 路由 `/grades/exams`(GET/PUT)与 `/grades/ranking`(GET)**故意不挂权限键** —— + 班级端的有效权限是空集,挂 `grade:read` 会把教室机器挡在门外;边界在 service 里 + (`assertClassAccess` + `canQueryStudentGrades`),与按学号查明细同一条。 + +81. **成绩查询开关(`Class.studentGradeQueryEnabled`)只有教师 / 管理员能改。** + 原先班级端自己也能改(`updateStudentGradeQuery` 里的 `isClassDevice` 分支), + 等于把"学校说不给看"变成一台机器说了算;现在班级端只读(客户端那个开关也撤掉了, + 只显示状态)。**注意别拿"通知显示位置(`Class.notificationChannel`)也是班级端自己改"来类比** —— + 那个确实是现场偏好,两者不再是一套模式。 + +82. **全校学期开始日期是 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改。** + 原先这一级只能来自 `.env` 的 `TERM_START_DATE`,填错了得动文件 + 重启。 + 现在 `loadTermContext()` 的三级回落是「`Class.termStartDate` → 全校设置 → `.env`」, + 逐周区间仍是「班级 → 全校默认 → 纯线性」。改完 `resolveCurrentWeek` 立刻按新日期算。 + + ## 6. 代码风格 - **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀** diff --git a/README.md b/README.md index 1430eed..4a608be 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,12 @@ ClassHelper是给中小学班级用的一套信息管理系统。
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单 - [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认 - [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级 -- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计 +- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计;单科排名自动出,可选「各科相加排总分榜」 - [x] ClassIsland 联动:签发设备令牌、下发提醒、把课表镜像回教室大屏 - [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换 - [x] 权限组:39 个权限键,建组分配账号 —— 没分组的按角色默认生效,分组后只按组的并集算 +- [x] 客户端(合并页):一个班一张卡片,大屏与灵动岛两条链路一起看;勾选可批量下发课表 / 更新,点进去做逐台操作 +- [x] 课表同步按需:点「下发课表」把课表写进教室并停用冲突的旧课表,点「获取课表」把教室的课表取回来 —— 没有"一直同步" - [x] 客户端设备:每台教室机器的版本、在线状态与最后心跳;一键推送更新,可下发关机 / 重启 / 注销 / 锁屏 - [x] 大模型助手:一句话解析成待确认的作业 / 通知 / 叫人,点确认才落库 - [x] 通知与作业批量发布:勾多个班一次发完,个别班失败不影响其余班级 @@ -66,7 +68,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。
- [x] 课表:「今天」时间轴(大时钟 + 正在上 / 下一节 + 距下课倒计时)与周视图 - [x] 作业看板:按科目分卡、可全屏铺满、字号可调,也能切回列表 - [x] 通知中心:未读红点、优先级标签、点击自动标为已读 -- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布 +- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布;按考试看单科排名,老师开启后还有总分排名 - [x] 教室机器可直接录入作业、勾选未交名单 - [x] 离线优先:断网回退 IndexedDB 缓存,恢复连接后自动同步 - [x] 断网期间错过的通知,重新上线时补弹一次;渲染进程崩了会安静地自己重建窗口 diff --git a/deploy/docker-compose.sqlite.yml b/deploy/docker-compose.sqlite.yml index fbdaf84..1111a2d 100644 --- a/deploy/docker-compose.sqlite.yml +++ b/deploy/docker-compose.sqlite.yml @@ -23,7 +23,7 @@ services: context: .. dockerfile: deploy/Dockerfile target: runtime - image: classhelper-server:${APP_VERSION:-1.3.0} + image: classhelper-server:${APP_VERSION:-1.3.1} restart: unless-stopped environment: NODE_ENV: production diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index f42f6bc..d16e8f6 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -60,7 +60,7 @@ services: context: .. dockerfile: deploy/Dockerfile target: runtime - image: classhelper-server:${APP_VERSION:-1.3.0} + image: classhelper-server:${APP_VERSION:-1.3.1} restart: unless-stopped environment: NODE_ENV: production diff --git a/deploy/package.runtime.json b/deploy/package.runtime.json index cd5b2cb..83b0625 100644 --- a/deploy/package.runtime.json +++ b/deploy/package.runtime.json @@ -1,6 +1,6 @@ { "name": "classhelper-server-runtime", - "version": "1.3.0", + "version": "1.3.1", "private": true, "type": "module", "description": "ClassHelper 服务端运行时(安装包 / 容器内置)", diff --git a/docs/reference.md b/docs/reference.md index 4f7ab36..4376f01 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -47,9 +47,10 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/ - [4.12 学期周次](#412-学期周次可逐周覆盖仅管理员) - [4.13 课表同步方向](#413-课表同步方向服务端--教室) - [4.14 权限组管理(角色只是默认模板)](#414-权限组管理角色只是默认模板) - - [4.15 客户端设备统一更新与远控](#415-客户端设备统一更新与远控) + - [4.15 客户端(合并页):统一更新与远控](#415-客户端合并了classisland-联动客户端设备统一更新与远控) - [4.16 通知按天归档与批量发布](#416-通知按天归档与批量发布) - [4.17 大模型助手与开发者模式 / 关怀模式](#417-大模型助手与开发者模式--关怀模式) + - [4.18 考试成绩:单科排名与总分排名](#418-考试成绩单科排名与总分排名) - [5. 技术参考](#5-技术参考) - [5.1 目录结构](#51-目录结构) - [5.2 技术栈](#52-技术栈) @@ -698,9 +699,23 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段) | 方向 | 做什么 | 老师在哪看 | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | -| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止时间),写入本班课表 | Web 端「ClassIsland 联动」实时状态卡片 | -| ClassHelper → ClassIsland | 老师在 Web 端发一条**提醒** → 落库 → 教室机器的插件取回 → ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不会重复弹 | Web 端「ClassIsland 联动 → 下发提醒」 | -| (可选)班级 → ClassIsland | 把 ClassHelper上排好的课表**镜像**回 ClassIsland:新建一份 `ClassHelper-<班级名>` 档案课表,不动老师原有的课表 | 同上页面的「开镜像」开关 | +| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止时间),写入本班课表 | 「客户端」页的班级卡片与详情 | +| ClassHelper → ClassIsland | 老师在 Web 端发一条**提醒** → 落库 → 教室机器的插件取回 → ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不会重复弹 | 「客户端」页 → 「下发提醒」 | +| ClassHelper → ClassIsland(课表) | 把本班课表**下发**到教室:写一份 `ClassHelper-<班级名>` 档案课表,并**停用**同一天同一单双周相位下冲突的旧课表(只停用、不删除) | 「客户端」页 → 「下发课表」 | + +#### 课表同步:两个方向都「按需」 + +**没有"一直同步"的开关**,两个方向都只在你点按钮时发生一次: + +| 动作 | 从哪点 | 插件做什么 | +| ------------------------ | ------------------------------------- | ---------------------------------------------------------------- | +| **下发课表**(→ 教室) | 「客户端」页的卡片 / 详情里的「下发课表」 | 拉本班课表 → 写进 ClassIsland → **停用**冲突的旧课表(强制替换) | +| **获取课表**(→ 服务端) | 同上的「获取课表」 | 把 ClassIsland 里**启用的**课表合并进本班课表 | + +> **为什么不做自动同步**:插件原先默认每 15 秒上报一次、每次上报都带全量课表、上报完还顺手镜像一遍 —— +> 教室的课表每 15 秒被重写、服务端也每 15 秒 merge + 清理一次幽灵行,老师看到的是「课表被反复删改」。 +> 现在定时上报**只报运行状态**(当前上什么课),课表永远只在点击时双向走一次。 +> 「强制替换」也**只停用不删除**:冲突的课表内容原样留在 ClassIsland 档案里,随时可以重新启用。 #### 接入流程(三步) @@ -875,9 +890,14 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma | 拉取放假安排 | 联网获取当年的法定节假日,**只给建议、不会自动改数据** | | 保存 | 校验区间不重叠(重叠会让"现在第几周"出现歧义),然后**下发给所有 ClassHelper 班级端** | -- 存储:`TermWeek`(`classId = ''` 表示全校默认)+ `Class.termStartDate`; -- 判定:`lib/term.ts` 的 `loadTermContext()` 做「班级覆盖 → 全校默认 → 纯线性」三级回落, +- 存储:`TermWeek`(`classId = ''` 表示全校默认)+ `Class.termStartDate`(班级)+ `AppSetting` + 的 `term.startDate`(全校默认的开学日期); +- 判定:`lib/term.ts` 的 `loadTermContext()` 做三级回落 —— + 开学日期按「`Class.termStartDate` → 全校设置 → `.env` 的 `TERM_START_DATE`」, + 逐周区间按「班级 → 全校默认 → 纯线性」, `resolveCurrentWeek(now, context)` 优先按区间判定; +- **开学日期在页面上就能改**(2026-10-11 起):原先全校默认那一级只能来自 `.env`,填错了得动文件 + + 重启;现在管理员在「学期周次」页填完保存即可,**当前周次立刻按新日期重算**,随时可以纠正。 - 机房/服务器**没有外网时,"拉取放假安排"返回 `ok:false` 是正常结果**(不是错误), 失败结果同样进缓存,避免断网点一次要白等一个超时。 @@ -922,7 +942,16 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma - 判定规则与完整矩阵见[角色与权限](dev/permissions.md);**服务端始终是唯一拦截点**, 前端只按 `GET /permissions/me` 隐藏入口,别把它当安全边界。 -### 4.15 客户端设备统一更新与远控 +### 4.15 客户端(合并了「ClassIsland 联动」+「客户端设备」,统一更新与远控) + +入口:Web 管理端 →「客户端」。**一个班一张卡片**,卡片上同时看两条链路的状态 +(ClassIsland 插件 = 教室大屏;ClassHelper 客户端 = 灵动岛),勾选可**批量下发课表 / 更新**, +点卡片进详情做逐台操作(签发令牌、重置、下发 / 获取课表、远控、移除记录)。 + +> 2026-10-11 合并:原先分成「ClassIsland 联动」与「客户端设备」两页,讲的却是同一个教室的两半 —— +> 要给一个班做一件事,得先想清楚该进哪一页,批量操作更是无从下手。 +> 入口权限用 `classisland:notify`(默认 ADMIN + TEACHER,教师要用「下发提醒」), +> 页内的设备管理 / 远控 / 下发更新各自再要 `device:manage` / `remote:control` / `client:update-push`。 每台教室机器的心跳间隔 `CLIENT_HEARTBEAT_INTERVAL_MS` = **30 秒**,一次请求把两件事带回: 「要不要更新 / 装哪个版本」与「有没有待执行的远控命令」(`POST /clients/heartbeat`,**不挂权限键**)。 @@ -1007,6 +1036,29 @@ BulkPublishClassResult { classId, ok, id?, code?, message?, details? } 侧栏折叠同样是本机偏好。`User.careMode` 列已经建好,但**服务端目前没有读写它** —— 别把它当成"已经有服务端开关"。 +### 4.18 考试成绩:单科排名与总分排名 + +一次考试(月考 / 期中考…)在本系统里就是一组同名 `examName` 的成绩。除了单科分数,还能看**排名**: + +| 能力 | 在哪 | 说明 | +| ---------------- | ------------------------------------------- | -------------------------------------------------------------------- | +| 单科排名 | 「成绩录入」页 / 教室「客户端」的成绩页 | 按得分率排;并列同名次(1、2、2、4) | +| **总分排名** | 同上 | 把这次考试的**所有科目相加**,按总分排 | +| 考试口径开关 | 「成绩录入」页 → 筛选出某个「考试名」后出现 | 「所有科目相加,排总分榜」/「只排单科,不合总分」 | +| 班级端成绩与排名 | 教室机器的「成绩」页 | 由**服务端**决定开不开(`Class.studentGradeQueryEnabled`),客户端只读 | + +两条口径写死在服务端(`modules/grades`),三端共用: + +- **总分是"考试"的属性,不是"分数"的属性** —— 语数外 150 分、音体美 100 分不是一个量纲, + 合不合总分由学校决定,所以按「班级 + 考试名」存一条 `Exam.countInTotal`; +- **同一学生同一科目只取最高分**:老师补录 / 重考会产生多行,不去重的话"补录一条"就变成 + "总分多一科",排名直接失真; +- 总分的**科目数**会一并返回(`subjectCount`),便于识别"只录了两科也上榜"的情况。 + +教室机器能不能看由 `Class.studentGradeQueryEnabled` 控制(默认开启);**关掉后客户端一律 403**。 +这个开关**只有教师 / 管理员能在 Web 端改**(2026-10-11 起)—— 原先教室机器自己也能改, +等于把"学校说不给看"变成了一台机器说了算。 + ## 5. 技术参考 ### 5.1 目录结构 diff --git a/package.json b/package.json index caee2d1..afbfb3a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "class-helper", - "version": "1.3.0", + "version": "1.3.1", "private": true, "description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "packageManager": "pnpm@11.8.0", diff --git a/packages/classisland-plugin/manifest.yml b/packages/classisland-plugin/manifest.yml index ed07b89..34457ac 100644 --- a/packages/classisland-plugin/manifest.yml +++ b/packages/classisland-plugin/manifest.yml @@ -14,6 +14,6 @@ description: |- 4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。 entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll' url: https://github.com/LoveChengke/ClassHelper -version: 1.3.0.0 +version: 1.3.1.0 apiVersion: 2.0.0.0 author: ClassHelper diff --git a/packages/classisland-plugin/scripts/verify.mjs b/packages/classisland-plugin/scripts/verify.mjs index 5b068db..ea306db 100644 --- a/packages/classisland-plugin/scripts/verify.mjs +++ b/packages/classisland-plugin/scripts/verify.mjs @@ -31,6 +31,7 @@ const clientSource = read('src/Interop/ClassHelperClient.cs'); const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs'); const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml'); const mapperSource = read('src/Services/ScheduleMapper.cs'); +const writerSource = read('src/Services/ClassPlanWriter.cs'); const settingsSource = read('src/Models/PluginSettings.cs'); const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts'); const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts'); @@ -255,15 +256,24 @@ check( ); check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource)); -// 3.10 课表同步方向(2026-10-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**; -// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。 +// 3.10 课表同步方向(2026-10-11 定稿):**两个方向都只由 Web 端的一次性动作触发**。 +// 2026-10-06 曾经把它做成"服务端 → 教室自动下发 + 本机两个开关",但默认 15 秒一轮的 +// 自动上传 + 自动镜像会让服务端与教室里的课表每 15 秒互相覆盖一次,老师看到的是 +// "课表被反复删改"(2026-10-11 实测)。现在本机没有任何"一直同步"的开关: +// · 教室 → 服务端:`scheduleRequested` 一次性待办(Web 端「从教室机器获取课表」) +// · 服务端 → 教室:`mirrorRequested` 一次性待办(Web 端「下发课表到教室」,强制替换) +// 这几条盯着新契约,别删。 check( - '默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)', - /_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource), + '课表两个方向都改为按需:本机不再有「自动回传 / 自动镜像」开关', + !/_uploadSchedule/.test(settingsSource) && !/_mirrorSchedule/.test(settingsSource), ); check( - '响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)', - /scheduleRequested/.test(clientSource), + '定时上报只报运行状态,课表仅在服务端显式请求时上传', + /var uploadSchedule = forceSchedule;/.test(bridgeSource), +); +check( + '响应 DTO 里有 scheduleRequested / mirrorRequested(两个方向的一次性待办)', + /scheduleRequested/.test(clientSource) && /mirrorRequested/.test(clientSource), ); check( '收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)', @@ -271,8 +281,13 @@ check( /forceSchedule:\s*true/.test(bridgeSource), ); check( - '服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)', - /forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource), + '只有收到 mirrorRequested 才镜像(不再跟着每一次上报走)', + /MirrorRequested\s*==\s*true/.test(bridgeSource), +); +check( + '镜像写入是「强制替换」(停用冲突的旧课表,而不是只留一条警告)', + /Apply\(profile, plan, force: true\)/.test(bridgeSource) && + /IsEnabled = false/.test(writerSource), ); check( '补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)', diff --git a/packages/classisland-plugin/src/Interop/ClassHelperClient.cs b/packages/classisland-plugin/src/Interop/ClassHelperClient.cs index d992977..c685375 100644 --- a/packages/classisland-plugin/src/Interop/ClassHelperClient.cs +++ b/packages/classisland-plugin/src/Interop/ClassHelperClient.cs @@ -88,6 +88,15 @@ public class ReportSettingsDto /// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。 /// [JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; } + + /// + /// 服务端请求**下发一次课表到教室**(老师在 Web 端点了「下发课表到教室」)。 + /// + /// 与 反方向,同样是一次性待办:看到就拉本班课表并 + /// **强制替换**写进 ClassIsland;拉取成功时由服务端清除标记。 + /// 没有"后台定时镜像"那条路径了 —— 那会让教室课表被反复重写(见 BridgeService 的说明)。 + /// + [JsonPropertyName("mirrorRequested")] public bool MirrorRequested { get; set; } } public class ReportResultDto diff --git a/packages/classisland-plugin/src/Models/PluginSettings.cs b/packages/classisland-plugin/src/Models/PluginSettings.cs index 3ec1630..4b73b70 100644 --- a/packages/classisland-plugin/src/Models/PluginSettings.cs +++ b/packages/classisland-plugin/src/Models/PluginSettings.cs @@ -17,11 +17,11 @@ public class PluginSettings : ObservableObject private string _deviceKey = ""; private bool _autoReport = true; private int _reportIntervalSeconds = 60; - // 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**; - // 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。 - // 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。 - private bool _uploadSchedule = false; - private bool _mirrorSchedule = true; + // 课表同步(2026-10-11 起):**两个方向都只由 Web 端的一次性动作触发** —— + // 「从教室机器获取课表」与「下发课表到教室」,都走服务端的一个待办标记。 + // 原先这里有两个开关(自动回传 / 自动镜像),默认 15 秒一轮,结果是服务端与教室里 + // 的课表每 15 秒互相覆盖一次,老师看到的就是"课表被反复删改"。 + // 定时上报现在**只报运行状态**(当前上什么课),不带课表。 private bool _receiveNotifications = true; private int _notificationDurationSeconds = 8; private int _notificationPollSeconds = 10; @@ -76,33 +76,6 @@ public class PluginSettings : ObservableObject set => SetProperty(ref _reportIntervalSeconds, Math.Clamp(value, 15, 3600)); } - /// - /// 是否把 ClassIsland 的课表**自动**同步到 ClassHelper。 - /// - /// **默认关闭**:课表以 ClassHelper为准,教室这边改完课表不会自己传上去 - /// (否则老师刚在 ClassHelper上排好的课会被教室的旧课表悄悄覆盖)。 - /// 要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求, - /// 与本开关无关(见 ReportSettingsDto.ScheduleRequested)。 - /// 关闭后仍会上报"当前上什么课"的状态。 - /// - public bool UploadSchedule - { - get => _uploadSchedule; - set => SetProperty(ref _uploadSchedule, value); - } - - /// - /// 是否把 ClassHelper上排好的课表镜像回 ClassIsland - /// (新建一份名为「ClassHelper-<班级名>」的档案课表,不覆盖老师原有的课表)。 - /// - /// **默认开启**:这是现在课表同步的主方向。 - /// - public bool MirrorSchedule - { - get => _mirrorSchedule; - set => SetProperty(ref _mirrorSchedule, value); - } - /// /// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。 /// diff --git a/packages/classisland-plugin/src/Services/BridgeService.cs b/packages/classisland-plugin/src/Services/BridgeService.cs index 733dd1e..ae8d308 100644 --- a/packages/classisland-plugin/src/Services/BridgeService.cs +++ b/packages/classisland-plugin/src/Services/BridgeService.cs @@ -206,10 +206,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable } } - /// 发起一次上报(事件驱动:已有上报在跑时直接丢弃本次,避免事件风暴打出并发请求)。 + /// + /// 发起一次**仅状态**的上报(定时器与课程事件都走这里)。 + /// + /// 刻意不带课表:课表只在服务端显式点「从教室机器获取课表」时上传(见 + /// 的 forceSchedule)。原先每次上报都带全量课表、默认 15 秒一次,服务端于是每 15 秒 + /// merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"(2026-10-11 实测)。 + /// public void RequestReport(string reason) { - _ = ReportAsync(reason); + _ = ReportAsync(reason, includeSchedule: false); } /// @@ -251,17 +257,14 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable var entryCount = 0; var layoutCount = 0; - // 开关关着时也要留下原因:否则日志只有"上报成功:仅状态", - // 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。 - // forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求, - // 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested) - var uploadSchedule = forceSchedule || _settings.UploadSchedule; + // 课表**只在服务端显式请求时**上传:没有"本机自动回传"这条路径了 + // (原先由本机 `UploadSchedule` 开关决定,默认开 + 定时 15 秒 = 服务端课表被反复 merge)。 + // forceSchedule:服务端点了「从教室机器获取课表」,这是一次人工请求。 + var uploadSchedule = forceSchedule; if (includeSchedule && !uploadSchedule) { - _logger.LogInformation( - "ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" + - "需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」"); + _logger.LogInformation("ClassHelper 联动:本次只上报运行状态;课表由 Web 端点「从教室机器获取课表」时才会拉取"); } if (includeSchedule && uploadSchedule && profile is not null) @@ -337,8 +340,12 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable _ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true); } - if (_settings.MirrorSchedule) + // 只有服务端点了「下发课表到教室」才镜像。原先跟着**每一次上报**走(本机 MirrorSchedule + // 开关 + 默认 15 秒周期),教室的课表被反复重写 —— 与服务端那边的 merge/清理叠在一起, + // 就是老师反馈的"课表一直在变"。现在是一次性待办,拉取成功由服务端清标记。 + if (data.Settings?.MirrorRequested == true) { + _logger.LogInformation("ClassHelper 联动:服务端请求下发课表,正在拉取并强制替换教室课表…"); await MirrorScheduleAsync().ConfigureAwait(false); } @@ -449,7 +456,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable // **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。 var outcome = await Dispatcher.UIThread.InvokeAsync(() => { - var applied = ClassPlanWriter.Apply(profile, plan); + var applied = ClassPlanWriter.Apply(profile, plan, force: true); if (!applied.Applied) { return applied; @@ -507,7 +514,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable /* ---------------------------------------------------------------- 内部实现 */ /// 插件版本(与 manifest.yml / csproj 保持一致)。 - private const string PluginVersion = "1.3.0.0"; + private const string PluginVersion = "1.3.1.0"; /// ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。 private static string SafeAppVersion() diff --git a/packages/classisland-plugin/src/Services/ClassPlanWriter.cs b/packages/classisland-plugin/src/Services/ClassPlanWriter.cs index a777842..7ca0ec6 100644 --- a/packages/classisland-plugin/src/Services/ClassPlanWriter.cs +++ b/packages/classisland-plugin/src/Services/ClassPlanWriter.cs @@ -41,7 +41,13 @@ public static class ClassPlanWriter /// /// 应用服务端下发的课表。 /// - public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source) + /// + /// 「强制更换课表」:同一星期几 + 同一单双周相位下老师还开着别的课表时,**停用**它们, + /// 让教室大屏立刻改成以 ClassHelper 下发的这份为准。 + /// 由 Web 端的「下发课表到教室」按钮触发(服务端那个一次性待办)。 + /// **只停用不删除** —— 课表内容原样留在档案里,老师随时能在 ClassIsland 里重新启用。 + /// + public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source, bool force = false) { var result = new ApplyResult(); if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0) @@ -132,7 +138,9 @@ public static class ClassPlanWriter profile.ClassPlans[planId] = plan; result.PlanNames.Add(planName); - // 冲突提示:同一星期几 + 同一单双周相位下老师还开着别的课表 + // 冲突处理:同一星期几 + 同一单双周相位下老师还开着别的课表。 + // ClassIsland 在同一个时间点上只会用一份**启用**的课表,所以"强制更换"就是把冲突的 + // 那份停用 —— 教室大屏上立刻显示成 ClassHelper 下发的这一份。 var conflicts = profile.ClassPlans .Where(pair => pair.Key != planId && pair.Value.IsEnabled @@ -140,13 +148,22 @@ public static class ClassPlanWriter && pair.Value.TimeRule.WeekDay == group.WeekDay && pair.Value.TimeRule.WeekCountDivTotal == group.WeekCountDivTotal && pair.Value.TimeRule.WeekCountDiv == group.WeekCountDiv) - .Select(pair => pair.Value.Name) .ToList(); if (conflicts.Count > 0) { - result.Warnings.Add( - $"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," + - "两者重叠时以 ClassHelper同步的课表为准"); + var names = string.Join("、", conflicts.Select(pair => pair.Value.Name)); + if (force) + { + foreach (var pair in conflicts) pair.Value.IsEnabled = false; + result.Warnings.Add( + $"星期 {group.WeekDay} 原有的课表({names})已停用,教室显示改以 ClassHelper 下发的课表为准" + + "(只停用、未删除,可在 ClassIsland 的课表面板里重新启用)"); + } + else + { + result.Warnings.Add( + $"星期 {group.WeekDay} 已有启用的课表({names}),两者重叠时以 ClassHelper 同步的课表为准"); + } } } diff --git a/packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml b/packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml index 5fa1fd1..0596c63 100644 --- a/packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml +++ b/packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml @@ -35,8 +35,7 @@