Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0ebf88138 | ||
|
|
b7cdc7c9ea | ||
|
|
227117ccb3 | ||
|
|
3236d3ad83 | ||
|
|
0b9a321bc9 | ||
|
|
966c8f9b5d | ||
|
|
817af02357 | ||
|
|
e51879667a |
No files matched your search
@@ -1,6 +1,24 @@
|
|||||||
# AGENTS.md — ClassHelper(Class Helper)
|
# AGENTS.md — ClassHelper(Class Helper)
|
||||||
|
|
||||||
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
|
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
|
||||||
|
|
||||||
|
**2026-10-16 更新**:当前仓库位于 `F:\ClassHelper`,F 盘可用。ClassIsland 联动插件、设备令牌、提醒通道与镜像接口已退役;离线课表 JSON 导入和界面设计参考保留。下文历史联动部署记录不再适用。
|
||||||
|
客户端维护只走 `/api/clients/*`,页面为 `ClientsView.vue`;默认权限按数据库中对应角色的默认组生效。
|
||||||
|
**2026-10-17 更新**:日常 `verify:desktop` / `verify:packaged` 默认是后台快速检查,不创建可见灵动岛或托盘;
|
||||||
|
完整桌面动画、透明度与截图验收必须显式加 `--visual`(`pnpm verify:desktop:visual`)。
|
||||||
|
下文关于置顶验证窗口、鼠标穿透与截图时序的历史约定仅适用于视觉模式。不要为了日常修改反复占用用户桌面。
|
||||||
|
联网冒烟显式启用,使用临时教师和班级;禁止回退到真实班级、重置其密码或向其发送测试通知。
|
||||||
|
|
||||||
|
本次 PowerShell 会话的 PATH 不含 pnpm:需要先把 `C:\Users\hck16\AppData\Local\deepseek-harness\bin`
|
||||||
|
加到 PATH 前面,再按下文设置 `DSH_NODE` 和 `DSH_PNPM_BIN`。Git Bash 外层找不到 pnpm 时可执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DSH_NODE='D:\Node.js\node.exe'
|
||||||
|
export DSH_PNPM_BIN='C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
|
||||||
|
node "C:/Users/hck16/AppData/Local/node/corepack/v1/pnpm/11.8.0/bin/pnpm.cjs" typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
外层直接调用用于解决找不到命令;环境变量让内部嵌套的 pnpm 仍使用真实运行时。
|
||||||
文中「本机环境」「已知坑」两节按**实测**写成;若与本机现状不符,以本机实测为准并顺手改掉这里。
|
文中「本机环境」「已知坑」两节按**实测**写成;若与本机现状不符,以本机实测为准并顺手改掉这里。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -10,12 +28,11 @@
|
|||||||
班级信息管理系统,**pnpm monorepo**,一份代码产出多个交付物:
|
班级信息管理系统,**pnpm monorepo**,一份代码产出多个交付物:
|
||||||
|
|
||||||
| 交付物 | 位置 | 形态 |
|
| 交付物 | 位置 | 形态 |
|
||||||
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
|
| ---------- | ------------------------- | -------------------------------------------------------- |
|
||||||
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 |
|
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 |
|
||||||
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
|
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
|
||||||
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
|
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
|
||||||
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
||||||
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
|
|
||||||
|
|
||||||
核心链路:
|
核心链路:
|
||||||
|
|
||||||
@@ -28,9 +45,6 @@
|
|||||||
▼
|
▼
|
||||||
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
|
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
|
||||||
|
|
||||||
ClassIsland(教室机器)──► ClassHelper 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
|
|
||||||
▲ │
|
|
||||||
└──── 老师下发的提醒(上报返回值里顺带带回)◄┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`)
|
- 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`)
|
||||||
@@ -118,10 +132,13 @@ website/ 产品官网(**纯静态、无构建步骤**,
|
|||||||
-x64-server-setup.exe),它们是离线兜底链接的唯一依据。
|
-x64-server-setup.exe),它们是离线兜底链接的唯一依据。
|
||||||
packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts
|
packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts
|
||||||
packages/server/
|
packages/server/
|
||||||
prisma/schema.prisma 数据模型(16 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
|
prisma/schema.prisma 数据模型(24 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
|
||||||
prisma/migrations/ 16 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
|
prisma/migrations/ 17 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
|
||||||
2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query /
|
2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query /
|
||||||
add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks)
|
add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks;
|
||||||
|
最后 1 个是 2026-10-10 的 add_permission_groups_and_clients:
|
||||||
|
权限组 3 表 + AppSetting + 客户端设备/命令/安装包 + AiAudit +
|
||||||
|
Notification.assignDate + User.careMode)
|
||||||
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
|
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
|
||||||
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
|
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
|
||||||
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项)
|
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项)
|
||||||
@@ -134,10 +151,12 @@ packages/server/
|
|||||||
password schemas session term version(读 package.json) web-static
|
password schemas session term version(读 package.json) web-static
|
||||||
src/middleware/ auth validate error security
|
src/middleware/ auth validate error security
|
||||||
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
|
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
|
||||||
src/modules/ 17 个功能模块 + registry.ts + module.types.ts
|
src/modules/ 20 个功能模块 + registry.ts + module.types.ts
|
||||||
(含 archives 毕业归档、term 学期周次)
|
(含 archives 毕业归档、term 学期周次、permissions 权限组、
|
||||||
packages/web-admin/src/ api stores router layouts views(14) components(5) composables styles config.ts
|
clients 客户端心跳与远控、system 开发者模式/开机自启、ai 大模型助手)
|
||||||
(含 ArchivesView 毕业归档、TermWeeksView 学期周次)
|
packages/web-admin/src/ api stores router layouts views(18) components(5) composables styles config.ts
|
||||||
|
(含 ArchivesView 毕业归档、TermWeeksView 学期周次、PermissionsView 权限组、
|
||||||
|
ClientsView 客户端设备、AssistantView/aiSettingsView 大模型助手)
|
||||||
packages/desktop-client/
|
packages/desktop-client/
|
||||||
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke
|
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke
|
||||||
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
|
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
|
||||||
@@ -145,16 +164,6 @@ packages/desktop-client/
|
|||||||
src/renderer/ ClassHelper 班级端渲染进程:api / cache(IndexedDB) / stores / views / island / router
|
src/renderer/ ClassHelper 班级端渲染进程:api / cache(IndexedDB) / stores / views / island / router
|
||||||
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
|
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
|
||||||
scripts/ build-main / dev / smoke / dist-win
|
scripts/ build-main / dev / smoke / dist-win
|
||||||
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
|
|
||||||
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.2.0.0
|
|
||||||
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
|
|
||||||
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
|
|
||||||
src/Models/PluginSettings.cs
|
|
||||||
src/Services/ BridgeService(上报·接收·镜像)/ ScheduleMapper / ClassPlanWriter
|
|
||||||
ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上)
|
|
||||||
src/Views/BridgeSettingsPage.axaml(.cs):Avalonia 设置页
|
|
||||||
src/Interop/ClassHelperClient.cs:HTTP 客户端 + 与服务端逐个字段对齐的 DTO
|
|
||||||
scripts/ build.mjs(编译 / 打包 .cipx)/ verify.mjs(静态契约校验,实测 74 项)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -162,9 +171,9 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
|
|||||||
## 3. 本机环境(2026-09-30 实测)
|
## 3. 本机环境(2026-09-30 实测)
|
||||||
|
|
||||||
| 项 | 状态 |
|
| 项 | 状态 |
|
||||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Node | **v24.19.0**(`D:\nodejs\node.exe`;要求 ≥ 20.19,Prisma 7 硬性要求) |
|
| Node | **v22.15.0**(`D:\Node.js\node.exe`;根 `engines` 要求 ≥ 20.19,Prisma 7 的 engine 约束是 `^20.19 \|\| ^22.12 \|\| >=24.0`,v22.15 都满足) |
|
||||||
| pnpm | 11.8.0(`packageManager` 锁定 11.8.0),宿主就是上面这个 node |
|
| pnpm | 11.8.0(`packageManager` 锁定 11.8.0)。本机可用的本体在 `C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs`;PATH 里那个 `pnpm.ps1` 是 DSH 桌面端的转发壳,**别绕它**(见下) |
|
||||||
| npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
|
| npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
|
||||||
| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
|
| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
|
||||||
| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
|
| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
|
||||||
@@ -180,6 +189,43 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
|
|||||||
> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
|
> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
|
||||||
> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
|
> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
|
||||||
|
|
||||||
|
#### AI 会话里跑 pnpm:别撞上 DSH 的转发壳(2026-10-11 实测)
|
||||||
|
|
||||||
|
在 DSH 的 shell 工具里直接写 `pnpm xxx` 会拿到
|
||||||
|
`pnpm.ps1 : Node.js runtime not found. Please run DeepSeek Harness Desktop to install it first.`,
|
||||||
|
而同一台机器上人在普通终端里跑得好好的。原因与绕过办法:
|
||||||
|
|
||||||
|
- PATH 里排在前面的是 **DSH 桌面端的转发壳** `C:\Users\hck16\AppData\Local\deepseek-harness\bin\pnpm.ps1`
|
||||||
|
(`.cmd` 同理)。它有两道会挡住的判据:① 认 `$env:DSH_NODE` / PATH 上的 `node`,
|
||||||
|
**但要求 `major ≥ 24` 或 `22.19+`**,本机的 `v22.15.0` 卡在门外;
|
||||||
|
② 它自己的兜底 `%APPDATA%\dsh-tauri\runtime\node.exe` 与 bundled pnpm
|
||||||
|
`%APPDATA%\dsh-tauri\dependencies\pnpm\bin\pnpm.cjs` **本机都不存在**。
|
||||||
|
- **正确解法是喂给这个壳两个环境变量**(它自己就认这两个覆盖开关,见 shim 第 10、52 行),
|
||||||
|
把 `node` 与 `pnpm` 都指到本机真实的那份,然后就**照常写 `pnpm xxx`**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:DSH_NODE = 'D:\Node.js\node.exe'
|
||||||
|
$env:DSH_PNPM_BIN = 'C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
|
||||||
|
pnpm typecheck # 与人在终端里跑完全等价
|
||||||
|
pnpm build:shared
|
||||||
|
```
|
||||||
|
|
||||||
|
- `DSH_NODE` 必须是**绝对路径**:壳拿到它就不再走"发现 PATH 上的 node 再卡版本"那段。
|
||||||
|
- `DSH_PNPM_BIN` 是**本体 .cjs 的路径**(不是目录,也不是 `pnpm.ps1`);壳会 `& $node $pnpmBin @args`
|
||||||
|
直接执行它。它同时替掉那条会失败的 `pnpmBin` 存在性检查。
|
||||||
|
- **`pnpm` 的嵌套调用**(`typecheck` = `build:shared && pnpm -r --no-bail typecheck`)靠这两个变量就够了:
|
||||||
|
子进程继承同一份环境,于是又落进同一个壳、又被同一对覆盖接住。
|
||||||
|
- **这三条都是踩过的坑**(同一个会话里依次试错,前两条各失败一次):
|
||||||
|
- 不要用 `DSH_PNPM`,那是"用户 pnpm 的可执行路径":壳转发给它时**仍然先过 ② 的版本门槛**,
|
||||||
|
而且**残留的 `DSH_PNPM` 会污染后面所有调用**——它与 `DSH_PNPM_BIN` 同时存在时壳会转发进
|
||||||
|
corepack 的 `pnpm.ps1`,那个壳又把它重置回不存在的 bundled 路径,于是原本能跑的
|
||||||
|
`typecheck` 报出 `[pnpm] pnpm not found`。要么别设,要么先 `Remove-Item Env:DSH_PNPM`。
|
||||||
|
- 也不要用 `node <pnpm.cjs> <script>` 绕开壳:只在**不带嵌套调用**的脚本上凑效
|
||||||
|
(`check:icons` / `lint` 能用),`typecheck` 这类内部还要再 `pnpm -r` 的会二次翻车。
|
||||||
|
|
||||||
|
- 反过来说:本机 **Node v22.15.0 够用**(根 `engines` ≥ 20.19,Prisma 7 允许 `^22.12`),
|
||||||
|
不需要为了跑命令去装 v24;上面那条 `runtime not found` 纯粹是壳的版本门槛,不是环境不满足要求。
|
||||||
|
|
||||||
### 从零重建环境(本机实测可行的顺序)
|
### 从零重建环境(本机实测可行的顺序)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -197,14 +243,12 @@ pnpm db:seed # 写演示数据
|
|||||||
# 工作目录 node_modules/.pnpm/electron@44.3.0/node_modules/electron
|
# 工作目录 node_modules/.pnpm/electron@44.3.0/node_modules/electron
|
||||||
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ node install.js
|
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ node install.js
|
||||||
|
|
||||||
# ClassIsland 插件(.NET 8 SDK 已装:8.0.425)
|
|
||||||
pnpm build:classisland-plugin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 演示账号(`pnpm db:seed` 产出)
|
### 演示账号(`pnpm db:seed` 产出)
|
||||||
|
|
||||||
| 角色 | 用户名 | 密码 |
|
| 角色 | 用户名 | 密码 |
|
||||||
| -------------- | ------------------------------- | ------------ |
|
| -------------------------- | ------------------------------- | ------------ |
|
||||||
| 管理员 | `admin` | `admin123` |
|
| 管理员 | `admin` | `admin123` |
|
||||||
| 教师 | `teacher1` / `teacher2` | `teacher123` |
|
| 教师 | `teacher1` / `teacher2` | `teacher123` |
|
||||||
| ClassHelper 班级端班级账号 | 班级码 `G101` / `G102` / `G203` | `123456` |
|
| ClassHelper 班级端班级账号 | 班级码 `G101` / `G102` / `G203` | `123456` |
|
||||||
@@ -226,15 +270,13 @@ pnpm build:classisland-plugin
|
|||||||
## 4. 常用命令
|
## 4. 常用命令
|
||||||
|
|
||||||
| 命令 | 说明 |
|
| 命令 | 说明 |
|
||||||
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
|
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
|
||||||
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||||
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
|
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||||
| `pnpm build` | 构建 shared + 后端 + Web 端 + 客户端 |
|
| `pnpm build` | 构建 shared + 后端 + Web 端 + 客户端 |
|
||||||
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
|
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
|
||||||
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||||
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) |
|
|
||||||
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` |
|
|
||||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||||
| `pnpm lint` / `pnpm lint:fix` | ESLint |
|
| `pnpm lint` / `pnpm lint:fix` | ESLint |
|
||||||
| `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) |
|
| `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) |
|
||||||
@@ -249,13 +291,11 @@ pnpm build:classisland-plugin
|
|||||||
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
|
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
|
||||||
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) |
|
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) |
|
||||||
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
|
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
|
||||||
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 44 项) |
|
| `pnpm dist:server` / `dist:win` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 全部 |
|
||||||
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 74 项) |
|
|
||||||
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
|
|
||||||
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
|
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
|
||||||
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) |
|
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 官网 / 文档站 / 界面内品牌标) |
|
||||||
| `pnpm version:check` | 逐处列出并比对 14 个落点的版本号,不一致即 exit 1(见 §4.4「版本迭代」) |
|
| `pnpm version:check` | 逐处列出并比对 12 个落点的版本号,不一致即 exit 1(见 §4.4「版本迭代」) |
|
||||||
| `pnpm version:bump patch\|minor\|major` | **改版本号的唯一入口**:一次改写全部 14 处(含插件清单、C# 里的常量、官网上手写的三处,以及下载页的兜底值) |
|
| `pnpm version:bump patch\|minor\|major` | **改版本号的唯一入口**:一次改写全部 12 处(含官网三处及下载页兜底值) |
|
||||||
| `pnpm docs:build` | 构建文档站到 `docs/_site`(需要 .NET SDK 8 + `dotnet tool install -g docfx`) |
|
| `pnpm docs:build` | 构建文档站到 `docs/_site`(需要 .NET SDK 8 + `dotnet tool install -g docfx`) |
|
||||||
| `pnpm docs:serve` | 构建文档站并起本地预览 → http://127.0.0.1:5181 |
|
| `pnpm docs:serve` | 构建文档站并起本地预览 → http://127.0.0.1:5181 |
|
||||||
| `pnpm docs:check` | 构建文档站并把 docfx 的警告当错误(发布前用;最常见的警告是"某个 .md 没被任何 toc.yml 引用") |
|
| `pnpm docs:check` | 构建文档站并把 docfx 的警告当错误(发布前用;最常见的警告是"某个 .md 没被任何 toc.yml 引用") |
|
||||||
@@ -281,20 +321,37 @@ pnpm build:classisland-plugin
|
|||||||
releases/
|
releases/
|
||||||
client/<版本>/
|
client/<版本>/
|
||||||
安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||||
免安装/ electron-builder 的 win-unpacked 内容
|
|
||||||
构建中间/ builder-*.yml、*.nsis.7z
|
构建中间/ builder-*.yml、*.nsis.7z
|
||||||
server/<版本>/
|
server/<版本>/
|
||||||
安装包/ ClassHelper 服务端-<版本>-x64-setup.exe
|
安装包/ ClassHelper 服务端-<版本>-x64-setup.exe
|
||||||
免安装/ classhelper-server 免安装目录(含 .env、node.exe、data/、logs/)
|
linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256
|
||||||
linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256 + 同内容解包目录
|
|
||||||
构建中间/ server-installer.generated.nsi
|
构建中间/ server-installer.generated.nsi
|
||||||
classisland-plugin/<版本>/
|
classisland-plugin/<版本>/
|
||||||
安装包/ ClassHelper.ClassIslandPlugin.cipx
|
安装包/ ClassHelper.ClassIslandPlugin.cipx
|
||||||
免安装/ ClassHelper.ClassIslandPlugin/(解压即用,丢进 Plugins 目录即可加载)
|
|
||||||
checksums.md
|
checksums.md
|
||||||
SHA256SUMS-<版本>.txt 跨组件哈希清单(人读,由发布流程维护)
|
SHA256SUMS-<版本>.txt 跨组件哈希清单(人读,由发布流程维护)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**`releases/` 里只放交付物,不放"解包出来的那一份"。** 这条是硬约定,2026-10-11 统一过一遍:
|
||||||
|
|
||||||
|
| 曾经有的目录 | 它是什么 | 现在 |
|
||||||
|
| ------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| `client/<版本>/免安装/` | electron-builder 的 `win-unpacked`(约 323MB) | 只在打包过程中存在,生成两个 exe 后由 `tidyArtifacts()` 删除 |
|
||||||
|
| `server/<版本>/免安装/` | `pnpm dist:server` 组装的运行时(约 335MB) | 组装到 `.cache/server-staging/<版本>/win/`,打完 NSIS 即删 |
|
||||||
|
| `server/<版本>/linux-x64/classhelper-server-linux-x64/` | tar.gz 的解包留档(约 265MB) | 打完 tar.gz 即删(内容与 tar 完全一样) |
|
||||||
|
| `classisland-plugin/<版本>/免安装/` | `.cipx` 的解压副本 | 不再产出(`.cipx` 本身就是 zip,解压即用) |
|
||||||
|
|
||||||
|
理由:这些都是 300MB 量级的**中间物**,而且长得跟交付物一样像"该拷走的东西" ——
|
||||||
|
五个客户端版本 + 七个服务端版本堆下来就是 4.8GB,其中 3.4GB 是重复的中间物。
|
||||||
|
`-portable.exe` 是自解压单文件,服务端安装程序内置 Node,**用户侧一个解包目录都不需要**。
|
||||||
|
需要临时看一眼解包内容时,直接解压 portable,或按下面那张表的路径去 `.cache/` 找。
|
||||||
|
(三个脚本仍会把 `构建中间/` 留在版本目录里 —— `builder-*.yml`、`*.nsis.7z`、
|
||||||
|
`server-installer.generated.nsi`,那是每个版本几 KB 的打包脚本与元数据,**保留**。
|
||||||
|
实测 8 个版本目录合计约 50 KB。)
|
||||||
|
|
||||||
|
**Linux 包同理**:`.tar.gz` 旁边的同名解包目录(约 265MB)已不再保留,需要看内容就
|
||||||
|
`tar -xzf` 一份到 `.cache/` 里(见 §5 第 59 条 ④ 的命名约定)。
|
||||||
|
|
||||||
版本号取自根 `package.json`(插件用 `manifest.yml` 的前三段,与产品版本对齐);
|
版本号取自根 `package.json`(插件用 `manifest.yml` 的前三段,与产品版本对齐);
|
||||||
重新打包只清**当前版本目录**里的产物,历史版本原样保留 —— 所以同版本多次构建留下的
|
重新打包只清**当前版本目录**里的产物,历史版本原样保留 —— 所以同版本多次构建留下的
|
||||||
不同产物要自己改名归档(例如 `1.0.0-重构建20261005/`),别把上一次的覆盖掉。
|
不同产物要自己改名归档(例如 `1.0.0-重构建20261005/`),别把上一次的覆盖掉。
|
||||||
@@ -302,14 +359,20 @@ releases/
|
|||||||
```bash
|
```bash
|
||||||
pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过)
|
pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过)
|
||||||
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
|
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
|
||||||
pnpm dist:win # 客户端:安装包 + 免安装目录;dist:dir 只出免安装目录
|
pnpm dist:win # 客户端:安装包 + 便携版
|
||||||
pnpm dist:classisland-plugin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后
|
`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后
|
||||||
再把原始布局整理成上面的分层(`win-unpacked/` → `免安装/`、`*.exe` → `安装包/`、`*.yml` → `构建中间/`);
|
再把原始布局整理成上面的分层(`*.exe` → `安装包/`、`*.yml` → `构建中间/`),**最后删掉 `win-unpacked/`**;
|
||||||
显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。
|
显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。
|
||||||
**交付物的完整性标签修正(§7 第 7 条)也挂在这条收尾链路上,别把 `tidyArtifacts` 之后的
|
两条别动的顺序约束:
|
||||||
|
|
||||||
|
- **删 `win-unpacked` 必须排在整理之后**:portable 目标就是拿这份解包结果做自解压包,
|
||||||
|
提前删会让 `-portable.exe` 直接失败。
|
||||||
|
- **输出目录的完整性标签要在打包前压回 Low**:上一轮收尾把它标成 Medium 之后,7za(自身带 Low)
|
||||||
|
读不动那些目录,pack 时报 `cannot open file ... Access is denied`(实测踩过)。
|
||||||
|
|
||||||
|
**交付物的完整性标签修正(§7 第 7 条)挂在收尾链路上,别把 `tidyArtifacts` 之后的
|
||||||
`normalizeIntegrityLabel(destDir)` 拆掉。**
|
`normalizeIntegrityLabel(destDir)` 拆掉。**
|
||||||
|
|
||||||
### 版本迭代(每一次 releases,不是每一次提交)
|
### 版本迭代(每一次 releases,不是每一次提交)
|
||||||
@@ -352,21 +415,22 @@ pnpm lint && pnpm typecheck && pnpm check:icons && pnpm docs:check
|
|||||||
|
|
||||||
# ③ 跑验收(另开一个终端跑着 pnpm dev:server)
|
# ③ 跑验收(另开一个终端跑着 pnpm dev:server)
|
||||||
pnpm verify:e2e
|
pnpm verify:e2e
|
||||||
pnpm verify:classisland
|
|
||||||
pnpm verify:classisland-plugin
|
|
||||||
pnpm build:desktop && pnpm verify:desktop
|
pnpm build:desktop && pnpm verify:desktop
|
||||||
pnpm build && pnpm verify:web
|
pnpm build && pnpm verify:web
|
||||||
|
|
||||||
# ④ 打安装包(产物落在 releases/<组件>/<版本>/)
|
# ④ 打安装包(产物落在 releases/<组件>/<版本>/)
|
||||||
pnpm dist:all
|
pnpm dist:all
|
||||||
pnpm verify:packaged --exe "releases/client/<版本>/免安装/ClassHelper.exe"
|
# 对**打包后的副本**复验:便携版就是自解压单文件,直接拿它当被测副本最省事
|
||||||
|
pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe"
|
||||||
|
|
||||||
# ⑤ 整理跨组件哈希清单 releases/SHA256SUMS-<版本>.txt(人读;Linux 那一行由 CI 合并进来)
|
# ⑤ 整理跨组件哈希清单 releases/SHA256SUMS-<版本>.txt(人读;Linux 那一行由 CI 合并进来)
|
||||||
# ⑥ 提交并推上去
|
# ⑥ 提交并推上去
|
||||||
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 \
|
||||||
@@ -379,7 +443,7 @@ gh release upload v<版本> \
|
|||||||
> 是给别人看、要贴进脚本的 —— 一律用 ASCII 形式,与构建产物一一对应(内容相同、sha256 相同):
|
> 是给别人看、要贴进脚本的 —— 一律用 ASCII 形式,与构建产物一一对应(内容相同、sha256 相同):
|
||||||
>
|
>
|
||||||
> | 构建产物 | Release 上的名字 |
|
> | 构建产物 | Release 上的名字 |
|
||||||
> | --------------------------- | ------------------------------------------- |
|
> | --------------------------- | -------------------------------------------- |
|
||||||
> | `<客户端>-x64-setup.exe` | `ClassHelper-<版本>-x64-client-setup.exe` |
|
> | `<客户端>-x64-setup.exe` | `ClassHelper-<版本>-x64-client-setup.exe` |
|
||||||
> | `<客户端>-x64-portable.exe` | `ClassHelper-<版本>-x64-client-portable.exe` |
|
> | `<客户端>-x64-portable.exe` | `ClassHelper-<版本>-x64-client-portable.exe` |
|
||||||
> | `<服务端>-x64-setup.exe` | `ClassHelper-<版本>-x64-server-setup.exe` |
|
> | `<服务端>-x64-setup.exe` | `ClassHelper-<版本>-x64-server-setup.exe` |
|
||||||
@@ -412,6 +476,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` 或
|
||||||
@@ -431,7 +525,7 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建
|
|||||||
|
|
||||||
- 顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata _chVersion=…` 注入),
|
- 顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata _chVersion=…` 注入),
|
||||||
**不要在文档里再写一份版本号**;官网首页那排芯片里的 `v1.1.2` 与下载页的
|
**不要在文档里再写一份版本号**;官网首页那排芯片里的 `v1.1.2` 与下载页的
|
||||||
`<meta name="ch-version">` 是手写的,发版时要一起改(都在 `pnpm version:bump` 的 14 个落点里);
|
`<meta name="ch-version">` 是手写的,发版时要一起改(都在 `pnpm version:bump` 的 12 个落点里);
|
||||||
- 下载页的版本清单**不在站点里存副本**:每次打开都由 `assets/download.js` 当场读
|
- 下载页的版本清单**不在站点里存副本**:每次打开都由 `assets/download.js` 当场读
|
||||||
GitHub Releases(匿名接口 60 次/小时/IP),读不到才退回上面那个 meta 按发布命名约定拼直链。
|
GitHub Releases(匿名接口 60 次/小时/IP),读不到才退回上面那个 meta 按发布命名约定拼直链。
|
||||||
所以发完 Release 不用重新部署这一页,但**兜底值必须跟着发版改**;
|
所以发完 Release 不用重新部署这一页,但**兜底值必须跟着发版改**;
|
||||||
@@ -446,9 +540,9 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建
|
|||||||
## 5. 架构硬约定(改代码前必须知道)
|
## 5. 架构硬约定(改代码前必须知道)
|
||||||
|
|
||||||
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
|
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
|
||||||
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 17 个:`auth / classes / courses / schedules /
|
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 21 个:`auth / classes / courses / schedules /
|
||||||
homeworks / notifications / calls / imports / integrations / grades / students / teachers / archives /
|
homeworks / notifications / calls / imports / integrations / grades / students / teachers / archives /
|
||||||
term / dashboard / database / update`。
|
term / dashboard / database / update / permissions / clients / system`。
|
||||||
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
|
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
|
||||||
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
|
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
|
||||||
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
|
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
|
||||||
@@ -494,26 +588,19 @@ term / dashboard / database / update`。
|
|||||||
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
|
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
|
||||||
(`--ch-radius-xl/lg/md/sm/pill`、`--ch-shadow-sm/md`),组件里**不要写死数值**;Element Plus 通过覆盖它的
|
(`--ch-radius-xl/lg/md/sm/pill`、`--ch-shadow-sm/md`),组件里**不要写死数值**;Element Plus 通过覆盖它的
|
||||||
CSS 变量对齐。手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card`。
|
CSS 变量对齐。手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card`。
|
||||||
12. **ClassIsland 联动是独立鉴权通道,不要塞进 JWT。** 插件用设备令牌(`X-ClassIsland-Token`,库里只存 sha256,
|
12. 联动专用约定已退役;离线 JSON 导入继续保留。
|
||||||
明文前缀为 `chci_`)调 `/api/integrations/classisland/*`;Web 端管理设备仍走 JWT + `requireRole`。
|
|
||||||
新增插件接口时,路由挂 `integrations.module.ts`,并在 `integrations.schemas.ts` 里写 zod —— 插件侧的 C# DTO
|
13. 联动专用约定已退役;离线 JSON 导入继续保留。
|
||||||
必须**逐字段对齐**(`pnpm verify:classisland-plugin` 会校验字段名与路由)。
|
|
||||||
13. **课表镜像契约必须带 `startTime`/`endTime`。** ClassIsland 的 `ClassPlan.Classes[i]` 强制与时间表里
|
14. 联动专用约定已退役;离线 JSON 导入继续保留。
|
||||||
**第 i 个 `TimeType == 0`(上课)的时间点**对齐:镜像下发少一个时间点,整份课表就会错位,
|
|
||||||
而且表现为"课表看着有内容、每节课都对不上",极难排查。
|
15. 联动专用约定已退役;离线 JSON 导入继续保留。
|
||||||
14. **所有发布入口共用同一份 ClassIsland 推送实现**(`integrations.service.ts` 的 `pushToClassIsland`):
|
|
||||||
通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时**必须**复用,不要再各写一套 ——
|
|
||||||
否则又会出现「某个页面发的通知不联动 ClassIsland」。
|
|
||||||
15. **提醒弹在哪个端由教室客户端选**,值存在 `Class.notificationChannel`(`both` / `client` / `classisland`):
|
|
||||||
客户端设置页写它(班级账号也有写权限,见 `PATCH /api/classes/:id/notification-channel`),
|
|
||||||
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
|
|
||||||
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
|
|
||||||
16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
|
16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
|
||||||
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
|
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
|
||||||
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
|
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
|
||||||
17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课),
|
17. 联动专用约定已退役;离线 JSON 导入继续保留。
|
||||||
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
|
|
||||||
服务端合成节次时间表同口径。
|
|
||||||
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
|
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
|
||||||
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);紧急(`URGENT`)同样立刻弹。
|
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);紧急(`URGENT`)同样立刻弹。
|
||||||
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
|
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
|
||||||
@@ -538,16 +625,8 @@ term / dashboard / database / update`。
|
|||||||
22. **灵动岛位置由「屏幕工作区 + 边距」算,默认边距 8px(`DEFAULT_ISLAND_APPEARANCE.marginX/marginY`)**:
|
22. **灵动岛位置由「屏幕工作区 + 边距」算,默认边距 8px(`DEFAULT_ISLAND_APPEARANCE.marginX/marginY`)**:
|
||||||
改默认值会直接改外观,`verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标,一动就红。
|
改默认值会直接改外观,`verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标,一动就红。
|
||||||
新增边距类设置请只改"可调范围",别动默认值(夹紧规则收口在主进程 `normalizeIslandAppearance`)。
|
新增边距类设置请只改"可调范围",别动默认值(夹紧规则收口在主进程 `normalizeIslandAppearance`)。
|
||||||
23. **课表行有"来源"字段 `Schedule.source`**(`manual` / `classisland`)。插件每次全量上报时,
|
23. 联动专用约定已退役;离线 JSON 导入继续保留。
|
||||||
服务端只清理「`source='classisland'` 且本次未再上报」的行 —— 这样老师在 ClassIsland 里删掉/挪动一节
|
|
||||||
能真正同步下来(原先 merge 只 upsert,服务端会永远留着幽灵课),**老师在小助手这边手排的课一律不删**。
|
|
||||||
因此:**新增写课表的入口要显式定 source**(Web 端录入/导入保持默认 `manual`,只有插件上报用 `classisland`),
|
|
||||||
否则要么幽灵课清不掉,要么手排的课被误删。回归用例见 `verify:classisland` 的「幽灵行清理」两条。
|
|
||||||
**`source` 只在 create 时确定,update 一律保持原值。** merge 的去重键是「星期 + 开始时间 + 单双周」,
|
|
||||||
老师在 Web 端手排的同一时间槽会被上报"收养";若收养时把来源改成 `classisland`,
|
|
||||||
下一次清理就会把这行手排的课删掉 —— 这个坑踩过一次:种子课表被删掉 2 行,
|
|
||||||
`verify:e2e` 的「上课状态接口」用例(AGENTS.md §7 已列为脆弱用例)随即报红。
|
|
||||||
同步时**不要**覆盖 `weekStart/weekEnd`:插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。
|
|
||||||
24. **两个登录入口都必须挂 `loginRateLimiter()`**(`app.ts` 用数组形式同时挂 `/auth/login` 与 `/auth/class-login`)。
|
24. **两个登录入口都必须挂 `loginRateLimiter()`**(`app.ts` 用数组形式同时挂 `/auth/login` 与 `/auth/class-login`)。
|
||||||
班级密码默认是 `123456`、班级码是固定字符集,班级登录曾是唯一没有暴力破解防护的入口。
|
班级密码默认是 `123456`、班级码是固定字符集,班级登录曾是唯一没有暴力破解防护的入口。
|
||||||
另外 `auth.service.login` 里**角色判定必须在密码校验之前**:否则"密码错(401) / 密码对但学生账号已停用(403)"
|
另外 `auth.service.login` 里**角色判定必须在密码校验之前**:否则"密码错(401) / 密码对但学生账号已停用(403)"
|
||||||
@@ -719,6 +798,31 @@ term / dashboard / database / update`。
|
|||||||
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
|
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
|
||||||
完整性标签(§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')` 先于步骤卡片渲染成立,
|
||||||
@@ -977,6 +1081,14 @@ term / dashboard / database / update`。
|
|||||||
并加 `--force-local`。该选项在 Linux 上无害(本地路径本来就该走本地分支),因此无条件加上、
|
并加 `--force-local`。该选项在 Linux 上无害(本地路径本来就该走本地分支),因此无条件加上、
|
||||||
不做平台分支 —— **Linux 上路径没有盘符,这个坑在 CI 与真机上永远复现不了**,只在交叉构建时现形。
|
不做平台分支 —— **Linux 上路径没有盘符,这个坑在 CI 与真机上永远复现不了**,只在交叉构建时现形。
|
||||||
|
|
||||||
|
还有一条**两边都会踩**的:**交付目录必须自己 `mkdir`,别指望谁来顺带创建它。**
|
||||||
|
`releases/server/<版本>/linux-x64/` 曾经是 staging(`linux-x64/classhelper-server-linux-x64/`)的父目录,
|
||||||
|
2026-10-11 把 staging 挪进 `.cache/server-staging/` 之后它就没有创建者了,`tar -czf <那里>/…`
|
||||||
|
直接报 `tar (child): …tar.gz: Cannot open: No such file or directory`、退出码 2 ——
|
||||||
|
**Windows 侧踩不到**(`buildInstaller()` 里有 `mkdirSync(INSTALLER_DIR)`),本地 `pnpm dist:server`
|
||||||
|
永远绿的,只有 CI 上那一次 `pnpm dist:server:linux` 才会红(v1.3.0 实测,run 38109984623)。
|
||||||
|
现在 `packLinuxTarball()` 开头显式建目录,别再把这句删掉。
|
||||||
|
|
||||||
⑤ **密码永远走 stdin,命令行一律不接受密码;调用服务端模块的工具必须压掉它的 stdout 噪声。**
|
⑤ **密码永远走 stdin,命令行一律不接受密码;调用服务端模块的工具必须压掉它的 stdout 噪声。**
|
||||||
`classhelper password` 用 `read -s` 优先读 `/dev/tty`、无 tty 回退 stdin(这样既能交互,
|
`classhelper password` 用 `read -s` 优先读 `/dev/tty`、无 tty 回退 stdin(这样既能交互,
|
||||||
也能被验收脚本用管道喂 —— 都不进 history/ps)。输入循环**必须有界**:stdin 结束时 `read`
|
也能被验收脚本用管道喂 —— 都不进 history/ps)。输入循环**必须有界**:stdin 结束时 `read`
|
||||||
@@ -993,6 +1105,17 @@ term / dashboard / database / update`。
|
|||||||
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 失败、以及**用 `$(函数)` 捕获输出时把日志一起捕获**(所以安装器用全局
|
||||||
@@ -1129,15 +1252,7 @@ term / dashboard / database / update`。
|
|||||||
直接写会撞 `User` 外键(见 §5 第 20 条「教室机器能录入作业」)。
|
直接写会撞 `User` 外键(见 §5 第 20 条「教室机器能录入作业」)。
|
||||||
班级端的成绩查询受 `Class.studentGradeQueryEnabled` 这个**按班开关**控制(默认开启)。
|
班级端的成绩查询受 `Class.studentGradeQueryEnabled` 这个**按班开关**控制(默认开启)。
|
||||||
|
|
||||||
65. **课表同步的主方向是「服务端 → 教室」,教室不再自动回传。**
|
65. 联动专用约定已退役;离线 JSON 导入继续保留。
|
||||||
`IntegrationDevice.mirrorScheduleToClassIsland` 默认 **true**(服务端改完课表自动下发),
|
|
||||||
`syncScheduleToServer` 默认 **false**。要取教室的课表走
|
|
||||||
`POST /integrations/devices/:id/request-schedule` —— 它只置一个待办
|
|
||||||
(`requestScheduleReport`),插件在下一次心跳看到就把课表推上来并清除标记。
|
|
||||||
Web 端点击前**必须弹警告**(会覆盖 / 合并本班课表)。
|
|
||||||
插件侧对应 `ReportSettingsDto.scheduleRequested` + `ReportAsync(..., forceSchedule: true)`,
|
|
||||||
且补发发生在**镜像之前**(顺序反了会把刚拿到的课表盖掉)——
|
|
||||||
`verify:classisland-plugin` 有 6 条护栏盯着这段契约,别删。
|
|
||||||
|
|
||||||
66. **学期周次可以逐周配,别再用"开学日期 + 每周七天"硬推。**
|
66. **学期周次可以逐周配,别再用"开学日期 + 每周七天"硬推。**
|
||||||
`TermWeek`(`classId = ''` 表示全校默认)存每一周的 `startDate`/`endDate`;
|
`TermWeek`(`classId = ''` 表示全校默认)存每一周的 `startDate`/`endDate`;
|
||||||
@@ -1149,6 +1264,125 @@ term / dashboard / database / update`。
|
|||||||
联网拉取调休建议(`GET /api/term/holidays`)**只给建议、不改数据**,且
|
联网拉取调休建议(`GET /api/term/holidays`)**只给建议、不改数据**,且
|
||||||
**机房无外网时 `ok:false` 是正常结果**,失败结果同样进缓存(否则断网点一次要白等一个超时)。
|
**机房无外网时 `ok:false` 是正常结果**,失败结果同样进缓存(否则断网点一次要白等一个超时)。
|
||||||
|
|
||||||
|
67. **功能级准入看「权限键」,角色只是默认模板(2026-10 权限组重构)。**
|
||||||
|
路由上挂 `requirePermission('…')`(AND)/ `requireAnyPermission(…)`,键目录在
|
||||||
|
`packages/shared/src/permissions.ts`(`PERMISSION_KEYS` 39 键 / `PERMISSION_LABELS` /
|
||||||
|
`PERMISSION_CATEGORIES` / `PERMISSION_DEFAULT_ROLES` / `ROLE_DEFAULT_PERMISSIONS`)。
|
||||||
|
有效权限由 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()` 算:
|
||||||
|
ADMIN 按可编辑默认组/已分配组取权限,且 `permission:manage`/`system:manage` 不可剥夺;CLASS_DEVICE=空集(不受分组影响);
|
||||||
|
有分组=并集(**可以比角色默认更少**,这就是"能收权");无分组=数据库对应角色的默认组。
|
||||||
|
它**每请求实时查库**(不缓存),所以改完分组立刻生效。
|
||||||
|
**不可松的边界**:权限组只管功能/路由级;班级维度的科目规则(`assertCanManageSubjectContent`)
|
||||||
|
故意不可被权限组覆盖,否则「班主任布置别人科目作业」的老 bug 会从新路长回来。
|
||||||
|
Web 端门控是「`meta.roles` 粗筛 + `meta.permission` 细筛」(`beforeEach` 里
|
||||||
|
`await auth.ensurePermissions()` 再 `auth.can(key)`,不通过 fail-closed),
|
||||||
|
权限矩阵与生效规则见 `docs/dev/permissions.md`。
|
||||||
|
|
||||||
|
68. **客户端设备身份是 `deviceKey`,它不接受外部改写。** 客户端配置里的 `deviceKey` 是首次启动生成的
|
||||||
|
UUID,服务端按它 upsert `ClientDevice`;`saveConfig()` **拒绝**渲染进程改写它(改了就等于换一台机器),
|
||||||
|
退出登录走 `clearConfig()` 时也**保留**它(连同 `autoLaunch`/`autoUpdate`/`developerMode`/`careMode`
|
||||||
|
与待安装的更新记录)。上报走「客户端定时拉」而不是服务端推:
|
||||||
|
`POST /api/clients/heartbeat`(30 秒一次,首报延迟 3 秒),响应里带回「目标版本 + 一条待执行命令」;
|
||||||
|
在线判定窗口 `CLIENT_ONLINE_WINDOW_MS`=90 秒。三条边界:**没有 token 就不上报**(未登录静默跳过)、
|
||||||
|
**401/403 退避 5 分钟**、**冒烟默认不上报**(要验这条链路才设 `ELECTRON_SMOKE_CLIENT_AGENT=1`)。
|
||||||
|
|
||||||
|
69. **远控命令要两端各确认一次,而且客户端**必须**回执。** 服务端 `POST /api/clients/commands` 只对
|
||||||
|
`remote:control`(默认仅 ADMIN)开放,动作限 `shutdown`/`restart`/`logoff`/`lock`,有效期
|
||||||
|
`CLIENT_COMMAND_EXPIRE_MS`=10 分钟;客户端收到后弹**默认选中「取消」**的对话框(60 秒未确认按取消处理),
|
||||||
|
执行只支持 Windows(`shutdown.exe /s|/r /t 0`、`/l`、`rundll32 user32.dll,LockWorkStation`),
|
||||||
|
然后 `POST /api/clients/commands/:id/ack`(body 带 `deviceKey`,服务端据此校验归属,重复回执幂等)。
|
||||||
|
同一台机器上**同一条命令只弹一次**(内存去重),回执失败只暂存重试、不重复弹窗。
|
||||||
|
|
||||||
|
**`executeCommand()` 必须显式监听子进程的 `error` 事件**(2026-10-11 修):
|
||||||
|
`spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在 next tick 发一个异步 `error` 事件 ——
|
||||||
|
只包 `try/catch` 会一路走到 `return { ok: true }`,回执写着"已下达系统命令",机器却纹丝不动。
|
||||||
|
现在等待进程退出并检查退出码,失败如实回执;关机/重启延迟 5 秒留出回执时间。
|
||||||
|
|
||||||
|
**「点了没反应」必须看得出来**:命令被取消 / 过期后就从 `pendingCommand` 里消失,Web 端原本一片空白,
|
||||||
|
而最常见的两种失败恰恰是"本机没人确认(60 秒自动取消)"与"下达时机器离线"。
|
||||||
|
所以 `GET /clients/devices` 一并返回 `lastCommand`(最近一条,不论状态),
|
||||||
|
「客户端」页把它翻成一句人话(`ClientHubView` 的 `commandStateText`)。
|
||||||
|
排查这类反馈时先看这一列,再看那台机器的**客户端版本**是否 ≥ 1.3.0 ——
|
||||||
|
远控命令的处理(`client-agent.ts`)是 1.3.0 才有的,旧客户端连心跳都不发。
|
||||||
|
|
||||||
|
70. **客户端安装包由服务端下载并缓存,教室机不直连 GitHub。** 原因有两条:Web 端 CSP
|
||||||
|
(`connect-src 'self' ws wss …`)不允许浏览器直连 GitHub,而教室机常常根本没有外网。
|
||||||
|
所以 `POST /api/update/client/fetch` 由**服务端**下载到 `<serverRoot>/../data/client-releases/<版本>/`
|
||||||
|
并登记 sha256+size,`GET /api/update/client/download/:version` **故意不挂权限键**(班级端要能下载)。
|
||||||
|
版本号一律先用 `^[0-9A-Za-z._-]+$` 校验(防路径穿越)。客户端自动安装的三条护栏:
|
||||||
|
**没有 sha256 就拒收**、**按下发策略等待:立即 / 放学后 / 空闲五分钟**、
|
||||||
|
冒烟模式**永不安装**。
|
||||||
|
|
||||||
|
71. **通知与作业一样按「所属日期」归档,批量发布在服务端做。** `Notification.assignDate` 与
|
||||||
|
`Homework.assignDate` 同口径(`dayKeyLocal()`,禁止 UTC 切片);列表支持 `date`/`from`/`to`/`createdBy`,
|
||||||
|
另有 `GET /notifications/days` 与 `/notifications/creators` 供"按天/按发布人归档"的界面用。
|
||||||
|
批量走 `POST /notifications/bulk` 与 `POST /homeworks/bulk`:**单班失败不影响整批**
|
||||||
|
(逐班返回 `BulkPublishClassResult`),作业的 `subjectName` 跨班**按名匹配**已有课程,
|
||||||
|
**找不到就整班记失败,绝不自动建课**(自动建课会把创建者写成任课老师,等于越权改任教关系)。
|
||||||
|
|
||||||
|
72. **大模型助手只解析、不落库。** `POST /api/ai/interpret` 返回的是**待确认**的结构化动作
|
||||||
|
(`create_homework` / `create_notification` / `call_student` / `unknown` + `params` + `preview`),
|
||||||
|
真正落库由前端确认后调**既有**端点 —— 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用。
|
||||||
|
模型给的 `params` **必须用与手动入口同一份 zod 再校验一次**(模型输出不可信)。
|
||||||
|
API Key 用 `packages/server/src/lib/secrets.ts` 的 AES-256-GCM(密钥由 `JWT_SECRET` 经 scrypt 派生)
|
||||||
|
加密后存 `AppSetting`,**只进不出**(GET 只回 `hasApiKey` 与尾 4 位)。
|
||||||
|
`provider='mock'` 是离线确定性解析,**自动化验收一律用它**(§5 第 52 条:不许访问外网);
|
||||||
|
每次解析写一条 `AiAudit`(不含密钥)。
|
||||||
|
|
||||||
|
73. **开发者模式是服务端的一个内存缓存开关。** 真值存在 `AppSetting`(键 `system.developerMode`),
|
||||||
|
但热路径(错误中间件)不可能每请求查库,所以 `packages/server/src/lib/settings.ts` 用
|
||||||
|
`isDeveloperMode()` / `refreshDeveloperMode()` 缓存它:**启动时刷一次、管理员改设置时更新**。
|
||||||
|
开启后只多做一件事:500 的 `ApiError` 在 `details` 里附 `error.stack`(见 `middleware/error.ts`)。
|
||||||
|
`GET /api/system/diagnostics` **绝不回传任何密钥**。开关与诊断接口都要求 `system:manage`。
|
||||||
|
|
||||||
|
74. **开机自启动必须"可选择 + 运行期可改"。** 安装器只负责**默认值**:
|
||||||
|
Windows 在 `scripts/nsis/server-installer.nsi` 里是**组件页上一个独立的 Section**(默认勾选),
|
||||||
|
Linux 在 `deploy/install.sh`;装完之后一律走 `GET/PUT /api/system/autostart`
|
||||||
|
(Windows 改 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClassHelperServer`,
|
||||||
|
Linux `systemctl enable/disable classhelper`)。**非安装形态(开发机 / 免安装目录 / 容器)必须返回
|
||||||
|
`supported:false` 并说明原因** —— 别往开发机的注册表里写自启项。
|
||||||
|
|
||||||
|
75. **服务端菜单回缩与关怀模式:折叠只用 CSS,放大只动字号。** 侧栏折叠**不要用 `el-menu` 的
|
||||||
|
`:collapse`**(它会重建 DOM,与桌面客户端侧栏踩过的坑同源),而是 CSS 收拢宽度 + 折叠态文字
|
||||||
|
「既无宽度又不透明」;覆盖 Element Plus 缩进时要**照抄它自己的选择器形状**(0-5-0/0-6-0),
|
||||||
|
写低特异度的选择器会静默失效。关怀模式(`html.care-mode` + `--ch-font-scale`)只放大**正文字号与控件**,
|
||||||
|
**不动任何几何令牌**(圆角/阴影/侧栏宽),否则桌面客户端侧栏导轨的折叠几何断言会红。
|
||||||
|
|
||||||
|
76. **客户端上线补弹离线期间收到的消息只做一次。** 起点(`localStorage` 的
|
||||||
|
`classhelper.offlineSince`)**只在"可达 → 不可达"的边界写**(不在每次失败上覆盖,否则窗口被无限推迟),
|
||||||
|
恢复时按起点筛未读通知、取**最近 5 条**并按时间升序推给灵动岛,推完清起点(同一次离线只弹一次)。
|
||||||
|
**冒烟模式直接跳过**:冒烟会反复断开/恢复网络,补弹会与注入的合成通知互相打乱队列,
|
||||||
|
把既有的条数/顺序/列表高度断言变成随机红。
|
||||||
|
|
||||||
|
77. **客户端崩溃自愈只在"没在上课、没人看着"时才重启进程。** `render-process-gone` 与
|
||||||
|
`unresponsive`(超 10 秒)先**静默重建主窗口**(10 分钟内最多 3 次),
|
||||||
|
`uncaughtException`/`unhandledRejection` 也只在 `isIdle()`(非上课 + 无可见窗口)时才
|
||||||
|
`app.relaunch()`;**冒烟模式绝不重启**(会杀掉验证实例)。
|
||||||
|
自愈模块在 `packages/desktop-client/src/main/crash-guard.ts`,限流与 `isIdle` 判据别放宽。
|
||||||
|
|
||||||
|
78. 联动专用约定已退役;离线 JSON 导入继续保留。
|
||||||
|
|
||||||
|
79. 联动专用约定已退役;离线 JSON 导入继续保留。
|
||||||
|
|
||||||
|
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` 分支),
|
||||||
|
等于把"学校说不给看"变成一台机器说了算;现在班级端只读(客户端那个开关也撤掉了,
|
||||||
|
只显示状态)。成绩查询开关仅教师/管理员可改。
|
||||||
|
|
||||||
|
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` 后缀**
|
||||||
@@ -1226,12 +1460,17 @@ term / dashboard / database / update`。
|
|||||||
判据与修法(都是实测过的):
|
判据与修法(都是实测过的):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 判据:看交付物(以及 node_modules/electron/dist)有没有 Low 标签
|
# 判据:看交付物(以及 node_modules/electron/dist)有没有 Low 标签。
|
||||||
icacls packages\desktop-client\release\win-unpacked\ClassHelper.exe | findstr Mandatory
|
# **正常状态是"没有 Mandatory Label 这一行"**(= 默认 Medium);出现
|
||||||
|
# `Mandatory Label\Low Mandatory Level` 才是坏的那个。所以别去找"是不是 Medium"。
|
||||||
|
# 目标就是下面这两个 exe —— 中间物 win-unpacked 打完就删了(§4「交付产物归集」)。
|
||||||
|
icacls "releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe" | findstr Mandatory
|
||||||
|
icacls "releases\client\<版本>\安装包\ClassHelper-<版本>-x64-portable.exe" | findstr Mandatory
|
||||||
# 修法:把交付物改成 Medium。dist-win.mjs 收尾会自动做,手工补救用:
|
# 修法:把交付物改成 Medium。dist-win.mjs 收尾会自动做,手工补救用:
|
||||||
icacls packages\desktop-client\release /setintegritylevel Medium /T /C
|
icacls "releases\client\<版本>" /setintegritylevel Medium /T /C
|
||||||
# 目录自身要还原成继承,否则下次构建时自带 Low 标签的 7za / makensis 写不进去
|
# 目录自身要还原成继承,否则下次构建时自带 Low 标签的 7za / makensis 写不进去
|
||||||
icacls packages\desktop-client\release /setintegritylevel "(OI)(CI)Low"
|
#(这一步 dist-win.mjs 现在在**打包前**就做了,见 §4)
|
||||||
|
icacls "releases\client\<版本>" /setintegritylevel "(OI)(CI)Low"
|
||||||
```
|
```
|
||||||
|
|
||||||
连带的三条经验:**归档内部的载荷标签无所谓**(装到仓库外得到的是默认 Medium 文件),
|
连带的三条经验:**归档内部的载荷标签无所谓**(装到仓库外得到的是默认 Medium 文件),
|
||||||
@@ -1294,11 +1533,12 @@ term / dashboard / database / update`。
|
|||||||
同一个排查动作 —— 先把 `TEMP` 指到仓库内一个**真实存在的 Windows 绝对路径**再重试:
|
同一个排查动作 —— 先把 `TEMP` 指到仓库内一个**真实存在的 Windows 绝对路径**再重试:
|
||||||
- `pnpm dist:server` 报 `Internal compiler error #12345: error creating mmap the size of N`:模板用了
|
- `pnpm dist:server` 报 `Internal compiler error #12345: error creating mmap the size of N`:模板用了
|
||||||
`SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件,盘紧就失败
|
`SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件,盘紧就失败
|
||||||
(免安装目录其实已生成好,只是最后一步打不出安装包)。加 `--reuse-deps` 还能跳过依赖安装。
|
(组装出来的运行时其实已经落在 `.cache/server-staging/<版本>/win/`,只是最后一步打不出安装包)。
|
||||||
|
加 `--reuse-deps` 还能跳过依赖安装。
|
||||||
- `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**,
|
- `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**,
|
||||||
而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成
|
而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成
|
||||||
`D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。
|
`D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。
|
||||||
`win-unpacked` 与 `*.nsis.7z` 此时都已生成(整理进 `安装包/`、`免安装/` 的那一步在打包
|
`win-unpacked` 与 `*.nsis.7z` 此时都已生成(整理进 `安装包/`、删 `win-unpacked/` 的那一步在打包
|
||||||
成功之后才跑,失败时还没执行),**只需重跑,不必重建前端**:
|
成功之后才跑,失败时还没执行),**只需重跑,不必重建前端**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1348,7 +1588,7 @@ term / dashboard / database / update`。
|
|||||||
### 已知脆弱用例(不是环境问题,别去"修环境")
|
### 已知脆弱用例(不是环境问题,别去"修环境")
|
||||||
|
|
||||||
| 用例 | 触发条件 |
|
| 用例 | 触发条件 |
|
||||||
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) |
|
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) |
|
||||||
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 |
|
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 |
|
||||||
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
|
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
|
||||||
@@ -1365,8 +1605,6 @@ pnpm typecheck # 全仓库类型检查
|
|||||||
|
|
||||||
pnpm dev:server # 另开终端
|
pnpm dev:server # 另开终端
|
||||||
pnpm verify:e2e # 后端端到端(2026-10-06 实测 205 项全过)
|
pnpm verify:e2e # 后端端到端(2026-10-06 实测 205 项全过)
|
||||||
pnpm verify:classisland # ClassIsland 联动链路(实测 44 项)
|
|
||||||
pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 74 项)
|
|
||||||
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
|
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
|
||||||
pnpm build && pnpm verify:web # Web 端真实点击回归(同上)
|
pnpm build && pnpm verify:web # Web 端真实点击回归(同上)
|
||||||
```
|
```
|
||||||
@@ -1393,31 +1631,19 @@ ssh root@<主机> "bash /tmp/verify-linux.sh --admin-password-stdin" < <(printf
|
|||||||
**改过这条链路就要在真机上跑一遍**——脚本写在 Windows 上跑不出问题,`set -e` 的坑、
|
**改过这条链路就要在真机上跑一遍**——脚本写在 Windows 上跑不出问题,`set -e` 的坑、
|
||||||
systemd 的 `EnvironmentFile` 解析、glibc 差异都只有在 Linux 上才暴露。
|
systemd 的 `EnvironmentFile` 解析、glibc 差异都只有在 Linux 上才暴露。
|
||||||
|
|
||||||
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
|
|
||||||
本机已装 ClassIsland 2.1.0.1(`D:\Classisland`)与 .NET 8 SDK(8.0.425),因此**真机验证是可行的**
|
|
||||||
(直接 `pnpm build:classisland-plugin` 编译,再按 §7 第 12 条部署):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
|
|
||||||
Get-Process ClassIsland.Desktop | Stop-Process -Force
|
|
||||||
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
|
|
||||||
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
|
|
||||||
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看日志里依次出现:
|
|
||||||
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
|
|
||||||
```
|
|
||||||
|
|
||||||
交付时要说明"编译 / 打包 / 静态契约已过,运行期需在装了 ClassIsland 的机器上实测"。
|
|
||||||
|
|
||||||
改动涉及打包/交付形态时,额外跑 `pnpm dist:all`(或 `dist:server` + `dist:win`),
|
改动涉及打包/交付形态时,额外跑 `pnpm dist:all`(或 `dist:server` + `dist:win`),
|
||||||
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
|
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
|
||||||
开发产物通过不等于用户机器上的副本通过。
|
开发产物通过不等于用户机器上的副本通过。
|
||||||
|
|
||||||
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对免安装目录本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
|
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对裁剪后的产物本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
|
||||||
从 `releases/server/<版本>/免安装/` 起,用**自带的 node.exe**:
|
产物有两处现成的,**都带裁剪**:装好的服务端 `%LOCALAPPDATA%\Programs\ClassHelperServer`,
|
||||||
|
或打包过程中的组装目录 `.cache/server-staging/<版本>/win/`(`dist-server.mjs` 在成功收尾时会删掉它,
|
||||||
|
所以要么在打包失败时留用、要么临时注掉那一步 `fs.rmSync(staging, ...)`)。
|
||||||
|
从那一层起,用**自带的 node.exe**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口)
|
# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口)
|
||||||
cd releases/server/<版本>/免安装 && PORT=4100 ./node.exe server/dist/index.js
|
cd %LOCALAPPDATA%\Programs\ClassHelperServer && set PORT=4100 && node.exe server/dist/index.js
|
||||||
curl.exe -s --noproxy '*' http://127.0.0.1:4100/healthz
|
curl.exe -s --noproxy '*' http://127.0.0.1:4100/healthz
|
||||||
curl.exe -s --noproxy '*' -X POST http://127.0.0.1:4100/api/auth/login \
|
curl.exe -s --noproxy '*' -X POST http://127.0.0.1:4100/api/auth/login \
|
||||||
-H "content-type: application/json" -d '{"username":"admin","password":"admin123"}'
|
-H "content-type: application/json" -d '{"username":"admin","password":"admin123"}'
|
||||||
@@ -1437,3 +1663,46 @@ DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \
|
|||||||
这里的目标是脚本刚建出来的空临时库,不是真实数据。)
|
这里的目标是脚本刚建出来的空临时库,不是真实数据。)
|
||||||
|
|
||||||
自测本地接口记得绕过代理(见 §7 第 1 条)。
|
自测本地接口记得绕过代理(见 §7 第 1 条)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 工作区维护(2026-10-11 整理留档)
|
||||||
|
|
||||||
|
工作区会随着每次发版慢慢长胖,而且长得**很像正经产物**。这一节记录清理的结论与边界,
|
||||||
|
下次觉得"盘要满了"直接照这里做,不要临场发挥。
|
||||||
|
|
||||||
|
### 这次删了什么、留了什么
|
||||||
|
|
||||||
|
| | 内容 | 量 |
|
||||||
|
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
|
| 删 | `releases/**/免安装/`(客户端 4 版 + 服务端 4 版 + 插件 4 版) | 2.57 GB |
|
||||||
|
| 删 | `releases/server/{0.1.0,1.0.0,1.0.0-重构建20261005}`(旧产品名"班级小助手"时期的孤立目录) | 204 MB |
|
||||||
|
| 删 | `releases/server/{1.1.0,1.1.2}/linux-x64/classhelper-server-linux-x64/`(tar.gz 的解包留档) | 530 MB |
|
||||||
|
| 删 | `releases/server/1.1.2/安装包/班级小助手服务端-1.1.2-x64-setup.exe`(改名前的重复包) | 62 MB |
|
||||||
|
| 删 | `.cache/` 里的草稿:smoke 日志与 JSON、`ui-smoke-profile`、`pages`、`tmp`、截图目录、`ci-*.html/css`、`beui-*`、`release-notes-*`、一次性脚本、`dev.db.bak-*` | 276 MB |
|
||||||
|
| 删 | 根目录 `ClassHelper video.cachedbg3.log`(0 字节)、`.acl-recovery/`(沙箱 ACL 修复的一次性备份) | ~0 |
|
||||||
|
| **留** | `.cache/{nuget,npm-cache,dotnet-home,mingit}`(工具缓存,按用户要求一个都不删) | 2.4 GB |
|
||||||
|
|
||||||
|
合计释放 **约 3.7 GB**,`releases/` 从 4.8 GB 降到 1.4 GB,其余是安装包本体。
|
||||||
|
|
||||||
|
### 边界(别删过界)
|
||||||
|
|
||||||
|
- **`releases/` 里的安装包本体与 `SHA256SUMS-*.txt` 一律留着**:每个都在 GitHub Release 上有对应资产
|
||||||
|
(v1.0.0 无 Linux 包,v1.1.0/v1.2.0 有),删了要重新下载才能对齐哈希。
|
||||||
|
- **`.cache/{nuget,npm-cache,dotnet-home,mingit}` 不删** —— 它们是工具缓存,删了要重新下载;
|
||||||
|
`dotnet-home` 里还有 docfx(`pnpm docs:build` 依赖)。
|
||||||
|
- **`packages/*/dist` 与 `packages/server/src/generated` 不删**:删了要重跑构建 + `db:generate`,
|
||||||
|
而且 `verify:*` 全部要重新准备前置产物。
|
||||||
|
- **`.cache/dev.db.bak-*` 这类"带日期的备份"删之前想一下**:本次删掉的是 2026-10-06 的
|
||||||
|
`dev.db` 快照,演示数据可用 `pnpm db:reset` 重建,所以算草稿。别把 `packages/server/prisma/` 下的库当草稿。
|
||||||
|
- **`.zcode/plans/` 与 `.claude/`** 是历史工具目录,内容已过时但不占空间,留着即可。
|
||||||
|
|
||||||
|
### 怎么不重新长回来
|
||||||
|
|
||||||
|
产出侧已经改成"只留交付物"(§4「交付产物归集」的硬约定)。另外接手时要清楚:
|
||||||
|
|
||||||
|
- **别把 `--reuse-deps` 的复用目录当成产物找回**:服务端的组装中间物现在在
|
||||||
|
`.cache/server-staging/<版本>/`,打完 `dist-server.mjs` 就会删掉它;想留一份能直接跑的副本,
|
||||||
|
就在打包过程中拷出来(或直接跑 `pnpm --filter @classhelper/server start`)。
|
||||||
|
- **别在 `releases/` 下留"临时跑一下"的东西**:需要看解包内容就解压 `-portable.exe` 到 `%TEMP%`。
|
||||||
|
- 判断某个目录能不能删,就问一句:**"它能不能用仓库里的脚本一条命令重建?"** 能就删。
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
[](https://github.com/LoveChengke/ClassHelper)
|
[](https://github.com/LoveChengke/ClassHelper)
|
||||||
|
|
||||||
ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
||||||
**老师发一条通知,教室那台机器上的灵动岛和 [ClassIsland](https://classisland.tech) 会同时弹出来**;<br/>
|
**老师发一条通知,教室客户端与灵动岛会实时提醒**;<br/>
|
||||||
学生打开客户端就能看到当天的课表和作业。
|
学生打开客户端就能看到当天的课表和作业。
|
||||||
|
|
||||||
#### [🚀 软件下载](https://github.com/LoveChengke/ClassHelper/releases/latest) | [📚 使用文档](docs/index.md) | [🧭 完整参考](docs/reference.md) | [💬 反馈问题](https://github.com/LoveChengke/ClassHelper/issues)
|
#### [🚀 软件下载](https://github.com/LoveChengke/ClassHelper/releases/latest) | [📚 使用文档](docs/index.md) | [🧭 完整参考](docs/reference.md) | [💬 反馈问题](https://github.com/LoveChengke/ClassHelper/issues)
|
||||||
@@ -26,17 +26,15 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
|
|
||||||
## 它由什么组成
|
## 它由什么组成
|
||||||
|
|
||||||
一份代码,四个交付物:
|
一份代码,三个交付物:
|
||||||
|
|
||||||
| 装在哪 | 是什么 | 谁在用 |
|
| 装在哪 | 是什么 | 谁在用 |
|
||||||
| -------------------------- | ---------------------------------------------------------------------------------- | ---------------------- |
|
| ----------------------- | -------------------------------------------------------------------- | -------------------- |
|
||||||
| 教师的电脑 / 学校服务器 | **服务端**(Express + Prisma + Socket.IO),同时托管 Web 管理端 | 其它三端 |
|
| 教师的电脑 / 学校服务器 | **服务端**(Express + Prisma + Socket.IO),同时托管 Web 管理端 | 管理端与客户端 |
|
||||||
| 浏览器(手机也行) | **Web 管理端**(Vue 3 + Element Plus,PWA 可安装) | 老师、班主任、管理员 |
|
| 浏览器(手机也行) | **Web 管理端**(Vue 3 + Element Plus,PWA 可安装) | 老师、班主任、管理员 |
|
||||||
| 教室那台机器 / 学生电脑 | **桌面客户端**(Electron + Vue 3),含「灵动岛」浮窗、托盘、离线缓存 | 教室大屏、学生 |
|
| 教室那台机器 / 学生电脑 | **桌面客户端**(Electron + Vue 3),含「灵动岛」浮窗、托盘、离线缓存 | 教室大屏、学生 |
|
||||||
| 教室机器上的 ClassIsland | **联动插件**(.NET 8 / C#),上报课表与上课状态、把老师的提醒弹到大屏上 | 教室机器 |
|
|
||||||
|
|
||||||
老师在 Web 端发布内容 → 服务端落库并按班级房间广播 → 客户端与灵动岛实时更新;
|
老师在 Web 端发布内容 → 服务端落库并按班级房间广播 → 客户端与灵动岛实时更新。
|
||||||
落到 ClassIsland 上的那部分走设备令牌的独立通道,教室网络不好也不影响。
|
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
@@ -49,9 +47,15 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单
|
- [x] 作业发布:按「所属日期」归类,支持附件与未交名单
|
||||||
- [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
|
- [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
|
||||||
- [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级
|
- [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级
|
||||||
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计
|
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计;单科排名自动出,可选「各科相加排总分榜」
|
||||||
- [x] ClassIsland 联动:签发设备令牌、下发提醒、把课表镜像回教室大屏
|
- [x] 客户端管理:查看设备在线状态与版本,远控关机、重启、注销与锁屏;推送更新可选立即、放学后或空闲时安装
|
||||||
- [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换
|
- [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换
|
||||||
|
- [x] 权限组:37 个权限键,建组分配账号 —— 默认组可自定义,没分组的按角色默认组生效,分组后只按组的并集算
|
||||||
|
- [x] 客户端设备:每台教室机器的版本、在线状态与最后心跳;一键推送更新,可下发关机 / 重启 / 注销 / 锁屏
|
||||||
|
- [x] 大模型助手:一句话解析成待确认的作业 / 通知 / 叫人,点确认才落库
|
||||||
|
- [x] 通知与作业批量发布:勾多个班一次发完,个别班失败不影响其余班级
|
||||||
|
- [x] 系统设置:开发者模式、运行诊断、开机自启开关(仅安装形态)
|
||||||
|
- [x] 关怀模式:字号与控件整体放大
|
||||||
- [x] 手机小屏适配,可「添加到主屏幕」当应用用
|
- [x] 手机小屏适配,可「添加到主屏幕」当应用用
|
||||||
|
|
||||||
### 教室里的桌面客户端
|
### 教室里的桌面客户端
|
||||||
@@ -60,9 +64,10 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
- [x] 课表:「今天」时间轴(大时钟 + 正在上 / 下一节 + 距下课倒计时)与周视图
|
- [x] 课表:「今天」时间轴(大时钟 + 正在上 / 下一节 + 距下课倒计时)与周视图
|
||||||
- [x] 作业看板:按科目分卡、可全屏铺满、字号可调,也能切回列表
|
- [x] 作业看板:按科目分卡、可全屏铺满、字号可调,也能切回列表
|
||||||
- [x] 通知中心:未读红点、优先级标签、点击自动标为已读
|
- [x] 通知中心:未读红点、优先级标签、点击自动标为已读
|
||||||
- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布
|
- [x] 成绩总览:全班成绩表格 + 柱状图 + 等级分布;按考试看单科排名,老师开启后还有总分排名
|
||||||
- [x] 教室机器可直接录入作业、勾选未交名单
|
- [x] 教室机器可直接录入作业、勾选未交名单
|
||||||
- [x] 离线优先:断网回退 IndexedDB 缓存,恢复连接后自动同步
|
- [x] 离线优先:断网回退 IndexedDB 缓存,恢复连接后自动同步
|
||||||
|
- [x] 断网期间错过的通知,重新上线时补弹一次;渲染进程崩了会安静地自己重建窗口
|
||||||
- [x] 浅色 / 深色主题,跟随系统
|
- [x] 浅色 / 深色主题,跟随系统
|
||||||
|
|
||||||
### 灵动岛(桌面通知浮窗)
|
### 灵动岛(桌面通知浮窗)
|
||||||
@@ -74,19 +79,13 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
- [x] 纯黑 / 毛玻璃 / 主题色渐变三种底
|
- [x] 纯黑 / 毛玻璃 / 主题色渐变三种底
|
||||||
- [x] 触摸屏(希沃白板这类一体机)也能点开
|
- [x] 触摸屏(希沃白板这类一体机)也能点开
|
||||||
|
|
||||||
### ClassIsland 联动
|
|
||||||
|
|
||||||
- [x] 教室机器上报课表与上课状态,Web 端能看到「现在在上什么」
|
|
||||||
- [x] 老师发的提醒在教室大屏上全屏弹出,支持语音朗读,播完回执不重复弹
|
|
||||||
- [x] 可以把 ClassHelper排好的课表镜像回 ClassIsland,不影响老师原有的课表
|
|
||||||
- [x] 提醒弹在客户端还是 ClassIsland,由教室那台机器自己选
|
|
||||||
|
|
||||||
### 部署与运维
|
### 部署与运维
|
||||||
|
|
||||||
- [x] Windows 一键安装:内置 Node 运行时,双击安装、开机自启、自动建库建号
|
- [x] Windows 一键安装:内置 Node 运行时,双击安装、开机自启、自动建库建号
|
||||||
- [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 后空闲时自动装
|
||||||
|
|
||||||
## 软件截图
|
## 软件截图
|
||||||
|
|
||||||
@@ -100,7 +99,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
<summary>展开其余页面……</summary>
|
<summary>展开其余页面……</summary>
|
||||||
|
|
||||||
| 页面 | 截图 |
|
| 页面 | 截图 |
|
||||||
| --- | --- |
|
| ---------- | -------------------------------------------------------------- |
|
||||||
| 仪表盘 | <img src="docs/images/web-dashboard.jpg" alt="仪表盘" /> |
|
| 仪表盘 | <img src="docs/images/web-dashboard.jpg" alt="仪表盘" /> |
|
||||||
| 班级管理 | <img src="docs/images/web-classes.jpg" alt="班级管理" /> |
|
| 班级管理 | <img src="docs/images/web-classes.jpg" alt="班级管理" /> |
|
||||||
| 学生管理 | <img src="docs/images/web-students.jpg" alt="学生管理" /> |
|
| 学生管理 | <img src="docs/images/web-students.jpg" alt="学生管理" /> |
|
||||||
@@ -109,7 +108,6 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
| 作业发布 | <img src="docs/images/web-homeworks.jpg" alt="作业发布" /> |
|
| 作业发布 | <img src="docs/images/web-homeworks.jpg" alt="作业发布" /> |
|
||||||
| 通知发布 | <img src="docs/images/web-notifications.jpg" alt="通知发布" /> |
|
| 通知发布 | <img src="docs/images/web-notifications.jpg" alt="通知发布" /> |
|
||||||
| 成绩录入 | <img src="docs/images/web-grades.jpg" alt="成绩录入" /> |
|
| 成绩录入 | <img src="docs/images/web-grades.jpg" alt="成绩录入" /> |
|
||||||
| ClassIsland 联动 | <img src="docs/images/web-integrations.jpg" alt="ClassIsland 联动" /> |
|
|
||||||
| 数据库管理 | <img src="docs/images/web-database.jpg" alt="数据库管理" /> |
|
| 数据库管理 | <img src="docs/images/web-database.jpg" alt="数据库管理" /> |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -122,7 +120,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
<summary>展开其余页面……</summary>
|
<summary>展开其余页面……</summary>
|
||||||
|
|
||||||
| 页面 | 截图 |
|
| 页面 | 截图 |
|
||||||
| --- | --- |
|
| ----------------------- | -------------------------------------------------------------------------- |
|
||||||
| 作业看板 | <img src="docs/images/client-homeworks.png" alt="作业看板" /> |
|
| 作业看板 | <img src="docs/images/client-homeworks.png" alt="作业看板" /> |
|
||||||
| 通知 | <img src="docs/images/client-notifications.png" alt="通知" /> |
|
| 通知 | <img src="docs/images/client-notifications.png" alt="通知" /> |
|
||||||
| 成绩 | <img src="docs/images/client-grades.png" alt="成绩" /> |
|
| 成绩 | <img src="docs/images/client-grades.png" alt="成绩" /> |
|
||||||
@@ -134,7 +132,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
### 灵动岛
|
### 灵动岛
|
||||||
|
|
||||||
| 形态 | 截图 | 说明 |
|
| 形态 | 截图 | 说明 |
|
||||||
| --- | --- | --- |
|
| ------------------ | ----------------------------------------------------------------------- | ----------------------------------------------- |
|
||||||
| 新消息胶囊 | <img src="docs/images/island-4-pill.png" alt="新消息胶囊" /> | 非上课时段直接出现,点一下展开 |
|
| 新消息胶囊 | <img src="docs/images/island-4-pill.png" alt="新消息胶囊" /> | 非上课时段直接出现,点一下展开 |
|
||||||
| 点开后的详情 | <img src="docs/images/island-5-clicked.png" alt="详情卡" /> | 标题、内容、时间 + 打开应用 / 标为已读 / 知道了 |
|
| 点开后的详情 | <img src="docs/images/island-5-clicked.png" alt="详情卡" /> | 标题、内容、时间 + 打开应用 / 标为已读 / 知道了 |
|
||||||
| 多条消息列表 | <img src="docs/images/island-9-list.png" alt="多条消息列表" /> | 按重要程度排序,默认显示三条 |
|
| 多条消息列表 | <img src="docs/images/island-9-list.png" alt="多条消息列表" /> | 按重要程度排序,默认显示三条 |
|
||||||
@@ -149,7 +147,7 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
<summary>展开……</summary>
|
<summary>展开……</summary>
|
||||||
|
|
||||||
| 场景 | 截图 |
|
| 场景 | 截图 |
|
||||||
| --- | --- |
|
| -------- | ------------------------------------------------------------------------ |
|
||||||
| 仪表盘 | <img src="docs/images/mobile-dashboard.jpg" alt="手机 · 仪表盘" /> |
|
| 仪表盘 | <img src="docs/images/mobile-dashboard.jpg" alt="手机 · 仪表盘" /> |
|
||||||
| 通知发布 | <img src="docs/images/mobile-notifications.jpg" alt="手机 · 通知发布" /> |
|
| 通知发布 | <img src="docs/images/mobile-notifications.jpg" alt="手机 · 通知发布" /> |
|
||||||
|
|
||||||
@@ -158,10 +156,9 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
|
|||||||
## 开始使用
|
## 开始使用
|
||||||
|
|
||||||
| 装在哪 | 装什么 | 从 Releases 里拿 |
|
| 装在哪 | 装什么 | 从 Releases 里拿 |
|
||||||
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- |
|
| ----------------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
|
||||||
| 教师的电脑 / 学校服务器 | **服务端**(内含 Web 管理端与 Node 运行时) | `ClassHelper-<版本>-x64-server-setup.exe` |
|
| 教师的电脑 / 学校服务器 | **服务端**(内含 Web 管理端与 Node 运行时) | `ClassHelper-<版本>-x64-server-setup.exe` |
|
||||||
| 教室机器 / 学生电脑 | **学生客户端** | `ClassHelper-<版本>-x64-client-setup.exe`,或 `-client-portable.exe` 免安装 |
|
| 教室机器 / 学生电脑 | **学生客户端** | `ClassHelper-<版本>-x64-client-setup.exe`,或 `-client-portable.exe` 免安装 |
|
||||||
| 教室机器的 ClassIsland | **联动插件** | `ClassHelper.ClassIslandPlugin.cipx` |
|
|
||||||
|
|
||||||
也可以在官网的[下载页](https://lovechengke.github.io/ClassHelper/download.html)上挑:
|
也可以在官网的[下载页](https://lovechengke.github.io/ClassHelper/download.html)上挑:
|
||||||
选一个版本,四个包排成四张卡,文件名、体积与 SHA256 清单都在那儿 ——
|
选一个版本,四个包排成四张卡,文件名、体积与 SHA256 清单都在那儿 ——
|
||||||
@@ -189,11 +186,10 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
|
|||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
| 层 | 选型 |
|
| 层 | 选型 |
|
||||||
| -------------- | ------------------------------------------------------------------------ |
|
| ---------- | ------------------------------------------------------------------- |
|
||||||
| 后端 | Node.js ≥ 20.19 + Express 5 + Prisma 7(SQLite / MySQL)+ Socket.IO |
|
| 后端 | Node.js ≥ 20.19 + Express 5 + Prisma 7(SQLite / MySQL)+ Socket.IO |
|
||||||
| Web 管理端 | Vue 3 + Vite + Element Plus + Pinia |
|
| Web 管理端 | Vue 3 + Vite + Element Plus + Pinia |
|
||||||
| 桌面客户端 | Electron 44 + Vue 3 + IndexedDB 离线缓存(含独立的灵动岛渲染进程) |
|
| 桌面客户端 | Electron 44 + Vue 3 + IndexedDB 离线缓存(含独立的灵动岛渲染进程) |
|
||||||
| ClassIsland 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) |
|
|
||||||
| 共享契约 | `packages/shared`:类型、常量、权限矩阵、动效令牌 |
|
| 共享契约 | `packages/shared`:类型、常量、权限矩阵、动效令牌 |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -209,12 +205,12 @@ pnpm dev # 后端 4000 + Web 端 5
|
|||||||
`pnpm db:seed` 会写一套演示数据(1 管理员 / 2 教师 / 3 个班 / 15 名学生 / 课表 / 作业 / 通知 / 成绩):
|
`pnpm db:seed` 会写一套演示数据(1 管理员 / 2 教师 / 3 个班 / 15 名学生 / 课表 / 作业 / 通知 / 成绩):
|
||||||
|
|
||||||
| 角色 | 账号 | 密码 |
|
| 角色 | 账号 | 密码 |
|
||||||
| -------------- | --------------- | ------------ |
|
| -------------------------- | ------------- | ------------ |
|
||||||
| 管理员 | `admin` | `admin123` |
|
| 管理员 | `admin` | `admin123` |
|
||||||
| 教师 | `teacher1` | `teacher123` |
|
| 教师 | `teacher1` | `teacher123` |
|
||||||
| ClassHelper 班级端(班级) | 班级码 `G101` | `123456` |
|
| ClassHelper 班级端(班级) | 班级码 `G101` | `123456` |
|
||||||
|
|
||||||
- [AGENTS.md](AGENTS.md) —— 工程约定与本机踩坑,**改代码前先读它**
|
- [AGENTS.md](AGENTS.md) —— 工程约定与开发环境注意事项,**改代码前先读它**
|
||||||
- [docs/reference.md](docs/reference.md) —— 完整参考(功能行为、接口、验收记录)
|
- [docs/reference.md](docs/reference.md) —— 完整参考(功能行为、接口、验收记录)
|
||||||
- [docs/dev/architecture.md](docs/dev/architecture.md) —— 架构总览
|
- [docs/dev/architecture.md](docs/dev/architecture.md) —— 架构总览
|
||||||
|
|
||||||
@@ -229,3 +225,9 @@ pnpm dev # 后端 4000 + Web 端 5
|
|||||||
如果这个项目对你有帮助,欢迎点亮 Star ⭐
|
如果这个项目对你有帮助,欢迎点亮 Star ⭐
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
### 客户端维护
|
||||||
|
|
||||||
|
支持远程关机、重启、注销与锁屏(管理端和本机分别确认),可查看最近一次执行结果。
|
||||||
|
统一更新支持立即安装、放学后安装或空闲时安装,支持取消下发。
|
||||||
|
管理员与教师的默认权限组均可自定义;管理员保留权限管理和系统管理入口。
|
||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-1.2.0}
|
image: classhelper-server:${APP_VERSION:-1.3.3}
|
||||||
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.2.0}
|
image: classhelper-server:${APP_VERSION:-1.3.3}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
# --node-tarball <url|路径> 直接用指定的 Node 压缩包(内网离线场景)
|
# --node-tarball <url|路径> 直接用指定的 Node 压缩包(内网离线场景)
|
||||||
# --with-nginx 顺带放一份 Nginx 反代配置(不自动申请证书)
|
# --with-nginx 顺带放一份 Nginx 反代配置(不自动申请证书)
|
||||||
# --open-firewall 按检测到的防火墙放行端口
|
# --open-firewall 按检测到的防火墙放行端口
|
||||||
|
# --no-autostart 不设开机自启(默认设为自启;装完仍可 systemctl enable 改回来)
|
||||||
# --yes 全部用默认值,不再交互(CI / 验收用)
|
# --yes 全部用默认值,不再交互(CI / 验收用)
|
||||||
#
|
#
|
||||||
# 幂等:重复执行 = 覆盖安装。已有 config.env(含 JWT_SECRET)与 data/ 一律保留,不重置密钥。
|
# 幂等:重复执行 = 覆盖安装。已有 config.env(含 JWT_SECRET)与 data/ 一律保留,不重置密钥。
|
||||||
@@ -91,6 +92,10 @@ NODE_DIR_NAME="runtime/node"
|
|||||||
|
|
||||||
WITH_NGINX=0
|
WITH_NGINX=0
|
||||||
OPEN_FIREWALL=0
|
OPEN_FIREWALL=0
|
||||||
|
# 是否设为开机自启(systemd enable)。需求:「支持选择开机自启动」——
|
||||||
|
# 安装器只负责**默认值**,装完之后随时可以用 `systemctl disable/enable classhelper` 改
|
||||||
|
# (Windows 侧对应 Web 管理端「系统设置」里的开关,见 PUT /api/system/autostart)。
|
||||||
|
AUTOSTART=1
|
||||||
ASSUME_YES=0
|
ASSUME_YES=0
|
||||||
CHECK_ONLY=0
|
CHECK_ONLY=0
|
||||||
UNINSTALL=0
|
UNINSTALL=0
|
||||||
@@ -231,6 +236,8 @@ parse_args() {
|
|||||||
--node-tarball) NODE_TARBALL="${2:-}"; NODE_SOURCE="tarball"; shift 2 ;;
|
--node-tarball) NODE_TARBALL="${2:-}"; NODE_SOURCE="tarball"; shift 2 ;;
|
||||||
--with-nginx) WITH_NGINX=1; shift ;;
|
--with-nginx) WITH_NGINX=1; shift ;;
|
||||||
--open-firewall) OPEN_FIREWALL=1; shift ;;
|
--open-firewall) OPEN_FIREWALL=1; shift ;;
|
||||||
|
--autostart) AUTOSTART=1; shift ;;
|
||||||
|
--no-autostart) AUTOSTART=0; shift ;;
|
||||||
--install-docker) INSTALL_DOCKER=1; shift ;;
|
--install-docker) INSTALL_DOCKER=1; shift ;;
|
||||||
--yes|-y) ASSUME_YES=1; shift ;;
|
--yes|-y) ASSUME_YES=1; shift ;;
|
||||||
--force) FORCE=1; shift ;;
|
--force) FORCE=1; shift ;;
|
||||||
@@ -477,6 +484,7 @@ collect_answers() {
|
|||||||
fi
|
fi
|
||||||
if ask_yes_no "顺带放一份 Nginx 反向代理配置到 ${CONFIG_DIR}/nginx.conf?" "n"; then WITH_NGINX=1; fi
|
if ask_yes_no "顺带放一份 Nginx 反向代理配置到 ${CONFIG_DIR}/nginx.conf?" "n"; then WITH_NGINX=1; fi
|
||||||
if ask_yes_no "按检测到的防火墙放行端口 ${PORT}?" "n"; then OPEN_FIREWALL=1; fi
|
if ask_yes_no "按检测到的防火墙放行端口 ${PORT}?" "n"; then OPEN_FIREWALL=1; fi
|
||||||
|
if ask_yes_no "设为开机自启(systemctl enable ${SERVICE_NAME})?" "y"; then AUTOSTART=1; else AUTOSTART=0; fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 用户与目录
|
# ---------------------------------------------------------------- 用户与目录
|
||||||
@@ -532,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,实测踩到)。
|
||||||
@@ -901,8 +915,12 @@ EOF
|
|||||||
fi
|
fi
|
||||||
chmod 644 "/etc/systemd/system/${SERVICE_NAME}.service"
|
chmod 644 "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
|
if [ "${AUTOSTART}" = "1" ]; then
|
||||||
systemctl enable "$SERVICE_NAME" >/dev/null
|
systemctl enable "$SERVICE_NAME" >/dev/null
|
||||||
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service 并设为开机自启"
|
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service 并设为开机自启"
|
||||||
|
else
|
||||||
|
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service(**未**设为开机自启;需要时执行 systemctl enable ${SERVICE_NAME})"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_aux_files() {
|
install_aux_files() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "classhelper-server-runtime",
|
"name": "classhelper-server-runtime",
|
||||||
"version": "1.2.0",
|
"version": "1.3.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
||||||
|
|||||||
+1
-2
@@ -53,5 +53,4 @@ description: 老师点名让学生过来:普通与紧急两档、快捷短语
|
|||||||
- `notification:new` 到班级房间(通知中心与灵动岛都靠它);
|
- `notification:new` 到班级房间(通知中心与灵动岛都靠它);
|
||||||
- `call:new` 定向到被叫学生 / 班级设备。
|
- `call:new` 定向到被叫学生 / 班级设备。
|
||||||
|
|
||||||
如果教室的「通知显示位置」不是"只在 ClassHelper",这条叫人也会推给 ClassIsland,
|
叫人通过实时通道送达本班客户端与灵动岛。
|
||||||
教室大屏一并播报。
|
|
||||||
+1
-1
@@ -27,7 +27,7 @@ description: ClassHelper每一项功能怎么用:课表、作业、通知、
|
|||||||
[角色与权限](../dev/permissions.md)。
|
[角色与权限](../dev/permissions.md)。
|
||||||
|
|
||||||
**二、上课时段不打扰课堂。** 「上课时段」由课表实时判定,普通通知与普通叫人只进队列、下课补弹;
|
**二、上课时段不打扰课堂。** 「上课时段」由课表实时判定,普通通知与普通叫人只进队列、下课补弹;
|
||||||
紧急通知与紧急叫人才会立刻打断。规则只有一份,客户端与 ClassIsland 都遵守它。
|
紧急通知与紧急叫人才会立刻打断。客户端与灵动岛共用这套规则。
|
||||||
细节见[通知与提醒](notifications.md)。
|
细节见[通知与提醒](notifications.md)。
|
||||||
|
|
||||||
**三、实时推送只走班级房间。** 老师一发内容,服务端写库后按班级房间广播,客户端立刻更新,
|
**三、实时推送只走班级房间。** 老师一发内容,服务端写库后按班级房间广播,客户端立刻更新,
|
||||||
|
|||||||
@@ -23,13 +23,11 @@ description: 通知的三档优先级、上课时段策略、紧急通知的二
|
|||||||
「上课时段」由课表实时判定(见[课表](schedules.md))。判定之后:
|
「上课时段」由课表实时判定(见[课表](schedules.md))。判定之后:
|
||||||
|
|
||||||
| ClassHelper 班级端行为 | 上课时段 | 课间 / 放学 |
|
| ClassHelper 班级端行为 | 上课时段 | 课间 / 放学 |
|
||||||
| ---------- | ---------------------------------------- | ----------- |
|
| ---------------------- | ---------------------------------------- | ----------- |
|
||||||
| 普通通知 | 收着不弹(窗口隐藏),进待发队列 | 直接弹 |
|
| 普通通知 | 收着不弹(窗口隐藏),进待发队列 | 直接弹 |
|
||||||
| 紧急通知 | **立刻弹** | 立刻弹 |
|
| 紧急通知 | **立刻弹** | 立刻弹 |
|
||||||
| 任何点击 | 一律不响应(不会展开、也不会回缩出胶囊) | 正常 |
|
| 任何点击 | 一律不响应(不会展开、也不会回缩出胶囊) | 正常 |
|
||||||
|
|
||||||
ClassIsland 那一端遵守同一套规则,否则会出现"学生在客户端看不到、却在教室大屏上被打断"。
|
|
||||||
|
|
||||||
## 紧急通知的二次确认
|
## 紧急通知的二次确认
|
||||||
|
|
||||||
上课时段发紧急通知,**服务端会拦一次**(`409 URGENT_DURING_CLASS`):
|
上课时段发紧急通知,**服务端会拦一次**(`409 URGENT_DURING_CLASS`):
|
||||||
@@ -48,20 +46,9 @@ Web 端在提交前会先查一次上课状态,命中就弹一个**全屏二
|
|||||||
|
|
||||||
**这个拦截在服务端,不是前端提醒。** 绕过界面直接调接口一样会被拦 —— 这是产品行为,不是可以关掉的提示。
|
**这个拦截在服务端,不是前端提醒。** 绕过界面直接调接口一样会被拦 —— 这是产品行为,不是可以关掉的提示。
|
||||||
|
|
||||||
## 提醒弹在哪个端
|
## 提醒显示
|
||||||
|
|
||||||
教室机器上的客户端「设置 → 提醒」里选,选择会同步到班级记录:
|
通知通过实时通道发送到 ClassHelper 客户端与灵动岛。设置页显示本机提醒规则,旧的跨应用通知渠道已移除。
|
||||||
|
|
||||||
| 选择 | ClassHelper 客户端 | ClassIsland |
|
|
||||||
| ----------------------------------------- | ------------------------------ | ----------- |
|
|
||||||
| `ClassHelper 与 ClassIsland 都弹`(默认) | 弹窗 + 灵动岛 | 全屏提醒 |
|
|
||||||
| `只在 ClassHelper 客户端弹` | 弹窗 + 灵动岛 | 不推送 |
|
|
||||||
| `只在 ClassIsland 上弹` | 只进通知中心(不弹窗、不上岛) | 全屏提醒 |
|
|
||||||
|
|
||||||
这条规则**任何入口都遵守**,包括 ClassIsland 联动页的「下发提醒」——
|
|
||||||
那种情况下会回一句"该班教室已设置为只在 ClassHelper 客户端显示"。
|
|
||||||
|
|
||||||
管理员与教师也可以用 `PATCH /api/classes/:id/notification-channel` 改,但默认由教室机器自己定。
|
|
||||||
|
|
||||||
## 已读、红点与删除
|
## 已读、红点与删除
|
||||||
|
|
||||||
@@ -74,9 +61,7 @@ Web 端在提交前会先查一次上课状态,命中就弹一个**全屏二
|
|||||||
|
|
||||||
## 发布入口只有一个实现
|
## 发布入口只有一个实现
|
||||||
|
|
||||||
「通知发布」页、叫人、ClassIsland 联动页的「下发提醒」三个入口,
|
「通知发布」与叫人复用同一套服务端发布流程和实时广播。
|
||||||
**共用同一份 ClassIsland 推送实现**。所以不存在"某个页面发的通知不联动"这种事 ——
|
|
||||||
以前有过,现在是硬约定:新增发通知的入口必须复用同一份代码。
|
|
||||||
|
|
||||||
## 接口
|
## 接口
|
||||||
|
|
||||||
|
|||||||
+5
-17
@@ -51,27 +51,15 @@ GET /api/schedules/current?classId=<班级 id>[&at=<ISO 时间>]
|
|||||||
|
|
||||||
返回 `{ inClass, current, next, week, serverTime }`。`at` 参数可以覆盖判定时刻,用来联调与测试。
|
返回 `{ inClass, current, next, week, serverTime }`。`at` 参数可以覆盖判定时刻,用来联调与测试。
|
||||||
|
|
||||||
判定结果被用在四处,规则完全一致:
|
判定结果被用在三处,规则完全一致:
|
||||||
|
|
||||||
1. 客户端灵动岛:上课时段普通通知**不显示**(窗口直接隐藏)并进队列,下课自动补弹;
|
1. 客户端灵动岛:上课时段普通通知**不显示**(窗口直接隐藏)并进队列,下课自动补弹;
|
||||||
2. ClassIsland 插件:同样暂存,下课后补弹;
|
2. 服务端发布接口:上课时段发紧急通知会被拦(`409 URGENT_DURING_CLASS`),确认后放行;
|
||||||
3. 服务端发布接口:上课时段发紧急通知会被拦(`409 URGENT_DURING_CLASS`),确认后放行;
|
3. Web 端:提交紧急通知前先查一次状态,命中就弹全屏二次确认。
|
||||||
4. Web 端:提交紧急通知前先查一次状态,命中就弹全屏二次确认。
|
|
||||||
|
|
||||||
## 课表的"来源"字段
|
## 课表来源
|
||||||
|
|
||||||
每一行课表都记着它是从哪来的:
|
课表由管理端维护,支持手工录入与离线 JSON 导入。升级会保留旧联动课表并将其来源归为 `manual`。
|
||||||
|
|
||||||
| 来源 | 什么时候产生 | 会被自动清理吗 |
|
|
||||||
| ------------- | ---------------------------------------------- | ---------------------------- |
|
|
||||||
| `manual` | Web 端手工录入、从 ClassIsland JSON 一次性导入 | **不会**,永远保留 |
|
|
||||||
| `classisland` | 教室插件上报同步过来的 | 会——本次没再上报的行会被删掉 |
|
|
||||||
|
|
||||||
这条规则解决了"老师在 ClassIsland 里删掉一节,小助手这边却永远留着"的幽灵课问题,
|
|
||||||
同时保证老师在小助手这边手排的课不会被插件覆盖掉。
|
|
||||||
|
|
||||||
> 去重键是「星期 + 开始时间 + 单双周」。如果老师手排的某一节恰好和上报的同一时间槽重合,
|
|
||||||
> 那一行会被上报"收养"(保留原本的 `manual` 来源),下一次清理不会删它。
|
|
||||||
|
|
||||||
## 排课 / 改课 / 导入
|
## 排课 / 改课 / 导入
|
||||||
|
|
||||||
|
|||||||
+37
-15
@@ -10,27 +10,29 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
|||||||
- 所有接口前缀 `/api`(设备接口也一样);
|
- 所有接口前缀 `/api`(设备接口也一样);
|
||||||
- 统一响应体:`{ "success": true, "data": {}, "message": "" }`,错误为 `success: false` + `code`;
|
- 统一响应体:`{ "success": true, "data": {}, "message": "" }`,错误为 `success: false` + `code`;
|
||||||
- 除登录、健康检查与设备接口外,都需要 `Authorization: Bearer <token>`;
|
- 除登录、健康检查与设备接口外,都需要 `Authorization: Bearer <token>`;
|
||||||
- **ClassIsland 设备接口用 `X-ClassIsland-Token`**,不走 JWT(见[ClassIsland 插件](classisland-plugin.md));
|
|
||||||
- 请求体校验走 zod(`middleware/validate.ts`),错误由 `middleware/error.ts` 统一映射。
|
- 请求体校验走 zod(`middleware/validate.ts`),错误由 `middleware/error.ts` 统一映射。
|
||||||
|
|
||||||
## 接口一览
|
## 接口一览
|
||||||
|
|
||||||
| 方法 | 路径 | 权限 | 说明 |
|
| 方法 | 路径 | 权限 | 说明 |
|
||||||
| --------------------------- | ------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------ |
|
| --------------------------- | ------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员的**工号** + 密码);学生不是账号,拿学号登录只会得到 401 |
|
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员的**工号** + 密码);学生不是账号,拿学号登录只会得到 401 |
|
||||||
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → 班级会话 |
|
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → 班级会话 |
|
||||||
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
|
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
|
||||||
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
|
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
|
||||||
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
|
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
|
||||||
|
| GET | `/permissions/me` | 登录 | 当前账号的生效权限(**无权限键**;班级端会话返回空集) |
|
||||||
|
| GET | `/permissions/catalog` | `permission:manage` | 权限键目录(权限页整棵勾选树的唯一来源) |
|
||||||
|
| GET / POST | `/permissions/groups` | `permission:manage` | 权限组列表 / 新建 |
|
||||||
|
| PATCH / DELETE | `/permissions/groups/:id` | `permission:manage` | 改名 / 改描述 / 整体替换权限键;内置组不可删 |
|
||||||
|
| GET | `/permissions/users` | `permission:manage` | 成员列表(所属权限组 + 生效权限数量) |
|
||||||
|
| PUT | `/permissions/users/:id/groups` | `permission:manage` | 整体替换某账号的权限组(空数组 = 回落角色默认模板) |
|
||||||
| GET | `/classes` | 登录 | 班级列表(按权限收敛) |
|
| GET | `/classes` | 登录 | 班级列表(按权限收敛) |
|
||||||
| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) |
|
| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) |
|
||||||
| POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) |
|
| POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) |
|
||||||
| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 |
|
| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 |
|
||||||
| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) |
|
| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) |
|
||||||
| PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 |
|
| PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 |
|
||||||
| GET | `/classes/:id/classisland-status` | 班级可见 | 本班 ClassIsland 联动状态(教室客户端设置页用) |
|
|
||||||
| GET | `/classes/:id/notification-channel` | 班级可见 | 读取「通知显示到哪个端」 |
|
|
||||||
| PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置「通知显示到哪个端」 |
|
|
||||||
| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生 |
|
| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生 |
|
||||||
| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 |
|
| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 |
|
||||||
| PUT | `/classes/:id/subject-teachers` | ADMIN | 设置各科任课老师(班级 + 科目 + 教师) |
|
| PUT | `/classes/:id/subject-teachers` | ADMIN | 设置各科任课老师(班级 + 科目 + 教师) |
|
||||||
@@ -46,11 +48,15 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
|||||||
| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) |
|
| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) |
|
||||||
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) |
|
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) |
|
||||||
| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 |
|
| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 |
|
||||||
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) |
|
| POST | `/homeworks/bulk` | `homework:write` | 一次给多个班级布置同一份作业(`subjectName` 跨班按名匹配科目;逐班结果) |
|
||||||
|
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态;另支持 `date` / `from` / `to` / `createdBy`) |
|
||||||
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
|
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
|
||||||
| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发紧急通知需 `confirmDuringClass` |
|
| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发紧急通知需 `confirmDuringClass` |
|
||||||
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) |
|
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) |
|
||||||
| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 |
|
| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 |
|
||||||
|
| GET | `/notifications/days?classId=&from=&to=&createdBy=` | 登录 | 通知按天归档(日期 + 条数,日期取 `assignDate`) |
|
||||||
|
| GET | `/notifications/creators?classId=&from=&to=&date=` | 登录 | 通知按发布人归档 |
|
||||||
|
| POST | `/notifications/bulk` | `notification:publish` | 一次给多个班级发同一条通知(逐班结果,**单班失败不影响整批**) |
|
||||||
| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) |
|
| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) |
|
||||||
| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) |
|
| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) |
|
||||||
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 |
|
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 |
|
||||||
@@ -69,10 +75,7 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
|||||||
| POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) |
|
| POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) |
|
||||||
| GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 |
|
| GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 |
|
||||||
| POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) |
|
| POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) |
|
||||||
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
|
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 23 张表行数 / 备份列表 / 定时配置) |
|
||||||
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
|
|
||||||
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
|
|
||||||
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 16 张表行数 / 备份列表 / 定时配置) |
|
|
||||||
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
|
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
|
||||||
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
|
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
|
||||||
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
|
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
|
||||||
@@ -80,11 +83,24 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
|||||||
| POST | `/database/import` | **管理员** | 导入快照(base64,整库覆盖;可在 SQLite / MySQL 之间互迁) |
|
| POST | `/database/import` | **管理员** | 导入快照(base64,整库覆盖;可在 SQLite / MySQL 之间互迁) |
|
||||||
| GET / PUT | `/database/backup-schedule` | **管理员** | 定时备份配置(enabled / intervalHours / keepCount) |
|
| GET / PUT | `/database/backup-schedule` | **管理员** | 定时备份配置(enabled / intervalHours / keepCount) |
|
||||||
| POST / GET | `/database/switch`、`/database/switch/jobs/:id` | **管理员** | **一键切换数据库**(异步任务)/ 轮询进度 |
|
| POST / GET | `/database/switch`、`/database/switch/jobs/:id` | **管理员** | **一键切换数据库**(异步任务)/ 轮询进度 |
|
||||||
| POST | `/integrations/classisland/report` | **设备令牌** | 插件上报状态 + 课表 + 节次时间 |
|
|
||||||
| GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) |
|
|
||||||
| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) |
|
|
||||||
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland |
|
|
||||||
| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) |
|
| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) |
|
||||||
|
| POST | `/clients/heartbeat` | 登录 | 客户端每 30 秒上报(**无权限键**);返回更新目标 + 安装包信息 + 一条待执行命令 |
|
||||||
|
| POST | `/clients/commands/:id/ack` | 登录 | 命令执行回执(**无权限键**,靠 body 的 `deviceKey` 校验归属,幂等) |
|
||||||
|
| POST | `/clients/commands` | `remote:control` | 下发远控命令(关机 / 重启 / 注销 / 锁屏;自动撤销该机更早的未回执命令) |
|
||||||
|
| GET | `/clients/devices?classId=` | `client:manage` | 客户端设备列表(版本 / 在线 / 待执行命令) |
|
||||||
|
| POST | `/clients/updates` | `client:update-push` | 统一下发客户端更新(按设备 / 班级 / 全部) |
|
||||||
|
| DELETE | `/clients/devices/:id` | `client:manage` | 移除设备记录(换机 / 旧机器清理) |
|
||||||
|
| GET | `/update/client/releases` | `client:manage` | 服务端已缓存了哪些客户端安装包 |
|
||||||
|
| POST | `/update/client/fetch` | `client:update-push` | 服务端代教室机器从 GitHub 下载安装包并登记 sha256 + size |
|
||||||
|
| POST | `/update/client/releases` | `client:update-push` | 手工登记已放进磁盘的安装包(内网 / U 盘分发兜底) |
|
||||||
|
| DELETE | `/update/client/releases/:version` | `client:update-push` | 删除缓存的安装包(元数据 + 磁盘文件) |
|
||||||
|
| GET | `/update/client/download/:version` | 登录 | 下载安装包(**故意不挂权限键**,班级端要能下;`res.download`) |
|
||||||
|
| GET / PUT | `/ai/settings` | `ai:manage` | 大模型设置(API Key 加密存储、**只进不出**,只回 `hasApiKey` / `keyHint`) |
|
||||||
|
| POST | `/ai/interpret` | `ai:use` | 一句话解析(**只解析不落库**);回 `action` / `params` / `preview` / `confidence` |
|
||||||
|
| GET | `/ai/audit?limit=` | `ai:manage` | 助手使用记录(每次解析写一条 `AiAudit`) |
|
||||||
|
| GET / PUT | `/system/settings` | `system:manage` | 系统设置(`developerMode`:开启后 500 响应带 `error.stack`) |
|
||||||
|
| GET / PUT | `/system/autostart` | `system:manage` | 开机自启状态 / 开关(Windows 注册表 Run 项,Linux systemd 单元) |
|
||||||
|
| GET | `/system/diagnostics` | `system:manage` | 运行诊断(版本 / Node / 运行时长 / 数据目录 / 在线客户端数 / 已加载模块) |
|
||||||
| GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块) |
|
| GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块) |
|
||||||
|
|
||||||
另有三个探针(不带 `/api` 前缀):`/healthz`(存活)、`/readyz`(就绪)、以及 `/api/health`(详情)。
|
另有三个探针(不带 `/api` 前缀):`/healthz`(存活)、`/readyz`(就绪)、以及 `/api/health`(详情)。
|
||||||
@@ -92,16 +108,22 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
|||||||
## 几条容易踩的接口约定
|
## 几条容易踩的接口约定
|
||||||
|
|
||||||
| 约定 | 说明 |
|
| 约定 | 说明 |
|
||||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
| -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **`POST /homeworks` 故意不加 `requireRole`** | 权限判在 `homeworks.service.createHomework` 里(staff 或本班班级账号),因为教室机器也要能录作业 |
|
| **`POST /homeworks` 故意不加 `requireRole`** | 权限判在 `homeworks.service.createHomework` 里(staff 或本班班级账号),因为教室机器也要能录作业 |
|
||||||
| **班级账号的 `sub` 是班级 id** | 写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键 |
|
| **班级账号的 `sub` 是班级 id** | 写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键 |
|
||||||
| **登录限流挂在两个入口上** | `/auth/login` 与 `/auth/class-login` 都挂 `loginRateLimiter()` |
|
| **登录限流挂在两个入口上** | `/auth/login` 与 `/auth/class-login` 都挂 `loginRateLimiter()` |
|
||||||
| **角色判定在密码校验之前** | 否则"密码错 / 密码对但账号停用"的差异会把学生账号密码变成可离线验证的预言机 |
|
| **角色判定在密码校验之前** | 否则"密码错 / 密码对但账号停用"的差异会把学生账号密码变成可离线验证的预言机 |
|
||||||
| **会渲染成链接的字段要校验 scheme** | `Homework.attachmentUrl` 只允许 `http(s)://` 或站内相对路径 |
|
| **会渲染成链接的字段要校验 scheme** | `Homework.attachmentUrl` 只允许 `http(s)://` 或站内相对路径 |
|
||||||
| **表格导入 8MB ↔ 请求体 12MB** | 文件以 base64 放进 JSON(约 4/3 倍),超限映射成 413 `IMPORT_TOO_LARGE` |
|
| **表格导入 8MB ↔ 请求体 12MB** | 文件以 base64 放进 JSON(约 4/3 倍),超限映射成 413 `IMPORT_TOO_LARGE` |
|
||||||
|
| **三个接口故意不挂权限键** | `/permissions/me`、`/clients/heartbeat`、`/update/client/download/:version` —— 前两者由班级端(`CLASS_DEVICE`,权限集为空)调用,下载接口要能装包;保护只有"已登录 + 限流" |
|
||||||
|
| **查询参数校验失败是 422** | `middleware/validate.ts` 的 zod 解析错误由错误中间件映射成 422 `VALIDATION_ERROR`(不是 400) |
|
||||||
|
| **批量发布逐班判定、不整批回滚** | `/notifications/bulk` 与 `/homeworks/bulk` 的单班失败(403 越权 / `URGENT_DURING_CLASS`)不影响其它班,成功班照常落库并实时推送 |
|
||||||
|
| **`/ai/interpret` 只解析不落库** | 助手只回结构化参数与中文预览;落库由前端确认后调既有端点 —— 权限与上课时段保护都在那条路径上 |
|
||||||
|
|
||||||
## 相关
|
## 相关
|
||||||
|
|
||||||
- 实时事件见[实时事件(Socket.IO)](realtime.md);
|
- 实时事件见[实时事件(Socket.IO)](realtime.md);
|
||||||
- 权限矩阵见[角色与权限](permissions.md);
|
- 权限矩阵见[角色与权限](permissions.md);
|
||||||
- 数据模型见[数据模型](data-model.md)。
|
- 数据模型见[数据模型](data-model.md)。
|
||||||
|
|
||||||
|
客户端心跳可携带 `agentProtocol: 2`,业务数据读取统一响应的 `data`。未带此标记时额外返回旧版需要的顶层字段;放学后更新在到时前不向旧客户端提供安装包。推送更新支持 `mode: immediate | after-school | idle`,`afterSchoolTime` 为服务端本地时区的 `HH:mm`。
|
||||||
+16
-12
@@ -18,7 +18,6 @@ description: 目录结构、技术栈、模块化设计与几条贯穿全局的
|
|||||||
| 校验 | zod | 4.6 |
|
| 校验 | zod | 4.6 |
|
||||||
| Web 端 | Vue + Vite + Element Plus + Pinia + Vue Router + Axios | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 |
|
| Web 端 | Vue + Vite + Element Plus + Pinia + Vue Router + Axios | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 |
|
||||||
| 客户端 | Electron + Vue + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 |
|
| 客户端 | Electron + Vue + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 |
|
||||||
| 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0 |
|
|
||||||
| 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 |
|
| 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 |
|
||||||
| 文档站 | docfx | 2.81 |
|
| 文档站 | docfx | 2.81 |
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ class-helper/
|
|||||||
│ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
|
│ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
|
||||||
│ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS)
|
│ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS)
|
||||||
│ ├── version.mjs # 版本号单一来源的读写与一致性校验(= pnpm version:*)
|
│ ├── version.mjs # 版本号单一来源的读写与一致性校验(= pnpm version:*)
|
||||||
│ ├── dist-server.mjs # 服务端 + Web 管理端打包(免安装目录 + NSIS 安装程序)
|
│ ├── dist-server.mjs # 服务端 + Web 管理端打包(组装运行时 → NSIS 安装程序)
|
||||||
│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟
|
│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟
|
||||||
│ ├── lib/ # electron-env / node-runtime / smoke-class
|
│ ├── lib/ # electron-env / node-runtime / smoke-class
|
||||||
│ ├── nsis/server-installer.nsi# 安装程序脚本模板(版本号由 dist-server 注入)
|
│ ├── nsis/server-installer.nsi# 安装程序脚本模板(版本号由 dist-server 注入)
|
||||||
@@ -56,7 +55,7 @@ class-helper/
|
|||||||
│ ├── get-started/ app/ management/ dev/ # 四个章节
|
│ ├── get-started/ app/ management/ dev/ # 四个章节
|
||||||
│ ├── templates/classhelper/ # docfx 模板覆盖(_master.tmpl + public/main.css + 图标)
|
│ ├── templates/classhelper/ # docfx 模板覆盖(_master.tmpl + public/main.css + 图标)
|
||||||
│ ├── images/ # 展示图:文档站 / README / 官网共用的同一份(1× 逻辑像素)
|
│ ├── images/ # 展示图:文档站 / README / 官网共用的同一份(1× 逻辑像素)
|
||||||
│ ├── screenshots/ # 回归测试留档的截图(island / client / web-mobile / classisland),页面不展示
|
│ ├── screenshots/ # 回归测试留档的截图(island / client / web-mobile),页面不展示
|
||||||
│ ├── build.mjs # 构建入口(注入版本号与 404 的 base)
|
│ ├── build.mjs # 构建入口(注入版本号与 404 的 base)
|
||||||
│ ├── serve.mjs # 本地预览(:5181)
|
│ ├── serve.mjs # 本地预览(:5181)
|
||||||
│ └── _site/ # 构建产物(gitignore)
|
│ └── _site/ # 构建产物(gitignore)
|
||||||
@@ -66,10 +65,10 @@ class-helper/
|
|||||||
├── shared/ # 三端共享:类型契约、常量、权限矩阵、动效令牌、工具函数
|
├── shared/ # 三端共享:类型契约、常量、权限矩阵、动效令牌、工具函数
|
||||||
│ └── src/{types,constants,permissions,motion,island-layout,utils,index}.ts
|
│ └── src/{types,constants,permissions,motion,island-layout,utils,index}.ts
|
||||||
├── server/ # 后端服务
|
├── server/ # 后端服务
|
||||||
│ ├── prisma/schema.prisma # 数据模型(14 个 model,只有一份,切库改 provider)
|
│ ├── prisma/schema.prisma # 数据模型(24 个 model,只有一份,切库改 provider)
|
||||||
│ ├── prisma/migrations/ # 迁移历史(10 个)
|
│ ├── prisma/migrations/ # 迁移历史(10 个)
|
||||||
│ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据)
|
│ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据)
|
||||||
│ ├── scripts/ # verify-e2e.mjs / verify-classisland.mjs / apply-column-migrations.cjs
|
│ ├── scripts/ # verify-e2e.mjs / apply-column-migrations.cjs
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── app.ts # Express 装配(探针 + 限流 + 模块挂载 + 静态托管 + 兜底)
|
│ ├── app.ts # Express 装配(探针 + 限流 + 模块挂载 + 静态托管 + 兜底)
|
||||||
│ ├── index.ts # 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
|
│ ├── index.ts # 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
|
||||||
@@ -80,7 +79,6 @@ class-helper/
|
|||||||
│ ├── middleware/ # auth / error / validate / security(helmet + 限流 + 耗时日志)
|
│ ├── middleware/ # auth / error / validate / security(helmet + 限流 + 耗时日志)
|
||||||
│ ├── realtime/ # socket.ts(房间)+ bus.ts(事件总线)
|
│ ├── realtime/ # socket.ts(房间)+ bus.ts(事件总线)
|
||||||
│ └── modules/ # 15 个功能模块 + registry.ts(模块注册表)
|
│ └── modules/ # 15 个功能模块 + registry.ts(模块注册表)
|
||||||
├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace)
|
|
||||||
│ ├── manifest.yml
|
│ ├── manifest.yml
|
||||||
│ ├── src/Plugin.cs
|
│ ├── src/Plugin.cs
|
||||||
│ ├── src/Services/ # BridgeService / ScheduleMapper / ClassPlanWriter /
|
│ ├── src/Services/ # BridgeService / ScheduleMapper / ClassPlanWriter /
|
||||||
@@ -105,9 +103,10 @@ class-helper/
|
|||||||
后端每个功能是一个独立目录,统一契约:
|
后端每个功能是一个独立目录,统一契约:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// packages/server/src/modules/registry.ts(当前 15 个模块)
|
// packages/server/src/modules/registry.ts(当前 20 个模块)
|
||||||
export const apiModules: ApiModule[] = [
|
export const apiModules: ApiModule[] = [
|
||||||
authModule,
|
authModule,
|
||||||
|
aiModule, // 大模型助手(只解析不落库,见 docs/dev/api.md)
|
||||||
classesModule,
|
classesModule,
|
||||||
coursesModule,
|
coursesModule,
|
||||||
schedulesModule,
|
schedulesModule,
|
||||||
@@ -115,13 +114,17 @@ export const apiModules: ApiModule[] = [
|
|||||||
notificationsModule,
|
notificationsModule,
|
||||||
callsModule,
|
callsModule,
|
||||||
importsModule,
|
importsModule,
|
||||||
integrationsModule,
|
|
||||||
gradesModule,
|
gradesModule,
|
||||||
studentsModule,
|
studentsModule,
|
||||||
teachersModule,
|
teachersModule,
|
||||||
|
archivesModule,
|
||||||
|
termModule,
|
||||||
dashboardModule,
|
dashboardModule,
|
||||||
databaseModule, // 数据库管理(仅管理员)
|
databaseModule, // 数据库管理(仅管理员)
|
||||||
updateModule, // 更新检查(服务端唯一出网请求)
|
updateModule, // 更新检查 + 客户端安装包缓存与统一下发
|
||||||
|
permissionsModule, // 权限组管理
|
||||||
|
clientsModule, // 客户端设备心跳 / 远控命令
|
||||||
|
systemModule, // 开发者模式 / 开机自启 / 运行诊断
|
||||||
].filter((module) => module.enabled !== false);
|
].filter((module) => module.enabled !== false);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -133,12 +136,13 @@ export const apiModules: ApiModule[] = [
|
|||||||
|
|
||||||
## 几条贯穿全局的硬约定
|
## 几条贯穿全局的硬约定
|
||||||
|
|
||||||
这些都是踩过坑之后固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
|
这些都是长期演进中固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
|
||||||
|
|
||||||
| 约定 | 为什么 |
|
| 约定 | 为什么 |
|
||||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **统一响应与校验** | 用 `lib/http.ts` 的 `sendOk/sendCreated`,用 `middleware/validate.ts` 挂 zod,错误交给 `middleware/error.ts` |
|
| **统一响应与校验** | 用 `lib/http.ts` 的 `sendOk/sendCreated`,用 `middleware/validate.ts` 挂 zod,错误交给 `middleware/error.ts` |
|
||||||
| **权限判在服务层** | 路由只做角色过滤,班级维度的判断用 `lib/access.ts` 断言 —— 前端隐藏 ≠ 权限 |
|
| **功能级准入看权限键** | 路由挂 `requirePermission('…')` / `requireAnyPermission(…)`,角色只是默认模板(`ROLE_DEFAULT_PERMISSIONS`)—— 见 `docs/dev/permissions.md` |
|
||||||
|
| **权限判在服务层** | 权限组只管功能级;班级维度与科目维度仍用 `lib/access.ts` 的断言(`assertCanManageSubjectContent` 等),**权限组不可覆盖** —— 前端隐藏 ≠ 权限 |
|
||||||
| **周一为新一周的第一天** | 周次口径唯一:`resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)` |
|
| **周一为新一周的第一天** | 周次口径唯一:`resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)` |
|
||||||
| **作业按 `assignDate` 归类** | 用 `createdAt`(UTC)会在晚上把作业算到第二天 |
|
| **作业按 `assignDate` 归类** | 用 `createdAt`(UTC)会在晚上把作业算到第二天 |
|
||||||
| **新增实时事件要改三处** | 服务端 bus/socket、Web 端订阅、客户端订阅(灵动岛也依赖) |
|
| **新增实时事件要改三处** | 服务端 bus/socket、Web 端订阅、客户端订阅(灵动岛也依赖) |
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
---
|
|
||||||
title: ClassIsland 插件
|
|
||||||
description: 联动插件的契约、镜像为什么要带节次时间、上课时段暂存,以及部署与调试。
|
|
||||||
---
|
|
||||||
|
|
||||||
# ClassIsland 插件
|
|
||||||
|
|
||||||
源码在 `packages/classisland-plugin/`,是一个 **.NET 8 / C#** 的 ClassIsland 插件
|
|
||||||
(ClassIsland 插件 API 2.0)。它**独立于 pnpm workspace**,不参与 `pnpm install`。
|
|
||||||
|
|
||||||
## 三条链路
|
|
||||||
|
|
||||||
```
|
|
||||||
ClassIsland(教室机器)
|
|
||||||
│ ① 上报:状态 + 课表 + 节次时间(每次上报顺带取回一条待弹提醒)
|
|
||||||
▼
|
|
||||||
/api/integrations/classisland/report ← X-ClassIsland-Token(设备令牌,非 JWT)
|
|
||||||
│
|
|
||||||
│ ② 下发:老师在 Web 端发提醒 → 落库 → 插件下次上报时取回 → ClassIsland 全屏弹出 → ack
|
|
||||||
▼
|
|
||||||
/api/integrations/classisland/pending、/ack
|
|
||||||
|
|
||||||
│ ③ 镜像(可选):服务端把本班课表下发,插件在 ClassIsland 里新建一份档案课表
|
|
||||||
▼
|
|
||||||
/api/integrations/classisland/class-plan
|
|
||||||
```
|
|
||||||
|
|
||||||
## 为什么走 HTTP 而不是 IPC
|
|
||||||
|
|
||||||
ClassIsland 的跨进程 IPC 数据质量不足以还原课表。插件内部**直接读**
|
|
||||||
`ILessonsService`(课程状态)与 `IProfileService`(档案课表)最准。
|
|
||||||
|
|
||||||
与后端之间用「**上报即拉取**」:每次上报的返回值里顺带带一条待弹出的提醒 ——
|
|
||||||
插件因此**不需要 WebSocket 依赖**,故障面更小,而且**上报间隔本身就是心跳**。
|
|
||||||
|
|
||||||
提醒是**轮询取回**的(默认最多 10 秒延迟,设置页「提醒轮询间隔」可调到 5 秒)。
|
|
||||||
|
|
||||||
## 设备令牌
|
|
||||||
|
|
||||||
- 令牌是**设备级**的,不是用户级;
|
|
||||||
- 插件在本机保存明文,服务端只存 **sha256** 与一个前缀提示(`chci_xxxxxxxx`)用于人眼识别;
|
|
||||||
- 同一班级下按 `deviceKey`(机器码)唯一 —— 插件重装后带同一 `deviceKey` 会**自动重绑**到原记录,
|
|
||||||
不会在设备列表里堆重复项;
|
|
||||||
- 关闭设备的「启用」开关后,该设备的上报与下发一律 403。
|
|
||||||
|
|
||||||
## 提醒的排队规则
|
|
||||||
|
|
||||||
| 提醒类型 | 上课时段 | 下课 / 放学 |
|
|
||||||
| ------------------------------------------------------ | ---------------------------- | ----------- |
|
|
||||||
| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹** | 自动补弹 |
|
|
||||||
| 主动通知:紧急提醒、**叫人**(`kind=call`) | **立刻弹**(老师正在等学生) | 立刻弹 |
|
|
||||||
|
|
||||||
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件);
|
|
||||||
客户端侧的实现在 `renderer/island/bridge.ts`。**改这条规则要两端一起改。**
|
|
||||||
|
|
||||||
暂存期间**不回执**,所以服务端会一直保留着待提醒;`ClassIslandPush.expiresAt` 到期后不再补发
|
|
||||||
(避免学生几天后突然弹出旧提醒)。
|
|
||||||
|
|
||||||
## 镜像:为什么必须带 `startTime` / `endTime`
|
|
||||||
|
|
||||||
ClassIsland 的 `ClassPlan.Classes[i]` **强制与时间表里第 i 个 `TimeType == 0`(上课)的时间点对齐**。
|
|
||||||
|
|
||||||
所以镜像下发时,每一条课目都要带上自己的 `startTime` / `endTime`。
|
|
||||||
|
|
||||||
> **少一个时间点,整份课表就会错位** —— 而且表现为"课表看着有内容、每节课都对不上",
|
|
||||||
> 极难排查。这是插件契约里最容易踩的坑。
|
|
||||||
|
|
||||||
另外镜像进 ClassIsland 的课表还要**带课间**:每两节之间补一段(上一节下课 → 下一节上课),
|
|
||||||
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
|
|
||||||
服务端合成节次时间表用的是同一口径。
|
|
||||||
|
|
||||||
## 上报课表:单向还是双向
|
|
||||||
|
|
||||||
**双向对齐**。插件每次全量上报时,服务端只清理「`source='classisland'` 且本次未再上报」的行:
|
|
||||||
|
|
||||||
- 老师在 ClassIsland 里删掉 / 挪动一节 → 小助手这边跟着同步;
|
|
||||||
- 老师在小助手这边手排的课 → **一律不删**。
|
|
||||||
|
|
||||||
merge 的去重键是「星期 + 开始时间 + 单双周」。导入时**不要覆盖 `weekStart` / `weekEnd`** ——
|
|
||||||
插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。
|
|
||||||
|
|
||||||
## 单双周的映射
|
|
||||||
|
|
||||||
ClassIsland 的 `WeekCountDiv` / `WeekCountDivTotal` ⇄ 本项目的 `ALL / ODD / EVEN`,
|
|
||||||
走 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` ——
|
|
||||||
**服务端与插件共用同一份语义**,两边不会各算各的。
|
|
||||||
|
|
||||||
## 代理:本地地址强制直连
|
|
||||||
|
|
||||||
`ClassHelperClient` 里有一个 `LocalAwareProxy`:对本地 / 内网地址**强制直连**,
|
|
||||||
不走系统代理。
|
|
||||||
|
|
||||||
原因:教室机器上如果开了系统代理,填好令牌后表现为"立刻 HTTP 404"(其实是代理返回的错误页)。
|
|
||||||
`pnpm verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。
|
|
||||||
|
|
||||||
## 构建、打包与验收
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build:classisland-plugin # 编译(.NET 8;缓存与临时目录自动指到仓库内 .cache/)
|
|
||||||
pnpm dist:classisland-plugin # 打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
|
|
||||||
pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需要服务端)
|
|
||||||
```
|
|
||||||
|
|
||||||
`verify:classisland-plugin` 会逐项校验:清单一致性、注册完整性、
|
|
||||||
**C# DTO ↔ 服务端 zod 与路由的字段对齐**,以及已修复坑的护栏。
|
|
||||||
|
|
||||||
### 那个预期内的编译警告
|
|
||||||
|
|
||||||
全量编译会有 **1 个警告 `AVLN3001`**:设置页只有 DI 构造函数、没有公开无参构造。
|
|
||||||
本插件走的是编译期 XAML + ClassIsland 的 DI 实例化,**加个无参构造反而会把 DI 弄坏**。
|
|
||||||
别为了消警告去改它。
|
|
||||||
|
|
||||||
## 部署到教室机器
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# 1) 完全退出 ClassIsland(它会锁住 DLL)
|
|
||||||
Get-Process ClassIsland.Desktop | Stop-Process -Force
|
|
||||||
# 2) 覆盖插件 DLL(**不要覆盖 Settings.json**,那里存着服务器地址与设备令牌)
|
|
||||||
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll `
|
|
||||||
D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
|
|
||||||
# 3) 重新启动
|
|
||||||
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
|
|
||||||
```
|
|
||||||
|
|
||||||
改配置要在 **ClassIsland 退出时**改:运行中改会被内存里的副本随时回写覆盖。
|
|
||||||
|
|
||||||
### 读日志要用 GBK
|
|
||||||
|
|
||||||
.NET 的控制台输出是**系统 ANSI(GBK)**编码。用 UTF-8 读会变成乱码,
|
|
||||||
**中文关键字一条都搜不到**(只能搜到 ASCII 的类名):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
[System.IO.File]::ReadAllLines($path, [System.Text.Encoding]::GetEncoding(936))
|
|
||||||
```
|
|
||||||
|
|
||||||
日志文件被运行中的进程占用时,先 `Copy-Item` 一份再读。
|
|
||||||
|
|
||||||
## 真机验收链路
|
|
||||||
|
|
||||||
`pnpm verify:classisland`(需要后端在跑)会真的走一遍:设备令牌 → 上报 → 提醒下发与回执 →
|
|
||||||
镜像契约 → 幽灵行清理。
|
|
||||||
|
|
||||||
> 这个脚本开头会先**抽干本班的历史积压提醒**。因为待提醒接口是「本班全部未确认提醒,
|
|
||||||
> 按时间升序取前 20 条」,而其他冒烟会真的往演示班发通知 —— 攒够 20 条之后,
|
|
||||||
> 本脚本刚发的提醒会被挤出窗口,表现为"待提醒里找不到刚发的那条",跟代码对不对毫无关系。
|
|
||||||
> **别把那个 drain 删掉。**
|
|
||||||
+38
-35
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: 数据模型
|
title: 数据模型
|
||||||
description: 16 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。
|
description: 24 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 数据模型
|
# 数据模型
|
||||||
@@ -30,61 +30,56 @@ description: 16 张表的职责、跨库通用约定、迁移历史与启动时
|
|||||||
同一位教师可以在同一个班同时是班主任和某科科任老师:两处指向同一个 `User`,不重复建号,
|
同一位教师可以在同一个班同时是班主任和某科科任老师:两处指向同一个 `User`,不重复建号,
|
||||||
权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
|
权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
|
||||||
|
|
||||||
## 16 张表
|
## 24 张表
|
||||||
|
|
||||||
| 表 | 职责 | 值得注意的字段 |
|
| 表 | 职责 | 值得注意的字段 |
|
||||||
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------------ | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`;**学生不在这里** |
|
| `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`、`careMode`(关怀模式偏好,目前只由本机界面读写);**学生不在这里** |
|
||||||
| `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` |
|
| `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` |
|
||||||
| `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) |
|
| `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) |
|
||||||
| `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 |
|
| `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 |
|
||||||
| `Class` | 班级 | `name`(**「2026级1班」**,由 `enrollmentYear` + `classIndex` 生成)、`code` + `passwordHash`(班级端账号)、`termWeeks`、`termStartDate`、`notificationChannel`、`studentGradeQueryEnabled`、`archivedYearId` |
|
| `Class` | 班级 | `name`(**「2026级1班」**,由 `enrollmentYear` + `classIndex` 生成)、`code` + `passwordHash`(班级端账号)、`termWeeks`、`termStartDate`、`studentGradeQueryEnabled`、`archivedYearId` |
|
||||||
| `TermWeek` | 学期逐周日期区间 | `classId`(**空串 = 全校默认**)、`weekNumber`、`startDate` / `endDate` |
|
| `TermWeek` | 学期逐周日期区间 | `classId`(**空串 = 全校默认**)、`weekNumber`、`startDate` / `endDate` |
|
||||||
| `Course` | 课程 = **「班级 + 科目 + 教师」任课关系** | `teacherId` 就是该班该科的任课老师;`@@unique([classId, name])` |
|
| `Course` | 课程 = **「班级 + 科目 + 教师」任课关系** | `teacherId` 就是该班该科的任课老师;`@@unique([classId, name])` |
|
||||||
| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) |
|
| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual) |
|
||||||
| `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 |
|
| `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 |
|
||||||
| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` |
|
| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` |
|
||||||
| `Notification` | 通知 | `priority` |
|
| `Notification` | 通知 | `priority`、`assignDate`(所属本地日期,禁止 UTC 切片);`@@index([classId, assignDate])` 供按天归档 |
|
||||||
| `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` |
|
| `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` |
|
||||||
| `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` |
|
| `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` |
|
||||||
| `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` |
|
| `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` |
|
||||||
| `IntegrationDevice` | ClassHelper 班级端 / ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`lastHeartbeatAt`(在线判定)、`lastSeenAt`(最后一次带状态的上报)、三个方向开关 |
|
| `PermissionGroup` | 权限组(角色之上的**默认模板**) | `name`(唯一)、`builtin`(内置组不可删);生效集合见 [角色与权限](permissions.md) |
|
||||||
| `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` |
|
| `PermissionGroupItem` | 权限组里的一个权限键(一行一个键) | `@@unique([groupId, key])`;键取值必须是 shared 的 `PERMISSION_KEYS` 之一 |
|
||||||
|
| `UserPermissionGroup` | 账号 ←→ 权限组 的分配关系(可属多个组) | `@@unique([userId, groupId])`;**没有任何行 = 回落角色默认模板** |
|
||||||
|
| `AppSetting` | 全局键值设置(大模型配置 / 开发者模式) | `key` 即主键、`value` 存字符串(复杂结构存 JSON);密钥类值做 AES-256-GCM 加密后存这里(**只进不出**) |
|
||||||
|
| `ClientDevice` | ClassHelper 客户端设备(教室 / 学生机) | `deviceKey`(唯一,客户端生成)、`classId`、`clientVersion`、`updateTargetVersion`、`lastHeartbeatAt`(**客户端在线判定看 `ClientDevice.lastHeartbeatAt`**:每 30 秒心跳,90 秒内有成功上报即在线。 |
|
||||||
|
| 更新目标、安装策略与最早安装时间持久化在设备记录中,离线设备下次上线继续领取。 |
|
||||||
|
|
||||||
### 几个容易忽略的设计
|
**`Schedule.source`** —— 联动退役后历史同步课表统一归为 `manual`,保留排课数据。
|
||||||
|
|
||||||
**`ClassIslandPush` 与 `IntegrationDevice`** —— 提醒是**落库**的,不是"发出去就算"。
|
|
||||||
设备离线时通知不丢:插件重连后拉取未确认的那条立即弹出,弹完回执(`ackedAt`)。
|
|
||||||
`expiresAt` 防止学生几天后突然弹出旧提醒。
|
|
||||||
|
|
||||||
**在线判定看 `lastHeartbeatAt`,不看 `lastSeenAt`** —— 同一台设备的上报其实有两种:
|
|
||||||
心跳(每 60 秒一次,只表明"我还在线")与带状态的上报(课表 / 上课状态)。
|
|
||||||
合成一个字段后无法区分"在线但一直没新状态"和"整个失联",所以拆开:
|
|
||||||
`lastHeartbeatAt` 每次上报都刷(60 秒窗口判在线),`lastSeenAt` 只在带运行状态时刷。
|
|
||||||
|
|
||||||
**课表同步方向:服务端 → 教室是主方向。**
|
|
||||||
`IntegrationDevice.mirrorScheduleToClassIsland` 默认 **true**(服务端自动下发),
|
|
||||||
`syncScheduleToServer` 默认 **false**(教室不再自动回传)。要取教室的课表,走
|
|
||||||
`POST /integrations/devices/:id/request-schedule` 这个**人工动作**(前端点击前会弹警告)——
|
|
||||||
自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
|
|
||||||
|
|
||||||
**`Schedule.source`** —— 决定插件上报时能清理哪些行:只清 `classisland` 来源且本次未再上报的行。
|
|
||||||
**`source` 只在 create 时确定,update 一律保持原值**(否则手排的课会被"收养"后又被清掉)。
|
|
||||||
|
|
||||||
**`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。
|
**`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。
|
||||||
保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。
|
保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。
|
||||||
|
|
||||||
|
**`Notification.assignDate`** —— 与 `Homework.assignDate` 同口径:**本地日期**(`YYYY-MM-DD`),
|
||||||
|
创建时用 `dayKeyLocal()` 写入,按天 / 区间归档都读它。**不要用 `createdAt` 切 UTC 当"哪一天"** ——
|
||||||
|
晚上发的通知会被算到第二天。`@@index([classId, assignDate])` 是归档查询的支撑。
|
||||||
|
|
||||||
|
**`User.careMode`** —— 关怀模式的账号级偏好列(放大正文与控件)。**目前服务端没有任何接口读写它**:
|
||||||
|
Web 端存在 `localStorage`(`classhelper.admin.careMode` + `careScale`),客户端存在本机 `config.json`。
|
||||||
|
列先建好是为了将来跨设备同步偏好时不必再迁移;别把它当成"已有服务端开关"。
|
||||||
|
|
||||||
**`TermWeek.classId` 用空串而不是 null 表示"全校默认"** —— SQLite 与 MySQL 的唯一约束都把
|
**`TermWeek.classId` 用空串而不是 null 表示"全校默认"** —— SQLite 与 MySQL 的唯一约束都把
|
||||||
NULL 视作互不相同,`@@unique([classId, weekNumber])` 用 null 就形同虚设。
|
NULL 视作互不相同,`@@unique([classId, weekNumber])` 用 null 就形同虚设。
|
||||||
|
|
||||||
## 迁移历史(16 个)
|
## 迁移历史(17 个)
|
||||||
|
|
||||||
```
|
```
|
||||||
init → add_time_layout → add_class_account → add_schedule_week_parity →
|
init → add_time_layout → add_class_account → add_schedule_week_parity →
|
||||||
add_class_term_weeks → add_classisland_integration → add_class_notification_channel →
|
add_class_term_weeks → add_classisland_integration → add_class_notification_channel →
|
||||||
add_homework_assign_date → add_push_kind → add_schedule_source →
|
add_homework_assign_date → add_push_kind → add_schedule_source →
|
||||||
add_student_table → add_student_grade_query → add_grade_level →
|
add_student_table → add_student_grade_query → add_grade_level →
|
||||||
add_classhelper_heartbeat → add_archives → add_term_weeks
|
add_classhelper_heartbeat → add_archives → add_term_weeks →
|
||||||
|
add_permission_groups_and_clients
|
||||||
```
|
```
|
||||||
|
|
||||||
`add_student_table` 是一次真正的表重构(不是加列):新建 `Student` / `StudentClassTransfer`、
|
`add_student_table` 是一次真正的表重构(不是加列):新建 `Student` / `StudentClassTransfer`、
|
||||||
@@ -92,6 +87,12 @@ add_classhelper_heartbeat → add_archives → add_term_weeks
|
|||||||
`NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
|
`NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
|
||||||
因为学生行的 id 保持不变,三张个人数据表的 `userId` 可以直接当 `studentId` 用,历史一条不丢。
|
因为学生行的 id 保持不变,三张个人数据表的 `userId` 可以直接当 `studentId` 用,历史一条不丢。
|
||||||
|
|
||||||
|
`add_permission_groups_and_clients` 是纯加表 / 加列:新建 `PermissionGroup` /
|
||||||
|
`PermissionGroupItem` / `UserPermissionGroup`(权限组)、`AppSetting`(全局键值设置)、
|
||||||
|
`ClientDevice` / `ClientCommand`(客户端设备与远控)、`ClientRelease`(安装包缓存)、
|
||||||
|
`AiAudit`(大模型审计),并给 `Notification` 加 `assignDate` + `[classId, assignDate]` 索引、
|
||||||
|
给 `User` 加 `careMode`。没有数据搬运,旧库直接补跑即可。
|
||||||
|
|
||||||
### 启动时自动迁移
|
### 启动时自动迁移
|
||||||
|
|
||||||
`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名:
|
`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名:
|
||||||
@@ -107,8 +108,10 @@ add_classhelper_heartbeat → add_archives → add_term_weeks
|
|||||||
|
|
||||||
## 备份格式就是这份模型的拓扑序导出
|
## 备份格式就是这份模型的拓扑序导出
|
||||||
|
|
||||||
「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:16 张表按拓扑序导出
|
「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:**23 张表按拓扑序导出**
|
||||||
(见 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES`),恢复时**临时关闭外键检查** ——
|
(见 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES`,现在共 23 项,顺序为
|
||||||
|
`User` → `PermissionGroup` → `PermissionGroupItem` → `UserPermissionGroup` → `AppSetting` →
|
||||||
|
`ArchivedYear` → `Class` → `Student` → … → `AiAudit`),恢复时**临时关闭外键检查** ——
|
||||||
因为 `Class.teacherId → User` 与 `Student.classId → Class` 是一条链、
|
因为 `Class.teacherId → User` 与 `Student.classId → Class` 是一条链、
|
||||||
`Class/Student.archivedYearId → ArchivedYear` 又是一条,任何排序都会被其中一侧卡死。
|
`Class/Student.archivedYearId → ArchivedYear` 又是一条,任何排序都会被其中一侧卡死。
|
||||||
见[数据库管理](../management/database.md)。
|
见[数据库管理](../management/database.md)。
|
||||||
@@ -116,15 +119,15 @@ add_classhelper_heartbeat → add_archives → add_term_weeks
|
|||||||
## 开发时的模型改动
|
## 开发时的模型改动
|
||||||
|
|
||||||
1. 改 `schema.prisma`;
|
1. 改 `schema.prisma`;
|
||||||
2. 在 `prisma/migrations/` 下加一个迁移目录(本机用
|
2. 在 `prisma/migrations/` 下加一个迁移目录(用
|
||||||
`pnpm --filter @classhelper/server db:deploy` 应用,**别用 `pnpm db:migrate`** ——
|
`pnpm --filter @classhelper/server db:deploy` 应用,**别用 `pnpm db:migrate`** ——
|
||||||
它在 Windows 上不可靠)。要改已有表结构而不只是加列时,可以先用
|
它在 Windows 上不可靠)。要改已有表结构而不只是加列时,可以先用
|
||||||
`pnpm exec prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`
|
`pnpm exec prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`
|
||||||
生成骨架,再手工补数据搬运语句;
|
生成骨架,再手工补数据搬运语句;
|
||||||
3. 改完核对 **`SNAPSHOT_TABLES`**(新增 / 删除表要同步)与 `deleteTeacher` 的护栏清单
|
3. 改完核对 **`SNAPSHOT_TABLES`**(新增 / 删除表要同步)与 `deleteTeacher` 的护栏清单
|
||||||
(它必须覆盖 `Class.teacherId` / `Course.teacherId` / `Homework.createdBy` /
|
(它必须覆盖 `Class.teacherId` / `Course.teacherId` / `Homework.createdBy` /
|
||||||
`Notification.createdBy` 四项;`ClassIslandPush.createdBy` 是裸列、无外键、不级联,不用加);
|
`Notification.createdBy` 四项);
|
||||||
再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。
|
再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。
|
||||||
|
|
||||||
模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准;
|
模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准;
|
||||||
本文里的数字标注的是当前版本(1.2.0)的状态。
|
本文里的数字标注的是**当前代码库**的状态。
|
||||||
+6
-9
@@ -8,12 +8,11 @@ description: ClassHelper的架构、接口、数据模型、权限、动效层
|
|||||||
ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
|
ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
|
||||||
|
|
||||||
| 交付物 | 位置 | 形态 |
|
| 交付物 | 位置 | 形态 |
|
||||||
| -------------------- | ----------------------------- | --------------------------------------------------------------- |
|
| ---------- | ------------------------- | --------------------------------------------------------------- |
|
||||||
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` |
|
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` |
|
||||||
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) |
|
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) |
|
||||||
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(ClassHelper 班级端 / 教室机器,含灵动岛浮窗) |
|
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(ClassHelper 班级端 / 教室机器,含灵动岛浮窗) |
|
||||||
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
||||||
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(**独立于 pnpm workspace**,不参与 `pnpm install`) |
|
|
||||||
|
|
||||||
核心链路:
|
核心链路:
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
|
|||||||
▼
|
▼
|
||||||
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI(含灵动岛)
|
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI(含灵动岛)
|
||||||
|
|
||||||
ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
|
|
||||||
▲ │
|
▲ │
|
||||||
└──── 老师下发的提醒 ◄──────┘
|
└──── 老师下发的提醒 ◄──────┘
|
||||||
```
|
```
|
||||||
@@ -38,18 +36,17 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
|||||||
| [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 |
|
| [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 |
|
||||||
| [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) |
|
| [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) |
|
||||||
| [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 |
|
| [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 |
|
||||||
| [数据模型](data-model.md) | 16 张表、迁移历史、跨库约定 |
|
| [数据模型](data-model.md) | 24 张表、迁移历史、跨库约定 |
|
||||||
| [角色与权限](permissions.md) | 权限矩阵与实现位置 |
|
| [角色与权限](permissions.md) | 权限矩阵与实现位置 |
|
||||||
| [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 |
|
| [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 |
|
||||||
| [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 |
|
|
||||||
| [灵动岛设计令牌](winisland-design-tokens.md) | 岛逐项的几何 / 配色 / 排版 token |
|
| [灵动岛设计令牌](winisland-design-tokens.md) | 岛逐项的几何 / 配色 / 排版 token |
|
||||||
| [验收与测试](testing.md) | 五套验收脚本怎么跑、哪些用例是脆弱的 |
|
| [验收与测试](testing.md) | 五套验收脚本怎么跑、哪些用例是脆弱的 |
|
||||||
| [版本迭代与发布](release.md) | 改版本号、出安装包、发 Release 的完整流程 |
|
| [版本迭代与发布](release.md) | 改版本号、出安装包、发 Release 的完整流程 |
|
||||||
|
|
||||||
## 仓库里另外两份文档
|
## 仓库里另外两份文档
|
||||||
|
|
||||||
- **[完整参考](../reference.md)** —— 产品说明书 + 验收对照表(原 README 全文),
|
- **[完整参考](../reference.md)** —— 产品说明书(原 README 全文),功能行为的**权威来源**;
|
||||||
功能行为的**权威来源**;仓库首页那份 README 是精简后的门面页,细节都在这里。
|
仓库首页那份 README 是精简后的门面页,细节都在这里。
|
||||||
- **[`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md)** ——
|
- **[`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md)** ——
|
||||||
写给 AI 编码代理的工程约定与本机踩坑记录,**改代码前必读**。
|
工程约定与开发环境注意事项,**改代码前必读**。里面每一条硬约定都对应一处容易踩空的地方,
|
||||||
里面每一条硬约定都对应一次真实的翻车,比代码注释更值得先看一遍。
|
比代码注释更值得先看一遍。
|
||||||
+77
-5
@@ -21,7 +21,7 @@ description: 权限矩阵、科目维度的收口规则,以及"前端隐藏
|
|||||||
班级内的角色由「账号角色 + 与该班级的关系」共同决定:
|
班级内的角色由「账号角色 + 与该班级的关系」共同决定:
|
||||||
|
|
||||||
| 角色 | 判定方式 | 可见范围 |
|
| 角色 | 判定方式 | 可见范围 |
|
||||||
| --------------------------- | ------------------------------------------ | ------------------------------------ |
|
| --------------------- | ---------------------------------------- | -------------------------- |
|
||||||
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
|
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
|
||||||
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
|
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
|
||||||
| 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 |
|
| 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 |
|
||||||
@@ -33,8 +33,12 @@ description: 权限矩阵、科目维度的收口规则,以及"前端隐藏
|
|||||||
|
|
||||||
## 权限矩阵
|
## 权限矩阵
|
||||||
|
|
||||||
|
下表描述角色与班级关系允许的范围;员工还必须拥有对应功能权限,管理员也不例外。
|
||||||
|
名单读取使用 `roster:read` / `teacher:read`,管理使用独立的 `roster:manage` / `teacher:manage`。
|
||||||
|
日志与客户端开发者模式不再单列无效权限键,服务端系统设置由 `system:manage` 控制。
|
||||||
|
|
||||||
| 操作 | 管理员 | 班主任 | 科任老师 | 班级端 |
|
| 操作 | 管理员 | 班主任 | 科任老师 | 班级端 |
|
||||||
| -------------------------------------------------------- | :----: | :----------: | :------: | :----: |
|
| ----------------------------------------------------------- | :----: | :----------: | :------: | :--------: |
|
||||||
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | ❌ |
|
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | ❌ |
|
||||||
| 分配班主任与各科任课老师 | ✅ | ❌ | ❌ | ❌ |
|
| 分配班主任与各科任课老师 | ✅ | ❌ | ❌ | ❌ |
|
||||||
| 学生名单管理(增删 / 导入 / 调班 / 转出) | ✅ | ✅(仅本班) | ❌ | ❌ |
|
| 学生名单管理(增删 / 导入 / 调班 / 转出) | ✅ | ✅(仅本班) | ❌ | ❌ |
|
||||||
@@ -55,7 +59,7 @@ description: 权限矩阵、科目维度的收口规则,以及"前端隐藏
|
|||||||
作业与成绩不是"凡是能进这个班就能改",而是**再按科目收一道**:
|
作业与成绩不是"凡是能进这个班就能改",而是**再按科目收一道**:
|
||||||
|
|
||||||
| 情形 | 谁可以写 |
|
| 情形 | 谁可以写 |
|
||||||
| --------------------------------------- | ------------------------------------------ |
|
| --------------------------- | ------------------------------------------------------ |
|
||||||
| 管理员 | 全部 |
|
| 管理员 | 全部 |
|
||||||
| 挂了科目(`courseId` 有值) | **该科的任课老师**(`Course.teacherId`) |
|
| 挂了科目(`courseId` 有值) | **该科的任课老师**(`Course.teacherId`) |
|
||||||
| 没挂科目(`courseId` 为空) | 本班**班主任** |
|
| 没挂科目(`courseId` 为空) | 本班**班主任** |
|
||||||
@@ -67,9 +71,50 @@ description: 权限矩阵、科目维度的收口规则,以及"前端隐藏
|
|||||||
实现在 `lib/access.ts` 的 **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**,
|
实现在 `lib/access.ts` 的 **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**,
|
||||||
作业 / 成绩 / 叫人三处写入统一走它。
|
作业 / 成绩 / 叫人三处写入统一走它。
|
||||||
|
|
||||||
|
## 权限键与权限组(2026-10 起:权限组驱动,角色只是默认模板)
|
||||||
|
|
||||||
|
从权限组重构开始,**功能级准入不再看角色,而看权限键**:键目录同样是
|
||||||
|
`packages/shared/src/permissions.ts` 的 `PERMISSION_KEYS`(37 个,形如 `domain:action`,
|
||||||
|
中文名在 `PERMISSION_LABELS`、分组在 `PERMISSION_CATEGORIES`)。路由上挂的是
|
||||||
|
|
||||||
|
```ts
|
||||||
|
requirePermission('notification:publish'); // 需要全部列出的键
|
||||||
|
requireAnyPermission('grade:write', 'grade:import'); // 有任意一个即可
|
||||||
|
```
|
||||||
|
|
||||||
|
**生效规则(四句话)**:
|
||||||
|
|
||||||
|
| 账号 | 有效权限 |
|
||||||
|
| ------------------------ | ----------------------------------------------------------------------- |
|
||||||
|
| `ADMIN` | 按权限组计算;其中 `permission:manage` / `system:manage` **不可被剥夺** |
|
||||||
|
| `CLASS_DEVICE`(班级端) | 空集,**不受任何分组影响**(它的能力来自班级维度断言) |
|
||||||
|
| 被分配了权限分组 | 分组键的**并集**(可以比角色默认更少 —— 这就是"能收权") |
|
||||||
|
| 没有被分配任何分组 | 使用可编辑的角色默认权限组 |
|
||||||
|
|
||||||
|
默认模板逐条镜像重构前的 `requireRole`,所以"不动分组"= 行为与以前完全一致。
|
||||||
|
实现见 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()`:它**每个请求实时查库**
|
||||||
|
(不缓存),因此管理员改完分组**立刻生效**,不需要等令牌过期 —— 与下面「鉴权中间件会回查数据库」是同一个理由。
|
||||||
|
|
||||||
|
**管理接口**(`packages/server/src/modules/permissions/`):
|
||||||
|
|
||||||
|
| 方法与路径 | 权限要求 | 说明 |
|
||||||
|
| --------------------------------------------------------------------- | ------------------- | -------------------------------------------------------------- |
|
||||||
|
| `GET /api/permissions/me` | 登录即可 | 当前账号的有效权限与来源(前端据此显示/隐藏入口) |
|
||||||
|
| `GET /api/permissions/catalog` | `permission:manage` | 全部权限键(含中文名、分类、默认角色、不可移除标记) |
|
||||||
|
| `GET/POST /api/permissions/groups`、`PATCH/DELETE /groups/:id` | `permission:manage` | 分组 CRUD;内置分组(`builtin=true`)可改不可删 |
|
||||||
|
| `GET /api/permissions/users`、`PUT /api/permissions/users/:id/groups` | `permission:manage` | 成员列表与「整组替换」式分配;管理员与教师可分配;班级端不参与 |
|
||||||
|
|
||||||
|
**一条不能松的边界**:权限组只管**功能/路由级**准入;**班级维度的科目规则不可被权限组覆盖**。
|
||||||
|
换句话说,给某人授了 `homework:write` 只代表"他能进布置作业这条路由",
|
||||||
|
**具体到哪个班、哪个科目**仍然由 `assertCanManageSubjectContent` 判(下一节)。
|
||||||
|
否则「班主任能布置别人科目的作业」这个老 bug 会从权限组这条新路重新长出来。
|
||||||
|
|
||||||
## 实现要点
|
## 实现要点
|
||||||
|
|
||||||
**路由级只做角色过滤**:`requireRole('ADMIN', 'TEACHER')`。
|
**路由级只做权限过滤**:`requirePermission('…')`(需要列出的键全有)或
|
||||||
|
`requireAnyPermission('…')`(任意一个即可)。改造前这里是 `requireRole('ADMIN','TEACHER')`,
|
||||||
|
按键目录里的 `PERMISSION_DEFAULT_ROLES` 逐条映射过来,因此**默认行为没有变化**。
|
||||||
|
403 的文案会直接点名缺哪个键:「当前账号缺少「X」权限,请联系管理员在「权限组」里分配」。
|
||||||
|
|
||||||
**班级维度的判断在服务层**:`src/lib/access.ts` 提供
|
**班级维度的判断在服务层**:`src/lib/access.ts` 提供
|
||||||
|
|
||||||
@@ -93,10 +138,16 @@ assertCanManageGrades / assertCanManageSubjectContent
|
|||||||
|
|
||||||
前端的隐藏只是"别让人看见入口":
|
前端的隐藏只是"别让人看见入口":
|
||||||
|
|
||||||
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理」;
|
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理 / 权限组 / 客户端设备」;
|
||||||
- 课表页的增删改按钮只对「管理员或本班班主任」显示;
|
- 课表页的增删改按钮只对「管理员或本班班主任」显示;
|
||||||
- 成绩页的录入 / 删除按钮对教师显示(科任也能用,**具体到某一条由后端按科目判定**)。
|
- 成绩页的录入 / 删除按钮对教师显示(科任也能用,**具体到某一条由后端按科目判定**)。
|
||||||
|
|
||||||
|
**门控怎么写的**:路由 `meta.roles` 做粗筛(角色),`meta.permission` 做细筛(权限键)——
|
||||||
|
`packages/web-admin/src/router/index.ts` 的 `beforeEach` 会 `await auth.ensurePermissions()`
|
||||||
|
再问 `auth.can(key)`(管理员同样按生效集合判断,仅保留权限和系统管理入口),不通过就 **fail-closed** 回仪表盘;
|
||||||
|
侧栏菜单项同理带 `permission` 字段(`AdminLayout.vue` 的 `visibleMenuItems`)。
|
||||||
|
`auth.ensurePermissions()` 读的就是 `GET /api/permissions/me`。
|
||||||
|
|
||||||
但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘),
|
但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘),
|
||||||
而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。
|
而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。
|
||||||
|
|
||||||
@@ -109,3 +160,24 @@ assertCanManageGrades / assertCanManageSubjectContent
|
|||||||
拿学号当账号登录只会得到 401("工号或密码错误"),连"这个学号存在吗"都探测不出来。
|
拿学号当账号登录只会得到 401("工号或密码错误"),连"这个学号存在吗"都探测不出来。
|
||||||
2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404),
|
2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404),
|
||||||
新建学生也不设密码 —— `Student` 表里压根没有那一列。
|
新建学生也不设密码 —— `Student` 表里压根没有那一列。
|
||||||
|
|
||||||
|
## 默认权限组与客户端维护(2026-10-16)
|
||||||
|
|
||||||
|
管理员和教师各有一个默认权限组;未分配其他组时使用对应默认组,编辑后立即生效。
|
||||||
|
默认组可以改名、增减权限,但不能删除。分配多个组时取并集。
|
||||||
|
管理员始终保留 `permission:manage` 和 `system:manage`,防止失去管理入口。
|
||||||
|
班级端不参与账号权限组,班级与科目范围的检查保持生效。
|
||||||
|
|
||||||
|
「客户端」页统一显示设备心跳、版本、是否接受自动更新、最近指令结果和安装时机。
|
||||||
|
更新先从本服务器下载并校验 SHA256,再按所选时机安装:
|
||||||
|
|
||||||
|
- 立即更新:下载完成后安装并重启客户端,会中断当前客户端界面。
|
||||||
|
- 放学后更新:按服务器当天日期,等待所选最早时间与本班最后一节课结束时间的较晚者;上课中继续等待。
|
||||||
|
- 空闲时更新:不上课且主窗口隐藏满五分钟后安装。
|
||||||
|
|
||||||
|
离线设备上线后补领更新;取消下发后,已下载的安装包不再自动安装。
|
||||||
|
客户端关闭自动更新时,任何下发策略都不会强制安装。
|
||||||
|
远控关机、重启、注销和锁屏仍需管理端确认及本机确认,取消或系统执行失败都会回执。
|
||||||
|
|
||||||
|
联动插件及相关接口已下线;升级迁移会删除旧联动设备和推送记录,保留业务课表、通知及离线 JSON 导入。
|
||||||
|
服务端兼容旧客户端的心跳字段读取,旧设备可领取修复版;第一次升级仍遵循旧版空闲安装条件,新策略需修复版客户端。
|
||||||
@@ -30,8 +30,6 @@ io(url, { auth: { token } }); // 握手阶段用 JWT 鉴权
|
|||||||
| `schedule:updated` | 服务端 → 客户端 | `{ classId, action, schedule? }` |
|
| `schedule:updated` | 服务端 → 客户端 | `{ classId, action, schedule? }` |
|
||||||
| `class:updated` | 服务端 → 客户端 | `{ classId, action }` |
|
| `class:updated` | 服务端 → 客户端 | `{ classId, action }` |
|
||||||
| `call:new` | 服务端 → 客户端 | 叫人消息(定向到 `user:{studentId}` / 班级设备) |
|
| `call:new` | 服务端 → 客户端 | 叫人消息(定向到 `user:{studentId}` / 班级设备) |
|
||||||
| `classisland:state` | 服务端 → 客户端 | `ClassIslandStateEvent`(教室现在上什么课) |
|
|
||||||
| `classisland:notification` | 服务端 → 客户端 | `ClassIslandNotificationEvent`(提醒已下发) |
|
|
||||||
| `class:join` / `class:leave` / `ping` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) |
|
| `class:join` / `class:leave` / `ping` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) |
|
||||||
|
|
||||||
## 房间规则
|
## 房间规则
|
||||||
|
|||||||
+38
-20
@@ -10,18 +10,31 @@ description: 改版本号、跑验收、打安装包、发 GitHub Release、发
|
|||||||
> **版本号按「发布」迭代,不按「提交」迭代。**
|
> **版本号按「发布」迭代,不按「提交」迭代。**
|
||||||
|
|
||||||
日常的 `feat` / `fix` 提交**不动版本号**。只有当你要产出一批安装包、在 GitHub 上开一个 Release 时,
|
日常的 `feat` / `fix` 提交**不动版本号**。只有当你要产出一批安装包、在 GitHub 上开一个 Release 时,
|
||||||
才走一次完整的版本迭代。理由很实际:安装包、`.cipx`、Docker 镜像 tag、文档站的版本徽标、
|
|
||||||
更新检查接口全都按版本号对齐;每次提交都动它,等于每天都在发版,用户那边的「检查更新」就废了。
|
更新检查接口全都按版本号对齐;每次提交都动它,等于每天都在发版,用户那边的「检查更新」就废了。
|
||||||
|
|
||||||
| 算一次发布 | 不算 |
|
| 算一次发布 | 不算 |
|
||||||
| --------------------------------------- | ---------------------------- |
|
| --------------------------------------- | ---------------------------- |
|
||||||
| 攒够一批功能 / 修好一个用户能感知的 bug | 改注释、改文档、重构、加测试 |
|
| 攒够一批功能 / 修好一个用户能感知的 bug | 改注释、改文档、重构、加测试 |
|
||||||
| 要给别人一份新安装包 | 只在自己机器上跑 |
|
| 要给别人一份新安装包 | 只在自己机器上跑 |
|
||||||
| 插件单独发补丁(只动插件第 4 段版本号) | — |
|
|
||||||
|
|
||||||
## 版本号写在哪(14 处 / 12 个文件)
|
## 每次发版必须一起交付的四样东西
|
||||||
|
|
||||||
同一个版本号分散在 14 个落点上,**手工改必然漏一处**,而漏掉的那处不会报错:
|
打安装包只是其中一件。**少一样都算「发到一半」**:
|
||||||
|
|
||||||
|
| # | 交付物 | 落在哪 | 怎么做 |
|
||||||
|
| --- | ------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||||
|
| ① | **更新日志** | 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 那两份写):
|
||||||
|
**一句话定位 → 分节讲功能 → ⚠️ 升级须知(含会自动应用的迁移)→ 验收表 → 产物表**。
|
||||||
|
验收表里**只写真跑过的数字**,没跑就直说没跑,不要照抄上一版的。
|
||||||
|
|
||||||
|
## 版本号写在哪(12 处 / 12 个文件)
|
||||||
|
|
||||||
|
同一个版本号分散在 12 个落点上,**手工改必然漏一处**,而漏掉的那处不会报错:
|
||||||
|
|
||||||
| 文件 | 内容 |
|
| 文件 | 内容 |
|
||||||
| ---------------------------------------------------------------- | ---------------------------------- |
|
| ---------------------------------------------------------------- | ---------------------------------- |
|
||||||
@@ -29,8 +42,6 @@ description: 改版本号、跑验收、打安装包、发 GitHub Release、发
|
|||||||
| `packages/{shared,server,web-admin,desktop-client}/package.json` | 各包的版本 |
|
| `packages/{shared,server,web-admin,desktop-client}/package.json` | 各包的版本 |
|
||||||
| `deploy/package.runtime.json` | 安装包 / 容器里的运行时依赖清单 |
|
| `deploy/package.runtime.json` | 安装包 / 容器里的运行时依赖清单 |
|
||||||
| `deploy/docker-compose.yml`、`docker-compose.sqlite.yml` | 镜像 tag 的默认值 |
|
| `deploy/docker-compose.yml`、`docker-compose.sqlite.yml` | 镜像 tag 的默认值 |
|
||||||
| `packages/classisland-plugin/manifest.yml` | 插件清单(四段:`x.y.z.0`) |
|
|
||||||
| `packages/classisland-plugin/src/Services/BridgeService.cs` | 插件上报的 `PluginVersion` 常量 |
|
|
||||||
| `website/index.html` × 3 | 官网顶栏徽标 / 首屏芯片 / 页脚 |
|
| `website/index.html` × 3 | 官网顶栏徽标 / 首屏芯片 / 页脚 |
|
||||||
| `website/download.html`(`<meta name="ch-version">`) | 下载页读不到版本清单时的兜底版本号 |
|
| `website/download.html`(`<meta name="ch-version">`) | 下载页读不到版本清单时的兜底版本号 |
|
||||||
|
|
||||||
@@ -50,9 +61,6 @@ pnpm version:check # 再确认一次
|
|||||||
|
|
||||||
脚本按表逐个改写,**任何一处没匹配到或匹配到多处就直接失败退出**,不做"尽力而为"的部分改写。
|
脚本按表逐个改写,**任何一处没匹配到或匹配到多处就直接失败退出**,不做"尽力而为"的部分改写。
|
||||||
|
|
||||||
> 插件版本是 **四段** `x.y.z.0`:前三段跟产品版本走,第 4 段留给"只改插件、不动产品版本"的补丁发布。
|
|
||||||
> `--bump` / `--set` 会把第 4 段归零;要单独发插件补丁就手工改那两处(清单 + `BridgeService.cs`)。
|
|
||||||
|
|
||||||
## 一次完整的发布
|
## 一次完整的发布
|
||||||
|
|
||||||
下面这一串命令就是全部流程,逐条照抄即可。
|
下面这一串命令就是全部流程,逐条照抄即可。
|
||||||
@@ -79,8 +87,6 @@ pnpm docs:check # 文档站构建(警告即失败)
|
|||||||
pnpm dev:server # 另开一个终端跑着
|
pnpm dev:server # 另开一个终端跑着
|
||||||
|
|
||||||
pnpm verify:e2e
|
pnpm verify:e2e
|
||||||
pnpm verify:classisland
|
|
||||||
pnpm verify:classisland-plugin
|
|
||||||
|
|
||||||
pnpm build:desktop && pnpm verify:desktop
|
pnpm build:desktop && pnpm verify:desktop
|
||||||
pnpm build && pnpm verify:web
|
pnpm build && pnpm verify:web
|
||||||
@@ -92,14 +98,18 @@ pnpm build && pnpm verify:web
|
|||||||
### 4. 打安装包
|
### 4. 打安装包
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dist:all # = dist:server + dist:win + dist:classisland-plugin
|
|
||||||
```
|
```
|
||||||
|
|
||||||
产物落在 `releases/<组件>/<版本>/`:`安装包/`、`免安装/`、`构建中间/`。
|
产物落在 `releases/<组件>/<版本>/`:`安装包/`(交付物)、`构建中间/`(可重新生成)。
|
||||||
|
**没有免安装目录** —— 组装/解包出来的那一份是中间物,打包脚本用完就删(见仓库根 `AGENTS.md` §4
|
||||||
|
「交付产物归集」)。
|
||||||
|
|
||||||
打完之后**对打包副本再验一次**(开发产物通过 ≠ 用户机器上的副本通过):
|
打完之后**对打包副本再验一次**(开发产物通过 ≠ 用户机器上的副本通过):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm verify:packaged --exe "releases/client/<版本>/免安装/ClassHelper.exe"
|
# 默认指向装好的客户端;推荐直接拿刚打出来的便携版单文件当被测副本
|
||||||
|
pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe"
|
||||||
```
|
```
|
||||||
|
|
||||||
> Linux 包**只能在 Linux 上构建**(依赖是平台相关的),所以不在这条命令里 ——
|
> Linux 包**只能在 Linux 上构建**(依赖是平台相关的),所以不在这条命令里 ——
|
||||||
@@ -108,7 +118,7 @@ pnpm verify:packaged --exe "releases/client/<版本>/免安装/ClassHelper.exe"
|
|||||||
### 5. 生成跨组件哈希清单
|
### 5. 生成跨组件哈希清单
|
||||||
|
|
||||||
`releases/SHA256SUMS-<版本>.txt` 是**人读的**(表头 + 体积 + 说明),由发布流程维护,
|
`releases/SHA256SUMS-<版本>.txt` 是**人读的**(表头 + 体积 + 说明),由发布流程维护,
|
||||||
本地只写 Windows 与插件那几行:
|
本地只写 Windows那几行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd releases/client/<版本>/安装包 && sha256sum *.exe > ../../../SHA256SUMS-<版本>.txt # 按实际文件名整理
|
cd releases/client/<版本>/安装包 && sha256sum *.exe > ../../../SHA256SUMS-<版本>.txt # 按实际文件名整理
|
||||||
@@ -137,9 +147,8 @@ mkdir -p .cache/release-staging
|
|||||||
cp "releases/client/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-client-setup.exe"
|
cp "releases/client/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-client-setup.exe"
|
||||||
cp "releases/client/$V/安装包/"*-x64-portable.exe ".cache/release-staging/ClassHelper-$V-x64-client-portable.exe"
|
cp "releases/client/$V/安装包/"*-x64-portable.exe ".cache/release-staging/ClassHelper-$V-x64-client-portable.exe"
|
||||||
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/
|
|
||||||
|
|
||||||
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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -157,7 +166,7 @@ gh release upload v$V .cache/release-staging/* "releases/SHA256SUMS-$V.txt" --cl
|
|||||||
构建 Linux 包(ubuntu runner)。它跑完之后会:
|
构建 Linux 包(ubuntu runner)。它跑完之后会:
|
||||||
|
|
||||||
1. 把 `classhelper-server-linux-x64-<版本>.tar.gz` 与 `.sha256` 挂到 Release 上;
|
1. 把 `classhelper-server-linux-x64-<版本>.tar.gz` 与 `.sha256` 挂到 Release 上;
|
||||||
2. **把 Linux 那一行合并进** `SHA256SUMS-<版本>.txt`(保留 Windows / 插件那几行)。
|
2. **把 Linux 那一行合并进** `SHA256SUMS-<版本>.txt`(保留 Windows那几行)。
|
||||||
|
|
||||||
> **顺序很重要**:Linux workflow 是在最后一步才去合并 SHA256SUMS 的,
|
> **顺序很重要**:Linux workflow 是在最后一步才去合并 SHA256SUMS 的,
|
||||||
> 所以第 5 步那份清单必须**先上传**。万一手速反了(workflow 先跑完、你后上传),
|
> 所以第 5 步那份清单必须**先上传**。万一手速反了(workflow 先跑完、你后上传),
|
||||||
@@ -169,6 +178,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 一变会自动重跑。
|
||||||
|
> 别拿「Windows 侧绿不绿」当判据:`pnpm dist:server` 走的是另一条分支,
|
||||||
|
> Linux 那一步红的时候本地完全看不出来。
|
||||||
|
>
|
||||||
|
> 另外,本页第 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` 或
|
||||||
@@ -185,7 +203,7 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站
|
|||||||
|
|
||||||
文档站顶栏的版本徽标取自根 `package.json`,所以第 1 步改完就跟着变了;
|
文档站顶栏的版本徽标取自根 `package.json`,所以第 1 步改完就跟着变了;
|
||||||
官网首屏那排芯片、顶栏徽标与页脚里的版本号,以及下载页那个兜底版本号,
|
官网首屏那排芯片、顶栏徽标与页脚里的版本号,以及下载页那个兜底版本号,
|
||||||
也都是**由 `pnpm version:bump` 一起改的**(它们是那 14 个落点里的四个)。
|
也都是**由 `pnpm version:bump` 一起改的**(它们是那 12 个落点里的四个)。
|
||||||
|
|
||||||
> 官网的「立即下载」会跳到 `/download.html`,那一页的版本清单是**当场**从 GitHub Releases 读的
|
> 官网的「立即下载」会跳到 `/download.html`,那一页的版本清单是**当场**从 GitHub Releases 读的
|
||||||
> (读不到才退回上面那个兜底版本号),所以发完 Release 不用重新部署那一页 —— 但**兜底值要跟着发版一起改**,
|
> (读不到才退回上面那个兜底版本号),所以发完 Release 不用重新部署那一页 —— 但**兜底值要跟着发版一起改**,
|
||||||
@@ -193,7 +211,7 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站
|
|||||||
|
|
||||||
### 9. 检查
|
### 9. 检查
|
||||||
|
|
||||||
- [ ] Release 页面上四个组件都在,`SHA256SUMS-<版本>.txt` 里 Windows / 插件 / Linux 三种行齐全;
|
- [ ] Release 页面上三个组件都在,`SHA256SUMS-<版本>.txt` 里 Windows / Linux 三种行齐全;
|
||||||
- [ ] 文档站能打开,顶栏版本号是新版;
|
- [ ] 文档站能打开,顶栏版本号是新版;
|
||||||
- [ ] 装一台机器试一遍(或至少跑 `pnpm verify:packaged`)。
|
- [ ] 装一台机器试一遍(或至少跑 `pnpm verify:packaged`)。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: 2026-10-17 审查修复记录
|
||||||
|
description: 按审查报告核对缺陷、修复行为与验收方式。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-10-17 审查修复记录
|
||||||
|
|
||||||
|
本次按仓库根的代码审查报告逐项核对,保留此前联动退役与更新策略改动。
|
||||||
|
|
||||||
|
| 报告条目 | 处理结果 |
|
||||||
|
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 0.1 | 环境文档补充 PATH 与 Git Bash 启动方式;变更文档统一格式化。 |
|
||||||
|
| 1.1 | 恢复学期周次、毕业归档、权限组菜单,开发期检查路由是否遗漏菜单。 |
|
||||||
|
| 1.2 | 叫人通知写本地所属日期;启动时分批幂等回填旧通知,兼容 SQLite / MySQL。 |
|
||||||
|
| 1.3、2.14、3.10、3.14、4.5 | 每次建窗挂关闭隐藏处理;捕获加载失败;仅主窗口触发恢复,恢复期间等待异步加载;普通 Promise 拒绝不再重启进程,移除 activate 重复加载。 |
|
||||||
|
| 1.4、1.5 | 缓存按服务端和班级隔离,个人资料按服务端和登录名隔离;忽略跨会话的迟到响应,打开失败和版本切换后可重试 IndexedDB。旧无命名空间缓存不再直接作为新会话数据读取。 |
|
||||||
|
| 1.6 | 学期周统一加载后再取灵动岛课表,教学周变化触发重取;失败且无缓存时保留已知课表;主窗口禁用后台节流。 |
|
||||||
|
| 1.7、3.11 | IPC 拒绝渲染端改安装记录;安装只接受更新专用目录中的文件,拒绝目录外及软链接目标;取消、更换目标和更新完成后清理旧包。 |
|
||||||
|
| 2.1 | 空角色原本已保留旧值;现在未识别的非空值也保留原角色,只有明确的教师/管理员值才变更。 |
|
||||||
|
| 2.2、2.9 | 员工的成绩明细、看板、更新检查与强制检查落实权限;班级端仍由本班边界授权。增加学生/教师名单只读权限,旧管理组继承对应读取能力;下线没有独立服务端能力的日志与开发者模式权限键,系统设置仍由系统管理权限控制。 |
|
||||||
|
| 2.3 | 当前班级范围已由 CLASS_DEVICE 角色正确解析,不存在报告描述的必然拒绝,也不会查询全班学生。补齐 Socket 身份中的 classSession,保持元数据完整。 |
|
||||||
|
| 2.4、2.5 | 课表导入读取本班学期长度,创建课表按实际兜底周数做区间交叉校验。 |
|
||||||
|
| 2.6 | 姓名重复时要求正确学号,不能静默绑定到同名学生。 |
|
||||||
|
| 2.7 | 增加共享删除事件与两端订阅,并移除通知中心及灵动岛中的已删通知。 |
|
||||||
|
| 2.8 | 批量录入接口本身只接收单个 classId;真正缺口在跨班批量改等级,现已逐班广播。 |
|
||||||
|
| 2.10 | 调班历史按学生当前班级及显式班级过滤条件校验权限;未分班学生仅管理员可读。 |
|
||||||
|
| 2.11—2.13 | 引导使用主题文字变量,仅可见时响应 Esc;成绩明细优先展示保存的等级。 |
|
||||||
|
| 2.15—2.17 | 助手叫人校验非空消息;周次草稿提交前校验日期;通知日期改变后清除不再可选的发布人。发布人选择本身已有 change 处理,未重复添加。 |
|
||||||
|
| 3.1、3.4、3.9 | 列表丢弃过期响应;考试口径读写固定同一个班级/考试快照并禁用加载中的开关;切班立即清空课程候选。 |
|
||||||
|
| 3.2 | 提交函数增加互斥与 finally 释放,按钮同步显示 loading,快速双击不会重复发请求。 |
|
||||||
|
| 3.3 | 考试下拉从独立考试列表生成,不再由筛选后的成绩反推。 |
|
||||||
|
| 3.5—3.8 | 不回退到已归档届别;使用主题语义色、清理死样式;拒绝协议相对和反斜线附件地址;人数加载失败显示不可用。 |
|
||||||
|
| 3.12、3.13 | 取消确认框正常返回;列表加载与实时新增、删除、已读合并,防止快照抹掉飞行中的变更。 |
|
||||||
|
| 3.15 | 核对并修正登录、班级开关、设备权限、菜单数量、Socket 房间注释。登录限流当前已经一次挂载到两个入口并共享计数器,该条报告不成立。 |
|
||||||
|
| 4.1 | 百分制/字母制批量重算使用分数计算等级;自定义等级没有通用换算规则,禁止整批猜算,保留逐条编辑。 |
|
||||||
|
| 4.2 | 审计明确记录解析结果 interpreted / unrecognized,不再写成永远等待执行的 pending;它不宣称前端业务发布已成功。 |
|
||||||
|
| 4.3、4.4 | 助手上下文与预览参数均校验班级、科目范围;缺失通知优先级按普通通知展示。 |
|
||||||
|
| 4.6 | 外观 IPC 在一帧内合并滑块事件,读取时先刷新最后一帧,避免丢失最终值。 |
|
||||||
|
| 5 | 清理无调用的名单/课程封装和事件标签等;班级页接通既有任课关系导入。旧时间配置弹窗仍直接调用四个 API,按报告的保留要求保留组件及依赖,不盲目删除。 |
|
||||||
|
|
||||||
|
管理员的 Web 权限判断已移除全权限短路,与服务端保持一致;默认组收回的功能入口会隐藏。
|
||||||
|
日常冒烟采用后台快速模式,视觉验收显式执行;具体命令和验证范围见[验收与测试](testing.md)。
|
||||||
|
|
||||||
|
## 本次验证
|
||||||
|
|
||||||
|
- 全仓类型检查、ESLint、构建、变更格式检查通过,文档构建无警告。
|
||||||
|
- 隔离测试库端到端 243/243;审查接口回归 31/31;客户端维护接口 28/28。
|
||||||
|
- 无窗口逻辑回归 18/18;模拟远控及更新代理 15/15;旧库升级回归通过。
|
||||||
|
- 后台 Web 回归 37/37,约 38 秒;客户端联网冒烟 10/10,二次启动 9/9,总计约 15 秒。
|
||||||
|
- 本次使用独立 SQLite 测试库,没有执行真实关机或安装;未运行会占用桌面的完整视觉验收。
|
||||||
+40
-35
@@ -1,27 +1,33 @@
|
|||||||
---
|
---
|
||||||
title: 验收与测试
|
title: 验收与测试
|
||||||
description: 五套验收脚本怎么跑、覆盖什么、哪些用例是脆弱的,以及怎么判断失败是不是真回归。
|
description: 验收验收脚本怎么跑、覆盖什么、哪些用例是脆弱的,以及怎么判断失败是不是真回归。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 验收与测试
|
# 验收与测试
|
||||||
|
|
||||||
这个仓库的验收方式是**跑真东西**:真的起后端、真的开 Electron 窗口、真的点击菜单、
|
验收分为后台快速冒烟、隔离的接口与逻辑回归、显式视觉验收。日常默认不显示窗口、不占用前台;
|
||||||
真的发一条通知。不是单元测试,是端到端回归。
|
涉及动画、桌面像素和灵动岛几何时,再单独运行视觉验收。
|
||||||
|
|
||||||
## 五套脚本
|
## 验收脚本
|
||||||
|
|
||||||
| 命令 | 覆盖 | 需要什么 |
|
| 命令 | 覆盖 | 需要什么 |
|
||||||
| -------------------------------- | -------------------------------------------------------------------------------- | --------------------------- |
|
| ---------------------- | -------------------------------------------------------------------------------- | --------------------------- |
|
||||||
| `pnpm verify:e2e` | 后端 + 实时推送 + RBAC 越权 + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查 | **后端已启动** |
|
| `pnpm verify:e2e` | 后端 + 实时推送 + RBAC 越权 + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查 | **后端已启动** |
|
||||||
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动) | 后端已启动 + Web 产物已构建 |
|
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动) | 后端已启动 + Web 产物已构建 |
|
||||||
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | 已构建的客户端产物 |
|
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | 已构建的客户端产物 |
|
||||||
| `pnpm verify:packaged` | 对**打包后 / 已安装**的客户端 EXE 复跑同一套冒烟(`--exe` 指定路径) | 打包产物 |
|
| `pnpm verify:packaged` | 对**打包后 / 已安装**的客户端 EXE 复跑同一套冒烟(`--exe` 指定路径) | 打包产物 |
|
||||||
| `pnpm verify:classisland` | 设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理 | 后端已启动 |
|
|
||||||
| `pnpm verify:classisland-plugin` | 插件静态契约(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由) | 不需要 .NET,也不需要后端 |
|
|
||||||
|
|
||||||
条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`),**文档里不写死容易过期的总数** ——
|
条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`),**文档里不写死容易过期的总数** ——
|
||||||
每次跑完看脚本输出。
|
每次跑完看脚本输出。
|
||||||
|
|
||||||
|
客户端维护专项回归:
|
||||||
|
|
||||||
|
- `pnpm verify:client-agent`:模拟操作系统验证远控、回执重试与安装策略,不执行真实关机或安装器。
|
||||||
|
- `pnpm verify:client-migrations`:创建临时旧库验证升级、联动移除与默认组编辑持久化。
|
||||||
|
- `pnpm verify:clients-api`:面向 `http://127.0.0.1:4199` 的演示测试库(可用 `VERIFY_BASE_URL` 指定),验证权限、远控与更新接口。会创建并清理测试记录,请使用隔离测试服务。
|
||||||
|
- `pnpm verify:review-client`:无窗口验证缓存故障恢复、通知竞态、教学周同步、管理员界面权限与崩溃恢复。
|
||||||
|
- `pnpm verify:review-api`:针对审查缺陷验证接口权限、导入边界和实时删除等,必须连接隔离测试服务。
|
||||||
|
|
||||||
另外三套是"门禁"而不是验收:`pnpm typecheck`、`pnpm lint`、`pnpm check:icons`。
|
另外三套是"门禁"而不是验收:`pnpm typecheck`、`pnpm lint`、`pnpm check:icons`。
|
||||||
|
|
||||||
### 一次完整的交付前检查
|
### 一次完整的交付前检查
|
||||||
@@ -32,8 +38,6 @@ pnpm typecheck # 全仓库类型检查(含 vue-tsc)
|
|||||||
|
|
||||||
pnpm dev:server # 另开一个终端
|
pnpm dev:server # 另开一个终端
|
||||||
pnpm verify:e2e
|
pnpm verify:e2e
|
||||||
pnpm verify:classisland
|
|
||||||
pnpm verify:classisland-plugin
|
|
||||||
|
|
||||||
pnpm build:desktop && pnpm verify:desktop
|
pnpm build:desktop && pnpm verify:desktop
|
||||||
pnpm build && pnpm verify:web
|
pnpm build && pnpm verify:web
|
||||||
@@ -47,45 +51,48 @@ pnpm build && pnpm verify:web
|
|||||||
下面这些用例**不是回归**,遇到时先看条件。完整清单在 `AGENTS.md` 的「已知脆弱用例」表。
|
下面这些用例**不是回归**,遇到时先看条件。完整清单在 `AGENTS.md` 的「已知脆弱用例」表。
|
||||||
|
|
||||||
| 用例 | 触发条件 |
|
| 用例 | 触发条件 |
|
||||||
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `verify:desktop`「真实通知链路」「灵动岛标为已读」等 3 项 | 要求**当前不在上课时段**。种子课表恰有课正在进行时,客户端会**正确地**把普通通知暂存到下课 —— 这三项就会红(日志里会写 `客户端判定上课中=true`) |
|
| `verify:desktop:visual`「真实通知链路」「灵动岛标为已读」等 3 项 | 要求**当前不在上课时段**。种子课表恰有课正在进行时,客户端会**正确地**把普通通知暂存到下课 —— 这三项就会红(日志里会写 `客户端判定上课中=true`) |
|
||||||
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`.env` 的 `TERM_START_DATE` 对齐即可全绿 |
|
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`.env` 的 `TERM_START_DATE` 对齐即可全绿 |
|
||||||
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 |
|
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 |
|
||||||
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 |
|
| `verify:desktop:visual`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 |
|
||||||
| `verify:desktop`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 |
|
| `verify:desktop:visual`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 |
|
||||||
| 岛交互 / 岛动画 / 岛截图留档 | **AI 会话里会间歇性失败**:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
|
| 岛交互 / 岛动画 / 岛截图留档 | 窗口被遮挡或跑在远程会话里时会间歇性失败:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
|
||||||
|
|
||||||
## 大面积报「登录失败 / 401」时
|
## 大面积报「登录失败 / 401」时
|
||||||
|
|
||||||
先核对本机 `teacher1` 的密码 —— 本机实测可能被改成了 `123456`,不是种子默认的 `teacher123`
|
先检查隔离服务是否启动,以及测试管理员的当前口令。不要修改真实班级密码来迁就测试。
|
||||||
(原因是「修改密码」弹窗留空会落到默认初始密码)。**处理方式是用环境变量传实际凭据,不要去改数据。**
|
联网客户端和 Web 回归会自行创建临时教师与班级;下面的环境变量只配置测试管理员:
|
||||||
|
|
||||||
两个演示账号的口令**不一定一起漂**,所以不能简单地全局替换。
|
| 脚本 | 管理员凭据 |
|
||||||
|
| ------------------- | -------------------------------------------------------- |
|
||||||
|
| 客户端联网/打包冒烟 | `ELECTRON_SMOKE_ADMIN` / `ELECTRON_SMOKE_ADMIN_PASSWORD` |
|
||||||
|
| Web 回归 | `UI_SMOKE_ADMIN` / `UI_SMOKE_ADMIN_PASS` |
|
||||||
|
| 后端端到端回归 | 使用隔离种子库,口令由种子脚本与验收脚本约定 |
|
||||||
|
|
||||||
| 脚本 | 覆盖凭据的方式 |
|
默认离线快速冒烟不需要服务端凭据。
|
||||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` |
|
|
||||||
| `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) |
|
|
||||||
| `verify:web` | `set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web`(另有 `UI_SMOKE_ADMIN*`) |
|
|
||||||
| `verify:e2e` | **不支持环境变量**,需用临时副本替换(且副本必须放在 `packages/server/scripts/` 里,否则 `import 'socket.io-client'` 会失败) |
|
|
||||||
|
|
||||||
## 冒烟会真的动你的屏幕
|
## 默认后台运行,视觉验收单独执行
|
||||||
|
|
||||||
`verify:web` / `verify:desktop` / `verify:packaged` 会**真的弹窗口**、真的发通知、真的移动鼠标:
|
`pnpm verify:desktop` 默认运行隐藏、不可聚焦的离屏窗口,仅检查启动、桥接白名单、缓存读写、
|
||||||
|
跨服务端/跨班级缓存隔离和关闭隐藏。二次启动也只跑快速检查,不再重复整套动画。
|
||||||
|
|
||||||
- 跑的时候别动键鼠;
|
需要联网时运行 `pnpm verify:desktop:online`,创建临时教师与临时班级,结束后删除。
|
||||||
- 验证实例的灵动岛是**洋红主题色**(`#e91e8c`),一眼可以与你自己客户端那个区分开。
|
不再自动探测并复用真实班级,也不会重置真实班级密码。Web 回归同样使用隔离的验证教师与班级。
|
||||||
两条反馈里的"屏幕上出现了第二个灵动岛""点它反应不对"都源于这个叠影;
|
|
||||||
- 冒烟会往演示班发几条「张老师 · _自检_」通知,用完即删;中途被 kill 会留下残条。
|
`pnpm verify:desktop:visual`(或客户端启动器 `--visual`)才运行灵动岛动画、透明度、桌面截图等完整验收,
|
||||||
|
这类用例确实需要使用桌面。`verify:packaged` 也默认快速模式,显式传 `--visual` 才跑视觉验收。
|
||||||
|
视觉验收不适合在用户操作屏幕时自动运行。截图采集命令需要传入 `--visual`(或设置 `ELECTRON_SMOKE_MODE=visual`)。
|
||||||
|
结果记录模式和耗时;启动器设置超时,避免验证进程无限等待。
|
||||||
|
|
||||||
## 截图留档
|
## 截图留档
|
||||||
|
|
||||||
截图分两套,各管一件事:
|
截图分两套,各管一件事:
|
||||||
|
|
||||||
| 目录 | 谁在用 | 谁产出 |
|
| 目录 | 谁在用 | 谁产出 |
|
||||||
| ------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------- |
|
| ------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||||
| `docs/images/` | **文档站、README、官网用的是同一份图**,页面上展示的都是它 | `capture-shots.mjs`(Web / 手机)+ 客户端冒烟(客户端 / 灵动岛) |
|
| `docs/images/` | **文档站、README、官网用的是同一份图**,页面上展示的都是它 | `capture-shots.mjs`(Web / 手机)+ 客户端冒烟(客户端 / 灵动岛) |
|
||||||
| `docs/screenshots/` | 回归留档,页面不展示;`verify:*` 的像素级断言比对的就是它们 | 各冒烟脚本自动写入(island / client / web-mobile / classisland 四组) |
|
| `docs/screenshots/` | 回归留档,页面不展示;`verify:*` 的像素级断言比对的就是它们 | 各冒烟脚本自动写入(island / client / web-mobile 三组) |
|
||||||
|
|
||||||
**展示图重新采集**(保证文档站与 README、官网用的是同一份图):
|
**展示图重新采集**(保证文档站与 README、官网用的是同一份图):
|
||||||
|
|
||||||
@@ -97,8 +104,7 @@ CAPTURE_OUT=docs/images,website/assets/shots node website/tools/capture-shots.mj
|
|||||||
# (*-window.png / *-backdrop.png),所以先落到临时目录,再挑要用的拷进去。
|
# (*-window.png / *-backdrop.png),所以先落到临时目录,再挑要用的拷进去。
|
||||||
ISLAND_SHOTS_DIR="$PWD/.cache/shots" ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/shots" \
|
ISLAND_SHOTS_DIR="$PWD/.cache/shots" ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/shots" \
|
||||||
ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \
|
ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \
|
||||||
ELECTRON_SMOKE_USER=teacher1 ELECTRON_SMOKE_PASSWORD=123456 \
|
node packages/desktop-client/scripts/smoke.mjs --visual --online
|
||||||
node packages/desktop-client/scripts/smoke.mjs
|
|
||||||
cp .cache/shots/{island-*,client-*}.png docs/images/
|
cp .cache/shots/{island-*,client-*}.png docs/images/
|
||||||
rm -f docs/images/*-window.png docs/images/*-backdrop.png
|
rm -f docs/images/*-window.png docs/images/*-backdrop.png
|
||||||
cp docs/images/{island-*,client-*}.png website/assets/shots/ # 官网那份保持同步
|
cp docs/images/{island-*,client-*}.png website/assets/shots/ # 官网那份保持同步
|
||||||
@@ -111,8 +117,7 @@ pnpm dev:server
|
|||||||
ISLAND_SHOTS_DIR="$PWD/.cache/island-shots" \
|
ISLAND_SHOTS_DIR="$PWD/.cache/island-shots" \
|
||||||
ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/client-shots" \
|
ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/client-shots" \
|
||||||
ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \
|
ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \
|
||||||
ELECTRON_SMOKE_USER=teacher1 ELECTRON_SMOKE_PASSWORD=123456 \
|
node packages/desktop-client/scripts/smoke.mjs --visual --online
|
||||||
node packages/desktop-client/scripts/smoke.mjs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
> 展示图都是**逻辑像素 1×**(灵动岛的 268×44、424×230 就是这个数,不再乘 2),
|
> 展示图都是**逻辑像素 1×**(灵动岛的 268×44、424×230 就是这个数,不再乘 2),
|
||||||
|
|||||||
+2
-2
@@ -12,11 +12,11 @@
|
|||||||
href: permissions.md
|
href: permissions.md
|
||||||
- name: 动效层与主题
|
- name: 动效层与主题
|
||||||
href: motion.md
|
href: motion.md
|
||||||
- name: ClassIsland 插件
|
|
||||||
href: classisland-plugin.md
|
|
||||||
- name: 灵动岛设计令牌
|
- name: 灵动岛设计令牌
|
||||||
href: winisland-design-tokens.md
|
href: winisland-design-tokens.md
|
||||||
- name: 验收与测试
|
- name: 验收与测试
|
||||||
href: testing.md
|
href: testing.md
|
||||||
|
- name: 2026-10-17 审查修复记录
|
||||||
|
href: review-2026-10-17.md
|
||||||
- name: 版本迭代与发布
|
- name: 版本迭代与发布
|
||||||
href: release.md
|
href: release.md
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# WinIsland 视觉设计系统(Design Token 规格)
|
# WinIsland 视觉设计系统(Design Token 规格)
|
||||||
|
|
||||||
来源:`D:\classhelper\.cache\winisland-ref\WinIsland-master`(只读 checkout)。
|
来源:[WinIsland](https://github.com/WinIslandProject/WinIsland) 的源码(`WinIsland-master`,只读 checkout)。
|
||||||
所有数值均取自代码常量,标注了「常量名 = 值 @ 文件」。未在代码中找到的项明确写「未找到」。
|
所有数值均取自代码常量,标注了「常量名 = 值 @ 文件」。未在代码中找到的项明确写「未找到」。
|
||||||
|
|
||||||
关键前提:**岛内所有几何尺寸都乘以 `compact_scale`(默认 1.0)或 `expanded_scale`(默认 1.0)**,
|
关键前提:**岛内所有几何尺寸都乘以 `compact_scale`(默认 1.0)或 `expanded_scale`(默认 1.0)**,
|
||||||
|
|||||||
+4
-22
@@ -7,24 +7,6 @@ description: 装不上、连不上、改了不生效——最常被问到的那
|
|||||||
|
|
||||||
## 安装与启动
|
## 安装与启动
|
||||||
|
|
||||||
### 客户端装不上 / 便捷版双击没反应 / 应用一闪就没了
|
|
||||||
|
|
||||||
**现象**:安装程序报「拒绝访问」,便捷版双击毫无反应,装上了也启动即退且不写日志
|
|
||||||
(退出码 `0x80000003`)。
|
|
||||||
|
|
||||||
**原因**:交付物被打了 **Low 完整性标签**。带 Low 标签的 exe 会被 Windows 降级运行:
|
|
||||||
写不进 `%LOCALAPPDATA%` / `%TEMP%`;即使装上,Chromium 的子进程(完整性更低)也读不到这些文件而秒崩。
|
|
||||||
|
|
||||||
**处置**:从 [Releases](https://github.com/LoveChengke/classhelper/releases) 下载的官方包不受影响
|
|
||||||
(打包脚本收尾会自动修正标签)。自己构建时如果碰到,用:
|
|
||||||
|
|
||||||
```cmd
|
|
||||||
icacls <产物目录> /setintegritylevel Medium /T /C
|
|
||||||
```
|
|
||||||
|
|
||||||
判据是 `icacls <exe> | findstr Mandatory` 里出现 `Low`。
|
|
||||||
另外**别把安装测试装进仓库目录里** —— 在那里又会继承 Low。
|
|
||||||
|
|
||||||
### 双击客户端"点了没反应"
|
### 双击客户端"点了没反应"
|
||||||
|
|
||||||
**关窗只是隐藏到托盘,进程还在。** 再双击 EXE 会被单实例锁秒退(看起来像没反应),
|
**关窗只是隐藏到托盘,进程还在。** 再双击 EXE 会被单实例锁秒退(看起来像没反应),
|
||||||
@@ -46,7 +28,7 @@ icacls <产物目录> /setintegritylevel Medium /T /C
|
|||||||
1. 服务器地址要填**局域网 IP**(`http://192.168.1.10:4000`),不要写 `localhost`(那是客户端自己);
|
1. 服务器地址要填**局域网 IP**(`http://192.168.1.10:4000`),不要写 `localhost`(那是客户端自己);
|
||||||
2. 服务器防火墙放行 **4000**;
|
2. 服务器防火墙放行 **4000**;
|
||||||
3. 服务器上如果开了系统代理,内网地址可能被代理接管 —— 浏览器与客户端**不会**自动绕过
|
3. 服务器上如果开了系统代理,内网地址可能被代理接管 —— 浏览器与客户端**不会**自动绕过
|
||||||
(只有 ClassIsland 插件对本地 / 内网做了强制直连);
|
请在系统代理中为内网服务端设置绕过规则;
|
||||||
4. 在服务器本机 `curl.exe --noproxy '*' http://127.0.0.1:4000/healthz` 是不是 200?
|
4. 在服务器本机 `curl.exe --noproxy '*' http://127.0.0.1:4000/healthz` 是不是 200?
|
||||||
|
|
||||||
### 页面能打开,但接口全 401
|
### 页面能打开,但接口全 401
|
||||||
@@ -75,7 +57,7 @@ icacls <产物目录> /setintegritylevel Medium /T /C
|
|||||||
|
|
||||||
1. 客户端顶栏的实时通道是不是"已连接"?
|
1. 客户端顶栏的实时通道是不是"已连接"?
|
||||||
2. 现在是上课时段吗?(同上,是设计行为)
|
2. 现在是上课时段吗?(同上,是设计行为)
|
||||||
3. 教室客户端的「设置 → 提醒」是不是设成了"只在 ClassIsland 上弹"?那样客户端只进通知中心。
|
3. 确认客户端登录的是这条通知对应的班级。
|
||||||
4. 反向代理放行 `/socket.io/` 的 WebSocket 升级了吗?
|
4. 反向代理放行 `/socket.io/` 的 WebSocket 升级了吗?
|
||||||
|
|
||||||
### 屏幕上出现了第二个灵动岛,点它反应不对
|
### 屏幕上出现了第二个灵动岛,点它反应不对
|
||||||
@@ -148,8 +130,8 @@ Linux 上跑 `sudo classhelper doctor` 有专门一条断言盯着这个软链
|
|||||||
|
|
||||||
## 还没解决?
|
## 还没解决?
|
||||||
|
|
||||||
- 开发环境的坑(命令、代理与 TLS、完整性标签、ClassIsland 路径与日志编码……)
|
- **开发与构建环境**的问题(命令、代理与 TLS、ClassIsland 路径与日志编码……)
|
||||||
都记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里;
|
记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里;
|
||||||
- 功能行为的权威说明在[完整参考](reference.md);
|
- 功能行为的权威说明在[完整参考](reference.md);
|
||||||
- 都不是的话,去 [Issues](https://github.com/LoveChengke/classhelper/issues) 提一个,
|
- 都不是的话,去 [Issues](https://github.com/LoveChengke/classhelper/issues) 提一个,
|
||||||
附上版本号(Web 端顶栏 / 客户端「设置 → 关于」里都有)和日志。
|
附上版本号(Web 端顶栏 / 客户端「设置 → 关于」里都有)和日志。
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
---
|
|
||||||
title: 接入 ClassIsland
|
|
||||||
description: 把教室里的 ClassIsland 接上 ClassHelper:下课表上报、大屏提醒下发、课表镜像三个方向。
|
|
||||||
---
|
|
||||||
|
|
||||||
# 接入 ClassIsland
|
|
||||||
|
|
||||||
课堂里真正"知道现在第几节、在上什么课"的是 [ClassIsland](https://classisland.tech)。
|
|
||||||
它跟 ClassHelper管的事情不重叠,接起来之后两边各拿各的好处:
|
|
||||||
|
|
||||||
| 方向 | 做什么 | 老师在哪看 |
|
|
||||||
| ------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- |
|
|
||||||
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止),写进本班课表 | Web 端「ClassIsland 联动」的实时状态卡片 |
|
|
||||||
| ClassHelper → ClassIsland | 老师发一条**提醒** → 落库 → 教室插件取回 → ClassIsland 上**全屏弹出**(可语音朗读) | Web 端「ClassIsland 联动 → 下发提醒」 |
|
|
||||||
| (可选)班级 → ClassIsland 镜像 | 把小助手这边排好的课表**镜像**回 ClassIsland,新建一份档案,不动老师原有的课表 | 同一页的「开镜像」开关 |
|
|
||||||
|
|
||||||
> 没接 ClassIsland 也能用 ClassHelper,只是教室里少一个大屏播报的出口。**这一步是可选的。**
|
|
||||||
|
|
||||||
## 前置:ClassIsland 装好了
|
|
||||||
|
|
||||||
本插件按 ClassIsland 插件 API 2.0 编写,ClassIsland 2.0.x 与 2.1.0.x 都能加载。
|
|
||||||
先确认教室机器上 ClassIsland 本身能正常显示课表。
|
|
||||||
|
|
||||||
## 第一步:在 Web 端签发设备令牌
|
|
||||||
|
|
||||||
**ClassIsland 联动**页(管理员 / 本班班主任)→「接入新设备」→ 选班级 → 生成令牌。
|
|
||||||
|
|
||||||
令牌形如 `chci_xxxxxxxx…`,**明文只显示这一次**:服务端只存它的 sha256 与一个前缀提示
|
|
||||||
(`chci_80fa1c…`),用来在设备列表里人眼区分。丢了就重置一个。
|
|
||||||
|
|
||||||
## 第二步:在教室机器上装插件并填令牌
|
|
||||||
|
|
||||||
### 装插件
|
|
||||||
|
|
||||||
从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取
|
|
||||||
`ClassHelper.ClassIslandPlugin.cipx`:
|
|
||||||
|
|
||||||
- **推荐**:在 ClassIsland 里直接安装这个 `.cipx`(插件页有「从文件安装」)。
|
|
||||||
- 或者手工解压:把包里的 `ClassHelper.ClassIslandPlugin/` 目录整个丢进
|
|
||||||
`<ClassIsland 数据目录>\Plugins\`,然后重启 ClassIsland。
|
|
||||||
|
|
||||||
> **ClassIsland 运行时会锁住 DLL**。要覆盖升级插件,必须先完全退出 ClassIsland 再覆盖,
|
|
||||||
> 否则写入会被拒。插件配置在 `Config\Plugins\<插件 id>\Settings.json`,
|
|
||||||
> 里面存着服务器地址与设备令牌 —— **升级时别覆盖这个文件**。
|
|
||||||
> 另外改配置要在 ClassIsland 退出时改,运行中改会被内存里的副本回写覆盖。
|
|
||||||
|
|
||||||
### 填令牌
|
|
||||||
|
|
||||||
ClassIsland 的**设置 → ClassHelper 联动**里填:
|
|
||||||
|
|
||||||
| 设置项 | 说明 |
|
|
||||||
| ---------------------------- | --------------------------------------------------------------- |
|
|
||||||
| 后端地址 | `http://<服务器IP>:4000` |
|
|
||||||
| 设备令牌 | 上一步生成的 `chci_…` |
|
|
||||||
| 上报课表到小助手 | 打开后才会把 ClassIsland 的课表同步过来 |
|
|
||||||
| 接收小助手提醒 | 关掉就收不到老师发的提醒 |
|
|
||||||
| 提醒轮询间隔 | 默认 10 秒(可调到 5 秒)。上报本身就是心跳,轮询只是顺带取提醒 |
|
|
||||||
| 把班级课表镜像回 ClassIsland | 见下面「镜像」一节 |
|
|
||||||
|
|
||||||
填完点**「立即上报」**。
|
|
||||||
|
|
||||||
## 第三步:验证
|
|
||||||
|
|
||||||
回到 Web 端的「ClassIsland 联动」页,应该出现设备卡片和「在线」标记,实时状态卡上会显示这台机器
|
|
||||||
现在上什么课。到这一步链路就通了。
|
|
||||||
|
|
||||||
如果一直离线:先看插件的日志,再看服务端能不能从教室机器访问到(**别让系统代理拦住内网地址** ——
|
|
||||||
插件对本地 / 内网做了强制直连,但填错地址一样连不上)。
|
|
||||||
|
|
||||||
## 提醒弹在哪个端:由教室客户端自己选
|
|
||||||
|
|
||||||
教室机器的**客户端**「设置 → 提醒」里选,选择会同步到班级记录:
|
|
||||||
|
|
||||||
| 选择 | ClassHelper 客户端 | ClassIsland |
|
|
||||||
| ----------------------------------------- | ------------------------------ | ----------- |
|
|
||||||
| `ClassHelper 与 ClassIsland 都弹`(默认) | 弹窗 + 灵动岛 | 全屏提醒 |
|
|
||||||
| `只在 ClassHelper 客户端弹` | 弹窗 + 灵动岛 | 不推送 |
|
|
||||||
| `只在 ClassIsland 上弹` | 只进通知中心(不弹窗、不上岛) | 全屏提醒 |
|
|
||||||
|
|
||||||
规则只有一条,**任何入口都遵守**:通知发布页、叫人、联动页的「下发提醒」三个入口共用同一份推送实现,
|
|
||||||
不存在"某个页面发的通知不联动"。
|
|
||||||
|
|
||||||
## 两个方向各需要哪些开关
|
|
||||||
|
|
||||||
| 要同步的内容 | 需要打开 |
|
|
||||||
| -------------------------------- | ------------------------------------------------------------------------------- |
|
|
||||||
| 服务端 → ClassIsland(镜像课表) | ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」 |
|
|
||||||
| ClassIsland → 服务端(上报课表) | 插件设置「上报课表到 ClassHelper」 |
|
|
||||||
| 提醒下发 | ① 教室客户端的「通知显示位置」不是"只在 ClassHelper" + ② 插件「接收小助手提醒」 |
|
|
||||||
|
|
||||||
## 上课时段:两边同一套规则
|
|
||||||
|
|
||||||
老师在**上课时段**发通知时,教室里的两个端遵守同一条规则,否则会出现"学生在客户端看不到、
|
|
||||||
却在 ClassIsland 上被打断"的怪事:
|
|
||||||
|
|
||||||
| 提醒类型 | 上课时段 | 下课 / 放学 |
|
|
||||||
| ------------------------------------------------------ | ---------------------------- | ----------- |
|
|
||||||
| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹**(两边都先收着) | 自动补弹 |
|
|
||||||
| 主动通知:**紧急提醒**、**叫人**(`kind=call`) | **立刻弹** | 立刻弹 |
|
|
||||||
|
|
||||||
ClassIsland 侧的暂存由插件实现:它订阅课程服务,下课后把暂存的提醒补弹出来;未弹出前不回执,
|
|
||||||
所以服务端会一直保留着待提醒,教室机器断网也不丢。
|
|
||||||
|
|
||||||
## 上报课表是"双向对齐"的
|
|
||||||
|
|
||||||
插件每次全量上报时,服务端只清理**「来源是 ClassIsland 且这次没再上报」**的课表行。于是:
|
|
||||||
|
|
||||||
- 老师在 ClassIsland 里删掉 / 挪动一节 → 小助手这边跟着同步;
|
|
||||||
- 老师在小助手这边手排的课 → 一律不删。
|
|
||||||
|
|
||||||
## 镜像:把小助手的课表写回 ClassIsland
|
|
||||||
|
|
||||||
开启后,插件会新建一份名为 `ClassHelper-<班级名>` 的档案课表,**不动老师原有的课表**。
|
|
||||||
|
|
||||||
镜像时会连节次时间一起带过去,并且**每两节之间补一段课间、最后一节之后不补**(放学)。
|
|
||||||
这一步不做的话,ClassIsland 的课表会整体错位 —— 表现为"看着有内容、每节课都对不上"。
|
|
||||||
|
|
||||||
细节与实现见[ClassIsland 插件](../dev/classisland-plugin.md)。
|
|
||||||
|
|
||||||
下一步:[发布第一条内容](first-content.md)。
|
|
||||||
@@ -13,19 +13,17 @@ description: 在教室机器上安装 ClassHelper桌面客户端,用班级码
|
|||||||
从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取:
|
从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取:
|
||||||
|
|
||||||
| 产物 | 用途 |
|
| 产物 | 用途 |
|
||||||
| ------------------------------------ | ------------------------------------------ |
|
| ------------------------------------- | ------------------------------------------ |
|
||||||
| `ClassHelper-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 |
|
| `ClassHelper-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 |
|
||||||
| `ClassHelper-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 |
|
| `ClassHelper-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 |
|
||||||
|
|
||||||
要自己打:
|
要自己打:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dist:win # 安装包 + 便携版;只要免安装目录用 pnpm dist:dir
|
pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的解包目录)
|
||||||
```
|
```
|
||||||
|
|
||||||
> **装不上 / 双击没反应 / 一闪就没**(报 `0x80000003`)通常不是产物坏了,而是交付物被打了
|
> **双击没反应?** 关窗只是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘右键退出。
|
||||||
> Low 完整性标签。打包脚本收尾已自动修正;从 Release 下载的包不受影响。
|
|
||||||
> 另外:关窗是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘退出。
|
|
||||||
|
|
||||||
## 2. 登录
|
## 2. 登录
|
||||||
|
|
||||||
@@ -73,8 +71,8 @@ pnpm dist:win # 安装包 + 便携版;只要免安装目录用 pnpm dist:d
|
|||||||
2. **调灵动岛** —— 设置 → 灵动岛:高度、宽度、字号、主题色、停靠位置、视觉风格。
|
2. **调灵动岛** —— 设置 → 灵动岛:高度、宽度、字号、主题色、停靠位置、视觉风格。
|
||||||
讲台机器上通常把字号调大一点、位置放到顶部居中。
|
讲台机器上通常把字号调大一点、位置放到顶部居中。
|
||||||
触摸屏(希沃白板等)走的是另一套命中逻辑,客户端会自动识别,不用手动开。
|
触摸屏(希沃白板等)走的是另一套命中逻辑,客户端会自动识别,不用手动开。
|
||||||
3. **选提醒弹在哪端** —— 设置 → 提醒:`ClassHelper 与 ClassIsland 都弹` / 只在客户端 / 只在 ClassIsland。
|
3. **查看提醒规则** —— 设置 → 提醒:普通通知下课显示,紧急提醒立即显示。
|
||||||
这个选择会同步到班级记录,服务端据此决定要不要推 ClassIsland。
|
这个选择会同步到班级记录,服务端据此决定要不要广播到客户端。
|
||||||
|
|
||||||
## 6. 确认实时链路是通的
|
## 6. 确认实时链路是通的
|
||||||
|
|
||||||
@@ -85,5 +83,3 @@ pnpm dist:win # 安装包 + 便携版;只要免安装目录用 pnpm dist:d
|
|||||||
- 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗?
|
- 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗?
|
||||||
|
|
||||||
排查清单见[常见问题](../faq.md)。
|
排查清单见[常见问题](../faq.md)。
|
||||||
|
|
||||||
下一步(可选):[接入 ClassIsland](classisland.md)。
|
|
||||||
@@ -58,21 +58,3 @@ ClassHelper 班级端那边默认是**看板模式**:按科目分卡片、每
|
|||||||
- 导入结果会给逐行的错误与警告,`errors[].row` 是 **Excel 视角的行号**(含表头),改完可以再导。
|
- 导入结果会给逐行的错误与警告,`errors[].row` 是 **Excel 视角的行号**(含表头),改完可以再导。
|
||||||
|
|
||||||
ClassHelper 班级端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。
|
ClassHelper 班级端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。
|
||||||
|
|
||||||
## 在教室大屏上播一条提醒(需要 ClassIsland)
|
|
||||||
|
|
||||||
**ClassIsland 联动 → 下发提醒** → 选班级、填标题内容、设置显示时长与语音朗读 → 下发。
|
|
||||||
|
|
||||||
它会**落库**再推给教室。教室机器断网也不丢:插件重连后会拉取还没确认的那条,
|
|
||||||
立刻弹出来,弹完回执,不会重复弹。
|
|
||||||
|
|
||||||
如果教室客户端把「通知显示位置」设成了"只在 ClassHelper 客户端显示",这一步会回一句
|
|
||||||
「该班教室已设置为只在 ClassHelper 客户端显示」—— 那是设计行为,不是报错。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
到这里你已经把整套系统跑通了。接下来:
|
|
||||||
|
|
||||||
- 每一项功能的完整行为 → [应用帮助](../app/index.md)
|
|
||||||
- 服务器怎么维护 → [管理运维](../management/index.md)
|
|
||||||
- 遇到问题 → [常见问题](../faq.md)
|
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
---
|
---
|
||||||
title: 快速上手
|
title: 快速上手
|
||||||
description: 从零把 ClassHelper跑起来:装服务端、认管理端、建班级排课、装 ClassHelper 班级端、接 ClassIsland、发第一条内容。
|
description: 从零把 ClassHelper跑起来:装服务端、认管理端、建班级排课、装 ClassHelper 班级端、发第一条内容。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 快速上手
|
# 快速上手
|
||||||
|
|
||||||
ClassHelper不是一个单机软件,它由四部分组成,**必须先有服务端,其他三端才有内容可看**:
|
ClassHelper不是一个单机软件,它由三部分组成,**必须先有服务端,管理端与客户端才有内容可看**:
|
||||||
|
|
||||||
| 组成部分 | 装在哪 | 谁用 | 必须先装? |
|
| 组成部分 | 装在哪 | 谁用 | 必须先装? |
|
||||||
| ------------------------ | --------------------------------------- | -------------------------- | ---------------- |
|
| -------------- | --------------------------------------- | -------------------------- | ---------------- |
|
||||||
| **服务端** | 一台能一直开着的机器(服务器/办公电脑) | —(它同时托管 Web 管理端) | ✅ 是 |
|
| **服务端** | 一台能一直开着的机器(服务器/办公电脑) | —(它同时托管 Web 管理端) | ✅ 是 |
|
||||||
| **Web 管理端** | 不用装 —— 浏览器打开服务端地址即可 | 老师、班主任、管理员 | 跟着服务端一起有 |
|
| **Web 管理端** | 不用装 —— 浏览器打开服务端地址即可 | 老师、班主任、管理员 | 跟着服务端一起有 |
|
||||||
| **桌面客户端** | 教室的电脑 / 教室大屏 | 学生看课表作业、老师录作业 | 服务端就绪之后 |
|
| **桌面客户端** | 教室的电脑 / 教室大屏 | 学生看课表作业、老师录作业 | 服务端就绪之后 |
|
||||||
| **ClassIsland 联动插件** | 教室那台装了 ClassIsland 的机器 | 教室大屏播报 | 可选 |
|
|
||||||
|
|
||||||
按顺序走下面这几页就行,全程大约半小时(不含下载时间):
|
按顺序走下面这几页就行,全程大约半小时(不含下载时间):
|
||||||
|
|
||||||
@@ -21,8 +20,7 @@ ClassHelper不是一个单机软件,它由四部分组成,**必须先有服
|
|||||||
3. [认识 Web 管理端](web-admin.md) —— 登录进去,认一认界面
|
3. [认识 Web 管理端](web-admin.md) —— 登录进去,认一认界面
|
||||||
4. [建班级、排课表](setup-class.md) —— 这是后面所有功能的前提
|
4. [建班级、排课表](setup-class.md) —— 这是后面所有功能的前提
|
||||||
5. [装 ClassHelper 班级端](client.md) —— 教室机器用班级码登录
|
5. [装 ClassHelper 班级端](client.md) —— 教室机器用班级码登录
|
||||||
6. [接入 ClassIsland](classisland.md) —— 让教室大屏也会弹提醒(可选)
|
6. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩
|
||||||
7. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩
|
|
||||||
|
|
||||||
## 只想先看看它长什么样?
|
## 只想先看看它长什么样?
|
||||||
|
|
||||||
@@ -30,7 +28,7 @@ ClassHelper不是一个单机软件,它由四部分组成,**必须先有服
|
|||||||
|
|
||||||
- **看界面**:本页文档里各处嵌的都是实机截图,比如[灵动岛](../app/island.md)、[课表](../app/schedules.md)。
|
- **看界面**:本页文档里各处嵌的都是实机截图,比如[灵动岛](../app/island.md)、[课表](../app/schedules.md)。
|
||||||
- **本地跑一套演示数据**:装好 Node 20.19+ 与 pnpm 之后,
|
- **本地跑一套演示数据**:装好 Node 20.19+ 与 pnpm 之后,
|
||||||
`pnpm install && pnpm build:shared && pnpm db:seed && pnpm dev` 会在本机拉起一套带演示班级的后端与
|
`pnpm install && pnpm build:shared && pnpm db:seed && pnpm dev` 会在本地拉起一套带演示班级的后端与
|
||||||
Web 管理端(详见[安装服务端](server.md#从源码跑开发模式))。
|
Web 管理端(详见[安装服务端](server.md#从源码跑开发模式))。
|
||||||
|
|
||||||
## 装完之后呢
|
## 装完之后呢
|
||||||
|
|||||||
@@ -64,9 +64,8 @@ ldd --version | head -1
|
|||||||
| 教室机器 / 老师电脑也要用 | 客户端与管理端填服务器的**局域网 IP**(如 `http://192.168.1.10:4000`),防火墙放行 4000 |
|
| 教室机器 / 老师电脑也要用 | 客户端与管理端填服务器的**局域网 IP**(如 `http://192.168.1.10:4000`),防火墙放行 4000 |
|
||||||
| 公网访问 | 前面加 Nginx / Caddy 反代并启用 HTTPS,见[生产部署指南](../management/production.md) |
|
| 公网访问 | 前面加 Nginx / Caddy 反代并启用 HTTPS,见[生产部署指南](../management/production.md) |
|
||||||
|
|
||||||
> **别用代理**。本机自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`;
|
> **别用代理**。自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`;
|
||||||
> 教室机器上如果开了系统代理,填服务端地址后可能拿到代理返回的 404。插件里对本地 / 内网地址做了强制直连,
|
> 教室机器上如果开了系统代理,填服务端地址后可能拿到代理返回的 404。请检查系统代理绕过列表是否包含服务端的局域网地址。
|
||||||
> 但浏览器与客户端本身不会绕过。
|
|
||||||
|
|
||||||
## 卸载 / 重置
|
## 卸载 / 重置
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已装依赖,跳过
|
|||||||
产物:
|
产物:
|
||||||
|
|
||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
| --------------------------------------------------------------------- | ------------------------------------------- |
|
| ----------------------------------------------------------------------- | -------------------------- |
|
||||||
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||||
| `releases/server/<版本>/免安装/` | 免安装目录,拷到任意 Windows x64 机器即可跑 |
|
|
||||||
|
> 交付产物只有安装程序。组装出来的那份运行时(内置 Node + 依赖 + 后端与 Web 产物,约 335 MB)
|
||||||
|
> 只是打安装包的中间物,落在 `.cache/server-staging/<版本>/` 下,打完脚本就删掉了 ——
|
||||||
|
> 想留一份可直接双击运行的副本,见本文末「不用安装程序的手动部署」。
|
||||||
|
|
||||||
包内自带 Node 运行时、后端产物、Web 管理端产物、生产依赖、迁移 SQL、随机 `JWT_SECRET` 的 `.env`
|
包内自带 Node 运行时、后端产物、Web 管理端产物、生产依赖、迁移 SQL、随机 `JWT_SECRET` 的 `.env`
|
||||||
与启停脚本。目标机器**不需要预先安装 Node.js**。
|
与启停脚本。目标机器**不需要预先安装 Node.js**。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: 建第一个班级、配班级账号、加学生与老师、排课
|
|||||||
|
|
||||||
# 建班级、排课表
|
# 建班级、排课表
|
||||||
|
|
||||||
后面所有功能(发通知、布置作业、录成绩、接 ClassIsland)都挂在**班级**上,所以先把班级建出来。
|
后面所有功能(发通知、布置作业、录成绩)都挂在**班级**上,所以先把班级建出来。
|
||||||
|
|
||||||
## 1. 建班级(仅管理员)
|
## 1. 建班级(仅管理员)
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,5 @@
|
|||||||
href: setup-class.md
|
href: setup-class.md
|
||||||
- name: 装 ClassHelper 班级端
|
- name: 装 ClassHelper 班级端
|
||||||
href: client.md
|
href: client.md
|
||||||
- name: 接入 ClassIsland
|
|
||||||
href: classisland.md
|
|
||||||
- name: 发布第一条内容
|
- name: 发布第一条内容
|
||||||
href: first-content.md
|
href: first-content.md
|
||||||
@@ -21,13 +21,13 @@ Web 管理端不用装:服务端起来之后,浏览器打开 `http://<服务
|
|||||||
布局沿用 1Panel 那一套:左边固定深色侧边栏,右边浅灰底 + 白色圆角卡片。
|
布局沿用 1Panel 那一套:左边固定深色侧边栏,右边浅灰底 + 白色圆角卡片。
|
||||||
|
|
||||||
| 页面 | 干什么 | 谁能进 |
|
| 页面 | 干什么 | 谁能进 |
|
||||||
| ---------------- | ---------------------------------------------- | ---------------------- |
|
| ---------- | ---------------------------------------------- | ---------------------- |
|
||||||
| 仪表盘 | 全校 / 本班的汇总数字与最近动态 | 所有登录用户 |
|
| 仪表盘 | 全校 / 本班的汇总数字与最近动态 | 所有登录用户 |
|
||||||
| 课表管理 | 排课、导入 ClassIsland 课表、节次时间 | 管理员、本班班主任 |
|
| 课表管理 | 排课、导入 ClassIsland 课表、节次时间 | 管理员、本班班主任 |
|
||||||
| 作业发布 | 发作业、按天查看、看未交名单 | 教师、管理员、班级账号 |
|
| 作业发布 | 发作业、按天查看、看未交名单 | 教师、管理员、班级账号 |
|
||||||
| 通知发布 | 发通知、发叫人 | 教师、管理员 |
|
| 通知发布 | 发通知、发叫人 | 教师、管理员 |
|
||||||
| 成绩录入 | 单条 / 批量录成绩、导入表格、看统计 | 管理员、本班班主任 |
|
| 成绩录入 | 单条 / 批量录成绩、导入表格、看统计 | 管理员、本班班主任 |
|
||||||
| ClassIsland 联动 | 接入教室设备、下发提醒、看实时状态、开镜像 | 管理员、教师 |
|
| 客户端管理 | 看设备状态、下发远控与更新 | 管理员、教师 |
|
||||||
| 班级管理 | 建班级、换班主任、改班级账号 | **仅管理员** |
|
| 班级管理 | 建班级、换班主任、改班级账号 | **仅管理员** |
|
||||||
| 学生管理 | 学生名单增删、导入 | **仅管理员** |
|
| 学生管理 | 学生名单增删、导入 | **仅管理员** |
|
||||||
| 教师管理 | 教师账号增删改、导入名单、重置密码 | **仅管理员** |
|
| 教师管理 | 教师账号增删改、导入名单、重置密码 | **仅管理员** |
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
+5
-7
@@ -5,18 +5,17 @@ description: ClassHelper(Class Helper)的官方文档:安装部署、功
|
|||||||
|
|
||||||
# ClassHelper文档
|
# ClassHelper文档
|
||||||
|
|
||||||
ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统。**一份代码,四个交付物**:
|
ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统。**一份代码,三个交付物**:
|
||||||
装在服务器上的后端、给老师用的 Web 管理端、装在教室机器的桌面客户端(ClassHelper 班级端 / 教室大屏),
|
装在服务器上的后端、给老师用的 Web 管理端、装在教室机器的桌面客户端(ClassHelper 班级端 / 教室大屏)。
|
||||||
以及装在 [ClassIsland](https://classisland.tech) 上的联动插件。
|
|
||||||
|
|
||||||
<p class="ch-tagline">
|
<p class="ch-tagline">
|
||||||
老师发一条通知,教室那台机器上的灵动岛和 ClassIsland 会同时弹出来;学生打开客户端就能看到当天的课表和作业。
|
老师发一条通知,教室那台机器上的客户端和灵动岛会实时显示;学生打开客户端就能看到当天的课表和作业。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="ch-cards">
|
<div class="ch-cards">
|
||||||
<a class="ch-card" href="get-started/index.md">
|
<a class="ch-card" href="get-started/index.md">
|
||||||
<span class="ch-card-title">第一次装</span>
|
<span class="ch-card-title">第一次装</span>
|
||||||
<span class="ch-card-desc">从零把服务端、管理端、ClassHelper 班级端和 ClassIsland 插件跑起来,大约半小时。</span>
|
<span class="ch-card-desc">从零把服务端、管理端、ClassHelper 班级端跑起来,大约半小时。</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="ch-card" href="app/index.md">
|
<a class="ch-card" href="app/index.md">
|
||||||
<span class="ch-card-title">已经在用了</span>
|
<span class="ch-card-title">已经在用了</span>
|
||||||
@@ -54,8 +53,7 @@ ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统
|
|||||||
|
|
||||||
## 这套系统的三件事
|
## 这套系统的三件事
|
||||||
|
|
||||||
**一、老师发,教室弹。** Web 端发布的内容通过 Socket.IO 按班级房间广播,客户端与灵动岛实时更新;
|
**一、老师发,教室弹。** Web 端发布的内容通过 Socket.IO 按班级房间广播,客户端与灵动岛实时更新。
|
||||||
落到 ClassIsland 上的那部分走设备令牌的独立通道,走 HTTP 上报,不依赖教室机器的网络环境有多好。
|
|
||||||
|
|
||||||
**二、上课时段不打扰。** 「上课时段」由课表实时判定,普通通知只进队列、下课自动补弹,
|
**二、上课时段不打扰。** 「上课时段」由课表实时判定,普通通知只进队列、下课自动补弹,
|
||||||
紧急通知和叫人则立刻弹出来 —— 服务端强制拦截,绕开界面直接调接口一样会被拦。
|
紧急通知和叫人则立刻弹出来 —— 服务端强制拦截,绕开界面直接调接口一样会被拦。
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
|
|||||||
|
|
||||||
| 能力 | 说明 |
|
| 能力 | 说明 |
|
||||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、16 张表的行数、连接串(MySQL 密码脱敏) |
|
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、23 张表的行数、连接串(MySQL 密码脱敏) |
|
||||||
| 连接测试 | 对任意目标库试连(切换前的第一步) |
|
| 连接测试 | 对任意目标库试连(切换前的第一步) |
|
||||||
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** |
|
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** |
|
||||||
| 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
|
| 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
|
||||||
@@ -23,7 +23,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
|
|||||||
|
|
||||||
**备份、导入导出、跨库迁移共用同一种「JSON 快照」**:
|
**备份、导入导出、跨库迁移共用同一种「JSON 快照」**:
|
||||||
|
|
||||||
- 16 张表按**拓扑序**导出;
|
- 23 张表按**拓扑序**导出;
|
||||||
- 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User`
|
- 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User`
|
||||||
是**互相引用**的,任何排序都会被其中一侧卡死;
|
是**互相引用**的,任何排序都会被其中一侧卡死;
|
||||||
- 分批 `createMany` 写入。
|
- 分批 `createMany` 写入。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ description: 部署形态怎么选、备份与恢复、换数据库、升级与
|
|||||||
| **Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | NSIS 安装程序,内置 Node 运行时 | [生产部署指南](production.md) |
|
| **Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | NSIS 安装程序,内置 Node 运行时 | [生产部署指南](production.md) |
|
||||||
| **Linux 原生 + systemd** | 学校已有 Linux 服务器 | 一键安装器 + `classhelper` 运维命令 | [Linux 部署与运维](linux-deploy.md) |
|
| **Linux 原生 + systemd** | 学校已有 Linux 服务器 | 一键安装器 + `classhelper` 运维命令 | [Linux 部署与运维](linux-deploy.md) |
|
||||||
| **Docker** | 云服务器、跟别的服务共用一台机器 | `deploy/docker-compose*.yml` | [生产部署指南](production.md) |
|
| **Docker** | 云服务器、跟别的服务共用一台机器 | `deploy/docker-compose*.yml` | [生产部署指南](production.md) |
|
||||||
| **手动部署** | 已有 Node 环境、需要自定义 | 免安装目录 | [生产部署指南](production.md) |
|
| **手动部署** | 已有 Node 环境、需要自定义 | 仓库源码(`pnpm build` 后直接跑) | [生产部署指南](production.md) |
|
||||||
|
|
||||||
一句话的取舍:
|
一句话的取舍:
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`,
|
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`,
|
||||||
> 不是 `$1` —— 脚本收不到它(实测踩到:本意是体检,结果走进了交互式安装)。
|
> 不是 `$1` —— 脚本收不到它(想体检却会走进交互式安装)。
|
||||||
> 先落盘再执行没这个坑,也不会因为下载中断而执行到半个脚本。
|
> 先落盘再执行没这个问题,也不会因为下载中断而执行到半个脚本。
|
||||||
|
|
||||||
以下是"已有安装包 / 内网离线"的用法:
|
以下是"已有安装包 / 内网离线"的用法:
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
|
|||||||
|
|
||||||
`pnpm dist:server:linux` 在非 Linux 上也**能跑**:脚本会用 npm 的 `--os=linux --cpu=x64`
|
`pnpm dist:server:linux` 在非 Linux 上也**能跑**:脚本会用 npm 的 `--os=linux --cpu=x64`
|
||||||
按目标平台解析可选依赖,所以 `@libsql/linux-x64-gnu` 这些"按平台分发"的包会被正确装进来。
|
按目标平台解析可选依赖,所以 `@libsql/linux-x64-gnu` 这些"按平台分发"的包会被正确装进来。
|
||||||
适合本地快速打一个包去验证安装与运维流程。已核对过的结论:
|
适合本地快速打一个包去验证安装与运维流程。逐项核对过的结论:
|
||||||
|
|
||||||
| 项 | 结论 |
|
| 项 | 结论 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
本文档覆盖三种上线形态,按适用场景选择:
|
本文档覆盖三种上线形态,按适用场景选择:
|
||||||
|
|
||||||
| 形态 | 适用场景 | 产物 | 本机验证情况 |
|
| 形态 | 适用场景 | 产物 | 验证状态 |
|
||||||
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
|
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
|
||||||
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
|
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已验证(静默安装 / 启停 / 卸载 / UI) |
|
||||||
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
|
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,尚未实机验证 |
|
||||||
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
|
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 实机验收清单见 [linux-deploy.md](linux-deploy.md) §10 |
|
||||||
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
|
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已验证(端到端验收通过) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -23,10 +23,13 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
|||||||
产物:
|
产物:
|
||||||
|
|
||||||
| 路径 | 说明 |
|
| 路径 | 说明 |
|
||||||
| ----------------------------------------------------------------------- | ----------------------------------------------- |
|
| ----------------------------------------------------------------------- | -------------------------- |
|
||||||
| `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
|
||||||
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||||
|
|
||||||
|
> 组装出来的运行时(内置 Node + 生产依赖 + 后端与 Web 产物,约 335 MB)是打安装包的中间物,
|
||||||
|
> 落在 `.cache/server-staging/<版本>/`,脚本打完就删 —— 交付目录里只留安装程序。
|
||||||
|
> 需要在有 Node 的机器上手动跑,见第四节。
|
||||||
|
|
||||||
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
|
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
|
||||||
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
|
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
|
||||||
|
|
||||||
@@ -38,7 +41,8 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
|||||||
- 静默安装:`"ClassHelper 服务端-1.2.0-x64-setup.exe" /S`
|
- 静默安装:`"ClassHelper 服务端-1.2.0-x64-setup.exe" /S`
|
||||||
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
||||||
- 桌面:`ClassHelper 服务端.lnk`(打开管理端)
|
- 桌面:`ClassHelper 服务端.lnk`(打开管理端)
|
||||||
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
|
- **开机自启动可选**:安装向导的组件页里勾选(默认勾选),装完之后还能在管理端「系统设置」里随时开关
|
||||||
|
(`GET/PUT /api/system/autostart`);勾选时写的是 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClassHelperServer`
|
||||||
- 卸载信息注册到「应用和功能」
|
- 卸载信息注册到「应用和功能」
|
||||||
|
|
||||||
安装完成后服务会自动启动并完成首次初始化:
|
安装完成后服务会自动启动并完成首次初始化:
|
||||||
@@ -97,8 +101,8 @@ uninstall-service.cmd :: 删除计划任务
|
|||||||
|
|
||||||
## 二、Docker + MySQL(形态 B,云部署推荐)
|
## 二、Docker + MySQL(形态 B,云部署推荐)
|
||||||
|
|
||||||
> 本机没有 Docker,因此这套文件未在本机实测;结构经过与已验证的产物对照(依赖清单、
|
> 这套文件与已验证的产物结构一致(依赖清单、目录布局、迁移方式与服务端完全相同),
|
||||||
> 目录布局、迁移方式与服务端完全一致)。
|
> 但**尚未在真实 Docker 环境跑过**。第一次上线请先在测试机走一遍。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp deploy/.env.example deploy/.env
|
cp deploy/.env.example deploy/.env
|
||||||
@@ -274,21 +278,27 @@ sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x6
|
|||||||
|
|
||||||
## 四、手动部署(形态 D)
|
## 四、手动部署(形态 D)
|
||||||
|
|
||||||
|
目标机已有 Node.js(≥ 20.19)时,不需要任何安装包,直接在仓库里构建后跑:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm build:shared
|
pnpm build:shared
|
||||||
pnpm --filter @classhelper/server build
|
pnpm --filter @classhelper/server build
|
||||||
pnpm --filter @classhelper/web-admin build
|
pnpm --filter @classhelper/web-admin build
|
||||||
pnpm dist:server -- --reuse-deps # 得到 releases/server/<版本>/免安装/ 免安装目录
|
|
||||||
```
|
```
|
||||||
|
|
||||||
把该目录拷贝到目标机,编辑 `.env`(端口、数据库、密钥、跨域),然后:
|
按目标机的实际情况写好 `packages/server/.env`(端口、数据库、密钥、跨域),然后:
|
||||||
|
|
||||||
```cmd
|
```bash
|
||||||
start.cmd :: Windows
|
pnpm --filter @classhelper/server start # 或 node packages/server/dist/index.js
|
||||||
# 或直接:node server/dist/index.js (Linux/macOS 用系统 node,见上面的形态 C)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> 想把这份构建结果整体拷到另一台机器,就自己带上这四样:
|
||||||
|
> `packages/server/dist`、`packages/server/node_modules`(含 `src/generated/prisma`)、
|
||||||
|
> `packages/web-admin/dist`(服务端按 `web/` 静态托管)、以及 `packages/shared/dist`。
|
||||||
|
> `pnpm dist:server` 组装的正是这套东西,只是它现在把结果当中间物用、打完即删 ——
|
||||||
|
> 需要留档时把 `.cache/server-staging/<版本>/win/` 在打包过程中拷出来即可。
|
||||||
|
|
||||||
数据库初始化二选一:
|
数据库初始化二选一:
|
||||||
|
|
||||||
- 让服务自愈:`.env` 中 `AUTO_MIGRATE=true`(SQLite 首次启动自动建表 + 建管理员)
|
- 让服务自愈:`.env` 中 `AUTO_MIGRATE=true`(SQLite 首次启动自动建表 + 建管理员)
|
||||||
@@ -382,7 +392,7 @@ start.cmd :: Windows
|
|||||||
|
|
||||||
## 八、性能与容量参考
|
## 八、性能与容量参考
|
||||||
|
|
||||||
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 62 项接口与实时推送全部达标。
|
- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),接口与实时推送均已通过端到端验收。
|
||||||
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,
|
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,
|
||||||
当前为单实例广播;如需多实例请参考 `packages/server/src/realtime/socket.ts` 引入 `@socket.io/redis-adapter`)。
|
当前为单实例广播;如需多实例请参考 `packages/server/src/realtime/socket.ts` 引入 `@socket.io/redis-adapter`)。
|
||||||
- 静态资源已设置 immutable 长缓存 + gzip;Web 管理端为 SPA,首屏包体约 900 KB(gzip 约 290 KB),
|
- 静态资源已设置 immutable 长缓存 + gzip;Web 管理端为 SPA,首屏包体约 900 KB(gzip 约 290 KB),
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ docker compose up -d
|
|||||||
|
|
||||||
### 手动 / 源码
|
### 手动 / 源码
|
||||||
|
|
||||||
拉新代码或替换免安装目录 → 重启服务端。启动时 `db-bootstrap` 会用账本表 `_ch_migrations`
|
拉新代码(或覆盖安装新的服务端程序)→ 重启服务端。启动时 `db-bootstrap` 会用账本表 `_ch_migrations`
|
||||||
对比已执行的迁移,只补跑缺的那些:
|
对比已执行的迁移,只补跑缺的那些:
|
||||||
|
|
||||||
- 全新安装:依次执行全部迁移并创建初始管理员;
|
- 全新安装:依次执行全部迁移并创建初始管理员;
|
||||||
@@ -115,11 +115,6 @@ docker compose up -d
|
|||||||
> 用户打开的还是旧的 `resources/app.asar` —— 表现为"改了但界面不生效"。
|
> 用户打开的还是旧的 `resources/app.asar` —— 表现为"改了但界面不生效"。
|
||||||
> 覆盖升级时用 `robocopy ... /E` 推一遍,再对**那个副本**跑一次打包版冒烟确认。
|
> 覆盖升级时用 `robocopy ... /E` 推一遍,再对**那个副本**跑一次打包版冒烟确认。
|
||||||
|
|
||||||
### ClassIsland 插件
|
|
||||||
|
|
||||||
**必须先完全退出 ClassIsland**(它会锁住 DLL),再覆盖插件,然后重启。
|
|
||||||
插件配置 `Config\Plugins\<插件 id>\Settings.json` 里存着服务器地址与设备令牌,**别覆盖它**。
|
|
||||||
|
|
||||||
## 升级前的准备清单
|
## 升级前的准备清单
|
||||||
|
|
||||||
- [ ] 备份数据(`classhelper backup` / 复制 `data` 目录 / `mysqldump`);
|
- [ ] 备份数据(`classhelper backup` / 复制 `data` 目录 / `mysqldump`);
|
||||||
@@ -130,3 +125,7 @@ docker compose up -d
|
|||||||
## 给维护者
|
## 给维护者
|
||||||
|
|
||||||
打版本、出安装包、发 Release 的完整流程见[版本迭代与发布](../dev/release.md)。
|
打版本、出安装包、发 Release 的完整流程见[版本迭代与发布](../dev/release.md)。
|
||||||
|
|
||||||
|
### 客户端远控与更新修复
|
||||||
|
|
||||||
|
先升级服务端,再推送修复后的客户端安装包。服务端兼容旧客户端读取顶层心跳字段,旧设备也可领取更新;第一次升级前仍遵循旧客户端的空闲安装条件。新客户端支持立即、放学后和空闲安装三种策略。放学后取指定的最早时间与当天最后一节课结束时间中的较晚者;设备离线时在再次上线后处理。
|
||||||
+319
-299
File diff suppressed because it is too large.
Load diff
+9
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "class-helper",
|
"name": "class-helper",
|
||||||
"version": "1.2.0",
|
"version": "1.3.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||||
"packageManager": "pnpm@11.8.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
"dev:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client dev",
|
"dev:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client dev",
|
||||||
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build",
|
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build",
|
||||||
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
|
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
|
||||||
"build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs",
|
|
||||||
"check:icons": "node scripts/check-icons.mjs",
|
"check:icons": "node scripts/check-icons.mjs",
|
||||||
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
|
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
@@ -30,11 +29,14 @@
|
|||||||
"db:switch:mysql": "node scripts/use-database.mjs mysql",
|
"db:switch:mysql": "node scripts/use-database.mjs mysql",
|
||||||
"db:switch:sqlite": "node scripts/use-database.mjs sqlite",
|
"db:switch:sqlite": "node scripts/use-database.mjs sqlite",
|
||||||
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
|
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
|
||||||
|
"verify:client-agent": "node scripts/verify-client-agent.mjs",
|
||||||
|
"verify:review-client": "node scripts/verify-review-client.mjs",
|
||||||
|
"verify:review-api": "node scripts/verify-review-api.mjs",
|
||||||
|
"verify:clients-api": "node scripts/verify-clients-api.mjs",
|
||||||
|
"verify:client-migrations": "node scripts/verify-client-migrations.mjs",
|
||||||
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
|
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
|
||||||
"verify:web": "node scripts/ui-smoke/run.mjs",
|
"verify:web": "node scripts/ui-smoke/run.mjs",
|
||||||
"verify:packaged": "node scripts/verify-packaged.mjs",
|
"verify:packaged": "node scripts/verify-packaged.mjs",
|
||||||
"verify:classisland": "pnpm --filter @classhelper/server verify:classisland",
|
|
||||||
"verify:classisland-plugin": "node packages/classisland-plugin/scripts/verify.mjs",
|
|
||||||
"icons": "node scripts/generate-icons.mjs",
|
"icons": "node scripts/generate-icons.mjs",
|
||||||
"version:check": "node scripts/version.mjs",
|
"version:check": "node scripts/version.mjs",
|
||||||
"version:bump": "node scripts/version.mjs --bump",
|
"version:bump": "node scripts/version.mjs --bump",
|
||||||
@@ -44,11 +46,11 @@
|
|||||||
"dev:site": "pnpm docs:build && node website/serve.mjs",
|
"dev:site": "pnpm docs:build && node website/serve.mjs",
|
||||||
"docs:check": "node docs/build.mjs --check",
|
"docs:check": "node docs/build.mjs --check",
|
||||||
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
|
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
|
||||||
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
|
|
||||||
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
|
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
|
||||||
"dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
|
"dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
|
||||||
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx",
|
"dist:all": "pnpm dist:server && pnpm dist:win",
|
||||||
"dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin"
|
"verify:desktop:visual": "node packages/desktop-client/scripts/smoke.mjs --visual",
|
||||||
|
"verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^10.0.5",
|
"concurrently": "^10.0.5",
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# .NET 构建产物(与仓库根的 .gitignore 分开:根 .gitignore 是 GBK 编码,apply_patch 读不了)
|
|
||||||
bin/
|
|
||||||
obj/
|
|
||||||
cipx/
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<!--
|
|
||||||
ClassIsland 2.0 / 2.1.0.x 面向 net8.0;2.1.1 起改为 net10.0。
|
|
||||||
本插件按 net8.0 交付,因此 SDK 版本固定到最后一个 net8.0 的版本(2.1.0.1),
|
|
||||||
这样 ClassIsland 2.0.x 与 2.1.0.x 都能加载(apiVersion 见 manifest.yml)。
|
|
||||||
-->
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<LangVersion>12</LangVersion>
|
|
||||||
<RootNamespace>ClassHelper.ClassIslandPlugin</RootNamespace>
|
|
||||||
<AssemblyName>ClassHelper.ClassIslandPlugin</AssemblyName>
|
|
||||||
<ClassIslandPluginSdkVersion>2.1.0.1</ClassIslandPluginSdkVersion>
|
|
||||||
<!-- 插件由 ClassIsland 加载,不需要生成自带运行时(ExcludeAssets 见下) -->
|
|
||||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
|
||||||
<!-- 设置页是 .axaml:需要让 Avalonia 的默认项把 **/*.axaml 收成 AvaloniaXaml 并编译 -->
|
|
||||||
<EnableDefaultItems>true</EnableDefaultItems>
|
|
||||||
<!-- 绑定走运行时解析:插件与主程序各自编译,编译期绑定会因看不到对方的类型而失败 -->
|
|
||||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
|
||||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<!-- 运行时与原生资源由 ClassIsland 本体提供,插件只编译期依赖 -->
|
|
||||||
<PackageReference Include="ClassIsland.PluginSdk" Version="$(ClassIslandPluginSdkVersion)">
|
|
||||||
<ExcludeAssets>runtime;native</ExcludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<None Update="manifest.yml">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="README.md">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
<None Update="icon.png">
|
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
|
||||||
</None>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"profiles": {
|
|
||||||
"ClassIsland 插件": {
|
|
||||||
"commandName": "Executable",
|
|
||||||
"executablePath": "$(ClassIsland_DebugBinaryFile)",
|
|
||||||
"commandLineArgs": "-epp $(TargetDir)",
|
|
||||||
"workingDirectory": "$(ClassIsland_DebugBinaryDirectory)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
# ClassHelper 联动插件
|
|
||||||
|
|
||||||
把 [ClassIsland](https://classisland.tech) 与 [ClassHelper](https://github.com/LoveChengke/ClassHelper) 连起来:
|
|
||||||
让 ClassHelper**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
|
||||||
|
|
||||||
## 它能做什么
|
|
||||||
|
|
||||||
| 方向 | 说明 |
|
|
||||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
|
||||||
| ClassIsland → ClassHelper | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
|
||||||
| ClassHelper → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
|
||||||
| ClassHelper → ClassIsland(可选) | 把 ClassHelper上排好的课表**镜像**回 ClassIsland(新建 `ClassHelper-<班级名>` 档案课表,不改动老师原有的课表) |
|
|
||||||
|
|
||||||
## 安装
|
|
||||||
|
|
||||||
1. 把插件放进 ClassIsland 的插件目录,二选一:
|
|
||||||
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
|
|
||||||
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
|
|
||||||
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
|
|
||||||
2. 重启 ClassIsland,在「设置 → 插件」里确认「ClassHelper 联动」已启用。
|
|
||||||
3. 打开「设置 → ClassHelper 联动」,填入:
|
|
||||||
- **服务器地址**:ClassHelper后端地址,例如 `http://127.0.0.1:4000`;
|
|
||||||
- **设备令牌**:在 ClassHelper Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
|
|
||||||
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
|
|
||||||
|
|
||||||
## 配置项(全部在「设置 → ClassHelper 联动」里)
|
|
||||||
|
|
||||||
| 配置项 | 说明 |
|
|
||||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
||||||
| 服务器地址 | ClassHelper后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
|
||||||
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
|
|
||||||
| 上报课表到 ClassHelper | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
|
||||||
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
|
|
||||||
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
|
|
||||||
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
|
|
||||||
| 接收 ClassHelper提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
|
||||||
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
|
|
||||||
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
|
|
||||||
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
|
|
||||||
| 显示调试信息 | 展开设备标识、与服务端的时钟差、配置目录等,排查「为什么同步不上」时打开 |
|
|
||||||
|
|
||||||
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
|
|
||||||
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
|
|
||||||
|
|
||||||
> **上课时段策略(与 ClassHelper 客户端同一套规则)**:
|
|
||||||
> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。
|
|
||||||
> 暂存期间不回执,服务端会保留这条待提醒。
|
|
||||||
|
|
||||||
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
|
|
||||||
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
|
|
||||||
> 不依赖 ClassHelper那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
|
|
||||||
|
|
||||||
> **两个方向各自需要的开关**:
|
|
||||||
>
|
|
||||||
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
|
|
||||||
> - ClassIsland → 服务端(上报课表):插件「上报课表到 ClassHelper」打开;
|
|
||||||
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到 ClassHelper」开关已关闭)」),不是上报失败;
|
|
||||||
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
|
|
||||||
|
|
||||||
> **提醒到底会不会弹,取决于两个开关:**
|
|
||||||
>
|
|
||||||
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
|
||||||
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
|
|
||||||
> 2. 本插件的「接收 ClassHelper提醒」关掉时,即使推过来也不弹。
|
|
||||||
>
|
|
||||||
> 两者都开着,提醒才会在 ClassIsland 上出现。
|
|
||||||
|
|
||||||
## 工作原理(为什么这么写)
|
|
||||||
|
|
||||||
- **不用跨进程 IPC,插件内直接读课程服务**:ClassIsland 的 IPC 暴露的是缓存数据,不足以还原课表;
|
|
||||||
插件直接读 `ILessonsService`(当前科目 / 状态 / 时间点)与 `IProfileService`(档案里的课表、时间表、科目),
|
|
||||||
拿到的才是「教室此刻真实的状态」。
|
|
||||||
- **「上报即拉取」,不引入 Socket 依赖**:每次上报的返回值里会顺带带一条「尚未确认的提醒」,
|
|
||||||
插件收到就交给提醒提供方显示;应用启动时额外拉一次 `pending`,
|
|
||||||
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
|
|
||||||
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
|
|
||||||
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
|
|
||||||
「ClassHelper提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
|
||||||
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
|
|
||||||
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
|
|
||||||
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
|
|
||||||
**第 i 个 `TimeType == 0`(上课)的时间点**一一对应,
|
|
||||||
因此镜像接口为每条课目带上 `startTime` / `endTime`,插件据此重建时间表
|
|
||||||
(课间点从服务端的时间表里按起始时刻对齐)。少一个时间点,整份课表就会错位。
|
|
||||||
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
|
|
||||||
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
|
|
||||||
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
|
|
||||||
- **非破坏性**:镜像只创建 / 更新名字以 `ClassHelper-` 开头的课表与时间表;
|
|
||||||
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
|
|
||||||
|
|
||||||
## 上机联调记录(ClassIsland 2.1.0.1 实测)
|
|
||||||
|
|
||||||
下面几条都是把插件装进真实 ClassIsland 跑出来的问题与结论,改动理由就写在这里,避免以后被“顺手优化”回去:
|
|
||||||
|
|
||||||
1. **提醒内容必须在 UI 线程构造并显示。** 上报回调在线程池上,而提醒模板里的图标是 Avalonia 对象
|
|
||||||
(`LucideIconSource → IconSource → AvaloniaObject` 的构造函数会 `Dispatcher.VerifyAccess()`),
|
|
||||||
在线程池上直接构造会抛 `Call from invalid thread`,表现就是“ClassHelper 客户端收到了、ClassIsland 毫无反应”。
|
|
||||||
现在统一用 `Dispatcher.UIThread.Post` 切回 UI 线程。
|
|
||||||
2. **同一条提醒在播放期间要按 id 去重。** 服务端在收到回执前会一直把提醒算作“待弹出”,
|
|
||||||
而轮询间隔(默认 10 秒)可能小于提醒时长(最长 120 秒)—— 不去重就会一遍遍重复弹。
|
|
||||||
只有播完(`Completed`/`Canceled`)才把 id 从“正在播放”里移除并回执。
|
|
||||||
3. **不要自己拼图标串。** 双图标遮罩模板的默认图标由模板自己创建(`new LucideIconSource("\ue0ff")` 这种**字形字符**),
|
|
||||||
自己传 `"fluent(...)"` 之类既可能格式不对、也可能字形不存在。紧急提醒改用 ClassIsland 自带的**滚动文本**正文来表达。
|
|
||||||
4. **`Subject.Fallback` 是占位符(名字就是 `???`)。** 没有课表时 ClassIsland 会把它挂到
|
|
||||||
`CurrentSubject`/`NextClassSubject` 上,直接上报会让 Web 端显示“正在上 ???”,因此上报前归一化成“没有科目”。
|
|
||||||
|
|
||||||
实测截图(真机弹出提醒的遮罩):[普通提醒](../../docs/screenshots/classisland/02-normal-mask.png)、
|
|
||||||
[紧急提醒](../../docs/screenshots/classisland/01-urgent-mask.png)。
|
|
||||||
|
|
||||||
## 构建与打包
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build:classisland-plugin # Release 构建(产物:packages/classisland-plugin/bin/Release)
|
|
||||||
pnpm dist:classisland-plugin # 追加打包 .cipx 并归集到 releases/classisland-plugin/
|
|
||||||
pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性 / 注册完整性 / DTO ↔ 服务端契约)
|
|
||||||
```
|
|
||||||
|
|
||||||
构建脚本会把 NuGet 缓存与临时目录指到仓库内的 `.cache/`(避免写满 C 盘),
|
|
||||||
并在打包后自检「清单 / 程序集名 / 版本号」三者一致。
|
|
||||||
|
|
||||||
## 开发提示
|
|
||||||
|
|
||||||
- 需要 **.NET 8 SDK**;SDK 包固定 `ClassIsland.PluginSdk 2.1.0.1`(2.1.1 起改为 net10.0,
|
|
||||||
本插件按 net8.0 交付,因此 2.0.x / 2.1.0.x 都能加载,见 `manifest.yml` 的 `apiVersion`)。
|
|
||||||
- 设置页是 Avalonia 的 `.axaml`(继承 `SettingsPageBase`);绑定走运行时解析
|
|
||||||
(`AvaloniaUseCompiledBindingsByDefault=false`),因为插件与主程序各自编译,编译期绑定看不到对方类型。
|
|
||||||
- **开发机没有安装 ClassIsland 本体**,所以插件只能做到编译 + 打包 + 静态校验;
|
|
||||||
运行期行为需要在装了 ClassIsland 的机器上按上面的「安装」步骤实测。
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是 ClassHelper的标准格式)**
|
|
||||||
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
|
|
||||||
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
|
|
||||||
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
|
|
||||||
如果仍然报 404,请依次检查:
|
|
||||||
|
|
||||||
1. 服务器地址是否写成了别的端口 / 别的机器(提示里会带上实际请求的完整 URL,照着核对最快);
|
|
||||||
2. 该地址在浏览器里打开 `<服务器地址>/api/health` 是否返回 `{"success":true,...}`;
|
|
||||||
3. 反向代理(Nginx 等)是否把 `/api/integrations` 也转发到了后端。
|
|
||||||
|
|
||||||
**上报失败,提示「设备令牌无效」**
|
|
||||||
在 Web 端重置该设备的令牌,然后把新令牌填回插件设置(旧令牌会立即失效)。
|
|
||||||
|
|
||||||
**发布提醒后 ClassIsland 没反应,或者要等一会儿才弹**
|
|
||||||
插件不持有长连接,提醒是**轮询取回**的:默认最多 10 秒(设置页「提醒轮询间隔」可调到 5 秒)。超过这个时间还没弹,按下面逐条排查。
|
|
||||||
|
|
||||||
**ClassIsland 上完全不弹提醒**
|
|
||||||
|
|
||||||
1. 确认插件设置里打开了「接收提醒」;
|
|
||||||
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
|
|
||||||
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
|
|
||||||
|
|
||||||
**课表上报了但 ClassHelper上没变化**
|
|
||||||
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
|
|
||||||
|
|
||||||
**镜像开关打开了,但 ClassIsland 的课表没变**
|
|
||||||
镜像需要 Web 端该设备开启「镜像课表」且班级里已有课表;插件只在**上报时**顺路拉取镜像,
|
|
||||||
所以打开开关后点一下「立即上报」或等一个上报间隔即可。镜像不会覆盖老师原有的课表。
|
|
||||||
|
|
||||||
## 许可
|
|
||||||
|
|
||||||
与 ClassHelper仓库一致。
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB |
@@ -1,19 +0,0 @@
|
|||||||
# ClassHelper ClassIsland 联动插件 —— 清单文件
|
|
||||||
#
|
|
||||||
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
|
|
||||||
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
|
|
||||||
|
|
||||||
id: classhelper.classisland.bridge
|
|
||||||
name: ClassHelper 联动
|
|
||||||
description: |-
|
|
||||||
把 ClassIsland 的课表与上课状态同步到 ClassHelper,并在 ClassIsland 上显示老师下发的提醒。
|
|
||||||
功能:
|
|
||||||
1. 上报当前课表与节次时间(ClassHelper Web 端与桌面客户端据此显示课程);
|
|
||||||
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
|
|
||||||
3. 接收老师在 ClassHelper上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
|
||||||
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
|
|
||||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
|
||||||
url: https://github.com/LoveChengke/ClassHelper
|
|
||||||
version: 1.2.0.0
|
|
||||||
apiVersion: 2.0.0.0
|
|
||||||
author: ClassHelper
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* ClassIsland 联动插件构建脚本:`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
|
|
||||||
* node scripts/build.mjs --cipx # 追加打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
|
|
||||||
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
|
|
||||||
*
|
|
||||||
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
|
|
||||||
* 1. `dotnet` 默认把 NuGet 缓存与临时文件写在 C 盘(`%USERPROFILE%\.nuget`、`%TEMP%`),
|
|
||||||
* 本机 C 盘紧张时会直接构建失败。这里统一改到仓库内 `.cache/`(已 gitignore)。
|
|
||||||
* 2. 插件包(.cipx)的最后一步要调 PowerShell 生成哈希清单,SDK 默认用 `pwsh`;
|
|
||||||
* 没有 pwsh 的机器会失败,因此这里探测一次并显式传给 MSBuild。
|
|
||||||
* 3. 构建完顺手校验"清单 / 程序集 / 版本号"三者一致,避免打出装不上的包。
|
|
||||||
*/
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const repoRoot = path.resolve(pluginDir, '..', '..');
|
|
||||||
const cacheDir = path.join(repoRoot, '.cache');
|
|
||||||
const releasesRoot = path.join(repoRoot, 'releases', 'classisland-plugin');
|
|
||||||
|
|
||||||
const argv = process.argv.slice(2);
|
|
||||||
const withCipx = argv.includes('--cipx');
|
|
||||||
/** `--` 之后的参数原样透传给 dotnet build;其余以 - 开头的参数同样透传 */
|
|
||||||
const passthrough = argv.filter((item) => item !== '--cipx' && item !== '--');
|
|
||||||
|
|
||||||
const log = (message) => console.log(`[classisland-plugin] ${message}`);
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 1. 环境准备 */
|
|
||||||
|
|
||||||
const env = { ...process.env };
|
|
||||||
for (const sub of ['nuget', 'tmp', 'dotnet-home']) {
|
|
||||||
fs.mkdirSync(path.join(cacheDir, sub), { recursive: true });
|
|
||||||
}
|
|
||||||
const setDefault = (key, value) => {
|
|
||||||
if (!env[key]) env[key] = value;
|
|
||||||
};
|
|
||||||
setDefault('NUGET_PACKAGES', path.join(cacheDir, 'nuget'));
|
|
||||||
setDefault('TEMP', path.join(cacheDir, 'tmp'));
|
|
||||||
setDefault('TMP', env.TEMP);
|
|
||||||
setDefault('DOTNET_CLI_HOME', path.join(cacheDir, 'dotnet-home'));
|
|
||||||
env.DOTNET_NOLOGO = '1';
|
|
||||||
env.DOTNET_CLI_TELEMETRY_OPTOUT = '1';
|
|
||||||
|
|
||||||
/** 可执行文件探测:先看显式配置,再问 PATH,最后试常见安装路径 */
|
|
||||||
function resolveExecutable(envKey, names, fallbacks) {
|
|
||||||
const candidates = [];
|
|
||||||
if (env[envKey]) candidates.push(env[envKey]);
|
|
||||||
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
||||||
for (const name of names) {
|
|
||||||
const found = spawnSync(finder, [name], { encoding: 'utf8', env });
|
|
||||||
if (found.status === 0) {
|
|
||||||
for (const line of String(found.stdout ?? '').split(/\r?\n/)) candidates.push(line.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
candidates.push(...fallbacks);
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (!candidate) continue;
|
|
||||||
try {
|
|
||||||
if (fs.statSync(candidate).isFile()) return candidate;
|
|
||||||
} catch {
|
|
||||||
/* 继续试下一个 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dotnet = resolveExecutable(
|
|
||||||
'DOTNET_EXE',
|
|
||||||
['dotnet'],
|
|
||||||
process.platform === 'win32'
|
|
||||||
? [path.join(env.ProgramFiles ?? 'C:\\Program Files', 'dotnet', 'dotnet.exe')]
|
|
||||||
: ['/usr/local/bin/dotnet', '/usr/bin/dotnet'],
|
|
||||||
);
|
|
||||||
if (!dotnet) {
|
|
||||||
throw new Error('未找到 dotnet SDK(需要 .NET 8 SDK)。安装后用 DOTNET_EXE 指定完整路径。');
|
|
||||||
}
|
|
||||||
|
|
||||||
const powerShell = resolveExecutable(
|
|
||||||
'POWERSHELL_EXE',
|
|
||||||
['pwsh', 'powershell'],
|
|
||||||
process.platform === 'win32'
|
|
||||||
? [path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 2. 构建 */
|
|
||||||
|
|
||||||
const buildArgs = ['build', '-c', 'Release'];
|
|
||||||
if (withCipx) {
|
|
||||||
buildArgs.push('-p:CreateCipx=true');
|
|
||||||
// SDK 的 cipx 目标默认调 pwsh;这里按探测结果显式指定,避免"没有 pwsh 就打包失败"
|
|
||||||
if (powerShell) buildArgs.push(`-p:PowershellBinaryName=${powerShell}`);
|
|
||||||
}
|
|
||||||
buildArgs.push(...passthrough);
|
|
||||||
|
|
||||||
log(`dotnet build(缓存目录 ${path.relative(repoRoot, cacheDir)})`);
|
|
||||||
const build = spawnSync(dotnet, buildArgs, { cwd: pluginDir, stdio: 'inherit', env });
|
|
||||||
if (build.error) throw new Error(`无法启动 dotnet:${build.error.message}`);
|
|
||||||
if (build.status !== 0) throw new Error(`dotnet build 失败(退出码 ${build.status})`);
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 3. 产物自检 */
|
|
||||||
|
|
||||||
const outputDir = path.join(pluginDir, 'bin', 'Release');
|
|
||||||
const manifestPath = path.join(outputDir, 'manifest.yml');
|
|
||||||
const assemblyPath = path.join(outputDir, 'ClassHelper.ClassIslandPlugin.dll');
|
|
||||||
|
|
||||||
for (const required of [manifestPath, assemblyPath, path.join(outputDir, 'icon.png')]) {
|
|
||||||
if (!fs.existsSync(required)) {
|
|
||||||
throw new Error(`构建产物缺少 ${path.relative(pluginDir, required)},ClassIsland 会加载失败`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const manifest = fs.readFileSync(manifestPath, 'utf8');
|
|
||||||
// prettier 会把 YAML 字符串统一成单引号,因此两种引号都要能剥掉
|
|
||||||
const entrance = /^entranceAssembly:\s*(.+?)\s*$/m.exec(manifest)?.[1]?.replace(/^['"]|['"]$/g, '');
|
|
||||||
if (entrance !== path.basename(assemblyPath)) {
|
|
||||||
throw new Error(
|
|
||||||
`manifest.yml 的 entranceAssembly(${entrance})与程序集(${path.basename(assemblyPath)})不一致`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const manifestVersion = /^version:\s*([\d.]+)/m.exec(manifest)?.[1];
|
|
||||||
const bridgeSource = fs.readFileSync(path.join(pluginDir, 'src', 'Services', 'BridgeService.cs'), 'utf8');
|
|
||||||
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
|
|
||||||
if (!manifestVersion || manifestVersion !== codeVersion) {
|
|
||||||
throw new Error(
|
|
||||||
`版本号不一致:manifest.yml=${manifestVersion ?? '缺失'},BridgeService.cs=${codeVersion ?? '缺失'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
log(`产物自检通过(版本 ${manifestVersion},入口程序集 ${entrance})`);
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 4. 打包归集 */
|
|
||||||
|
|
||||||
if (!withCipx) {
|
|
||||||
log('完成。加 --cipx 可同时打包成 ClassIsland 插件包(.cipx)');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cipxDir = path.join(pluginDir, 'cipx');
|
|
||||||
const cipxFile = path.join(cipxDir, 'ClassHelper.ClassIslandPlugin.cipx');
|
|
||||||
if (!fs.existsSync(cipxFile)) {
|
|
||||||
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
|
|
||||||
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
|
|
||||||
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
|
|
||||||
const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
|
|
||||||
const versionDir = path.join(releasesRoot, pluginVersion);
|
|
||||||
const installerDir = path.join(versionDir, '安装包');
|
|
||||||
const portableDir = path.join(versionDir, '免安装', 'ClassHelper.ClassIslandPlugin');
|
|
||||||
fs.rmSync(installerDir, { recursive: true, force: true });
|
|
||||||
fs.rmSync(path.dirname(portableDir), { recursive: true, force: true });
|
|
||||||
fs.mkdirSync(installerDir, { recursive: true });
|
|
||||||
fs.mkdirSync(portableDir, { recursive: true });
|
|
||||||
|
|
||||||
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
|
|
||||||
for (const name of fs.readdirSync(outputDir)) {
|
|
||||||
const from = path.join(outputDir, name);
|
|
||||||
if (!fs.statSync(from).isFile()) continue;
|
|
||||||
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
|
|
||||||
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
fs.copyFileSync(from, path.join(portableDir, name));
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const file of fs.readdirSync(cipxDir)) {
|
|
||||||
// .cipx 是插件包本体,校验清单跟着版本目录走
|
|
||||||
const to = file.toLowerCase().endsWith('.cipx') ? installerDir : versionDir;
|
|
||||||
fs.copyFileSync(path.join(cipxDir, file), path.join(to, file));
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
|
|
||||||
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
|
|
||||||
for (const file of fs.readdirSync(portableDir)) log(` 免安装/ClassHelper.ClassIslandPlugin/${file}`);
|
|
||||||
@@ -1,390 +0,0 @@
|
|||||||
/**
|
|
||||||
* ClassIsland 联动插件静态校验:`pnpm verify:classisland-plugin`
|
|
||||||
*
|
|
||||||
* 为什么是"静态"校验:插件要跑起来必须有 ClassIsland 本体,而本机(以及 CI)都没有,
|
|
||||||
* 所以这里校验"装不上 / 对不上"的那几类问题 —— 它们不需要运行就能发现:
|
|
||||||
*
|
|
||||||
* 1. 清单三件套一致性:manifest.yml ↔ csproj ↔ 代码里的版本号;
|
|
||||||
* 2. 注册完整性:插件入口、提醒提供方、设置页是否都挂上了(挂漏了功能会"静默消失");
|
|
||||||
* 3. **接口契约**:C# DTO 的 JSON 字段名 ↔ 服务端路由 / 请求字段,
|
|
||||||
* 这类错误(比如 dayOfWeek 写成 dayofweek)在运行时只会表现为"上报成功但课表是空的",
|
|
||||||
* 最难排查,所以专门盯住。
|
|
||||||
*/
|
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const repoRoot = path.resolve(pluginDir, '..', '..');
|
|
||||||
|
|
||||||
const results = [];
|
|
||||||
const check = (name, ok, detail = '') => results.push({ name, ok: Boolean(ok), detail });
|
|
||||||
const read = (relative) => fs.readFileSync(path.join(pluginDir, relative), 'utf8');
|
|
||||||
const readRepo = (relative) => fs.readFileSync(path.join(repoRoot, relative), 'utf8');
|
|
||||||
|
|
||||||
const manifest = read('manifest.yml');
|
|
||||||
const csproj = read('ClassHelper.ClassIslandPlugin.csproj');
|
|
||||||
const pluginSource = read('src/Plugin.cs');
|
|
||||||
const providerSource = read('src/Services/ClassHelperNotificationProvider.cs');
|
|
||||||
const bridgeSource = read('src/Services/BridgeService.cs');
|
|
||||||
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 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');
|
|
||||||
const serverService = readRepo('packages/server/src/modules/integrations/integrations.service.ts');
|
|
||||||
const sharedTypes = readRepo('packages/shared/src/types.ts');
|
|
||||||
const sharedConstants = readRepo('packages/shared/src/constants.ts');
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 1. 清单一致性 */
|
|
||||||
|
|
||||||
/** 取 YAML 里某个顶层字段的值(prettier 会把字符串统一成单引号,两种引号都要能剥掉) */
|
|
||||||
function field(name) {
|
|
||||||
const raw = new RegExp('^' + name + ':\\s*(.+?)\\s*$', 'm').exec(manifest)?.[1];
|
|
||||||
return raw?.replace(/^['"]|['"]$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
check('manifest.yml 有插件 id', Boolean(field('id')));
|
|
||||||
check('manifest.yml 声明了入口程序集', Boolean(field('entranceAssembly')));
|
|
||||||
check('manifest.yml 声明了 apiVersion', /\d+\.\d+\.\d+\.\d+/.test(field('apiVersion') ?? ''));
|
|
||||||
|
|
||||||
const assemblyName = /<AssemblyName>([^<]+)<\/AssemblyName>/.exec(csproj)?.[1];
|
|
||||||
check(
|
|
||||||
'entranceAssembly 与 csproj 的 AssemblyName 一致',
|
|
||||||
field('entranceAssembly') === assemblyName + '.dll',
|
|
||||||
'entranceAssembly=' + field('entranceAssembly') + ' AssemblyName=' + assemblyName,
|
|
||||||
);
|
|
||||||
|
|
||||||
const manifestVersion = field('version');
|
|
||||||
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
|
|
||||||
check(
|
|
||||||
'插件版本号在清单与代码里一致',
|
|
||||||
Boolean(manifestVersion) && manifestVersion === codeVersion,
|
|
||||||
'manifest=' + manifestVersion + ' code=' + codeVersion,
|
|
||||||
);
|
|
||||||
|
|
||||||
check(
|
|
||||||
'图标与自述文件按清单约定命名(icon.png / README.md)',
|
|
||||||
fs.existsSync(path.join(pluginDir, 'icon.png')) && fs.existsSync(path.join(pluginDir, 'README.md')),
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 2. 注册完整性 */
|
|
||||||
|
|
||||||
check(
|
|
||||||
'插件入口带 [PluginEntrance] 且继承 PluginBase',
|
|
||||||
/\[PluginEntrance\]/.test(pluginSource) && /:\s*PluginBase/.test(pluginSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'初始化时注册了提醒提供方',
|
|
||||||
/AddNotificationProvider<\s*ClassHelperNotificationProvider\s*>/.test(pluginSource),
|
|
||||||
);
|
|
||||||
check('初始化时注册了设置页', /AddSettingsPage<\s*BridgeSettingsPage\s*>/.test(pluginSource));
|
|
||||||
check('初始化时把联动服务注册为单例', /AddSingleton<\s*BridgeService\s*>/.test(pluginSource));
|
|
||||||
check('应用启动后才接课程服务(不能更早)', /AppStarted/.test(pluginSource));
|
|
||||||
check(
|
|
||||||
'设置以 JSON 持久化到插件配置目录',
|
|
||||||
/PluginConfigFolder/.test(pluginSource) && /Settings\.json/.test(pluginSource),
|
|
||||||
);
|
|
||||||
|
|
||||||
const providerGuid = /ProviderGuidValue = "([0-9A-Fa-f-]{36})"/.exec(providerSource)?.[1];
|
|
||||||
check('提醒提供方声明了合法 GUID', Boolean(providerGuid));
|
|
||||||
check(
|
|
||||||
'提醒提供方属性引用同一个 GUID 常量',
|
|
||||||
/NotificationProviderInfo\(\s*ProviderGuidValue/.test(providerSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'提醒提供方会在播完后回执给服务端',
|
|
||||||
/Completed/.test(providerSource) && /AckAsync/.test(providerSource),
|
|
||||||
);
|
|
||||||
|
|
||||||
check(
|
|
||||||
'设置页带 [SettingsPageInfo] 且声明了页面 id',
|
|
||||||
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"ClassHelper 联动"/.test(settingsPageSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'设置页 XAML 的 x:Class 与代码后置类一致',
|
|
||||||
settingsPageXaml.includes('x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"') &&
|
|
||||||
/namespace ClassHelper\.ClassIslandPlugin\.Views;/.test(settingsPageSource),
|
|
||||||
);
|
|
||||||
check('设置页 XAML 继承 ClassIsland 的设置页基类', /SettingsPageBase/.test(settingsPageXaml));
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 3. 接口契约 */
|
|
||||||
|
|
||||||
// 3.1 路由:插件调用的路径必须都在服务端注册过
|
|
||||||
const clientPaths = [...clientSource.matchAll(/"(\/classisland\/[a-z-]+)"/g)].map((item) => item[1]);
|
|
||||||
const registeredPaths = new Set(
|
|
||||||
[...serverModule.matchAll(/router\.(?:get|post|patch|delete)\(\s*'(\/classisland\/[a-z-]+)'/g)].map(
|
|
||||||
(item) => item[1],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
for (const route of [
|
|
||||||
'/classisland/report',
|
|
||||||
'/classisland/pending',
|
|
||||||
'/classisland/ack',
|
|
||||||
'/classisland/class-plan',
|
|
||||||
]) {
|
|
||||||
check('服务端注册了 ' + route, registeredPaths.has(route));
|
|
||||||
}
|
|
||||||
check(
|
|
||||||
'插件调用的路由都存在于服务端',
|
|
||||||
clientPaths.length > 0 && clientPaths.every((item) => registeredPaths.has(item)),
|
|
||||||
'插件调用=[' + clientPaths.join(',') + ']',
|
|
||||||
);
|
|
||||||
check('插件把接口前缀拼成 /api/integrations', clientSource.includes('/api/integrations'));
|
|
||||||
check('插件用设备令牌头鉴权', clientSource.includes('X-ClassIsland-Token'));
|
|
||||||
|
|
||||||
// 3.2 上报请求字段:服务端 zod schema 里出现的字段名,插件必须真的会发
|
|
||||||
const reportBlock = /export const classIslandReportSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
|
||||||
const requestFields = [
|
|
||||||
'pluginVersion',
|
|
||||||
'classIslandVersion',
|
|
||||||
'state',
|
|
||||||
'schedule',
|
|
||||||
'timeLayout',
|
|
||||||
'inClass',
|
|
||||||
'subject',
|
|
||||||
'nextSubject',
|
|
||||||
'timeState',
|
|
||||||
'periodStart',
|
|
||||||
'periodEnd',
|
|
||||||
'classPlanLoaded',
|
|
||||||
'clientTime',
|
|
||||||
'mode',
|
|
||||||
'entries',
|
|
||||||
'items',
|
|
||||||
];
|
|
||||||
const missingRequestFields = requestFields.filter(
|
|
||||||
(name) => reportBlock.includes(name) && !new RegExp('\\b' + name + '\\b').test(clientSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'上报请求字段都出现在插件 DTO 里',
|
|
||||||
missingRequestFields.length === 0,
|
|
||||||
missingRequestFields.length > 0 ? '缺少=' + missingRequestFields.join(',') : '',
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3.3 课表条目字段:这些是"上报成功但课表为空"的典型来源
|
|
||||||
const entrySchema = /export const reportScheduleEntrySchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
|
||||||
const entryFields = [
|
|
||||||
'dayOfWeek',
|
|
||||||
'startTime',
|
|
||||||
'endTime',
|
|
||||||
'subject',
|
|
||||||
'teacherName',
|
|
||||||
'weekParity',
|
|
||||||
'weekCountDiv',
|
|
||||||
'weekCountDivTotal',
|
|
||||||
'planName',
|
|
||||||
];
|
|
||||||
const csharpJsonNames = new Set(
|
|
||||||
[...clientSource.matchAll(/\[JsonPropertyName\("([^"]+)"\)\]/g)].map((item) => item[1]),
|
|
||||||
);
|
|
||||||
const missingEntryFields = entryFields.filter(
|
|
||||||
(name) => entrySchema.includes(name) && !csharpJsonNames.has(name),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'课表条目字段服务端 zod 与插件 DTO 对齐',
|
|
||||||
missingEntryFields.length === 0,
|
|
||||||
missingEntryFields.length > 0 ? '插件缺少=' + missingEntryFields.join(',') : '',
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3.4 插件 DTO 里的 JSON 字段名必须能在共享契约里找到(防错拼)
|
|
||||||
const knownLocalOnly = new Set(['data', 'success', 'message', 'code']);
|
|
||||||
const unknownJsonNames = [...csharpJsonNames].filter((name) => {
|
|
||||||
if (knownLocalOnly.has(name)) return false;
|
|
||||||
return !new RegExp('\\b' + name + '\\b').test(sharedTypes);
|
|
||||||
});
|
|
||||||
check(
|
|
||||||
'插件 DTO 的 JSON 字段名都能在 @classhelper/shared 里找到',
|
|
||||||
unknownJsonNames.length === 0,
|
|
||||||
unknownJsonNames.length > 0 ? '疑似拼写错误=' + unknownJsonNames.join(',') : '',
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3.5 课表镜像:服务端给了 Classes[i] 对齐所需的时间,插件必须读
|
|
||||||
for (const name of ['startTime', 'endTime', 'timeLayouts', 'profileName', 'weekDay', 'weekCountDivTotal']) {
|
|
||||||
check('镜像契约字段 ' + name + ' 在插件侧存在', csharpJsonNames.has(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3.6 提醒下发字段(Web 端 → 服务端 → 插件)
|
|
||||||
const notifySchema = /export const sendNotificationSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
|
||||||
for (const name of [
|
|
||||||
'classId',
|
|
||||||
'title',
|
|
||||||
'content',
|
|
||||||
'durationSeconds',
|
|
||||||
'speech',
|
|
||||||
'speechContent',
|
|
||||||
'saveToNotifications',
|
|
||||||
'priority',
|
|
||||||
]) {
|
|
||||||
check('下发提醒字段 ' + name + ' 在服务端 schema 里', notifySchema.includes(name));
|
|
||||||
}
|
|
||||||
check(
|
|
||||||
'提醒时长有上界(避免老师发出永不消失的提醒)',
|
|
||||||
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(serverSchemas) &&
|
|
||||||
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(sharedConstants),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3.7 实时事件:Web 端订阅的事件名必须在共享契约里声明过
|
|
||||||
check(
|
|
||||||
'共享契约声明了 classisland 实时事件',
|
|
||||||
sharedTypes.includes("'classisland:state'") && sharedTypes.includes("'classisland:notification'"),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'服务端广播事件名取自共享常量(不是手写字符串)',
|
|
||||||
/SOCKET_EVENTS\.classislandState/.test(serverService),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3.8 网络层:教室机器(以及开发机)常配了系统代理,而 Windows 的代理设置默认连 127.0.0.1
|
|
||||||
// 也会走代理,表现为"填了令牌立刻 HTTP 404"(其实是代理自己的错误页,跟本服务无关)。
|
|
||||||
// 插件必须对本地/内网地址绕开系统代理,否则内网部署必然失败。
|
|
||||||
check(
|
|
||||||
'HttpClient 显式配置代理(不裸用默认行为)',
|
|
||||||
/SocketsHttpHandler\s*\{[^}]*Proxy\s*=/.test(clientSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'本地/内网地址绕开系统代理',
|
|
||||||
/class LocalAwareProxy/.test(clientSource) && /IsBypassed\(Uri host\)/.test(clientSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'代理判定覆盖回环与内网网段',
|
|
||||||
/IsLoopback/.test(clientSource) &&
|
|
||||||
/10\.0\.0\.0\/8/.test(clientSource) &&
|
|
||||||
/192\.168\.0\.0\/16/.test(clientSource),
|
|
||||||
);
|
|
||||||
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
|
|
||||||
|
|
||||||
// 3.10 课表同步方向(2026-10-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
|
||||||
// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。
|
|
||||||
check(
|
|
||||||
'默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)',
|
|
||||||
/_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)',
|
|
||||||
/scheduleRequested/.test(clientSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
|
|
||||||
/ScheduleRequested\s*==\s*true/.test(bridgeSource) &&
|
|
||||||
/forceSchedule:\s*true/.test(bridgeSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)',
|
|
||||||
/forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
|
|
||||||
bridgeSource.indexOf('ScheduleRequested == true') < bridgeSource.indexOf('MirrorScheduleAsync()'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'课表拉取 DTO 带 weekRanges(管理员逐周配的学期周次区间)',
|
|
||||||
/weekRanges/.test(clientSource) && /TermWeekRangeDto/.test(clientSource),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
|
|
||||||
check(
|
|
||||||
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
|
|
||||||
providerSource.includes('Dispatcher.UIThread.Post'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'同一条提醒在播放期间按 id 去重(否则轮询会让它反复弹)',
|
|
||||||
providerSource.includes('_playing') && providerSource.includes('HashSet<string>'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'不再自造图标串(用模板默认图标,紧急用滚动文本表达)',
|
|
||||||
// 不再给 CreateTwoIconsMask 传 leftIcon(自造图标串会踩格式/字形坑)
|
|
||||||
!providerSource.includes('leftIcon') && providerSource.includes('CreateRollingTextContent'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
|
|
||||||
mapperSource.includes('Subject.Fallback'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'上课时段暂存非主动通知(与客户端灵动岛同一套规则)',
|
|
||||||
providerSource.includes('_deferred') &&
|
|
||||||
providerSource.includes('IsImmediate') &&
|
|
||||||
providerSource.includes('FlushDeferred') &&
|
|
||||||
providerSource.includes('OnBreakingTime'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'主动通知(紧急 / 叫人)上课时段也立即弹',
|
|
||||||
providerSource.includes("\"call\"") && providerSource.includes('notification.Urgent'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
|
|
||||||
bridgeSource.includes('上报课表到 ClassHelper') && bridgeSource.includes('开关已关闭'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
|
||||||
settingsSource.includes('NotificationPollSeconds') &&
|
|
||||||
bridgeSource.includes('_notifyTimer') &&
|
|
||||||
bridgeSource.includes('NotificationPollSeconds'),
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 8. 回归护栏:本次修复过的坑 */
|
|
||||||
|
|
||||||
check(
|
|
||||||
'课表配对用原始 TimeType==0 列表,**不用** ValidTimeLayoutItems(后者会剔除被停用的节次,导致整份课表错位)',
|
|
||||||
// 只查代码形态(`.Count`),因为源码注释里特意点名了这个属性来说明"为什么不能用它"
|
|
||||||
mapperSource.includes('classPoints[index]') && !mapperSource.includes('.ValidTimeLayoutItems.Count'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'上报字段按服务端 zod 上限截断(一个超长名字会让整批上报 400,连状态与心跳一起丢掉)',
|
|
||||||
mapperSource.includes('Truncate(') &&
|
|
||||||
mapperSource.includes('SubjectMaxLength') &&
|
|
||||||
mapperSource.includes('PlanNameMaxLength'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'课表镜像在 UI 线程改写档案(线程池上改 Observable 集合会与落盘并发,可能写坏档案)',
|
|
||||||
bridgeSource.includes('Dispatcher.UIThread.InvokeAsync') && bridgeSource.includes('SaveProfile()'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'镜像内容未变化时跳过(否则每次上报都重建课表并全量落盘)',
|
|
||||||
bridgeSource.includes('_lastMirrorSignature') && bridgeSource.includes('MirrorSignature('),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'上报异常有 catch(fire-and-forget 的异常会被静默吞掉)',
|
|
||||||
bridgeSource.includes('catch (Exception exception)') && bridgeSource.includes('上报异常'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'镜像前过滤时间非法的条目(避免档案里出现 00:00 的课次,且必须与 Classes 同步过滤)',
|
|
||||||
read('src/Services/ClassPlanWriter.cs').includes('IsValidTime'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'上报体带上 deviceKey,且服务端 schema 也声明了该字段(回填机器码的契约两端一致)',
|
|
||||||
clientSource.includes('"deviceKey"') && serverSchemas.includes('deviceKey:'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
|
|
||||||
clientSource.includes('响应不是 ClassHelper的标准格式') &&
|
|
||||||
clientSource.includes('MaxResponseContentBufferSize'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'提醒提供方惰性获取课程服务并重试(拿不到服务时"上课暂存"会永久失效且静默)',
|
|
||||||
providerSource.includes('EnsureLessons') && providerSource.includes('_lessonsHooked'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'设置落盘先写临时文件再原子替换(逐字符写盘 + 截断式写入会留下坏 JSON)',
|
|
||||||
pluginSource.includes('File.Move(temp, path, overwrite: true)'),
|
|
||||||
);
|
|
||||||
check(
|
|
||||||
'服务器地址只接受 http/https(裸 Uri.TryCreate 会放过 file:// / ftp://)',
|
|
||||||
settingsSource.includes('TryGetServerUri') && settingsSource.includes('UriSchemeHttp'),
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 结果 */
|
|
||||||
|
|
||||||
const failed = results.filter((item) => !item.ok);
|
|
||||||
for (const item of results) {
|
|
||||||
console.log((item.ok ? '√ ' : '× ') + item.name + (item.detail ? ' (' + item.detail + ')' : ''));
|
|
||||||
}
|
|
||||||
console.log('');
|
|
||||||
console.log(
|
|
||||||
'ClassIsland 联动插件静态校验:' + (results.length - failed.length) + '/' + results.length + ' 项通过',
|
|
||||||
);
|
|
||||||
if (failed.length > 0) {
|
|
||||||
console.log('未通过:\n - ' + failed.map((item) => item.name).join('\n - '));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http;
|
|
||||||
using System.Net.Sockets;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Models;
|
|
||||||
|
|
||||||
namespace ClassHelper.ClassIslandPlugin.Interop;
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 服务端契约(与 packages/shared 保持一致) */
|
|
||||||
|
|
||||||
/// <summary>插件上报的课表条目。</summary>
|
|
||||||
public class ScheduleEntryDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("dayOfWeek")] public int DayOfWeek { get; set; }
|
|
||||||
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
|
||||||
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
|
||||||
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
|
|
||||||
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
|
||||||
/// <summary>ALL / ODD / EVEN;留空则由服务端按 weekCountDiv 推导。</summary>
|
|
||||||
[JsonPropertyName("weekParity")] public string? WeekParity { get; set; }
|
|
||||||
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
|
|
||||||
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
|
|
||||||
[JsonPropertyName("planName")] public string? PlanName { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>插件上报的节次时间。</summary>
|
|
||||||
public class TimeLayoutItemDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("index")] public int Index { get; set; }
|
|
||||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
|
||||||
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
|
||||||
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
|
||||||
/// <summary>class / break / divider / action</summary>
|
|
||||||
[JsonPropertyName("type")] public string Type { get; set; } = "class";
|
|
||||||
[JsonPropertyName("skipped")] public bool Skipped { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>本机运行状态快照。</summary>
|
|
||||||
public class StateDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("inClass")] public bool InClass { get; set; }
|
|
||||||
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
|
||||||
[JsonPropertyName("nextSubject")] public string? NextSubject { get; set; }
|
|
||||||
[JsonPropertyName("timeState")] public string? TimeState { get; set; }
|
|
||||||
[JsonPropertyName("periodStart")] public string? PeriodStart { get; set; }
|
|
||||||
[JsonPropertyName("periodEnd")] public string? PeriodEnd { get; set; }
|
|
||||||
[JsonPropertyName("week")] public int? Week { get; set; }
|
|
||||||
[JsonPropertyName("classPlanLoaded")] public bool ClassPlanLoaded { get; set; }
|
|
||||||
[JsonPropertyName("clientTime")] public string ClientTime { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SchedulePayloadDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("mode")] public string Mode { get; set; } = "merge";
|
|
||||||
[JsonPropertyName("entries")] public List<ScheduleEntryDto> Entries { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TimeLayoutPayloadDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("name")] public string Name { get; set; } = "ClassIsland 时间表";
|
|
||||||
[JsonPropertyName("mode")] public string Mode { get; set; } = "replace";
|
|
||||||
[JsonPropertyName("items")] public List<TimeLayoutItemDto> Items { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ReportRequestDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
|
|
||||||
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
|
|
||||||
/// <summary>本机机器码:设备创建时服务端还不知道它,靠这里回填(见服务端 syncDeviceKey)。</summary>
|
|
||||||
[JsonPropertyName("deviceKey")] public string DeviceKey { get; set; } = "";
|
|
||||||
[JsonPropertyName("state")] public StateDto? State { get; set; }
|
|
||||||
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
|
|
||||||
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ReportSettingsDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
|
|
||||||
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 服务端请求**立刻上报一次课表**(老师在 Web 端点了「从教室机器获取课表」)。
|
|
||||||
///
|
|
||||||
/// 它与本机的「上报课表到 ClassHelper」开关无关:那个开关管的是"要不要**自动**回传",
|
|
||||||
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ReportResultDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("scheduleApplied")] public bool ScheduleApplied { get; set; }
|
|
||||||
[JsonPropertyName("scheduleCreated")] public int ScheduleCreated { get; set; }
|
|
||||||
[JsonPropertyName("scheduleUpdated")] public int ScheduleUpdated { get; set; }
|
|
||||||
[JsonPropertyName("courses")] public List<string> Courses { get; set; } = new();
|
|
||||||
[JsonPropertyName("timeLayoutApplied")] public bool TimeLayoutApplied { get; set; }
|
|
||||||
[JsonPropertyName("week")] public int Week { get; set; }
|
|
||||||
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
|
||||||
[JsonPropertyName("settings")] public ReportSettingsDto? Settings { get; set; }
|
|
||||||
[JsonPropertyName("pendingNotification")] public PushNotificationDto? PendingNotification { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>服务端下发的提醒。</summary>
|
|
||||||
public class PushNotificationDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
|
||||||
[JsonPropertyName("title")] public string Title { get; set; } = "";
|
|
||||||
[JsonPropertyName("content")] public string Content { get; set; } = "";
|
|
||||||
[JsonPropertyName("durationSeconds")] public int DurationSeconds { get; set; } = 8;
|
|
||||||
[JsonPropertyName("speechContent")] public string? SpeechContent { get; set; }
|
|
||||||
[JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = "";
|
|
||||||
[JsonPropertyName("urgent")] public bool Urgent { get; set; }
|
|
||||||
|
|
||||||
/// <summary>提醒类型:notification(默认)/ call(叫人,算"主动通知",上课时段也立刻弹)</summary>
|
|
||||||
[JsonPropertyName("kind")] public string? Kind { get; set; }
|
|
||||||
[JsonPropertyName("classId")] public string ClassId { get; set; } = "";
|
|
||||||
[JsonPropertyName("className")] public string? ClassName { get; set; }
|
|
||||||
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PendingResultDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("notifications")] public List<PushNotificationDto> Notifications { get; set; } = new();
|
|
||||||
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
|
||||||
[JsonPropertyName("week")] public int Week { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 课表镜像(服务端 → ClassIsland) */
|
|
||||||
|
|
||||||
public class ClassPlanEntryMirrorDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("weekDay")] public int WeekDay { get; set; }
|
|
||||||
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
|
|
||||||
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
|
|
||||||
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
|
|
||||||
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
|
||||||
/// <summary>起止时间(HH:mm)。Classes[i] 必须与第 i 个上课时间点对齐,靠它重建时间表。</summary>
|
|
||||||
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
|
||||||
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
|
||||||
[JsonPropertyName("timeLayoutId")] public string TimeLayoutId { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TimeLayoutMirrorDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
|
||||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
|
||||||
[JsonPropertyName("layouts")] public List<TimeLayoutItemMirrorDto> Layouts { get; set; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TimeLayoutItemMirrorDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
|
||||||
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
|
||||||
[JsonPropertyName("timeType")] public int TimeType { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ClassPlanMirrorDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("entries")] public List<ClassPlanEntryMirrorDto> Entries { get; set; } = new();
|
|
||||||
[JsonPropertyName("timeLayouts")] public List<TimeLayoutMirrorDto> TimeLayouts { get; set; } = new();
|
|
||||||
[JsonPropertyName("profileName")] public string ProfileName { get; set; } = "";
|
|
||||||
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>服务端逐周配的学期周次区间(管理员在「学期周次」里维护)。</summary>
|
|
||||||
public class TermWeekRangeDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("weekNumber")] public int WeekNumber { get; set; }
|
|
||||||
/// <summary>YYYY-MM-DD</summary>
|
|
||||||
[JsonPropertyName("startDate")] public string StartDate { get; set; } = "";
|
|
||||||
/// <summary>YYYY-MM-DD</summary>
|
|
||||||
[JsonPropertyName("endDate")] public string EndDate { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PullResultDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
|
|
||||||
[JsonPropertyName("week")] public int Week { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 学期逐周区间;空 = 服务端只按学期开始日期线性推算。
|
|
||||||
///
|
|
||||||
/// ClassIsland 自身只认"教学周序号 + 单双周",没有"按日期区间定义某一周"的模型
|
|
||||||
/// (本机装的 2.1.0.1 没有该字段),所以这份区间在插件侧只用于:
|
|
||||||
/// 1) 校正"现在是第几周"(调休会让线性推算整体错位);
|
|
||||||
/// 2) 在设置页里显示教室这台机器当前处在哪一段日期区间,方便老师核对。
|
|
||||||
/// 课表本身的周次口径仍由 <see cref="ClassPlanMirrorDto.TermStartDate"/> 与单双周表达。
|
|
||||||
/// </summary>
|
|
||||||
[JsonPropertyName("weekRanges")] public List<TermWeekRangeDto> WeekRanges { get; set; } = new();
|
|
||||||
|
|
||||||
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 统一响应体 */
|
|
||||||
|
|
||||||
internal class ApiEnvelope<T>
|
|
||||||
{
|
|
||||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
|
||||||
[JsonPropertyName("data")] public T? Data { get; set; }
|
|
||||||
[JsonPropertyName("message")] public string Message { get; set; } = "";
|
|
||||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>调用 ClassHelper后端的结果(不抛异常,便于设置页展示原因)。</summary>
|
|
||||||
public class ApiCallResult
|
|
||||||
{
|
|
||||||
public bool Ok { get; init; }
|
|
||||||
public string Message { get; init; } = "";
|
|
||||||
public bool NetworkError { get; init; }
|
|
||||||
|
|
||||||
public static ApiCallResult Success(string message) => new() { Ok = true, Message = message };
|
|
||||||
public static ApiCallResult Failure(string message) => new() { Ok = false, Message = message };
|
|
||||||
public static ApiCallResult Offline(string message) => new() { Ok = false, Message = message, NetworkError = true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ClassHelper后端客户端。
|
|
||||||
///
|
|
||||||
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
|
|
||||||
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
|
|
||||||
/// (见 https://docs.classisland.tech/dev/plugins/basics.html 的程序集隔离说明)。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ClassHelperClient : IDisposable
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 单次请求超时。教室机器可能是内网慢链路,但也不该卡住上报线程太久。
|
|
||||||
/// </summary>
|
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 响应体上限(4MB)。默认是 2GB —— 服务器地址填错(指到一个会吐大页面的地址)时,
|
|
||||||
/// 一次请求就能在 12 秒内把内存吃光。
|
|
||||||
/// </summary>
|
|
||||||
private const long MaxResponseBytes = 4 * 1024 * 1024;
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
||||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 与 ClassHelper通信的 HttpClient。
|
|
||||||
///
|
|
||||||
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
|
|
||||||
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
|
|
||||||
/// 那个 404 其实是**代理自己的错误页**,跟本服务毫无关系。
|
|
||||||
/// 因此这里用 <see cref="LocalAwareProxy"/>:本地/内网地址直连,公网地址照旧走系统代理。
|
|
||||||
/// </summary>
|
|
||||||
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
|
|
||||||
{
|
|
||||||
Timeout = Timeout,
|
|
||||||
MaxResponseContentBufferSize = MaxResponseBytes,
|
|
||||||
};
|
|
||||||
|
|
||||||
public string LastError { get; private set; } = "";
|
|
||||||
public DateTimeOffset? LastSuccessAt { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 包装系统代理:**本地/内网地址标记为"绕过"**,其余交给系统代理决定。
|
|
||||||
///
|
|
||||||
/// 判定为本地/内网的情形:localhost、回环地址、IPv4/IPv6 私有段与链路本地段,
|
|
||||||
/// 以及不含点的主机名(`server` / `classhelper` 这类校内机器名,DNS 也可能解析不出来)。
|
|
||||||
/// 判定为公网时保持原行为,避免"云服务器 + 公司代理"这种场景被我们改坏。
|
|
||||||
/// </summary>
|
|
||||||
private sealed class LocalAwareProxy : IWebProxy
|
|
||||||
{
|
|
||||||
private readonly IWebProxy _inner = HttpClient.DefaultProxy;
|
|
||||||
|
|
||||||
// 只读透传即可:HttpClient.DefaultProxy 是全局单例,改它的凭据会波及宿主进程里的其它请求
|
|
||||||
public ICredentials? Credentials { get; set; }
|
|
||||||
|
|
||||||
public Uri? GetProxy(Uri destination) => _inner.GetProxy(destination);
|
|
||||||
|
|
||||||
public bool IsBypassed(Uri host) => IsLocalHost(host) || _inner.IsBypassed(host);
|
|
||||||
|
|
||||||
/// <summary>该地址是否属于"不该走代理"的本地/内网范围。</summary>
|
|
||||||
internal static bool IsLocalHost(Uri uri)
|
|
||||||
{
|
|
||||||
var host = uri.Host;
|
|
||||||
if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true;
|
|
||||||
// 不含点的主机名(校内机器名)走直连:解析成内网 IP 的可能性远大于公网
|
|
||||||
if (!host.Contains('.')) return true;
|
|
||||||
|
|
||||||
if (!IPAddress.TryParse(host, out var address)) return false;
|
|
||||||
if (IPAddress.IsLoopback(address)) return true;
|
|
||||||
|
|
||||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
|
||||||
{
|
|
||||||
var bytes = address.GetAddressBytes();
|
|
||||||
return bytes[0] == 10 // 10.0.0.0/8
|
|
||||||
|| (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) // 172.16.0.0/12
|
|
||||||
|| (bytes[0] == 192 && bytes[1] == 168) // 192.168.0.0/16
|
|
||||||
|| (bytes[0] == 169 && bytes[1] == 254); // 169.254.0.0/16(链路本地)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPv6:fc00::/7(唯一本地地址)与 fe80::/10(链路本地)
|
|
||||||
var first = address.GetAddressBytes()[0];
|
|
||||||
return (first & 0xFE) == 0xFC || (first & 0xC0) == 0x80;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>拼接 API 地址,容忍用户填了带/不带末尾斜杠、甚至带 /api 后缀的地址。</summary>
|
|
||||||
private static string BuildUrl(string serverUrl, string path)
|
|
||||||
{
|
|
||||||
var baseUrl = (serverUrl ?? "").Trim().TrimEnd('/');
|
|
||||||
if (baseUrl.EndsWith("/api", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
baseUrl = baseUrl[..^4];
|
|
||||||
}
|
|
||||||
return $"{baseUrl}/api/integrations{path}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpRequestMessage BuildRequest(HttpMethod method, string serverUrl, string path, string token)
|
|
||||||
{
|
|
||||||
var request = new HttpRequestMessage(method, BuildUrl(serverUrl, path));
|
|
||||||
if (!string.IsNullOrWhiteSpace(token))
|
|
||||||
{
|
|
||||||
request.Headers.TryAddWithoutValidation("X-ClassIsland-Token", token);
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ApiEnvelope<T>?> SendAsync<T>(
|
|
||||||
HttpMethod method,
|
|
||||||
string serverUrl,
|
|
||||||
string path,
|
|
||||||
string token,
|
|
||||||
object? body,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var request = BuildRequest(method, serverUrl, path, token);
|
|
||||||
if (body is not null)
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(body, JsonOptions);
|
|
||||||
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
|
||||||
}
|
|
||||||
|
|
||||||
using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
|
||||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
ApiEnvelope<T>? envelope = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
envelope = JsonSerializer.Deserialize<ApiEnvelope<T>>(text, JsonOptions);
|
|
||||||
}
|
|
||||||
catch (JsonException)
|
|
||||||
{
|
|
||||||
// 非 JSON 响应(例如被网关拦截)——交给下面的兜底分支
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var message = envelope?.Message;
|
|
||||||
if (string.IsNullOrWhiteSpace(message))
|
|
||||||
{
|
|
||||||
// 响应不是本服务的统一响应体:最常见的原因就是被系统代理 / 网关拦下来
|
|
||||||
// (典型表现:HTTP 404 且 body 是一张 HTML 错误页)。把 URL 一起报出来,
|
|
||||||
// 老师截图给我们就能一眼定位。
|
|
||||||
message = envelope is null
|
|
||||||
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
|
||||||
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确、" +
|
|
||||||
"或该系统代理是否拦截了内网请求"
|
|
||||||
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
|
|
||||||
}
|
|
||||||
LastError = message!;
|
|
||||||
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (envelope is null)
|
|
||||||
{
|
|
||||||
// 2xx 但响应不是本服务的统一响应体(网关/反代的 HTML 错误页、地址指到了别的服务等)。
|
|
||||||
// 必须留下原因:否则调用方拿到的是"成功但无数据 + 空错误消息",
|
|
||||||
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
|
|
||||||
LastError =
|
|
||||||
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
|
||||||
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
|
|
||||||
return new ApiEnvelope<T> { Success = false, Message = LastError };
|
|
||||||
}
|
|
||||||
|
|
||||||
LastError = "";
|
|
||||||
LastSuccessAt = DateTimeOffset.Now;
|
|
||||||
return envelope;
|
|
||||||
}
|
|
||||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
LastError = "请求超时(请检查服务器地址与网络)";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (HttpRequestException exception)
|
|
||||||
{
|
|
||||||
LastError = $"无法连接 ClassHelper服务:{exception.Message}";
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
LastError = exception.Message;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>上报状态 +(可选)课表。</summary>
|
|
||||||
public async Task<(ApiCallResult Result, ReportResultDto? Data)> ReportAsync(
|
|
||||||
PluginSettings settings,
|
|
||||||
ReportRequestDto payload,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var envelope = await SendAsync<ReportResultDto>(
|
|
||||||
HttpMethod.Post, settings.ServerUrl, "/classisland/report", settings.DeviceToken, payload, cancellationToken);
|
|
||||||
if (envelope is null)
|
|
||||||
{
|
|
||||||
return (ApiCallResult.Offline(LastError), null);
|
|
||||||
}
|
|
||||||
if (!envelope.Success || envelope.Data is null)
|
|
||||||
{
|
|
||||||
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
|
|
||||||
}
|
|
||||||
return (ApiCallResult.Success(envelope.Message), envelope.Data);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>拉取尚未确认的下发提醒(插件启动/重连时补齐)。</summary>
|
|
||||||
public async Task<(ApiCallResult Result, List<PushNotificationDto> Items)> PendingAsync(
|
|
||||||
PluginSettings settings,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var envelope = await SendAsync<PendingResultDto>(
|
|
||||||
HttpMethod.Get, settings.ServerUrl, "/classisland/pending", settings.DeviceToken, null, cancellationToken);
|
|
||||||
if (envelope is null) return (ApiCallResult.Offline(LastError), new List<PushNotificationDto>());
|
|
||||||
if (!envelope.Success || envelope.Data is null)
|
|
||||||
{
|
|
||||||
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError),
|
|
||||||
new List<PushNotificationDto>());
|
|
||||||
}
|
|
||||||
return (ApiCallResult.Success(envelope.Message), envelope.Data.Notifications);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>确认提醒已弹出(之后服务端不再补发)。</summary>
|
|
||||||
public async Task<ApiCallResult> AckAsync(PluginSettings settings, string id, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var envelope = await SendAsync<JsonElement>(
|
|
||||||
HttpMethod.Post, settings.ServerUrl, "/classisland/ack", settings.DeviceToken, new { id }, cancellationToken);
|
|
||||||
if (envelope is null) return ApiCallResult.Offline(LastError);
|
|
||||||
return envelope.Success
|
|
||||||
? ApiCallResult.Success("已确认")
|
|
||||||
: ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>拉取班级课表(用于镜像回 ClassIsland)。未开启镜像时服务端返回 data=null。</summary>
|
|
||||||
public async Task<(ApiCallResult Result, ClassPlanMirrorDto? Plan)> PullClassPlanAsync(
|
|
||||||
PluginSettings settings,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var envelope = await SendAsync<PullResultDto>(
|
|
||||||
HttpMethod.Get, settings.ServerUrl, "/classisland/class-plan", settings.DeviceToken, null, cancellationToken);
|
|
||||||
if (envelope is null) return (ApiCallResult.Offline(LastError), null);
|
|
||||||
if (!envelope.Success)
|
|
||||||
{
|
|
||||||
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
|
|
||||||
}
|
|
||||||
if (envelope.Data?.ClassPlan is null)
|
|
||||||
{
|
|
||||||
return (ApiCallResult.Failure(envelope.Message.Length > 0
|
|
||||||
? envelope.Message
|
|
||||||
: "该设备未开启「把班级课表镜像到 ClassIsland」"), null);
|
|
||||||
}
|
|
||||||
return (ApiCallResult.Success(envelope.Message), envelope.Data.ClassPlan);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() => _http.Dispose();
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
|
|
||||||
namespace ClassHelper.ClassIslandPlugin.Models;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 插件设置。
|
|
||||||
///
|
|
||||||
/// 存储由 ClassIsland 负责:插件入口把它保存在 <c>PluginConfigFolder/Settings.json</c>
|
|
||||||
/// (**不要**放在插件安装目录,那里不会随档案备份、升级时还会被清掉)。
|
|
||||||
/// 因此这里必须继承 <see cref="ObservableObject"/> 并让每个属性都走 <c>SetProperty</c>:
|
|
||||||
/// ClassIsland 通过监听 <c>PropertyChanged</c> 来即时保存。
|
|
||||||
/// </summary>
|
|
||||||
public class PluginSettings : ObservableObject
|
|
||||||
{
|
|
||||||
private string _serverUrl = "http://127.0.0.1:4000";
|
|
||||||
private string _deviceToken = "";
|
|
||||||
private string _deviceKey = "";
|
|
||||||
private bool _autoReport = true;
|
|
||||||
private int _reportIntervalSeconds = 60;
|
|
||||||
// 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
|
||||||
// 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。
|
|
||||||
// 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。
|
|
||||||
private bool _uploadSchedule = false;
|
|
||||||
private bool _mirrorSchedule = true;
|
|
||||||
private bool _receiveNotifications = true;
|
|
||||||
private int _notificationDurationSeconds = 8;
|
|
||||||
private int _notificationPollSeconds = 10;
|
|
||||||
private bool _speechEnabled = false;
|
|
||||||
private bool _showDebugInfo = false;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ClassHelper后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
|
||||||
/// </summary>
|
|
||||||
public string ServerUrl
|
|
||||||
{
|
|
||||||
get => _serverUrl;
|
|
||||||
set => SetProperty(ref _serverUrl, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 设备令牌(形如 <c>chci_...</c>):在 ClassHelper Web 端「ClassIsland 联动」页面生成。
|
|
||||||
/// </summary>
|
|
||||||
public string DeviceToken
|
|
||||||
{
|
|
||||||
get => _deviceToken;
|
|
||||||
// 在 setter 里就去掉首尾空白:粘贴令牌时带前导空格非常常见,
|
|
||||||
// 而 Normalize() 只在启动加载时跑一次 —— 运行期粘进来的脏值会让 IsConfigured
|
|
||||||
// 一直为 false,用户看着明明填好了却查不出原因。
|
|
||||||
set => SetProperty(ref _deviceToken, (value ?? "").Trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 本机标识(首次生成后固定):服务端据此把同一台机器识别为同一设备,重装插件不会产生重复设备。
|
|
||||||
/// </summary>
|
|
||||||
public string DeviceKey
|
|
||||||
{
|
|
||||||
get => _deviceKey;
|
|
||||||
set => SetProperty(ref _deviceKey, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否自动上报:启动后、课表变化时、以及每隔 <see cref="ReportIntervalSeconds"/> 秒各上报一次。
|
|
||||||
/// </summary>
|
|
||||||
public bool AutoReport
|
|
||||||
{
|
|
||||||
get => _autoReport;
|
|
||||||
set => SetProperty(ref _autoReport, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 定时上报间隔(秒),最小 15 秒。
|
|
||||||
/// </summary>
|
|
||||||
public int ReportIntervalSeconds
|
|
||||||
{
|
|
||||||
get => _reportIntervalSeconds;
|
|
||||||
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>
|
|
||||||
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
|
|
||||||
/// </summary>
|
|
||||||
public bool ReceiveNotifications
|
|
||||||
{
|
|
||||||
get => _receiveNotifications;
|
|
||||||
set => SetProperty(ref _receiveNotifications, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 提醒轮询间隔(秒),最小 5 秒。
|
|
||||||
///
|
|
||||||
/// 插件与后端之间没有长连接(刻意不引入 Socket 依赖),提醒是"轮询取回"的,
|
|
||||||
/// 因此这个值就是**老师按下发送到教室弹出之间的最大延迟**。
|
|
||||||
/// 单独一个轻量轮询(只查待提醒,不上传课表)比把上报间隔改小划算得多。
|
|
||||||
/// </summary>
|
|
||||||
public int NotificationPollSeconds
|
|
||||||
{
|
|
||||||
get => _notificationPollSeconds;
|
|
||||||
set => SetProperty(ref _notificationPollSeconds, Math.Clamp(value, 5, 600));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 默认提醒显示时长(秒);老师在下发时可以单独指定更长的时长。
|
|
||||||
/// </summary>
|
|
||||||
public int NotificationDurationSeconds
|
|
||||||
{
|
|
||||||
get => _notificationDurationSeconds;
|
|
||||||
set => SetProperty(ref _notificationDurationSeconds, Math.Clamp(value, 1, 120));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 提醒弹出时是否语音朗读(沿用 ClassIsland 的语音设置与语音提供方)。
|
|
||||||
/// </summary>
|
|
||||||
public bool SpeechEnabled
|
|
||||||
{
|
|
||||||
get => _speechEnabled;
|
|
||||||
set => SetProperty(ref _speechEnabled, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 是否在设置页显示调试信息(最后一次上报结果、课表条目数等),排查问题时打开。
|
|
||||||
/// </summary>
|
|
||||||
public bool ShowDebugInfo
|
|
||||||
{
|
|
||||||
get => _showDebugInfo;
|
|
||||||
set => SetProperty(ref _showDebugInfo, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 兼容旧配置:反序列化后把 DeviceKey 补齐(首次运行时由服务生成)。
|
|
||||||
/// </summary>
|
|
||||||
public void Normalize()
|
|
||||||
{
|
|
||||||
ServerUrl = (ServerUrl ?? "").Trim().TrimEnd('/');
|
|
||||||
DeviceToken = (DeviceToken ?? "").Trim();
|
|
||||||
DeviceKey = (DeviceKey ?? "").Trim();
|
|
||||||
ReportIntervalSeconds = Math.Clamp(ReportIntervalSeconds, 15, 3600);
|
|
||||||
NotificationDurationSeconds = Math.Clamp(NotificationDurationSeconds, 1, 120);
|
|
||||||
NotificationPollSeconds = Math.Clamp(NotificationPollSeconds, 5, 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
|
|
||||||
public bool IsConfigured =>
|
|
||||||
TryGetServerUri(out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 解析服务器地址,**只接受 http/https 绝对地址**。
|
|
||||||
///
|
|
||||||
/// 不用裸的 `Uri.TryCreate(..., UriKind.Absolute)`:它会接受 `file://` / `ftp://`,
|
|
||||||
/// 那种地址会在发请求时抛一句难以理解的 NotSupportedException,用户根本猜不到是地址填错了。
|
|
||||||
/// </summary>
|
|
||||||
public bool TryGetServerUri(out Uri uri)
|
|
||||||
{
|
|
||||||
uri = null!;
|
|
||||||
if (!Uri.TryCreate((ServerUrl ?? "").Trim(), UriKind.Absolute, out var parsed)) return false;
|
|
||||||
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) return false;
|
|
||||||
uri = parsed;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Models;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Services;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Views;
|
|
||||||
using ClassIsland.Core;
|
|
||||||
using ClassIsland.Core.Abstractions;
|
|
||||||
using ClassIsland.Core.Abstractions.Services;
|
|
||||||
using ClassIsland.Core.Attributes;
|
|
||||||
using ClassIsland.Core.Extensions.Registry;
|
|
||||||
using ClassIsland.Shared;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace ClassHelper.ClassIslandPlugin;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 插件入口。
|
|
||||||
///
|
|
||||||
/// 职责只有三件:读配置 → 注册扩展(提醒提供方 / 设置页 / 联动服务)→ 在应用启动后接上课程服务。
|
|
||||||
/// 真正的联动逻辑都在 <see cref="BridgeService"/> 里,入口保持"看一眼就知道装了哪些东西"。
|
|
||||||
/// </summary>
|
|
||||||
[PluginEntrance]
|
|
||||||
public class Plugin : PluginBase
|
|
||||||
{
|
|
||||||
/// <summary>插件设置文件名。存在 <see cref="PluginBase.PluginConfigFolder"/> 下:随档案备份走,升级插件不丢。</summary>
|
|
||||||
private const string SettingsFileName = "Settings.json";
|
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>插件设置。注册成单例后,设置页与 <see cref="BridgeService"/> 拿到的是同一个对象。</summary>
|
|
||||||
public PluginSettings Settings { get; private set; } = new();
|
|
||||||
|
|
||||||
/// <summary>联动只接一次(AppStarted 与"运行中启用"两条路都会走到这里)</summary>
|
|
||||||
private bool _bridgeStarted;
|
|
||||||
|
|
||||||
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
|
||||||
{
|
|
||||||
Settings = LoadSettings();
|
|
||||||
|
|
||||||
// 设置页与联动服务共用一个实例:设置页改了值,服务立刻能看到(不需要重启 ClassIsland)
|
|
||||||
services.AddSingleton(Settings);
|
|
||||||
services.AddSingleton<BridgeService>();
|
|
||||||
|
|
||||||
services.AddSettingsPage<BridgeSettingsPage>();
|
|
||||||
services.AddNotificationProvider<ClassHelperNotificationProvider>();
|
|
||||||
|
|
||||||
// 初始化阶段课程服务还没准备好(它在主机启动流程里才被创建),
|
|
||||||
// 因此这里先挂一个"应用已启动"的回调,等到那时再取 ILessonsService 并开始上报。
|
|
||||||
var application = AppBase.Current;
|
|
||||||
if (application is null)
|
|
||||||
{
|
|
||||||
Logger?.LogError("ClassHelper 联动:拿不到 ClassIsland 应用实例,联动未启动");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
application.AppStarted += (_, _) => StartBridge();
|
|
||||||
|
|
||||||
// 插件是"运行中才被启用"时,AppStarted 早就过去了 —— 主窗口已经存在就直接接一次
|
|
||||||
if (application.MainWindow is not null) StartBridge();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void StartBridge()
|
|
||||||
{
|
|
||||||
if (_bridgeStarted) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lessons = IAppHost.TryGetService<ILessonsService>();
|
|
||||||
if (lessons is null)
|
|
||||||
{
|
|
||||||
Logger?.LogError("ClassHelper 联动:拿不到课程服务,联动已停用");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bridge = IAppHost.TryGetService<BridgeService>();
|
|
||||||
if (bridge is null)
|
|
||||||
{
|
|
||||||
Logger?.LogError("ClassHelper 联动:拿不到联动服务,联动已停用");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_bridgeStarted = true;
|
|
||||||
bridge.Attach(lessons);
|
|
||||||
bridge.RestartTimer();
|
|
||||||
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
|
|
||||||
// 不这么做的话,最长要等一个上报间隔才会弹出来
|
|
||||||
_ = bridge.PullPendingAsync();
|
|
||||||
Logger?.LogInformation("ClassHelper 联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
// 联动失败绝不能把 ClassIsland 本体拖下水
|
|
||||||
Logger?.LogError(exception, "ClassHelper 联动:初始化联动服务失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ILogger<Plugin>? Logger => IAppHost.TryGetService<ILogger<Plugin>>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 读取设置。首次运行时 <c>DeviceKey</c> 为空,这里补一个随机值并立即落盘 ——
|
|
||||||
/// 服务端用它把同一台机器识别为同一设备,重装插件不会堆出重复设备。
|
|
||||||
/// </summary>
|
|
||||||
private PluginSettings LoadSettings()
|
|
||||||
{
|
|
||||||
var path = Path.Combine(PluginConfigFolder, SettingsFileName);
|
|
||||||
var settings = new PluginSettings();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (File.Exists(path))
|
|
||||||
{
|
|
||||||
var loaded = JsonSerializer.Deserialize<PluginSettings>(File.ReadAllText(path), JsonOptions);
|
|
||||||
if (loaded is not null) settings = loaded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
|
|
||||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
|
||||||
"ClassHelper 联动:读取设置失败,已回退到默认设置({Path})", path);
|
|
||||||
}
|
|
||||||
|
|
||||||
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
|
|
||||||
settings.Normalize();
|
|
||||||
if (needGenerateKey)
|
|
||||||
{
|
|
||||||
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车。
|
|
||||||
// 注意长度:原先写的是 `[..Math.Min(24, machine.Length + 33)]`,而 machine.Length + 33
|
|
||||||
// 恒大于 24,所以实际**永远只取前 24 个字符** —— 机器名稍长就把随机段整个切掉,
|
|
||||||
// "同名机器不撞车"的保障就没了。这里改成"机器名按需截短 + 保留 8 位随机段"。
|
|
||||||
var machine = Environment.MachineName.ToLowerInvariant();
|
|
||||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
|
||||||
var maxMachineLength = Math.Max(4, 24 - suffix.Length - 1);
|
|
||||||
if (machine.Length > maxMachineLength) machine = machine[..maxMachineLength];
|
|
||||||
settings.DeviceKey = $"{machine}-{suffix}";
|
|
||||||
}
|
|
||||||
|
|
||||||
Settings = settings;
|
|
||||||
// 落盘:一是固定首次生成的 DeviceKey,二是把缺字段的旧配置补齐成完整文件
|
|
||||||
SaveSettings(settings, path);
|
|
||||||
// 设置变化时即时保存
|
|
||||||
settings.PropertyChanged += (_, _) => SaveSettings(settings, path);
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void SaveSettings(PluginSettings settings, string path)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var folder = Path.GetDirectoryName(path);
|
|
||||||
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
|
|
||||||
// 先写临时文件再原子替换。
|
|
||||||
//
|
|
||||||
// 设置页的地址/令牌输入框是 TwoWay 绑定(逐字符提交),直接 `File.WriteAllText`
|
|
||||||
// 会就地截断原文件;一旦此刻进程被杀或断电,留下的是半截 JSON,而 LoadSettings 的
|
|
||||||
// catch 会**静默回退到默认设置** —— 用户看到的是"令牌和地址莫名其妙没了",
|
|
||||||
// 而且原文件已被覆盖,无从恢复。
|
|
||||||
var temp = path + ".tmp";
|
|
||||||
File.WriteAllText(temp, JsonSerializer.Serialize(settings, JsonOptions));
|
|
||||||
File.Move(temp, path, overwrite: true);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
|
||||||
"ClassHelper 联动:保存设置失败({Path})", path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,640 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using System.Text;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Interop;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Models;
|
|
||||||
using ClassIsland.Core;
|
|
||||||
using ClassIsland.Core.Abstractions.Services;
|
|
||||||
using ClassIsland.Core.Models.Notification;
|
|
||||||
using ClassIsland.Shared;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 联动主循环。三件事:
|
|
||||||
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
|
|
||||||
/// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper;
|
|
||||||
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
|
|
||||||
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
|
|
||||||
/// 3. <b>镜像</b>:开启镜像开关时,把 ClassHelper上排好的课表写回 ClassIsland 档案。
|
|
||||||
///
|
|
||||||
/// 为什么用"上报即拉取"而不是长连接:
|
|
||||||
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
|
|
||||||
/// 而教室机器的上报间隔本身就是心跳 —— 顺路把待提醒带回来,依赖更少、故障面更小。
|
|
||||||
///
|
|
||||||
/// 线程约定:HttpClient 回调在线程池上,因此所有跨线程可见的状态都用 <c>lock</c> 或
|
|
||||||
/// 赋值引用(<c>string</c> / <c>int</c>)保护,属性变化通过 <see cref="PropertyChanged"/> 通知设置页。
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|
||||||
{
|
|
||||||
private readonly PluginSettings _settings;
|
|
||||||
private readonly ILogger<BridgeService> _logger;
|
|
||||||
private readonly ClassHelperClient _client = new();
|
|
||||||
private readonly object _gate = new();
|
|
||||||
|
|
||||||
private ILessonsService? _lessons;
|
|
||||||
private Timer? _timer;
|
|
||||||
private Timer? _notifyTimer;
|
|
||||||
private bool _disposed;
|
|
||||||
private bool _reporting;
|
|
||||||
private bool _pulling;
|
|
||||||
private bool _eventsHooked;
|
|
||||||
/// <summary>
|
|
||||||
/// 上次成功镜像的课表内容指纹。内容没变就完全跳过镜像:既省掉每分钟一次的全量重建 + 落盘,
|
|
||||||
/// 也把"跨线程改档案"的窗口从"每分钟一次"降回"只在课表真的变化时"。
|
|
||||||
/// </summary>
|
|
||||||
private volatile string? _lastMirrorSignature;
|
|
||||||
|
|
||||||
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
|
|
||||||
{
|
|
||||||
_settings = settings;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public event PropertyChangedEventHandler? PropertyChanged;
|
|
||||||
|
|
||||||
/// <summary>上报结果变化时触发(设置页据此刷新)。</summary>
|
|
||||||
public event EventHandler? StateChanged;
|
|
||||||
|
|
||||||
/// <summary>需要把提醒丢给提醒提供方去显示。</summary>
|
|
||||||
public event EventHandler<PushNotificationDto>? NotificationReceived;
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 供设置页绑定的状态 */
|
|
||||||
|
|
||||||
private string _lastReportMessage = "尚未上报";
|
|
||||||
|
|
||||||
/// <summary>最近一次上报的结果描述。</summary>
|
|
||||||
public string LastReportMessage
|
|
||||||
{
|
|
||||||
get => _lastReportMessage;
|
|
||||||
private set => SetField(ref _lastReportMessage, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _lastReportOk;
|
|
||||||
public bool LastReportOk
|
|
||||||
{
|
|
||||||
get => _lastReportOk;
|
|
||||||
private set => SetField(ref _lastReportOk, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private DateTimeOffset? _lastReportAt;
|
|
||||||
public DateTimeOffset? LastReportAt
|
|
||||||
{
|
|
||||||
get => _lastReportAt;
|
|
||||||
private set
|
|
||||||
{
|
|
||||||
if (SetField(ref _lastReportAt, value)) Raise(nameof(LastReportAtText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>供界面直接显示的"最后上报时间"。</summary>
|
|
||||||
public string LastReportAtText => LastReportAt is null
|
|
||||||
? "从未"
|
|
||||||
: LastReportAt.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
|
|
||||||
|
|
||||||
private int _lastScheduleEntryCount;
|
|
||||||
/// <summary>最近一次上报里的课表条目数。</summary>
|
|
||||||
public int LastScheduleEntryCount
|
|
||||||
{
|
|
||||||
get => _lastScheduleEntryCount;
|
|
||||||
private set => SetField(ref _lastScheduleEntryCount, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int _lastTimeLayoutItemCount;
|
|
||||||
/// <summary>最近一次上报里的节次数量。</summary>
|
|
||||||
public int LastTimeLayoutItemCount
|
|
||||||
{
|
|
||||||
get => _lastTimeLayoutItemCount;
|
|
||||||
private set => SetField(ref _lastTimeLayoutItemCount, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private int? _lastServerWeek;
|
|
||||||
/// <summary>服务端返回的教学周。</summary>
|
|
||||||
public int? LastServerWeek
|
|
||||||
{
|
|
||||||
get => _lastServerWeek;
|
|
||||||
private set
|
|
||||||
{
|
|
||||||
if (SetField(ref _lastServerWeek, value)) Raise(nameof(ClientTimeOffsetText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private int _notificationCount;
|
|
||||||
/// <summary>已弹出并回执的提醒数量。</summary>
|
|
||||||
public int NotificationCount
|
|
||||||
{
|
|
||||||
get => _notificationCount;
|
|
||||||
private set => SetField(ref _notificationCount, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool _busy;
|
|
||||||
/// <summary>是否正在上报("立即上报"按钮据此禁用)。</summary>
|
|
||||||
public bool Busy
|
|
||||||
{
|
|
||||||
get => _busy;
|
|
||||||
private set => SetField(ref _busy, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private TimeSpan? _serverTimeOffset;
|
|
||||||
/// <summary>服务端与本机的时钟差(诊断用:差得多说明教室机器时间不对)。</summary>
|
|
||||||
public string ClientTimeOffsetText => _serverTimeOffset is null
|
|
||||||
? "—"
|
|
||||||
: $"{_serverTimeOffset.Value.TotalSeconds:+0;-0;0} 秒";
|
|
||||||
|
|
||||||
/// <summary>当前配置是否完整(设置页据此提示"还没填令牌")。</summary>
|
|
||||||
public bool IsConfigured => _settings.IsConfigured;
|
|
||||||
|
|
||||||
/// <summary>是否已接入课程服务(拿到 ILessonsService 才算真正跑起来)。</summary>
|
|
||||||
public bool IsAttached
|
|
||||||
{
|
|
||||||
get { lock (_gate) return _lessons is not null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 生命周期 */
|
|
||||||
|
|
||||||
/// <summary>接入课程服务并订阅课程事件(由插件入口在 AppStarted 时调用)。</summary>
|
|
||||||
public void Attach(ILessonsService lessons)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_lessons = lessons;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_eventsHooked)
|
|
||||||
{
|
|
||||||
// 上课 / 下课 / 放学 / 状态变化时立刻上报一次,不等到下一个定时点。
|
|
||||||
// 这些事件在 ClassIsland 的公开约定里是在**主线程**触发的,可以直接订阅。
|
|
||||||
lessons.OnClass += (_, _) => RequestReport("上课");
|
|
||||||
lessons.OnBreakingTime += (_, _) => RequestReport("下课");
|
|
||||||
lessons.OnAfterSchool += (_, _) => RequestReport("放学");
|
|
||||||
lessons.CurrentTimeStateChanged += (_, _) => RequestReport("状态变化");
|
|
||||||
_eventsHooked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
Raise(nameof(IsAttached));
|
|
||||||
_logger.LogInformation("ClassHelper 联动:已订阅课程事件");
|
|
||||||
RequestReport("应用启动");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>按设置重建定时器(设置页改间隔后调用)。</summary>
|
|
||||||
public void RestartTimer()
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_timer?.Dispose();
|
|
||||||
_timer = null;
|
|
||||||
_notifyTimer?.Dispose();
|
|
||||||
_notifyTimer = null;
|
|
||||||
|
|
||||||
if (!_settings.IsConfigured) return;
|
|
||||||
|
|
||||||
if (_settings.AutoReport)
|
|
||||||
{
|
|
||||||
var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.ReportIntervalSeconds, 15, 3600));
|
|
||||||
_timer = new Timer(_ => RequestReport("定时"), null, interval, interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提醒单独用一个轻量轮询(只查待提醒,不上传课表):
|
|
||||||
// 老师按下发送到教室弹出之间的延迟就是这个间隔,因此默认 10 秒,不跟随上报间隔。
|
|
||||||
if (_settings.ReceiveNotifications)
|
|
||||||
{
|
|
||||||
var poll = TimeSpan.FromSeconds(Math.Clamp(_settings.NotificationPollSeconds, 5, 600));
|
|
||||||
_notifyTimer = new Timer(_ => _ = PullPendingAsync(), null, poll, poll);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>发起一次上报(事件驱动:已有上报在跑时直接丢弃本次,避免事件风暴打出并发请求)。</summary>
|
|
||||||
public void RequestReport(string reason)
|
|
||||||
{
|
|
||||||
_ = ReportAsync(reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 执行一次上报并返回是否真的发出去了。
|
|
||||||
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
|
|
||||||
/// </summary>
|
|
||||||
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true, bool forceSchedule = false)
|
|
||||||
{
|
|
||||||
if (_disposed) return false;
|
|
||||||
|
|
||||||
ILessonsService? lessons;
|
|
||||||
lock (_gate) lessons = _lessons;
|
|
||||||
if (lessons is null) return false;
|
|
||||||
|
|
||||||
if (!_settings.IsConfigured)
|
|
||||||
{
|
|
||||||
SetState(false, "尚未配置服务器地址或设备令牌", 0, 0, null);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
if (_reporting) return false;
|
|
||||||
_reporting = true;
|
|
||||||
}
|
|
||||||
Busy = true;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var profile = ResolveProfile();
|
|
||||||
var request = new ReportRequestDto
|
|
||||||
{
|
|
||||||
PluginVersion = PluginVersion,
|
|
||||||
ClassIslandVersion = SafeAppVersion(),
|
|
||||||
// 机器码:设备创建时服务端只能用 pending-… 占位,靠这里回填,Web 端设备列表才能与教室机器对上
|
|
||||||
DeviceKey = _settings.DeviceKey,
|
|
||||||
State = BuildState(lessons),
|
|
||||||
};
|
|
||||||
|
|
||||||
var entryCount = 0;
|
|
||||||
var layoutCount = 0;
|
|
||||||
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
|
|
||||||
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
|
|
||||||
// forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
|
|
||||||
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
|
|
||||||
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
|
|
||||||
|
|
||||||
if (includeSchedule && !uploadSchedule)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
|
|
||||||
"需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (includeSchedule && uploadSchedule && profile is not null)
|
|
||||||
{
|
|
||||||
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
|
||||||
foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning);
|
|
||||||
entryCount = entries.Count;
|
|
||||||
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
|
|
||||||
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
|
|
||||||
// 日志里只出现"仅状态"时,用户会以为上报失败,实际是 ClassIsland 里还没有课表。
|
|
||||||
if (entries.Count == 0)
|
|
||||||
{
|
|
||||||
request.Schedule = null;
|
|
||||||
_logger.LogWarning(
|
|
||||||
"ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
|
||||||
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份");
|
|
||||||
}
|
|
||||||
|
|
||||||
var layout = ResolvePrimaryTimeLayout(profile);
|
|
||||||
if (layout is not null)
|
|
||||||
{
|
|
||||||
var items = ScheduleMapper.MapTimeLayout(layout);
|
|
||||||
layoutCount = items.Count;
|
|
||||||
request.TimeLayout = new TimeLayoutPayloadDto
|
|
||||||
{
|
|
||||||
Name = ScheduleMapper.Truncate(
|
|
||||||
string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
|
|
||||||
ScheduleMapper.PlanNameMaxLength)!,
|
|
||||||
Mode = "replace",
|
|
||||||
Items = items,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (result, data) = await _client.ReportAsync(_settings, request, CancellationToken.None)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
if (!result.Ok || data is null)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("ClassHelper 联动上报失败({Reason}):{Message}", reason, result.Message);
|
|
||||||
SetState(false, result.Message, entryCount, layoutCount, null);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
LastServerWeek = data.Week;
|
|
||||||
UpdateClockOffset(data.ServerTime);
|
|
||||||
if (data.ScheduleApplied)
|
|
||||||
{
|
|
||||||
_logger.LogInformation(
|
|
||||||
"ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
|
||||||
data.ScheduleCreated, data.ScheduleUpdated);
|
|
||||||
}
|
|
||||||
|
|
||||||
var detail = entryCount > 0
|
|
||||||
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
|
||||||
: uploadSchedule
|
|
||||||
? "仅状态(ClassIsland 里还没有启用的课表)"
|
|
||||||
: "仅状态(本机「上报课表到 ClassHelper」开关已关闭)";
|
|
||||||
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
|
|
||||||
entryCount, layoutCount, data.Week);
|
|
||||||
|
|
||||||
// 顺路把"还没弹出过的"提醒带回来
|
|
||||||
var pending = data.PendingNotification;
|
|
||||||
if (pending is not null && _settings.ReceiveNotifications)
|
|
||||||
{
|
|
||||||
NotificationReceived?.Invoke(this, pending);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。
|
|
||||||
// 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。
|
|
||||||
if (data.Settings?.ScheduleRequested == true && !uploadSchedule)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…");
|
|
||||||
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_settings.MirrorSchedule)
|
|
||||||
{
|
|
||||||
await MirrorScheduleAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
// ReportAsync 是通过 `_ = ReportAsync(reason)` 这种 fire-and-forget 方式发起的
|
|
||||||
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
|
|
||||||
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
|
|
||||||
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
|
|
||||||
_logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason);
|
|
||||||
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Busy = false;
|
|
||||||
lock (_gate) _reporting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 拉取本班尚未弹出过的提醒并逐条交给提醒提供方(用于"应用刚起来还没到第一个上报点"的场景)。
|
|
||||||
/// </summary>
|
|
||||||
public async Task PullPendingAsync()
|
|
||||||
{
|
|
||||||
if (_disposed) return;
|
|
||||||
if (!_settings.IsConfigured || !_settings.ReceiveNotifications) return;
|
|
||||||
|
|
||||||
// 轮询与"上报顺路拉取"可能同时到达:同一时刻只放一个请求过去
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
if (_pulling) return;
|
|
||||||
_pulling = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
|
||||||
if (!result.Ok)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("ClassHelper 联动:拉取待提醒失败({Message})", result.Message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var item in items)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
|
|
||||||
NotificationReceived?.Invoke(this, item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
lock (_gate) _pulling = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>把服务端的一条提醒标记为已弹出。</summary>
|
|
||||||
public async Task AckAsync(string id)
|
|
||||||
{
|
|
||||||
var result = await _client.AckAsync(_settings, id, CancellationToken.None).ConfigureAwait(false);
|
|
||||||
if (result.Ok)
|
|
||||||
{
|
|
||||||
NotificationCount += 1;
|
|
||||||
_logger.LogInformation("ClassHelper 联动:已确认提醒 {Id}", id);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
|
|
||||||
_logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>把 ClassHelper上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
|
|
||||||
public async Task MirrorScheduleAsync()
|
|
||||||
{
|
|
||||||
var profile = ResolveProfile();
|
|
||||||
if (profile is null) return;
|
|
||||||
|
|
||||||
var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
|
||||||
if (!result.Ok || plan is null)
|
|
||||||
{
|
|
||||||
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
|
|
||||||
_logger.LogInformation(
|
|
||||||
"ClassHelper 联动:跳过课表镜像({Message})。" +
|
|
||||||
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
|
|
||||||
result.Message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 内容没变就直接返回:上报是"启动 + 每个课程事件 + 每 N 秒"触发的,原先每次都要
|
|
||||||
// 重建全部课表对象并整体落盘 —— 无谓的网络/磁盘 I/O,还会把档案页刷得不停重绘。
|
|
||||||
var signature = MirrorSignature(plan);
|
|
||||||
if (signature == _lastMirrorSignature)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// **必须在 UI 线程上改写档案**:ClassIsland 的档案是 ObservableDictionary /
|
|
||||||
// ObservableCollection,落盘是"属性变化即整体序列化 + FileStream 就地截断"。
|
|
||||||
// 而本方法是从线程池线程继续执行的(ReportAsync 里 ConfigureAwait(false) 之后,
|
|
||||||
// 定时器回调本就在线程池上),在线程池上改集合会与 UI 线程的保存/绑定并发:
|
|
||||||
// 轻则 InvalidOperationException(Collection was modified),重则两次写入互相截断,
|
|
||||||
// **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。
|
|
||||||
var outcome = await Dispatcher.UIThread.InvokeAsync(() =>
|
|
||||||
{
|
|
||||||
var applied = ClassPlanWriter.Apply(profile, plan);
|
|
||||||
if (!applied.Applied)
|
|
||||||
{
|
|
||||||
return applied;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
|
|
||||||
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
|
|
||||||
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
|
|
||||||
return applied;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!outcome.Applied)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})",
|
|
||||||
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastMirrorSignature = signature;
|
|
||||||
_logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})",
|
|
||||||
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
|
|
||||||
foreach (var warning in outcome.Warnings)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
_logger.LogError(exception, "ClassHelper 联动:课表镜像失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>镜像载荷的内容指纹(只用于"内容没变就跳过",不参与任何业务判断)</summary>
|
|
||||||
private static string MirrorSignature(ClassPlanMirrorDto plan)
|
|
||||||
{
|
|
||||||
var builder = new StringBuilder();
|
|
||||||
foreach (var entry in plan.Entries)
|
|
||||||
{
|
|
||||||
builder.Append(entry.WeekDay).Append('|').Append(entry.WeekCountDiv).Append('|')
|
|
||||||
.Append(entry.WeekCountDivTotal).Append('|').Append(entry.Subject).Append('|')
|
|
||||||
.Append(entry.StartTime).Append('|').Append(entry.EndTime).Append(';');
|
|
||||||
}
|
|
||||||
foreach (var layout in plan.TimeLayouts)
|
|
||||||
{
|
|
||||||
builder.Append('#').Append(layout.Id).Append(':');
|
|
||||||
foreach (var item in layout.Layouts)
|
|
||||||
{
|
|
||||||
builder.Append(item.StartTime).Append('-').Append(item.EndTime).Append('@')
|
|
||||||
.Append(item.TimeType).Append(',');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return builder.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 内部实现 */
|
|
||||||
|
|
||||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
|
||||||
private const string PluginVersion = "1.2.0.0";
|
|
||||||
|
|
||||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
|
||||||
private static string SafeAppVersion()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return AppBase.AppVersion ?? "";
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 取得当前状态快照。
|
|
||||||
/// <c>CurrentTimeLayoutItem</c> 在"没有课表"时是 <see cref="ClassIsland.Shared.Models.Profile.TimeLayoutItem.Empty"/>,
|
|
||||||
/// 语义上等价于"没有节次",因此要转成 null 而不是 00:00。
|
|
||||||
/// </summary>
|
|
||||||
private StateDto BuildState(ILessonsService lessons)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return ScheduleMapper.BuildState(lessons, LastServerWeek);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
|
|
||||||
_logger.LogWarning(exception, "ClassHelper 联动:读取课程状态失败,本次只上报心跳");
|
|
||||||
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。
|
|
||||||
///
|
|
||||||
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
|
|
||||||
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
|
|
||||||
/// 服务端那边的节次时间表就退化了。取上课点最多的那份,得到的是完整作息。
|
|
||||||
/// 并列时优先"每周"型(单双周的两份内容一致,每周型更通用)。
|
|
||||||
/// </summary>
|
|
||||||
private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout(
|
|
||||||
ClassIsland.Shared.Models.Profile.Profile profile)
|
|
||||||
{
|
|
||||||
if (profile.TimeLayouts.Count == 0) return null;
|
|
||||||
|
|
||||||
var best = profile.TimeLayouts
|
|
||||||
.Select(pair => (pair.Value, ClassPoints: pair.Value.Layouts.Count(item => item.TimeType == 0)))
|
|
||||||
.Where(item => item.ClassPoints > 0)
|
|
||||||
.OrderByDescending(item => item.ClassPoints)
|
|
||||||
.ToList();
|
|
||||||
if (best.Count > 0) return best[0].Value;
|
|
||||||
|
|
||||||
// 兜底:档案里被激活的时间表,最后才是任意一个
|
|
||||||
return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated)
|
|
||||||
?? profile.TimeLayouts.Values.First();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ClassIsland.Shared.Models.Profile.ClassPlan? CurrentClassPlan(
|
|
||||||
ClassIsland.Shared.Models.Profile.Profile profile)
|
|
||||||
{
|
|
||||||
// 用课程服务拿"今天该上哪张课表"最准
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var lessons = IAppHost.TryGetService<ILessonsService>();
|
|
||||||
return lessons?.CurrentClassPlan;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private ClassIsland.Shared.Models.Profile.Profile? ResolveProfile()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 档案由 ProfileService 持有;这里用 TryGetService 避免服务未注册时抛异常
|
|
||||||
return IAppHost.TryGetService<IProfileService>()?.Profile;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(exception, "ClassHelper 联动:读取档案失败");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>用服务端时间校准本机时钟差(只影响设置页展示,不改系统时间)。</summary>
|
|
||||||
private void UpdateClockOffset(string serverTime)
|
|
||||||
{
|
|
||||||
if (!DateTimeOffset.TryParse(serverTime, out var parsed)) return;
|
|
||||||
_serverTimeOffset = parsed - DateTimeOffset.Now;
|
|
||||||
Raise(nameof(ClientTimeOffsetText));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetState(bool ok, string message, int entryCount, int layoutCount, int? week)
|
|
||||||
{
|
|
||||||
LastReportOk = ok;
|
|
||||||
LastReportMessage = message;
|
|
||||||
LastReportAt = DateTimeOffset.Now;
|
|
||||||
LastScheduleEntryCount = entryCount;
|
|
||||||
LastTimeLayoutItemCount = layoutCount;
|
|
||||||
if (week.HasValue) LastServerWeek = week;
|
|
||||||
StateChanged?.Invoke(this, EventArgs.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Raise(string propertyName) =>
|
|
||||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
|
||||||
|
|
||||||
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
|
||||||
{
|
|
||||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
|
||||||
field = value;
|
|
||||||
Raise(propertyName!);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_disposed = true;
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
_timer?.Dispose();
|
|
||||||
_timer = null;
|
|
||||||
_notifyTimer?.Dispose();
|
|
||||||
_notifyTimer = null;
|
|
||||||
}
|
|
||||||
_client.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
using Avalonia.Threading;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Interop;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Models;
|
|
||||||
using ClassIsland.Core.Abstractions.Services;
|
|
||||||
using ClassIsland.Shared;
|
|
||||||
using ClassIsland.Shared.Enums;
|
|
||||||
using ClassIsland.Core.Abstractions.Services.NotificationProviders;
|
|
||||||
using ClassIsland.Core.Attributes;
|
|
||||||
using ClassIsland.Core.Models.Notification;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 提醒提供方:把老师在 ClassHelper上发布的提醒显示到 ClassIsland 上。
|
|
||||||
///
|
|
||||||
/// 与 <see cref="BridgeService"/> 的分工:
|
|
||||||
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
|
|
||||||
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
|
|
||||||
/// 全屏特效 / 语音 / 音效那一整套流程。
|
|
||||||
///
|
|
||||||
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「ClassHelper 联动」设置页里,
|
|
||||||
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
|
|
||||||
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
|
|
||||||
/// </summary>
|
|
||||||
// 第 3 个参数是 Fluent 图标字体里的字形字符(ClassIsland 内置提醒提供方用的也是字形字符,
|
|
||||||
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
|
|
||||||
[NotificationProviderInfo(
|
|
||||||
ProviderGuidValue,
|
|
||||||
"ClassHelper提醒",
|
|
||||||
"\uE8BD",
|
|
||||||
"显示老师从 ClassHelper下发的提醒。")]
|
|
||||||
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 提醒提供方 GUID(全局唯一,别改:改了等于换了一个提供方,用户原有的提醒设置会失联)。
|
|
||||||
/// 名字带 Value 后缀是为了不和基类的 <see cref="NotificationProviderBase.ProviderGuid"/> 属性撞名。
|
|
||||||
/// </summary>
|
|
||||||
public const string ProviderGuidValue = "7C4B1F6E-2A85-4A1F-9E3C-5B7D0C1A9E42";
|
|
||||||
|
|
||||||
private readonly BridgeService _bridge;
|
|
||||||
private readonly PluginSettings _settings;
|
|
||||||
private readonly ILogger<ClassHelperNotificationProvider> _logger;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 正在播放(尚未回执)的提醒 id。
|
|
||||||
///
|
|
||||||
/// 服务端在提醒被回执前会**一直**把它算作"待弹出",而插件是按固定间隔轮询取回的,
|
|
||||||
/// 因此时长超过轮询间隔的提醒会被反复取到 —— 没有这层去重就会一遍遍重复弹。
|
|
||||||
/// 只有播完(或用户关掉)才从集合里移除,那一刻才回执。
|
|
||||||
/// </summary>
|
|
||||||
private readonly HashSet<string> _playing = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 上课时段被暂存的提醒(与客户端灵动岛同一套规则)。
|
|
||||||
///
|
|
||||||
/// 为什么要有它:老师在**上课时段**发通知,教室里正上着课,直接全屏弹会打断课堂。
|
|
||||||
/// 客户端的灵动岛就是"上课先收着、下课再弹",ClassIsland 这侧必须一致,
|
|
||||||
/// 否则学生在客户端看不到、却在 ClassIsland 上被打断。
|
|
||||||
///
|
|
||||||
/// 例外("主动通知"):紧急提醒与**叫人**,老师正在等学生,必须立刻弹。
|
|
||||||
/// </summary>
|
|
||||||
private readonly List<PushNotificationDto> _deferred = new();
|
|
||||||
|
|
||||||
private ILessonsService? _lessons;
|
|
||||||
private bool _lessonsHooked;
|
|
||||||
|
|
||||||
public ClassHelperNotificationProvider(
|
|
||||||
BridgeService bridge,
|
|
||||||
PluginSettings settings,
|
|
||||||
ILogger<ClassHelperNotificationProvider> logger)
|
|
||||||
{
|
|
||||||
_bridge = bridge;
|
|
||||||
_settings = settings;
|
|
||||||
_logger = logger;
|
|
||||||
_bridge.NotificationReceived += OnNotificationReceived;
|
|
||||||
|
|
||||||
// 尽力而为地先订阅一次;拿不到也没关系,后面每次需要判定时都会重试
|
|
||||||
// (本类的构造函数可能在课程服务建好之前就被 DI 调用)
|
|
||||||
EnsureLessons();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 惰性获取课程服务并**只订阅一次**课程事件(下课后补弹上课时段暂存的提醒)。
|
|
||||||
///
|
|
||||||
/// 为什么不能只在构造函数里订阅:提供方是随主机启动被构造的,而课程服务在启动流程里的
|
|
||||||
/// 创建时机并不固定。一旦构造函数里拿到 null,原先的实现既不重试、也不打日志,
|
|
||||||
/// 于是 `IsInClass()` 恒为 false —— "上课时段先暂存、下课再补弹"这条规则**永久失效**,
|
|
||||||
/// 老师在课上发的通知会直接全屏打断课堂,而且完全静默、无从排查。
|
|
||||||
/// </summary>
|
|
||||||
private void EnsureLessons()
|
|
||||||
{
|
|
||||||
if (_lessonsHooked) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_lessons = IAppHost.TryGetService<ILessonsService>();
|
|
||||||
if (_lessons is null) return;
|
|
||||||
|
|
||||||
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
|
||||||
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
|
||||||
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
|
||||||
_lessonsHooked = true;
|
|
||||||
_logger.LogInformation("ClassHelper 联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(exception, "ClassHelper 联动:订阅课程事件失败,将在下次提醒时重试");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
|
|
||||||
private bool IsInClass()
|
|
||||||
{
|
|
||||||
EnsureLessons();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return _lessons?.CurrentState == TimeState.OnClass;
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 这条提醒是否必须立刻弹("主动通知"):
|
|
||||||
/// 紧急提醒,或叫人(老师正在等学生)。
|
|
||||||
/// </summary>
|
|
||||||
private static bool IsImmediate(PushNotificationDto notification) =>
|
|
||||||
notification.Urgent || string.Equals(notification.Kind, "call", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
/// <summary>下课后补弹暂存的提醒(按进入顺序)</summary>
|
|
||||||
private void FlushDeferred()
|
|
||||||
{
|
|
||||||
if (IsInClass()) return; // 还在上课(例如只是状态在 OnClass 内部变化)
|
|
||||||
if (_deferred.Count == 0) return;
|
|
||||||
|
|
||||||
var pending = _deferred.ToList();
|
|
||||||
_deferred.Clear();
|
|
||||||
_logger.LogInformation("ClassHelper 联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
|
|
||||||
foreach (var item in pending) ShowOnUiThread(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnNotificationReceived(object? sender, PushNotificationDto notification)
|
|
||||||
{
|
|
||||||
if (!_settings.ReceiveNotifications) return;
|
|
||||||
if (string.IsNullOrWhiteSpace(notification.Id)) return;
|
|
||||||
|
|
||||||
// 同一条提醒在播放期间会被后续轮询反复取回,这里按 id 去重(详见 _playing 的说明)
|
|
||||||
lock (_playing)
|
|
||||||
{
|
|
||||||
if (!_playing.Add(notification.Id))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("ClassHelper 联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 必须切回 UI 线程:提醒内容里的图标、以及提醒主机本身都是 Avalonia 的 UI 对象,
|
|
||||||
// 而本方法是从上报回调(线程池)里被调用的 —— 在线程池上直接显示会静默失败/抛异常。
|
|
||||||
Dispatcher.UIThread.Post(() => ShowOnUiThread(notification));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ShowOnUiThread(PushNotificationDto notification)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 上课时段:非"主动通知"先收着(与客户端灵动岛同一套规则),下课后补弹
|
|
||||||
if (IsInClass() && !IsImmediate(notification))
|
|
||||||
{
|
|
||||||
_deferred.Add(notification);
|
|
||||||
_logger.LogInformation("ClassHelper 联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = BuildRequest(notification);
|
|
||||||
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
|
||||||
AckWhenFinished(request, notification);
|
|
||||||
ShowNotification(request);
|
|
||||||
_logger.LogInformation("ClassHelper 联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
|
||||||
notification.Title, notification.DurationSeconds);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
|
|
||||||
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
|
|
||||||
lock (_playing) _playing.Remove(notification.Id);
|
|
||||||
_logger.LogError(exception, "ClassHelper 联动:显示提醒「{Title}」失败", notification.Title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 把服务端下发的提醒转成 ClassIsland 的提醒请求。
|
|
||||||
///
|
|
||||||
/// 时长口径:老师指定的时长给**正文**(正文才是持续显示的那一段),遮罩只停留几秒 ——
|
|
||||||
/// 遮罩占满整个时长会把正文压没,不如"遮罩几秒 + 正文 N 秒"清楚。
|
|
||||||
/// </summary>
|
|
||||||
private NotificationRequest BuildRequest(PushNotificationDto notification)
|
|
||||||
{
|
|
||||||
var seconds = Math.Clamp(
|
|
||||||
notification.DurationSeconds > 0 ? notification.DurationSeconds : _settings.NotificationDurationSeconds,
|
|
||||||
1,
|
|
||||||
120);
|
|
||||||
var duration = TimeSpan.FromSeconds(seconds);
|
|
||||||
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
|
|
||||||
|
|
||||||
var title = string.IsNullOrWhiteSpace(notification.Title) ? "ClassHelper" : notification.Title;
|
|
||||||
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
|
|
||||||
|
|
||||||
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
|
|
||||||
var speech = _settings.SpeechEnabled ? notification.SpeechContent : null;
|
|
||||||
var speechEnabled = !string.IsNullOrWhiteSpace(speech);
|
|
||||||
|
|
||||||
// 遮罩用双图标模板的**默认图标**:这两个图标由模板自己创建(LucideIconSource),
|
|
||||||
// 自己塞图标串很容易踩到"图标表达式格式不对/字形不存在",没必要冒这个险。
|
|
||||||
var mask = NotificationContent.CreateTwoIconsMask(title, factory: content_ =>
|
|
||||||
{
|
|
||||||
content_.Duration = maskDuration;
|
|
||||||
content_.SpeechContent = speech ?? string.Empty;
|
|
||||||
// 没勾朗读时明确关掉,否则 ClassIsland 会按默认设置把标题也念出来
|
|
||||||
content_.IsSpeechEnabled = speechEnabled;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 正文:普通提醒用静态文本;紧急提醒改用**滚动文本**(ClassIsland 自带的抢注意力模板),
|
|
||||||
// 这样"紧急"在视觉上真的有区别,而不是只靠一个可能不存在的图标。
|
|
||||||
NotificationContent overlay = notification.Urgent
|
|
||||||
? NotificationContent.CreateRollingTextContent(content, factory: content_ =>
|
|
||||||
{
|
|
||||||
content_.Duration = duration;
|
|
||||||
content_.SpeechContent = speech ?? string.Empty;
|
|
||||||
content_.IsSpeechEnabled = speechEnabled;
|
|
||||||
})
|
|
||||||
: NotificationContent.CreateSimpleTextContent(content, factory: content_ =>
|
|
||||||
{
|
|
||||||
content_.Duration = duration;
|
|
||||||
content_.SpeechContent = speech ?? string.Empty;
|
|
||||||
content_.IsSpeechEnabled = speechEnabled;
|
|
||||||
});
|
|
||||||
|
|
||||||
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>提醒播完 / 被关掉后回执给 ClassHelper,服务端不再补发。</summary>
|
|
||||||
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
|
|
||||||
{
|
|
||||||
void Handler(object? sender, EventArgs args)
|
|
||||||
{
|
|
||||||
request.Completed -= Handler;
|
|
||||||
request.Canceled -= Handler;
|
|
||||||
|
|
||||||
// 播完再放行:即使回执失败(例如断网)也移除,让后续轮询能重新投递一次,
|
|
||||||
// 避免"回执丢了 + 不再重试"把提醒彻底吞掉
|
|
||||||
lock (_playing) _playing.Remove(notification.Id);
|
|
||||||
_ = _bridge.AckAsync(notification.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
request.Completed += Handler;
|
|
||||||
request.Canceled += Handler;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Interop;
|
|
||||||
using ClassIsland.Shared.Models.Profile;
|
|
||||||
|
|
||||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 把 ClassHelper拉回来的课表写进 ClassIsland 档案。
|
|
||||||
///
|
|
||||||
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
|
|
||||||
/// (即一个星期几 + 一个单双周相位),而 ClassHelper的课表同时覆盖 7 天与单双周。
|
|
||||||
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
|
|
||||||
///
|
|
||||||
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
|
|
||||||
/// <c>TimeLayout.Layouts</c> 里第 i 个 <c>TimeType == 0</c>(上课)的点一一对应。
|
|
||||||
/// 与其去"匹配服务端时间表里的时间点"(一旦对不齐整份课表就错位),
|
|
||||||
/// 这里**按分组内的条目顺序直接重建时间点**:每条课目生成一个上课点,
|
|
||||||
/// 并在其结束后接上服务端时间表里同一起始时刻的课间点。这样索引关系天然成立。
|
|
||||||
///
|
|
||||||
/// **非破坏性**:
|
|
||||||
/// - 只创建/更新名字带 <see cref="PlanNamePrefix"/> 前缀的课表与时间表;
|
|
||||||
/// - 老师手动建的课表一律不删除、不改名(同名星期的冲突会在日志里警告);
|
|
||||||
/// - 重复镜像时原地更新,不会越积越多。
|
|
||||||
/// </summary>
|
|
||||||
public static class ClassPlanWriter
|
|
||||||
{
|
|
||||||
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
|
|
||||||
public const string PlanNamePrefix = "ClassHelper-";
|
|
||||||
|
|
||||||
/// <summary>时间表名字后缀。</summary>
|
|
||||||
public const string TimeLayoutNameSuffix = "(ClassHelper)";
|
|
||||||
|
|
||||||
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
|
|
||||||
public sealed class ApplyResult
|
|
||||||
{
|
|
||||||
public List<string> PlanNames { get; } = new();
|
|
||||||
public List<string> Warnings { get; } = new();
|
|
||||||
public bool Applied { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 应用服务端下发的课表。
|
|
||||||
/// </summary>
|
|
||||||
public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source)
|
|
||||||
{
|
|
||||||
var result = new ApplyResult();
|
|
||||||
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
|
|
||||||
{
|
|
||||||
result.Warnings.Add("服务端返回的课表为空,未改动 ClassIsland 档案");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
var layout = source.TimeLayouts[0];
|
|
||||||
|
|
||||||
// 1) 补齐科目(同名的复用,避免每次镜像堆出新的 Subject)
|
|
||||||
EnsureSubjects(profile, source);
|
|
||||||
|
|
||||||
// 2) 按(星期几 + 单双周)分组,每组一份课表
|
|
||||||
var groups = source.Entries
|
|
||||||
.GroupBy(entry => new
|
|
||||||
{
|
|
||||||
entry.WeekDay,
|
|
||||||
entry.WeekCountDiv,
|
|
||||||
entry.WeekCountDivTotal,
|
|
||||||
})
|
|
||||||
.Select(group => new
|
|
||||||
{
|
|
||||||
group.Key.WeekDay,
|
|
||||||
group.Key.WeekCountDiv,
|
|
||||||
group.Key.WeekCountDivTotal,
|
|
||||||
// 时间点顺序 = 课表顺序:`Classes[i]` 靠这个顺序与上课时间点对齐,
|
|
||||||
// 「HH:mm」是定长字符串,字典序就是时间序
|
|
||||||
Entries = group.OrderBy(entry => entry.StartTime, StringComparer.Ordinal).ToList(),
|
|
||||||
})
|
|
||||||
.OrderBy(group => group.WeekDay)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var group in groups)
|
|
||||||
{
|
|
||||||
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
|
|
||||||
group.WeekCountDivTotal, groups.Count);
|
|
||||||
|
|
||||||
// 时间非法的条目必须**在这里**就整批丢掉:`Classes` 与时间表里的上课点必须等长且同序,
|
|
||||||
// 只在一处跳过会让整份课表错位。原先 ParseTime 把无法解析的值悄悄变成 00:00,
|
|
||||||
// 于是档案里凭空出现 00:00-00:00 的课次(时间轴显示与当前节次判断都会乱),还没有任何日志。
|
|
||||||
var usable = group.Entries
|
|
||||||
.Where(entry => IsValidTime(entry.StartTime) && IsValidTime(entry.EndTime))
|
|
||||||
.ToList();
|
|
||||||
if (usable.Count < group.Entries.Count)
|
|
||||||
{
|
|
||||||
result.Warnings.Add(
|
|
||||||
$"「{planName}」有 {group.Entries.Count - usable.Count} 节的时间无法解析(需要 HH:mm),已跳过");
|
|
||||||
}
|
|
||||||
if (usable.Count == 0) continue;
|
|
||||||
|
|
||||||
var planId = FindPlanId(profile, planName);
|
|
||||||
var (timeLayoutId, timeLayout) = BuildTimeLayout(profile, planName, usable);
|
|
||||||
|
|
||||||
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
|
|
||||||
? existing
|
|
||||||
: new ClassPlan();
|
|
||||||
plan.Name = planName;
|
|
||||||
plan.TimeLayoutId = timeLayoutId;
|
|
||||||
plan.TimeRule = new TimeRule
|
|
||||||
{
|
|
||||||
WeekDay = group.WeekDay,
|
|
||||||
WeekCountDiv = group.WeekCountDiv,
|
|
||||||
WeekCountDivTotal = group.WeekCountDivTotal,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Classes[i] ↔ 第 i 个上课点:按分组内条目的时间顺序直接构造,索引天然一致
|
|
||||||
// ClassIsland 的 `ClassInfo.CurrentTimeLayoutItem` = CurrentTimeLayout 里第 Index 个上课点,
|
|
||||||
// 因此这里必须显式把 Index 与 CurrentTimeLayout 一起写上 —— 只在构造时依赖
|
|
||||||
// `RefreshClassesList()` 是不够的:更新已有课表时 `TimeLayouts` 引用没变,
|
|
||||||
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
|
|
||||||
var classes = new ObservableCollection<ClassInfo>();
|
|
||||||
var index = 0;
|
|
||||||
foreach (var entry in usable)
|
|
||||||
{
|
|
||||||
var subjectId = profile.Subjects
|
|
||||||
.FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key;
|
|
||||||
classes.Add(new ClassInfo
|
|
||||||
{
|
|
||||||
Index = index++,
|
|
||||||
CurrentTimeLayout = timeLayout,
|
|
||||||
SubjectId = subjectId,
|
|
||||||
IsEnabled = true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
plan.Classes = classes;
|
|
||||||
plan.IsEnabled = true;
|
|
||||||
profile.ClassPlans[planId] = plan;
|
|
||||||
result.PlanNames.Add(planName);
|
|
||||||
|
|
||||||
// 冲突提示:同一星期几 + 同一单双周相位下老师还开着别的课表
|
|
||||||
var conflicts = profile.ClassPlans
|
|
||||||
.Where(pair => pair.Key != planId
|
|
||||||
&& pair.Value.IsEnabled
|
|
||||||
&& !pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)
|
|
||||||
&& 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同步的课表为准");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 停用上一次镜像留下来的、本次不再需要的课表(只动自己创建的)
|
|
||||||
var currentNames = new HashSet<string>(result.PlanNames);
|
|
||||||
foreach (var pair in profile.ClassPlans)
|
|
||||||
{
|
|
||||||
if (!pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)) continue;
|
|
||||||
if (currentNames.Contains(pair.Value.Name)) continue;
|
|
||||||
pair.Value.IsEnabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
result.Applied = true;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void EnsureSubjects(Profile profile, ClassPlanMirrorDto source)
|
|
||||||
{
|
|
||||||
foreach (var name in source.Entries.Select(entry => entry.Subject).Distinct())
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
|
||||||
if (profile.Subjects.Any(pair => pair.Value.Name == name)) continue;
|
|
||||||
profile.Subjects[Guid.NewGuid()] = new Subject { Name = name };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Guid FindPlanId(Profile profile, string planName)
|
|
||||||
{
|
|
||||||
var existing = profile.ClassPlans.FirstOrDefault(pair => pair.Value.Name == planName);
|
|
||||||
return existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
|
|
||||||
///
|
|
||||||
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
|
|
||||||
/// 不依赖服务端有没有给课间点 —— 这样无论 ClassHelper那边是否维护节次时间表,
|
|
||||||
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
|
|
||||||
/// </summary>
|
|
||||||
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
|
|
||||||
Profile profile,
|
|
||||||
string planName,
|
|
||||||
IReadOnlyList<ClassPlanEntryMirrorDto> entries)
|
|
||||||
{
|
|
||||||
var layoutName = planName + TimeLayoutNameSuffix;
|
|
||||||
var existing = profile.TimeLayouts.FirstOrDefault(pair => pair.Value.Name == layoutName);
|
|
||||||
var id = existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
|
|
||||||
|
|
||||||
var layout = new TimeLayout { Name = layoutName, IsActivated = true };
|
|
||||||
for (var index = 0; index < entries.Count; index++)
|
|
||||||
{
|
|
||||||
var entry = entries[index];
|
|
||||||
layout.Layouts.Add(new TimeLayoutItem
|
|
||||||
{
|
|
||||||
TimeType = 0, // 上课
|
|
||||||
StartTime = ParseTime(entry.StartTime),
|
|
||||||
EndTime = ParseTime(entry.EndTime),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 课间:下一节开始晚于本节结束才有空隙;最后一节之后(放学)不插
|
|
||||||
if (index + 1 >= entries.Count) continue;
|
|
||||||
var next = entries[index + 1];
|
|
||||||
var breakStart = ParseTime(entry.EndTime);
|
|
||||||
var breakEnd = ParseTime(next.StartTime);
|
|
||||||
if (breakEnd <= breakStart) continue;
|
|
||||||
|
|
||||||
layout.Layouts.Add(new TimeLayoutItem
|
|
||||||
{
|
|
||||||
TimeType = 1, // 课间
|
|
||||||
StartTime = breakStart,
|
|
||||||
EndTime = breakEnd,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
profile.TimeLayouts[id] = layout;
|
|
||||||
return (id, layout);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 课表名:单组时用「ClassHelper-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
|
||||||
/// </summary>
|
|
||||||
private static string BuildPlanName(
|
|
||||||
string profileName,
|
|
||||||
int weekDay,
|
|
||||||
int weekCountDiv,
|
|
||||||
int weekCountDivTotal,
|
|
||||||
int groupCount)
|
|
||||||
{
|
|
||||||
var baseName = string.IsNullOrWhiteSpace(profileName) ? "ClassHelper课表" : profileName.Trim();
|
|
||||||
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
baseName = PlanNamePrefix + baseName;
|
|
||||||
}
|
|
||||||
if (groupCount <= 1) return baseName;
|
|
||||||
|
|
||||||
var weekday = weekDay switch
|
|
||||||
{
|
|
||||||
0 => "周日",
|
|
||||||
1 => "周一",
|
|
||||||
2 => "周二",
|
|
||||||
3 => "周三",
|
|
||||||
4 => "周四",
|
|
||||||
5 => "周五",
|
|
||||||
6 => "周六",
|
|
||||||
_ => $"星期{weekDay}",
|
|
||||||
};
|
|
||||||
var parity = weekCountDivTotal <= 1
|
|
||||||
? "每周"
|
|
||||||
: $"第{weekCountDiv}/{weekCountDivTotal}周";
|
|
||||||
return $"{baseName} · {weekday} · {parity}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>"HH:mm" / "HH:mm:ss" → TimeSpan(解析失败回退到 0 点)。</summary>
|
|
||||||
private static TimeSpan ParseTime(string value)
|
|
||||||
{
|
|
||||||
return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 时间字符串是否可用(HH:mm / HH:mm:ss)。
|
|
||||||
///
|
|
||||||
/// 服务端输出侧没有校验 `Schedule.startTime`(老数据/异常数据可能是空串),
|
|
||||||
/// 而 <see cref="ParseTime"/> 会把非法值变成 0 点 —— 因此调用方必须先用本方法过滤,
|
|
||||||
/// 并把 Classes 与时间表**同时**按过滤后的列表构造,保证索引仍然一致。
|
|
||||||
/// </summary>
|
|
||||||
private static bool IsValidTime(string? value)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
|
||||||
return TimeSpan.TryParse(value, out var parsed)
|
|
||||||
&& parsed >= TimeSpan.Zero
|
|
||||||
&& parsed < TimeSpan.FromDays(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Interop;
|
|
||||||
using ClassIsland.Core.Abstractions.Services;
|
|
||||||
using ClassIsland.Shared;
|
|
||||||
using ClassIsland.Shared.Enums;
|
|
||||||
using ClassIsland.Shared.Models.Profile;
|
|
||||||
|
|
||||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 课表 / 状态 → 上报载荷 的换算层。
|
|
||||||
///
|
|
||||||
/// 这里集中全部"ClassIsland 口径 ↔ ClassHelper口径"的转换,保证只有一处需要跟着
|
|
||||||
/// ClassIsland 的模型变化而修改:
|
|
||||||
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
|
|
||||||
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
|
|
||||||
/// - 节次:ClassIsland <c>TimeLayoutItem.TimeType</c> 0=上课 1=课间 2=分割线 3=行动。
|
|
||||||
/// </summary>
|
|
||||||
public static class ScheduleMapper
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// ClassIsland 的 WeekDay(0=周日…6=周六)→ 本系统 dayOfWeek(1=周一…7=周日)。
|
|
||||||
/// </summary>
|
|
||||||
public static int ToServerDayOfWeek(int weekDay)
|
|
||||||
{
|
|
||||||
var normalized = ((weekDay % 7) + 7) % 7;
|
|
||||||
return normalized == 0 ? 7 : normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// ClassIsland 的 WeekCountDiv/Total → 本系统单双周。
|
|
||||||
/// 与 <c>packages/shared/src/utils.ts</c> 的 <c>weekParityFromDiv</c> 完全一致:
|
|
||||||
/// 3 周及以上的循环本系统无法表达,降级为「每周」。
|
|
||||||
/// </summary>
|
|
||||||
public static string ToWeekParity(int weekCountDiv, int weekCountDivTotal)
|
|
||||||
{
|
|
||||||
if (weekCountDiv <= 0 || weekCountDivTotal <= 1) return "ALL";
|
|
||||||
var position = ((weekCountDiv - 1) % weekCountDivTotal) + 1;
|
|
||||||
if (weekCountDivTotal == 2) return position == 1 ? "ODD" : "EVEN";
|
|
||||||
return "ALL";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>ClassIsland TimeType → 本系统节次类型。</summary>
|
|
||||||
public static string ToTimeLayoutType(int timeType) => timeType switch
|
|
||||||
{
|
|
||||||
1 => "break",
|
|
||||||
2 => "divider",
|
|
||||||
3 => "action",
|
|
||||||
_ => "class",
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>TimeSpan → "HH:mm"(跨天的时间点做钳制,避免出现 24:xx)。</summary>
|
|
||||||
public static string ToHhMm(TimeSpan time)
|
|
||||||
{
|
|
||||||
var totalMinutes = (int)Math.Round(time.TotalMinutes);
|
|
||||||
totalMinutes = Math.Clamp(totalMinutes, 0, 24 * 60 - 1);
|
|
||||||
return $"{totalMinutes / 60:D2}:{totalMinutes % 60:D2}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
|
|
||||||
///
|
|
||||||
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
|
|
||||||
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,课间 / 分割线 / 行动不占位。
|
|
||||||
///
|
|
||||||
/// **注意不要用 `plan.ValidTimeLayoutItems`**:它的口径与 `Classes` 不同 ——
|
|
||||||
/// `GetValidTimeLayoutItems()`(ClassIsland ClassPlan.cs)会正反两遍扫描,
|
|
||||||
/// 把 `ClassInfo.IsEnabled == false`(老师在课表编辑里"停用"这一节)的课**连同其后的课间**
|
|
||||||
/// 一起从结果里剔除;而 `RefreshClassesList()` 只按 `TimeType == 0` 的**总数**对齐
|
|
||||||
/// `Classes` 的长度,`ClassInfo.CurrentTimeLayoutItem` 也是按原始列表的第 `Index` 个取的。
|
|
||||||
/// 于是只要停用过任意一节,用 ValidTimeLayoutItems 配对就会让索引整体前移:
|
|
||||||
/// 第 i 节配上第 i+1 节的时间点、最后一节丢失,且 `Math.Min` 会把缺口静默吃掉 ——
|
|
||||||
/// 表现就是"课表看着有内容、每节课都对不上",极难排查。
|
|
||||||
/// </summary>
|
|
||||||
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
|
|
||||||
{
|
|
||||||
warning = null;
|
|
||||||
var entries = new List<ScheduleEntryDto>();
|
|
||||||
|
|
||||||
var timeLayout = ResolveTimeLayout(plan, profile);
|
|
||||||
if (timeLayout is null)
|
|
||||||
{
|
|
||||||
warning = $"课表「{plan.Name}」找不到对应的时间表,已跳过";
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致(见上面注释)
|
|
||||||
var classPoints = timeLayout.Layouts.Where(item => item.TimeType == 0).ToList();
|
|
||||||
|
|
||||||
var weekDay = ToServerDayOfWeek(plan.TimeRule.WeekDay);
|
|
||||||
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
|
||||||
if (plan.TimeRule.WeekCountDivTotal > 2)
|
|
||||||
{
|
|
||||||
warning = "存在 3 周以上的轮换课表,ClassHelper只支持单双周,已按「每周」处理";
|
|
||||||
}
|
|
||||||
|
|
||||||
var classes = plan.Classes;
|
|
||||||
var count = Math.Min(classes.Count, classPoints.Count);
|
|
||||||
for (var index = 0; index < count; index++)
|
|
||||||
{
|
|
||||||
var info = classes[index];
|
|
||||||
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
|
|
||||||
var point = classPoints[index];
|
|
||||||
if (info is null) continue;
|
|
||||||
// 老师停用的一节:本系统没有"停用"概念,不上报(否则 ClassHelper 班级端会看到一节实际上不上的课)
|
|
||||||
if (!info.IsEnabled) continue;
|
|
||||||
|
|
||||||
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
|
||||||
var subject = ResolveSubjectName(info.SubjectId, profile);
|
|
||||||
if (string.IsNullOrWhiteSpace(subject)) continue;
|
|
||||||
|
|
||||||
entries.Add(new ScheduleEntryDto
|
|
||||||
{
|
|
||||||
DayOfWeek = weekDay,
|
|
||||||
StartTime = ToHhMm(point.StartTime),
|
|
||||||
EndTime = ToHhMm(point.EndTime),
|
|
||||||
// 服务端对这几个字段有 zod 长度上限,且是**整批**校验:一个超长名字会让整次上报
|
|
||||||
// 400,连"现在上什么课"的状态与心跳都不更新,Web 端把教室显示成离线。
|
|
||||||
Subject = Truncate(subject, SubjectMaxLength)!,
|
|
||||||
TeacherName = Truncate(ResolveSubjectTeacher(info.SubjectId, profile), NameMaxLength),
|
|
||||||
WeekParity = parity,
|
|
||||||
WeekCountDiv = plan.TimeRule.WeekCountDiv,
|
|
||||||
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
|
|
||||||
PlanName = Truncate(plan.Name, PlanNameMaxLength),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>服务端 zod 对各类名称的长度上限(超出会让整批上报 400,所以要截断) */
|
|
||||||
public const int SubjectMaxLength = 40;
|
|
||||||
public const int NameMaxLength = 40;
|
|
||||||
public const int PlanNameMaxLength = 60;
|
|
||||||
|
|
||||||
/// <summary>按服务端上限截断(null / 空串原样返回)</summary>
|
|
||||||
public static string? Truncate(string? value, int max)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(value)) return value;
|
|
||||||
var trimmed = value.Trim();
|
|
||||||
return trimmed.Length <= max ? trimmed : trimmed[..max];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
|
|
||||||
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
|
|
||||||
{
|
|
||||||
var items = new List<TimeLayoutItemDto>();
|
|
||||||
// ClassIsland 的上课时间点没有独立名称,这里按"它之前有几个上课点"编出"第 N 节"
|
|
||||||
var classPointIndex = 0;
|
|
||||||
for (var index = 0; index < layout.Layouts.Count; index++)
|
|
||||||
{
|
|
||||||
var point = layout.Layouts[index];
|
|
||||||
items.Add(new TimeLayoutItemDto
|
|
||||||
{
|
|
||||||
Index = index + 1,
|
|
||||||
// 节次名同样是服务端有上限的字段(max 40),超长会让整批上报失败
|
|
||||||
Name = Truncate(BuildTimePointName(point, ref classPointIndex), NameMaxLength)!,
|
|
||||||
StartTime = ToHhMm(point.StartTime),
|
|
||||||
EndTime = ToHhMm(point.EndTime),
|
|
||||||
Type = ToTimeLayoutType(point.TimeType),
|
|
||||||
Skipped = false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 节次名称:ClassIsland 只有课间有名字(BreakName),上课时间点没有独立名称,
|
|
||||||
/// 因此按类型生成"第 N 节 / 课间 / 分割线",保证本系统的时间轴可读。
|
|
||||||
/// </summary>
|
|
||||||
private static string BuildTimePointName(TimeLayoutItem point, ref int classPointIndex)
|
|
||||||
{
|
|
||||||
switch (point.TimeType)
|
|
||||||
{
|
|
||||||
case 1:
|
|
||||||
return string.IsNullOrWhiteSpace(point.BreakName) ? "课间" : point.BreakName!;
|
|
||||||
case 2:
|
|
||||||
return "分割线";
|
|
||||||
case 3:
|
|
||||||
return "行动";
|
|
||||||
default:
|
|
||||||
classPointIndex++;
|
|
||||||
return $"第 {classPointIndex} 节";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>取出课表使用的时间表:优先按 TimeLayoutId,其次用档案里第一个。</summary>
|
|
||||||
public static TimeLayout? ResolveTimeLayout(ClassPlan plan, Profile profile)
|
|
||||||
{
|
|
||||||
if (profile.TimeLayouts.Count == 0) return null;
|
|
||||||
if (plan.TimeLayoutId != Guid.Empty && profile.TimeLayouts.TryGetValue(plan.TimeLayoutId, out var byId))
|
|
||||||
{
|
|
||||||
return byId;
|
|
||||||
}
|
|
||||||
return plan.TimeLayout ?? profile.TimeLayouts.Values.FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>科目名(找不到时返回空字符串,由调用方决定是否跳过)。</summary>
|
|
||||||
public static string? ResolveSubjectName(Guid subjectId, Profile profile)
|
|
||||||
{
|
|
||||||
if (subjectId == Guid.Empty) return null;
|
|
||||||
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.Name : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>科目任课老师(ClassIsland 在 Subject 上记录 TeacherName)。</summary>
|
|
||||||
public static string? ResolveSubjectTeacher(Guid subjectId, Profile profile)
|
|
||||||
{
|
|
||||||
if (subjectId == Guid.Empty) return null;
|
|
||||||
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.TeacherName : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 收集档案里今天(按当前周次与星期)应该上的课表,
|
|
||||||
/// 用于"只上报今天"这类轻量场景;当前实现为上报全部课表(服务端按周次并存)。
|
|
||||||
/// </summary>
|
|
||||||
public static List<ScheduleEntryDto> MapAllClassPlans(Profile profile, out List<string> warnings)
|
|
||||||
{
|
|
||||||
warnings = new List<string>();
|
|
||||||
var all = new List<ScheduleEntryDto>();
|
|
||||||
foreach (var plan in profile.ClassPlans.Values)
|
|
||||||
{
|
|
||||||
if (!plan.IsEnabled) continue;
|
|
||||||
if (plan.IsOverlay) continue; // 临时层课表不下发,避免把临时调整固化到班级课表
|
|
||||||
var entries = MapClassPlan(plan, profile, out var warning);
|
|
||||||
if (!string.IsNullOrWhiteSpace(warning) && !warnings.Contains(warning!))
|
|
||||||
{
|
|
||||||
warnings.Add(warning!);
|
|
||||||
}
|
|
||||||
all.AddRange(entries);
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 科目名归一化。
|
|
||||||
///
|
|
||||||
/// 没有课表 / 没有当前科目时,ClassIsland 会把 <see cref="Subject.Fallback"/> 挂到
|
|
||||||
/// `CurrentSubject` / `NextClassSubject` 上(见 LessonsService:
|
|
||||||
/// `CurrentSubject = currentSubject ?? Subject.Fallback`),而它的名字就是占位符本身。
|
|
||||||
/// 直接上报会让 Web 端显示成"正在上 ???",因此这里统一折算成"没有科目"。
|
|
||||||
/// </summary>
|
|
||||||
private static string? NormalizeSubjectName(Subject? subject)
|
|
||||||
{
|
|
||||||
if (subject is null) return null;
|
|
||||||
if (ReferenceEquals(subject, Subject.Fallback)) return null;
|
|
||||||
|
|
||||||
var name = subject.Name?.Trim();
|
|
||||||
if (string.IsNullOrEmpty(name)) return null;
|
|
||||||
// 兜底:万一拿到的是另一个同名占位符实例(不是同一个引用)
|
|
||||||
if (string.Equals(name, Subject.Fallback.Name, StringComparison.Ordinal)) return null;
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>当前状态快照:服务端据此在 Web 端展示"现在上什么课"。</summary>
|
|
||||||
public static StateDto BuildState(ILessonsService lessons, int? currentWeek)
|
|
||||||
{
|
|
||||||
var current = lessons.CurrentTimeLayoutItem;
|
|
||||||
var state = lessons.CurrentState;
|
|
||||||
return new StateDto
|
|
||||||
{
|
|
||||||
InClass = state == TimeState.OnClass,
|
|
||||||
Subject = NormalizeSubjectName(lessons.CurrentSubject),
|
|
||||||
NextSubject = NormalizeSubjectName(lessons.NextClassSubject),
|
|
||||||
TimeState = state.ToString(),
|
|
||||||
PeriodStart = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.StartTime),
|
|
||||||
PeriodEnd = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.EndTime),
|
|
||||||
Week = currentWeek,
|
|
||||||
ClassPlanLoaded = lessons.IsClassPlanLoaded,
|
|
||||||
ClientTime = DateTimeOffset.Now.ToString("o"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
<controls:SettingsPageBase x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"
|
|
||||||
xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
|
||||||
xmlns:local="clr-namespace:ClassHelper.ClassIslandPlugin.Views"
|
|
||||||
xmlns:controls="clr-namespace:ClassIsland.Core.Abstractions.Controls;assembly=ClassIsland.Core"
|
|
||||||
xmlns:ci="clr-namespace:ClassIsland.Core.Controls;assembly=ClassIsland.Core"
|
|
||||||
xmlns:fa="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
|
||||||
mc:Ignorable="d"
|
|
||||||
d:DesignHeight="450" d:DesignWidth="800"
|
|
||||||
d:DataContext="{d:DesignInstance local:BridgeSettingsPage}"
|
|
||||||
Loaded="OnLoaded"
|
|
||||||
Unloaded="OnUnloaded">
|
|
||||||
<ScrollViewer>
|
|
||||||
<StackPanel Classes="settings-container animated-intro" Spacing="0">
|
|
||||||
<!-- 连接 -->
|
|
||||||
<TextBlock Text="连接 ClassHelper"
|
|
||||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
|
||||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
|
||||||
Text="填入 ClassHelper后端的地址与设备令牌。设备令牌在 ClassHelper Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
|
|
||||||
|
|
||||||
<TextBox Watermark="http://127.0.0.1:4000"
|
|
||||||
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
|
|
||||||
<TextBox Margin="0 8 0 0"
|
|
||||||
Watermark="chci_..."
|
|
||||||
PasswordChar="●"
|
|
||||||
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
|
|
||||||
<!-- 令牌是设备级长期凭证(只有管理员在 Web 端重置才会失效),而 ClassIsland 的插件配置
|
|
||||||
是明文 JSON 且会随档案备份/迁移一起被带走,所以这里明确提示用户别外传。 -->
|
|
||||||
<TextBlock Margin="0 6 0 0" TextWrapping="Wrap" Opacity="0.7"
|
|
||||||
Text="令牌是这台教室机器的长期凭证(仅管理员在 Web 端重置才会失效),会明文保存在本机配置文件里并随 ClassIsland 档案备份一起复制,请不要外传或提交到版本库。" />
|
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
|
|
||||||
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
|
||||||
<Button Content="检查连接" Command="{Binding TestCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
|
||||||
<Button Content="拉取待提醒" Command="{Binding PullCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
|
||||||
<Button Content="重建档案课表" Command="{Binding MirrorCommand}"
|
|
||||||
IsEnabled="{Binding Settings.MirrorSchedule}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- 状态 -->
|
|
||||||
<Border Margin="0 16 0 0" Padding="12" CornerRadius="4"
|
|
||||||
Background="{DynamicResource SystemFillColorNeutralBackgroundBrush}">
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Text="{Binding StatusText}" TextWrapping="Wrap" FontWeight="SemiBold" />
|
|
||||||
<TextBlock Text="{Binding Bridge.LastReportMessage}" TextWrapping="Wrap" />
|
|
||||||
<TextBlock Text="{Binding DetailText}" TextWrapping="Wrap" Opacity="0.8" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- 同步 -->
|
|
||||||
<TextBlock Margin="0 24 0 0" Text="同步内容"
|
|
||||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
|
||||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
|
||||||
Text="课表以「合并」方式写入 ClassHelper:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
|
||||||
|
|
||||||
<fa:SettingsExpander Header="自动上报课表到 ClassHelper"
|
|
||||||
Description="把 ClassIsland 里启用的课表**自动**同步给 ClassHelper。默认关闭:课表以 ClassHelper 为准,自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,与本开关无关。">
|
|
||||||
<fa:SettingsExpander.IconSource>
|
|
||||||
<ci:FluentIconSource Glyph="" />
|
|
||||||
</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="自动上报"
|
|
||||||
Description="应用启动、课表变化以及每隔一段时间各上报一次;关闭后只能用上面的「立即上报」手动触发。">
|
|
||||||
<fa:SettingsExpander.IconSource>
|
|
||||||
<ci:FluentIconSource Glyph="" />
|
|
||||||
</fa:SettingsExpander.IconSource>
|
|
||||||
<fa:SettingsExpander.Footer>
|
|
||||||
<ToggleSwitch IsChecked="{Binding Settings.AutoReport, Mode=TwoWay}" />
|
|
||||||
</fa:SettingsExpander.Footer>
|
|
||||||
</fa:SettingsExpander>
|
|
||||||
|
|
||||||
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
|
|
||||||
<StackPanel>
|
|
||||||
<TextBlock Text="上报间隔(秒)" />
|
|
||||||
<TextBlock Text="最小 15 秒;教室机器建议 60 秒。" Opacity="0.7" TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
<NumericUpDown Grid.Column="1" Width="140"
|
|
||||||
Minimum="15" Maximum="3600" Increment="15"
|
|
||||||
FormatString="0"
|
|
||||||
IsEnabled="{Binding Settings.AutoReport}"
|
|
||||||
Value="{Binding Settings.ReportIntervalSeconds, Mode=TwoWay}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- 提醒 -->
|
|
||||||
<TextBlock Margin="0 24 0 0" Text="提醒"
|
|
||||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
|
||||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
|
||||||
Text="老师在 ClassHelper上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
|
||||||
|
|
||||||
<fa:SettingsExpander Header="接收 ClassHelper提醒"
|
|
||||||
Description="关闭后本机会忽略老师在 ClassHelper上发布的提醒(提醒仍会留在班级通知中心)。">
|
|
||||||
<fa:SettingsExpander.IconSource>
|
|
||||||
<ci:FluentIconSource Glyph="" />
|
|
||||||
</fa:SettingsExpander.IconSource>
|
|
||||||
<fa:SettingsExpander.Footer>
|
|
||||||
<ToggleSwitch IsChecked="{Binding Settings.ReceiveNotifications, Mode=TwoWay}" />
|
|
||||||
</fa:SettingsExpander.Footer>
|
|
||||||
</fa:SettingsExpander>
|
|
||||||
|
|
||||||
<fa:SettingsExpander Header="语音朗读"
|
|
||||||
Description="提醒弹出时按 ClassIsland 的语音设置为全机朗读提醒内容。">
|
|
||||||
<fa:SettingsExpander.IconSource>
|
|
||||||
<ci:FluentIconSource Glyph="" />
|
|
||||||
</fa:SettingsExpander.IconSource>
|
|
||||||
<fa:SettingsExpander.Footer>
|
|
||||||
<ToggleSwitch IsChecked="{Binding Settings.SpeechEnabled, Mode=TwoWay}" />
|
|
||||||
</fa:SettingsExpander.Footer>
|
|
||||||
</fa:SettingsExpander>
|
|
||||||
|
|
||||||
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
|
|
||||||
<StackPanel>
|
|
||||||
<TextBlock Text="默认显示时长(秒)" />
|
|
||||||
<TextBlock Text="老师在下发单条提醒时可以另外指定更长的时长。" Opacity="0.7" TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
<NumericUpDown Grid.Column="1" Width="140"
|
|
||||||
Minimum="1" Maximum="120" Increment="1"
|
|
||||||
FormatString="0"
|
|
||||||
Value="{Binding Settings.NotificationDurationSeconds, Mode=TwoWay}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
|
|
||||||
<StackPanel>
|
|
||||||
<TextBlock Text="提醒轮询间隔(秒)" />
|
|
||||||
<TextBlock Text="老师按下发送到教室弹出之间的最大延迟(插件不持有长连接,是轮询取回提醒)。默认 10 秒。"
|
|
||||||
Opacity="0.7" TextWrapping="Wrap" />
|
|
||||||
</StackPanel>
|
|
||||||
<NumericUpDown Grid.Column="1" Width="140"
|
|
||||||
Minimum="5" Maximum="600" Increment="5"
|
|
||||||
FormatString="0"
|
|
||||||
IsEnabled="{Binding Settings.ReceiveNotifications}"
|
|
||||||
Value="{Binding Settings.NotificationPollSeconds, Mode=TwoWay}" />
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- 调试 -->
|
|
||||||
<TextBlock Margin="0 24 0 0" Text="调试"
|
|
||||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
|
||||||
<fa:SettingsExpander Header="显示调试信息"
|
|
||||||
Description="显示设备标识、时钟差等细节,排查「为什么同步不上」时打开。">
|
|
||||||
<fa:SettingsExpander.IconSource>
|
|
||||||
<ci:FluentIconSource Glyph="" />
|
|
||||||
</fa:SettingsExpander.IconSource>
|
|
||||||
<fa:SettingsExpander.Footer>
|
|
||||||
<ToggleSwitch IsChecked="{Binding Settings.ShowDebugInfo, Mode=TwoWay}" />
|
|
||||||
</fa:SettingsExpander.Footer>
|
|
||||||
</fa:SettingsExpander>
|
|
||||||
|
|
||||||
<StackPanel Margin="0 8 0 0" Spacing="2" IsVisible="{Binding Settings.ShowDebugInfo}">
|
|
||||||
<TextBlock Text="{Binding DebugText}" TextWrapping="Wrap" FontFamily="Consolas,monospace" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
|
|
||||||
Text="提示:ClassHelper Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
</controls:SettingsPageBase>
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
using System.ComponentModel;
|
|
||||||
using System.Runtime.CompilerServices;
|
|
||||||
using Avalonia.Threading;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Models;
|
|
||||||
using ClassHelper.ClassIslandPlugin.Services;
|
|
||||||
using ClassIsland.Core.Abstractions.Controls;
|
|
||||||
using ClassIsland.Core.Attributes;
|
|
||||||
using ClassIsland.Core.Enums.SettingsWindow;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
|
|
||||||
namespace ClassHelper.ClassIslandPlugin.Views;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 「ClassHelper 联动」设置页。
|
|
||||||
///
|
|
||||||
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
|
|
||||||
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
|
|
||||||
/// 不需要再套一层 ViewModel,改配置项时也少一处"两边同步"的机会。
|
|
||||||
/// </summary>
|
|
||||||
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
|
|
||||||
[SettingsPageInfo(
|
|
||||||
"classhelper.classisland.bridge.settings",
|
|
||||||
"ClassHelper 联动",
|
|
||||||
"\uE71B",
|
|
||||||
"\uE71B",
|
|
||||||
SettingsPageCategory.External)]
|
|
||||||
public partial class BridgeSettingsPage : SettingsPageBase, INotifyPropertyChanged
|
|
||||||
{
|
|
||||||
public BridgeSettingsPage(PluginSettings settings, BridgeService bridge)
|
|
||||||
{
|
|
||||||
Settings = settings;
|
|
||||||
Bridge = bridge;
|
|
||||||
|
|
||||||
ReportCommand = new AsyncRelayCommand(ReportAsync);
|
|
||||||
TestCommand = new AsyncRelayCommand(TestAsync);
|
|
||||||
PullCommand = new AsyncRelayCommand(PullAsync);
|
|
||||||
MirrorCommand = new AsyncRelayCommand(MirrorAsync);
|
|
||||||
|
|
||||||
InitializeComponent();
|
|
||||||
DataContext = this;
|
|
||||||
Raise(nameof(StatusText));
|
|
||||||
}
|
|
||||||
|
|
||||||
public PluginSettings Settings { get; }
|
|
||||||
|
|
||||||
public BridgeService Bridge { get; }
|
|
||||||
|
|
||||||
public IAsyncRelayCommand ReportCommand { get; }
|
|
||||||
public IAsyncRelayCommand TestCommand { get; }
|
|
||||||
public IAsyncRelayCommand PullCommand { get; }
|
|
||||||
public IAsyncRelayCommand MirrorCommand { get; }
|
|
||||||
|
|
||||||
private PropertyChangedEventHandler? _propertyChanged;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 显式实现 <see cref="INotifyPropertyChanged"/>:<c>AvaloniaObject</c> 自带一个同名但类型不同的事件,
|
|
||||||
/// 直接声明会遮蔽基类成员。显式实现既不影响 Avalonia 的绑定查找(它按接口取),也不会有歧义。
|
|
||||||
/// </summary>
|
|
||||||
event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged
|
|
||||||
{
|
|
||||||
add => _propertyChanged += value;
|
|
||||||
remove => _propertyChanged -= value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 绑定用的展示文本 */
|
|
||||||
|
|
||||||
/// <summary>一句话结论:还没配 / 已配置 / 已连接 / 连接异常。</summary>
|
|
||||||
public string StatusText
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
if (!Settings.IsConfigured) return "尚未配置:请填写服务器地址与设备令牌";
|
|
||||||
if (!Bridge.IsAttached) return "已填写配置,等待 ClassIsland 课程服务就绪";
|
|
||||||
return Bridge.LastReportOk ? "已连接" : "连接异常";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public string DetailText
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var week = Bridge.LastServerWeek?.ToString() ?? "—";
|
|
||||||
return $"最后上报:{Bridge.LastReportAtText} 教学周:{week} " +
|
|
||||||
$"课表 {Bridge.LastScheduleEntryCount} 节 / 节次 {Bridge.LastTimeLayoutItemCount} 条 " +
|
|
||||||
$"已弹出提醒:{Bridge.NotificationCount} 条";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>调试信息(设置页里打开"显示调试信息"才可见)。</summary>
|
|
||||||
public string DebugText
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
var lines = new List<string>
|
|
||||||
{
|
|
||||||
$"服务器地址:{(string.IsNullOrWhiteSpace(Settings.ServerUrl) ? "(未填)" : Settings.ServerUrl)}",
|
|
||||||
$"设备令牌:{(string.IsNullOrWhiteSpace(Settings.DeviceToken) ? "(未填)" : Mask(Settings.DeviceToken))}",
|
|
||||||
$"设备标识:{(string.IsNullOrWhiteSpace(Settings.DeviceKey) ? "(未生成)" : Settings.DeviceKey)}",
|
|
||||||
$"课程服务:{(Bridge.IsAttached ? "已接入" : "未接入")}",
|
|
||||||
$"与服务端时钟差:{Bridge.ClientTimeOffsetText}",
|
|
||||||
$"ClassIsland 版本:{SafeVersion()}",
|
|
||||||
$"配置目录:{PluginConfigFolder}",
|
|
||||||
};
|
|
||||||
return string.Join(Environment.NewLine, lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>设置页所在的插件配置目录(从插件入口拿;拿不到时留空)。</summary>
|
|
||||||
private string PluginConfigFolder =>
|
|
||||||
ClassIsland.Shared.IAppHost.TryGetService<ClassIsland.Core.Abstractions.PluginBase>()?.PluginConfigFolder ?? "—";
|
|
||||||
|
|
||||||
private static string SafeVersion()
|
|
||||||
{
|
|
||||||
try { return ClassIsland.Core.AppBase.AppVersion ?? "—"; }
|
|
||||||
catch { return "—"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>令牌只露头尾,避免设置页截图时把可用凭证泄露出去。</summary>
|
|
||||||
private static string Mask(string token) =>
|
|
||||||
token.Length <= 14 ? token : $"{token[..12]}…{token[^4..]}";
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 交互 */
|
|
||||||
|
|
||||||
private async Task ReportAsync()
|
|
||||||
{
|
|
||||||
await Bridge.ReportAsync("手动").ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>不带课表上报一次:用来快速验证"地址 + 令牌"是否可用,不会因为课表为空而出错。</summary>
|
|
||||||
private async Task TestAsync()
|
|
||||||
{
|
|
||||||
await Bridge.ReportAsync("连接检查", includeSchedule: false).ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PullAsync()
|
|
||||||
{
|
|
||||||
await Bridge.PullPendingAsync().ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task MirrorAsync()
|
|
||||||
{
|
|
||||||
await Bridge.MirrorScheduleAsync().ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs args)
|
|
||||||
{
|
|
||||||
// 影响上报链路的改动需要立刻重建定时器;纯界面项(如"显示调试信息")不用管
|
|
||||||
if (args.PropertyName is nameof(PluginSettings.ServerUrl)
|
|
||||||
or nameof(PluginSettings.DeviceToken)
|
|
||||||
or nameof(PluginSettings.AutoReport)
|
|
||||||
or nameof(PluginSettings.ReportIntervalSeconds)
|
|
||||||
or nameof(PluginSettings.ReceiveNotifications)
|
|
||||||
or nameof(PluginSettings.NotificationPollSeconds))
|
|
||||||
{
|
|
||||||
Bridge.RestartTimer();
|
|
||||||
}
|
|
||||||
|
|
||||||
Raise(nameof(StatusText));
|
|
||||||
Raise(nameof(DebugText));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnBridgeChanged(object? sender, PropertyChangedEventArgs args)
|
|
||||||
{
|
|
||||||
// Bridge 的属性变化可能来自线程池(HttpClient 回调),绑定必须在 UI 线程上更新
|
|
||||||
if (Dispatcher.UIThread.CheckAccess())
|
|
||||||
{
|
|
||||||
RaiseStateProperties();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Dispatcher.UIThread.Post(RaiseStateProperties);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RaiseStateProperties()
|
|
||||||
{
|
|
||||||
Raise(nameof(StatusText));
|
|
||||||
Raise(nameof(DetailText));
|
|
||||||
Raise(nameof(DebugText));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLoaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
|
||||||
{
|
|
||||||
// 改完地址/令牌/间隔要重建定时器,否则"改了设置但上报节奏还是旧的"
|
|
||||||
Settings.PropertyChanged += OnSettingsChanged;
|
|
||||||
// 运行状态(上一次上报结果 / 待弹出提醒数)变了要刷新状态卡片
|
|
||||||
Bridge.PropertyChanged += OnBridgeChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnUnloaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
|
||||||
{
|
|
||||||
Settings.PropertyChanged -= OnSettingsChanged;
|
|
||||||
Bridge.PropertyChanged -= OnBridgeChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void Raise([CallerMemberName] string? propertyName = null) =>
|
|
||||||
_propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/desktop-client",
|
"name": "@classhelper/desktop-client",
|
||||||
"version": "1.2.0",
|
"version": "1.3.3",
|
||||||
"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",
|
||||||
@@ -14,8 +14,7 @@
|
|||||||
"start": "electron .",
|
"start": "electron .",
|
||||||
"smoke": "node scripts/smoke.mjs",
|
"smoke": "node scripts/smoke.mjs",
|
||||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
|
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
|
||||||
"dist:win": "npm run build && node scripts/dist-win.mjs",
|
"dist:win": "npm run build && node scripts/dist-win.mjs"
|
||||||
"dist:dir": "npm run build && node scripts/dist-win.mjs --dir"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@classhelper/shared": "workspace:*",
|
"@classhelper/shared": "workspace:*",
|
||||||
|
|||||||
@@ -10,12 +10,15 @@
|
|||||||
*
|
*
|
||||||
* 用法:
|
* 用法:
|
||||||
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
|
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
|
||||||
* node scripts/dist-win.mjs --dir # 仅生成免安装目录
|
|
||||||
*
|
*
|
||||||
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
|
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
|
||||||
* 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
* 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||||
* 免安装/ electron-builder 的 win-unpacked 内容
|
|
||||||
* 构建中间/ builder-*.yml、*.nsis.7z
|
* 构建中间/ builder-*.yml、*.nsis.7z
|
||||||
|
*
|
||||||
|
* 产物里**没有免安装目录**:electron-builder 的 `win-unpacked/` 只是生成两个 exe 的中间物
|
||||||
|
* (每版 320MB 上下),打完就删 —— 便携版 `-portable.exe` 已是自解压单文件,交付不需要解包目录。
|
||||||
|
* 因此也没有 `--dir`:那等于只产出中间物、拿不到任何交付物。需要临时跑一下解包目录时直接调
|
||||||
|
* electron-builder,别往 releases/ 里塞。
|
||||||
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
|
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
|
||||||
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
|
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
|
||||||
*/
|
*/
|
||||||
@@ -27,9 +30,7 @@ import { resolveNodeRuntime } from '../../../scripts/lib/node-runtime.mjs';
|
|||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const repoRoot = path.resolve(root, '..', '..');
|
const repoRoot = path.resolve(root, '..', '..');
|
||||||
const args = process.argv.slice(2);
|
const passthrough = process.argv.slice(2);
|
||||||
const dirOnly = args.includes('--dir');
|
|
||||||
const passthrough = args.filter((item) => item !== '--dir');
|
|
||||||
|
|
||||||
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
|
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
|
||||||
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
|
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
|
||||||
@@ -74,7 +75,7 @@ const destDir = explicitOutput
|
|||||||
: path.join(repoRoot, 'releases', 'client', clientVersion);
|
: path.join(repoRoot, 'releases', 'client', clientVersion);
|
||||||
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
|
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
|
||||||
|
|
||||||
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
|
const cliArgs = [cli, '--win', ...overrides, ...passthrough];
|
||||||
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
|
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
|
||||||
|
|
||||||
// 必须用真正的 node 执行 electron-builder CLI:
|
// 必须用真正的 node 执行 electron-builder CLI:
|
||||||
@@ -98,7 +99,17 @@ for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
|
|||||||
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
|
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cleaned) console.log('[dist] 已清理上一轮的免安装目录 / 安装包 / 构建中间');
|
if (cleaned) console.log('[dist] 已清理上一轮的中间物与安装包');
|
||||||
|
|
||||||
|
// 打包**之前**就要把输出目录的完整性标签压回 Low(继承),不留到收尾 ——
|
||||||
|
// 实测过一次:上一轮留下的 win-unpacked 是收尾时用 icacls 标成 Medium 的,
|
||||||
|
// 而 7za 自身带 Low 标签,pack 时读不动这些 Medium 目录,报
|
||||||
|
// "cannot open file ... Access is denied",打包直接失败。
|
||||||
|
try {
|
||||||
|
execFileSync('icacls', [destDir, '/setintegritylevel', '(OI)(CI)Low'], { stdio: 'pipe', windowsHide: true });
|
||||||
|
} catch {
|
||||||
|
/* 宿主目录树本就没有 Low 标签体系时会被系统拒绝,保持继承即可 */
|
||||||
|
}
|
||||||
|
|
||||||
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
|
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
|
||||||
|
|
||||||
@@ -138,9 +149,9 @@ function normalizeIntegrityLabel(dir) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
|
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
|
||||||
* win-unpacked/ → 免安装/
|
|
||||||
* *.exe / *.blockmap → 安装包/
|
* *.exe / *.blockmap → 安装包/
|
||||||
* *.yml / *.nsis.7z → 构建中间/
|
* *.yml / *.nsis.7z → 构建中间/
|
||||||
|
* win-unpacked/ → **直接删掉**(见文件头:它只是生成两个 exe 的中间物)
|
||||||
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
|
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
|
||||||
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
|
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
|
||||||
*/
|
*/
|
||||||
@@ -152,11 +163,12 @@ function tidyArtifacts(dir) {
|
|||||||
fs.renameSync(from, path.join(to, path.basename(from)));
|
fs.renameSync(from, path.join(to, path.basename(from)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const unpacked = path.join(dir, 'win-unpacked');
|
// 必须留在最后删:electron-builder 的 portable 目标就是拿它自己解包出来的 app 做自解压包,
|
||||||
const portableDir = path.join(dir, '免安装');
|
// 提前删会让 `-portable.exe` 这一步找不到源文件。
|
||||||
if (fs.existsSync(unpacked)) {
|
const throwaway = [];
|
||||||
fs.rmSync(portableDir, { recursive: true, force: true });
|
for (const name of ['win-unpacked', '免安装']) {
|
||||||
fs.renameSync(unpacked, portableDir);
|
const from = path.join(dir, name);
|
||||||
|
if (fs.existsSync(from)) throwaway.push(from);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const name of fs.readdirSync(dir)) {
|
for (const name of fs.readdirSync(dir)) {
|
||||||
@@ -165,6 +177,31 @@ function tidyArtifacts(dir) {
|
|||||||
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
|
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
|
||||||
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
|
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const from of throwaway) {
|
||||||
|
// 体积必须在删之前量:删完再 walk 只会得到 0
|
||||||
|
const mb = Math.round(dirSizeMb(from));
|
||||||
|
try {
|
||||||
|
fs.rmSync(from, { recursive: true, force: true });
|
||||||
|
console.log(`[dist] 已删除中间物 ${path.basename(from)}/(约 ${mb} MB)`);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[dist] 中间物 ${path.basename(from)}/ 删除失败(不影响交付物):${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 交付目录里没有现成的体积统计函数,这里只在打日志时用一次 */
|
||||||
|
function dirSizeMb(target) {
|
||||||
|
let total = 0;
|
||||||
|
const walk = (dir) => {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) walk(full);
|
||||||
|
else total += fs.statSync(full).size;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (fs.existsSync(target)) walk(target);
|
||||||
|
return total / 1024 / 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ import { spawn } from 'node:child_process';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { provisionSmokeClass } from '../../../scripts/lib/smoke-class.mjs';
|
||||||
import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs';
|
import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs';
|
||||||
|
|
||||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const repoRoot = path.resolve(root, '..', '..');
|
const repoRoot = path.resolve(root, '..', '..');
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const mode = process.argv.includes('--visual') ? 'visual' : (process.env.ELECTRON_SMOKE_MODE ?? 'quick');
|
||||||
|
if (!['quick', 'visual'].includes(mode)) throw new Error('冒烟模式只能是 quick 或 visual');
|
||||||
|
const resultFile =
|
||||||
|
process.env.ELECTRON_SMOKE_RESULT ?? path.join(repoRoot, '.cache', 'desktop-smoke-result.json');
|
||||||
|
console.log(`[smoke] 模式:${mode === 'visual' ? '视觉验收(会使用桌面)' : '后台快速检查(不占用前台)'}`);
|
||||||
const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron'));
|
const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron'));
|
||||||
const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')];
|
const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')];
|
||||||
const missing = required.filter((file) => !fs.existsSync(file));
|
const missing = required.filter((file) => !fs.existsSync(file));
|
||||||
@@ -37,124 +44,13 @@ try {
|
|||||||
}
|
}
|
||||||
console.log(`[smoke] 独立配置目录:${profileDir}`);
|
console.log(`[smoke] 独立配置目录:${profileDir}`);
|
||||||
|
|
||||||
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
|
// 联网验收必须显式指定,避免自动碰到用户正在使用的实例。
|
||||||
let online = process.env.ELECTRON_SMOKE_ONLINE;
|
const online = process.argv.includes('--online') ? '1' : (process.env.ELECTRON_SMOKE_ONLINE ?? '0');
|
||||||
if (online === undefined) {
|
|
||||||
online = '0';
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), 2500);
|
|
||||||
const response = await fetch('http://127.0.0.1:4000/api/health', { signal: controller.signal });
|
|
||||||
clearTimeout(timer);
|
|
||||||
online = response.ok ? '1' : '0';
|
|
||||||
} catch {
|
|
||||||
online = '0';
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
online === '1'
|
|
||||||
? '[smoke] 检测到后端在线,将执行联网集成与侧边栏点击测试'
|
|
||||||
: '[smoke] 后端未在线,仅执行本地检查(离线缓存/界面渲染);如需联网测试请先启动 pnpm dev:server',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 准备一个已知可用的班级账号(ClassHelper 班级端已改为班级码 + 班级密码登录):
|
|
||||||
* 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
|
|
||||||
* 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
|
|
||||||
* 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
|
|
||||||
*/
|
|
||||||
async function provisionClassAccount() {
|
|
||||||
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
|
|
||||||
const username = process.env.ELECTRON_SMOKE_ADMIN ?? 'admin';
|
|
||||||
const adminPassword = process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123';
|
|
||||||
const password = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? 'smoke123456';
|
|
||||||
const CANDIDATES = [...new Set([password, process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456'])];
|
|
||||||
const tryLogin = async (code, candidate) => {
|
|
||||||
const response = await fetch(`${base}/auth/class-login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ code, password: candidate }),
|
|
||||||
});
|
|
||||||
return response.ok;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const loginResponse = await fetch(`${base}/auth/login`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username, password: adminPassword }),
|
|
||||||
});
|
|
||||||
const login = await loginResponse.json();
|
|
||||||
const token = login?.data?.token;
|
|
||||||
if (!token) return null;
|
|
||||||
|
|
||||||
const authHeaders = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
|
||||||
const classesResponse = await fetch(`${base}/classes`, { headers: authHeaders });
|
|
||||||
const classes = (await classesResponse.json())?.data ?? [];
|
|
||||||
const target = classes[0];
|
|
||||||
if (!target) return null;
|
|
||||||
|
|
||||||
// 冒烟**永不修改班级码**(班级码是给学生用的,不应被测试工具改写):
|
|
||||||
// 只有"现有班级码 + 已知密码全部登录失败"时才重置密码,班级码原样保留。
|
|
||||||
const code = target.code;
|
|
||||||
if (!code) return null;
|
|
||||||
|
|
||||||
for (const candidate of CANDIDATES) {
|
|
||||||
if (await tryLogin(code, candidate)) return { code, password: candidate, className: target.name };
|
|
||||||
}
|
|
||||||
|
|
||||||
const patchResponse = await fetch(`${base}/classes/${target.id}/class-account`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: authHeaders,
|
|
||||||
body: JSON.stringify({ code, password }),
|
|
||||||
});
|
|
||||||
if (!patchResponse.ok) return null;
|
|
||||||
|
|
||||||
const patched = (await patchResponse.json())?.data;
|
|
||||||
// 记下"需要恢复",脚本收尾时改回种子默认密码(已知密码都不通时无法得知原密码)
|
|
||||||
patchedAccount = { id: target.id, code: patched?.code ?? code, base, authHeaders };
|
|
||||||
console.log(
|
|
||||||
`[smoke] 已知密码均不可用,已把班级 ${target.name} 的密码临时重置为 ${password}` +
|
|
||||||
`(结束时恢复为 ${CANDIDATES[1]})`,
|
|
||||||
);
|
|
||||||
return { code: patched?.code ?? code, password, className: target.name };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 冒烟临时重置过的班级账号(结束时恢复) */
|
|
||||||
let patchedAccount = null;
|
|
||||||
|
|
||||||
/** 恢复被冒烟临时改过的班级密码(失败只告警,不影响冒烟结论) */
|
|
||||||
async function restoreClassAccount() {
|
|
||||||
if (!patchedAccount) return;
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${patchedAccount.base}/classes/${patchedAccount.id}/class-account`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: patchedAccount.authHeaders,
|
|
||||||
body: JSON.stringify({
|
|
||||||
code: patchedAccount.code,
|
|
||||||
password: process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
console.log(
|
|
||||||
response.ok
|
|
||||||
? '[smoke] 已恢复班级密码为种子默认 123456(冒烟不留副作用)'
|
|
||||||
: `[smoke] [WARN] 班级密码恢复失败:status=${response.status},请到后台重新设置`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(`[smoke] [WARN] 班级密码恢复失败:${error?.message ?? error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let classCredentials = null;
|
let classCredentials = null;
|
||||||
if (online === '1') {
|
if (online === '1') {
|
||||||
classCredentials = await provisionClassAccount();
|
classCredentials = await provisionSmokeClass();
|
||||||
console.log(
|
if (!classCredentials) throw new Error('无法建立独立验证班级,请检查测试服务端与管理员凭据');
|
||||||
classCredentials
|
console.log('[smoke] 已建立独立验证班级:' + classCredentials.code);
|
||||||
? `[smoke] 班级账号就绪:${classCredentials.code}(${classCredentials.className})`
|
|
||||||
: '[smoke] 未能准备班级账号,联网集成将使用种子默认凭据',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启动一次冒烟(返回退出码) */
|
/** 启动一次冒烟(返回退出码) */
|
||||||
@@ -163,26 +59,48 @@ function launchSmoke(extraEnv) {
|
|||||||
const child = spawn(electronPath, ['.'], {
|
const child = spawn(electronPath, ['.'], {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
|
windowsHide: true,
|
||||||
env: resolveElectronEnv({
|
env: resolveElectronEnv({
|
||||||
ELECTRON_SMOKE_TEST: '1',
|
ELECTRON_SMOKE_TEST: '1',
|
||||||
|
ELECTRON_SMOKE_MODE: mode,
|
||||||
|
ELECTRON_SMOKE_RESULT: resultFile,
|
||||||
ELECTRON_SMOKE_ONLINE: online,
|
ELECTRON_SMOKE_ONLINE: online,
|
||||||
ELECTRON_SMOKE_PROFILE: profileDir,
|
ELECTRON_SMOKE_PROFILE: profileDir,
|
||||||
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
|
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
|
||||||
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
|
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
|
||||||
// 教师凭据只从这里传入:主进程的冒烟代码从环境变量读,
|
// 教师凭据只从这里传入:主进程的冒烟代码从环境变量读,
|
||||||
// 因此安装包(app.asar)里不会留下教师口令字面量(本脚本不进安装包)
|
// 因此安装包(app.asar)里不会留下教师口令字面量(本脚本不进安装包)
|
||||||
ELECTRON_SMOKE_USER: process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
|
ELECTRON_SMOKE_USER:
|
||||||
ELECTRON_SMOKE_PASSWORD: process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
|
classCredentials?.teacherUsername ?? process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
|
||||||
|
ELECTRON_SMOKE_PASSWORD:
|
||||||
|
classCredentials?.teacherPassword ?? process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
|
||||||
|
ELECTRON_SMOKE_CLASS_ID: classCredentials?.classId ?? '',
|
||||||
...extraEnv,
|
...extraEnv,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
child.on('exit', (code) => resolve(code ?? 1));
|
const deadline = setTimeout(
|
||||||
|
() => {
|
||||||
|
console.error('[smoke] 超时,结束验证进程');
|
||||||
|
child.kill();
|
||||||
|
resolve(124);
|
||||||
|
},
|
||||||
|
mode === 'visual' ? 360_000 : 45_000,
|
||||||
|
);
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
resolve(code ?? 1);
|
||||||
|
});
|
||||||
|
child.once('error', (error) => {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
console.error(error);
|
||||||
|
resolve(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const code = await launchSmoke();
|
const code = await launchSmoke();
|
||||||
console.log(`\n[smoke] Electron 退出,code=${code}`);
|
console.log(`\n[smoke] Electron 退出,code=${code}`);
|
||||||
await restoreClassAccount();
|
await classCredentials?.cleanup();
|
||||||
if (code !== 0) process.exit(code);
|
if (code !== 0) process.exit(code);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -208,7 +126,11 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n[smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
|
console.log('\n[smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
|
||||||
const relaunchCode = await launchSmoke({ ELECTRON_SMOKE_ONLINE: '0', ELECTRON_SMOKE_RESULT: relaunchResult });
|
const relaunchCode = await launchSmoke({
|
||||||
|
ELECTRON_SMOKE_ONLINE: '0',
|
||||||
|
ELECTRON_SMOKE_MODE: 'quick',
|
||||||
|
ELECTRON_SMOKE_RESULT: relaunchResult,
|
||||||
|
});
|
||||||
|
|
||||||
let relaunchPassed = 0;
|
let relaunchPassed = 0;
|
||||||
let relaunchTotal = 0;
|
let relaunchTotal = 0;
|
||||||
@@ -232,4 +154,5 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
// 二次启动可能又建了目录,删除失败不影响结论(首次删除已证明锁已释放)
|
// 二次启动可能又建了目录,删除失败不影响结论(首次删除已证明锁已释放)
|
||||||
}
|
}
|
||||||
|
console.log('[smoke] 总耗时:' + ((Date.now() - startedAt) / 1000).toFixed(1) + ' 秒');
|
||||||
process.exit(resourcesOk ? 0 : 1);
|
process.exit(resourcesOk ? 0 : 1);
|
||||||
@@ -67,6 +67,15 @@ const isCall = computed(() => kind.value === 'call');
|
|||||||
const isHomework = computed(() => kind.value === 'homework');
|
const isHomework = computed(() => kind.value === 'homework');
|
||||||
const queueCount = computed(() => state.value.queued.length);
|
const queueCount = computed(() => state.value.queued.length);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是不是"点了知道了 / 标为已读"之后的**收回动画**阶段(`reason === 'closing'`)。
|
||||||
|
*
|
||||||
|
* 这一阶段主进程已经把状态清空(`active` 为 null、`queued` 为空),形态却切回了胶囊:
|
||||||
|
* 卡片正沿既有的形变路径收回,**胶囊里不该显示任何文字** —— 否则会闪出一句
|
||||||
|
* "新消息 · 点击查看"(那是"有新消息"的语义,与"正在消失"正好相反)。
|
||||||
|
*/
|
||||||
|
const isClosing = computed(() => state.value.reason === 'closing');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这一批**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」排序。
|
* 这一批**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」排序。
|
||||||
* 与主进程 `pendingNotifications()` 同一口径(同一份 `PRIORITY_RANK`)。
|
* 与主进程 `pendingNotifications()` 同一口径(同一份 `PRIORITY_RANK`)。
|
||||||
@@ -199,7 +208,7 @@ function rowBadgeClass(item: IslandNotification): string {
|
|||||||
const itemKind = rowKind(item);
|
const itemKind = rowKind(item);
|
||||||
if (itemKind === 'call') return 'badge-call';
|
if (itemKind === 'call') return 'badge-call';
|
||||||
if (itemKind === 'homework') return 'badge-homework';
|
if (itemKind === 'homework') return 'badge-homework';
|
||||||
return `badge-${item.priority.toLowerCase()}`;
|
return `badge-${(item.priority ?? 'NORMAL').toLowerCase()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowSubText(item: IslandNotification): string {
|
function rowSubText(item: IslandNotification): string {
|
||||||
@@ -460,6 +469,8 @@ const islandClass = computed(() => ({
|
|||||||
call: isCall.value,
|
call: isCall.value,
|
||||||
homework: isHomework.value,
|
homework: isHomework.value,
|
||||||
'after-class': showAfterClassHint.value,
|
'after-class': showAfterClassHint.value,
|
||||||
|
// 收回动画:胶囊里不显示内容(见 isClosing 与 .island-card.closing 的说明)
|
||||||
|
closing: isClosing.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1048,6 +1059,18 @@ body {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收回动画(`reason === 'closing'`):胶囊收回过程中**不显示任何胶囊内容**。
|
||||||
|
*
|
||||||
|
* 这时状态已经在同一帧清空(没有可展示的消息),若照常渲染胶囊层,卡片刚收回就会闪出
|
||||||
|
* 一句"新消息 · 点击查看" —— 语义正好相反。这里保留 DOM(宽度固定为 `--island-w`,
|
||||||
|
* 内容盒不会重排,符合"开合时胶囊内容不许重排"的约定)只把内容隐藏,
|
||||||
|
* 卡片形状照样沿既有弹簧收回,随后由主进程淡出窗口。
|
||||||
|
*/
|
||||||
|
.island-card.closing .pill-inner {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.pill-icon {
|
.pill-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { BrowserWindow, app } from 'electron';
|
||||||
|
import {
|
||||||
|
isNewerVersion,
|
||||||
|
CLIENT_UPDATE_IDLE_MS,
|
||||||
|
type ClientHeartbeatResponse,
|
||||||
|
type ClientUpdateInfoDto,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
import { getConfig, saveConfig } from './config.js';
|
||||||
|
import { island } from './island.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
|
||||||
|
*
|
||||||
|
* 设计要点(都是"教室机器"这个使用场景逼出来的):
|
||||||
|
* 1. **下载随时进行,按下发策略安装**:立即安装;放学后到指定时刻且不在上课时安装;
|
||||||
|
* 空闲安装要求不在上课且主窗口隐藏满五分钟;
|
||||||
|
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
|
||||||
|
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
|
||||||
|
* 3. **安装脚本脱离本进程**:PowerShell 等旧进程退出、安装器完成后重新拉起应用,
|
||||||
|
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
|
||||||
|
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
let policy: Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> | null =
|
||||||
|
null;
|
||||||
|
function policyPath(): string {
|
||||||
|
return path.join(pendingUpdateDir(), 'policy.json');
|
||||||
|
}
|
||||||
|
function loadPolicy(): typeof policy {
|
||||||
|
if (policy) return policy;
|
||||||
|
try {
|
||||||
|
policy = JSON.parse(fs.readFileSync(policyPath(), 'utf8'));
|
||||||
|
} catch {
|
||||||
|
/* 尚无下发策略 */
|
||||||
|
}
|
||||||
|
return policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新包只来自专用目录;清理与执行均不得接受目录外的配置路径。 */
|
||||||
|
function isManagedInstaller(file: string): boolean {
|
||||||
|
return path.dirname(path.resolve(file)) === path.resolve(pendingUpdateDir()) && /\.exe$/i.test(file);
|
||||||
|
}
|
||||||
|
function removeManagedInstaller(file: string): void {
|
||||||
|
if (!file || !isManagedInstaller(file)) return;
|
||||||
|
try {
|
||||||
|
fs.rmSync(file, { force: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`清理旧更新包失败:${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每次成功心跳先同步策略;取消或更换目标后旧包不得自行安装。 */
|
||||||
|
export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
|
||||||
|
policy = {
|
||||||
|
updateTargetVersion: next.updateTargetVersion,
|
||||||
|
updateMode: next.updateMode ?? 'idle',
|
||||||
|
updateNotBefore: next.updateNotBefore ?? null,
|
||||||
|
};
|
||||||
|
fs.mkdirSync(pendingUpdateDir(), { recursive: true });
|
||||||
|
fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8');
|
||||||
|
if (getConfig().pendingUpdateVersion !== (next.updateTargetVersion ?? '')) {
|
||||||
|
removeManagedInstaller(getConfig().pendingUpdateFile);
|
||||||
|
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
|
||||||
|
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
|
||||||
|
|
||||||
|
/** 下载文件名的合法性(服务端给的 fileName 会拼进本地路径,必须挡住路径穿越) */
|
||||||
|
const SAFE_FILE_NAME = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
|
/** 待安装更新的目录:`<userData>/updates`(放在 userData 下,随配置一起被卸载/重置清掉) */
|
||||||
|
export function pendingUpdateDir(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'updates');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主窗口"已隐藏多久"的起点(null = 窗口当前可见或还没建) */
|
||||||
|
let mainWindowHiddenAt: number | null = null;
|
||||||
|
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
|
||||||
|
let installing = false;
|
||||||
|
/**
|
||||||
|
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
|
||||||
|
*
|
||||||
|
* 为什么用注册而不是直接 `import { quitApp } from './index.js'`:那会形成
|
||||||
|
* index.ts ⇄ auto-update.ts 的循环导入,ESM 下 `quitApp` 的绑定在求值顺序变化时会变成未初始化引用。
|
||||||
|
*/
|
||||||
|
let quitHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
/** 注册"安装前退出本进程"的回调(index.ts 启动时调用一次) */
|
||||||
|
export function setQuitHandler(handler: () => void): void {
|
||||||
|
quitHandler = handler;
|
||||||
|
}
|
||||||
|
/** 正在下载的版本(同一版本不并发下载) */
|
||||||
|
const downloading = new Set<string>();
|
||||||
|
|
||||||
|
/** 这次运行的主窗口(安装判定要看它可见性;窗口重建后重新挂一遍) */
|
||||||
|
let hookedWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挂主窗口的显示/隐藏监听(index.ts 在 `createWindow()` 之后调用)。
|
||||||
|
*
|
||||||
|
* 为什么要"记时间戳"而不是每次现算:`BrowserWindow` 没有"隐藏了多久"这种查询,
|
||||||
|
* 而「窗口隐藏满 5 分钟」是本功能的核心判据,只能在 hide/show 事件上自己打点。
|
||||||
|
*/
|
||||||
|
export function watchMainWindow(win: BrowserWindow): void {
|
||||||
|
if (hookedWindow === win) return;
|
||||||
|
hookedWindow = win;
|
||||||
|
win.on('hide', () => {
|
||||||
|
if (mainWindowHiddenAt === null) mainWindowHiddenAt = Date.now();
|
||||||
|
// 刚隐藏正是最合适的安装时机(人已经走了),立即判一次
|
||||||
|
void maybeInstallPendingUpdate();
|
||||||
|
});
|
||||||
|
win.on('show', () => {
|
||||||
|
mainWindowHiddenAt = null;
|
||||||
|
});
|
||||||
|
win.on('closed', () => {
|
||||||
|
mainWindowHiddenAt = null;
|
||||||
|
if (hookedWindow === win) hookedWindow = null;
|
||||||
|
});
|
||||||
|
mainWindowHiddenAt = win.isVisible() ? null : Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
|
||||||
|
function mainWindowIdleLongEnough(): boolean {
|
||||||
|
if (mainWindowHiddenAt === null) return false;
|
||||||
|
return Date.now() - mainWindowHiddenAt >= CLIENT_UPDATE_IDLE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主窗口是否可见(空闲判定的一部分:可见 ⇒ 有人在用,绝不重启) */
|
||||||
|
function mainWindowVisible(): boolean {
|
||||||
|
if (hookedWindow && !hookedWindow.isDestroyed()) return hookedWindow.isVisible();
|
||||||
|
return BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 服务端是否要求升级 / 本机是否确实更旧 */
|
||||||
|
export function shouldConsider(version: string): boolean {
|
||||||
|
return getConfig().autoUpdate !== false && !!version.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理一次服务端下发的更新信息:该下就下、下好了就写进待安装配置。
|
||||||
|
*
|
||||||
|
* - `autoUpdate === false`:用户/管理员关掉了自动更新,直接返回(设置页会显示服务端的目标版本,由人工处理);
|
||||||
|
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
|
||||||
|
*/
|
||||||
|
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
|
||||||
|
const version = targetVersion.trim() || info.version.trim();
|
||||||
|
if (!shouldConsider(version)) {
|
||||||
|
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (downloading.has(version)) {
|
||||||
|
logger.debug(`更新 ${version} 正在下载中,跳过重复请求`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
if (config.pendingUpdateVersion === version) {
|
||||||
|
const existing = config.pendingUpdateFile;
|
||||||
|
if (existing && fs.existsSync(existing)) {
|
||||||
|
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
|
||||||
|
await maybeInstallPendingUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.warn(`更新 ${version} 的安装包已不在磁盘上(${existing || '未记录路径'}),重新下载`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SAFE_FILE_NAME.test(info.fileName)) {
|
||||||
|
logger.error(`拒绝下载更新:服务端给的文件名不合法(${info.fileName})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = pendingUpdateDir();
|
||||||
|
const target = path.join(dir, info.fileName);
|
||||||
|
const temp = `${target}.part`;
|
||||||
|
const token = config.token?.trim() ?? '';
|
||||||
|
if (!token) {
|
||||||
|
logger.warn(`暂不下载更新 ${version}:尚未登录(没有可用的下载令牌)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloading.add(version);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
// 先删掉上次中断留下的临时文件
|
||||||
|
await fsp.rm(temp, { force: true });
|
||||||
|
|
||||||
|
const url = `${config.serverUrl}${info.url}`;
|
||||||
|
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`下载失败:HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('下载失败:响应没有内容');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式写盘:教室机的安装包几十 MB,不能整个读进内存
|
||||||
|
const handle = await fsp.open(temp, 'w');
|
||||||
|
try {
|
||||||
|
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
|
||||||
|
await handle.write(chunk);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const digest = await sha256File(temp);
|
||||||
|
const expected = info.sha256.trim().toLowerCase();
|
||||||
|
if (!expected) {
|
||||||
|
// 服务端没给哈希就没法校验:宁可不装,也不留一个来源不明的安装包在磁盘上
|
||||||
|
await fsp.rm(temp, { force: true });
|
||||||
|
logger.error(`更新 ${version} 的安装包没有提供 sha256,已删除(不做信任放行)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (digest !== expected) {
|
||||||
|
await fsp.rm(temp, { force: true });
|
||||||
|
logger.error(
|
||||||
|
`更新 ${version} 的安装包校验失败(sha256 不符),已删除:期望 ${expected},实际 ${digest}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fsp.rm(target, { force: true });
|
||||||
|
await fsp.rename(temp, target);
|
||||||
|
if (loadPolicy()?.updateTargetVersion !== version) {
|
||||||
|
removeManagedInstaller(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
|
||||||
|
logger.info(
|
||||||
|
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
|
||||||
|
(Date.now() - startedAt) / 1000,
|
||||||
|
)} 秒),将按服务端指定的时机安装`,
|
||||||
|
);
|
||||||
|
await maybeInstallPendingUpdate();
|
||||||
|
} catch (error) {
|
||||||
|
await fsp.rm(temp, { force: true }).catch(() => undefined);
|
||||||
|
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
downloading.delete(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件的 sha256(十六进制小写) */
|
||||||
|
async function sha256File(file: string): Promise<string> {
|
||||||
|
const hash = crypto.createHash('sha256');
|
||||||
|
const handle = await fsp.open(file, 'r');
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.alloc(1024 * 256);
|
||||||
|
for (;;) {
|
||||||
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
||||||
|
if (bytesRead <= 0) break;
|
||||||
|
hash.update(buffer.subarray(0, bytesRead));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 人类可读的字节数(日志与界面提示用) */
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return '大小未知';
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 如果条件全部满足就装更新。
|
||||||
|
*
|
||||||
|
* 非冒烟模式、有有效目标与安装包且自动更新开启;其余条件按安装策略判断。
|
||||||
|
* 每次心跳后与主窗口隐藏后都会调用。
|
||||||
|
*/
|
||||||
|
export async function maybeInstallPendingUpdate(): Promise<void> {
|
||||||
|
if (installing) return;
|
||||||
|
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
const version = config.pendingUpdateVersion?.trim() ?? '';
|
||||||
|
const installer = config.pendingUpdateFile?.trim() ?? '';
|
||||||
|
if (!version || !installer || config.autoUpdate === false || !config.token) return;
|
||||||
|
if (!isNewerVersion(app.getVersion(), version)) {
|
||||||
|
removeManagedInstaller(installer);
|
||||||
|
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const active = loadPolicy();
|
||||||
|
if (!active || active.updateTargetVersion !== version) return;
|
||||||
|
if (active.updateMode === 'after-school') {
|
||||||
|
const at = Date.parse(active.updateNotBefore ?? '');
|
||||||
|
if (!Number.isFinite(at) || Date.now() < at) return;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(installer)) {
|
||||||
|
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
|
||||||
|
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isManagedInstaller(installer) ||
|
||||||
|
path.dirname(fs.realpathSync(installer)) !== fs.realpathSync(pendingUpdateDir())
|
||||||
|
) {
|
||||||
|
logger.error('拒绝执行更新目录之外的安装文件');
|
||||||
|
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (active.updateMode !== 'immediate' && island.getState().inClass) {
|
||||||
|
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (active.updateMode === 'idle' && mainWindowVisible()) {
|
||||||
|
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (active.updateMode === 'idle' && !mainWindowIdleLongEnough()) {
|
||||||
|
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
|
||||||
|
logger.debug(
|
||||||
|
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
|
||||||
|
`(需满 ${Math.round(CLIENT_UPDATE_IDLE_MS / 1000)} 秒),暂不安装`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
// 本项目客户端只发 Windows 版;其它平台(开发机上跑 Linux/macOS)只记日志
|
||||||
|
logger.warn(
|
||||||
|
`更新 ${version} 已就绪,但当前平台(${process.platform})不支持静默安装,请手动运行:${installer}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
installing = true;
|
||||||
|
logger.info(`开始静默安装更新 ${version}:${installer}`);
|
||||||
|
try {
|
||||||
|
const exe = app.getPath('exe');
|
||||||
|
const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
|
||||||
|
const script =
|
||||||
|
`$ErrorActionPreference = 'Stop'; Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
|
||||||
|
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -Wait -PassThru; ` +
|
||||||
|
`if ($installer.ExitCode -ne 0 -and (Test-Path -LiteralPath ${quote(exe)})) { Start-Process -FilePath ${quote(exe)} }`;
|
||||||
|
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
||||||
|
const child = spawn(
|
||||||
|
'powershell.exe',
|
||||||
|
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', encoded],
|
||||||
|
{
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
windowsHide: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
child.once('spawn', resolve);
|
||||||
|
child.once('error', reject);
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
// 安装器从实际安装目录启动新版;免安装版的旧 exe 可能随退出消失,只在安装失败时尝试恢复它。
|
||||||
|
if (quitHandler) {
|
||||||
|
quitHandler();
|
||||||
|
} else {
|
||||||
|
logger.warn('没有注册退出回调,改为自行退出(配置会在退出前同步落盘)');
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
installing = false;
|
||||||
|
logger.error(
|
||||||
|
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅供冒烟/自检使用:当前是否处于"窗口已隐藏满 5 分钟"状态 */
|
||||||
|
export function isMainWindowIdleLongEnough(): boolean {
|
||||||
|
return mainWindowIdleLongEnough();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* IPC 通道名集中管理(主进程与 preload 共用同一份字符串)。
|
||||||
|
*
|
||||||
|
* **为什么单独一个文件、而且这里一个 import 都不能有**:preload 需要这些字符串,
|
||||||
|
* 但 preload 在 `sandbox: true` 下运行 —— 沙箱化 preload **不能 require `node:fs` /
|
||||||
|
* `node:path` / `node:crypto` 这类内建模块**。而 esbuild 会把 preload 的**运行时 import**
|
||||||
|
* 整条依赖链打进同一个产物:preload 一旦 `import { IPC_CHANNELS } from './ipc.js'`,
|
||||||
|
* 就会把 `ipc.js` → `client-agent.js` / `config.js` / `logger.js` / `auto-update.js`
|
||||||
|
* 一起拉进来(它们都要 `node:fs`),于是 preload 在沙箱里**加载即失败**,
|
||||||
|
* 表现是渲染进程里 `window.desktop === undefined`(冒烟断言「preload contextBridge 注入」直接红,
|
||||||
|
* 后续所有 `window.desktop.*` 调用抛 `Cannot read properties of undefined`)。
|
||||||
|
*
|
||||||
|
* 所以这个文件是**叶子模块**:只有字符串常量,没有任何 import、没有任何副作用。
|
||||||
|
* 新增通道时加在这里,`main/ipc.ts` 与 `preload/index.ts` 各自 import 它。
|
||||||
|
*/
|
||||||
|
export const IPC_CHANNELS = {
|
||||||
|
getConfig: 'classhelper:config:get',
|
||||||
|
saveConfig: 'classhelper:config:save',
|
||||||
|
clearConfig: 'classhelper:config:clear',
|
||||||
|
appInfo: 'classhelper:app:info',
|
||||||
|
openExternal: 'classhelper:shell:open-external',
|
||||||
|
updateCheck: 'classhelper:update:check',
|
||||||
|
updateIgnore: 'classhelper:update:ignore',
|
||||||
|
getAutoLaunch: 'classhelper:app:get-autolaunch',
|
||||||
|
setAutoLaunch: 'classhelper:app:set-autolaunch',
|
||||||
|
openLogDir: 'classhelper:app:open-log-dir',
|
||||||
|
openDevTools: 'classhelper:app:open-devtools',
|
||||||
|
clientStatus: 'classhelper:client:status',
|
||||||
|
clientBeatNow: 'classhelper:client:beat-now',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 主进程 → 渲染进程的单向推送:有可用更新(同理,供 preload 与主进程共用) */
|
||||||
|
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import os from 'node:os';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { BrowserWindow, app, dialog } from 'electron';
|
||||||
|
import {
|
||||||
|
CLIENT_COMMAND_LABELS,
|
||||||
|
CLIENT_HEARTBEAT_INTERVAL_MS,
|
||||||
|
isNewerVersion,
|
||||||
|
type ClientCommandDto,
|
||||||
|
type ApiResponse,
|
||||||
|
type ClientCommandKind,
|
||||||
|
type ClientHeartbeatRequest,
|
||||||
|
type ClientHeartbeatResponse,
|
||||||
|
type ClientUpdateInfoDto,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
import { maybeInstallPendingUpdate, considerUpdate, syncUpdatePolicy } from './auto-update.js';
|
||||||
|
import { getConfig } from './config.js';
|
||||||
|
import { island } from './island.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面客户端的服务端联动代理(心跳上报)。
|
||||||
|
*
|
||||||
|
* **为什么是"客户端定时拉"而不是服务端推**:教室机的连接经常断(代理、休眠、重启),
|
||||||
|
* 而「这台机器是什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上 —— 所以统一用
|
||||||
|
* 「服务端记状态 + 客户端定时上报」的范式,不引入新的长连接握手。
|
||||||
|
* 每次上报的响应里同时带回「服务端指定的目标版本 + 待执行命令」。
|
||||||
|
*
|
||||||
|
* 三条边界:
|
||||||
|
* 1. **没有 token 就不上报**:未登录时静默跳过(登录后下一次自然就上报了),
|
||||||
|
* 不能拿空令牌去打服务端,否则日志里会刷满 401;
|
||||||
|
* 2. **401/403 退避到 5 分钟**:令牌可能已失效(班级密码被改 / 令牌过期),
|
||||||
|
* 继续 30 秒一次只是白刷服务端与日志;
|
||||||
|
* 3. **冒烟模式默认不上报**:每次冒烟都在服务端库里造一条设备记录没有意义,
|
||||||
|
* 只有显式 `ELECTRON_SMOKE_CLIENT_AGENT=1` 时才照常跑(给需要验证这条链路的用例用)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 启动后第一次上报的延迟:等窗口、登录流程与灵动岛都就绪,也不跟首屏抢带宽 */
|
||||||
|
const FIRST_BEAT_DELAY_MS = 3_000;
|
||||||
|
|
||||||
|
/** 令牌失效(401/403)后的退避间隔:5 分钟一次,直到用户重新登录 */
|
||||||
|
const UNAUTHORIZED_BACKOFF_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
/** 单次上报的超时:教室机网络慢,但也不能挂着不放 */
|
||||||
|
const HEARTBEAT_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远控命令的「本机二次确认」等待时长:60 秒内没人点确认就按取消处理。
|
||||||
|
*
|
||||||
|
* 为什么必须有这个超时:管理员在办公室下发关机,而教室里可能已经没人了 ——
|
||||||
|
* 一个永远挂着等确认的对话框会让这条命令一直"待执行",既没执行也看不出失败。
|
||||||
|
*/
|
||||||
|
const COMMAND_CONFIRM_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「在线」判定窗口:与 shared 的 `CLIENT_ONLINE_WINDOW_MS`(90 秒)同口径 ——
|
||||||
|
* 连续丢两次心跳(30 秒 × 3)才算离线。这里不复用那个常量是因为它属于**服务端**的判定口径,
|
||||||
|
* 客户端展示的是"我这边的上报还新不新",单独留一个本地常量更容易看出两边的关系。
|
||||||
|
*/
|
||||||
|
const ONLINE_WINDOW_MS = 90_000;
|
||||||
|
|
||||||
|
/** 对外暴露的联动状态(设置页「服务端联动状态」与冒烟断言用) */
|
||||||
|
export interface ClientAgentStatus {
|
||||||
|
/** 设备标识尾号(服务端设备列表里显示的那串用来对账,完整 UUID 不上界面) */
|
||||||
|
deviceKeyTail: string;
|
||||||
|
/** 最近一次上报成功的时刻(ISO 字符串;从未上报过为 null) */
|
||||||
|
lastHeartbeatAt: string | null;
|
||||||
|
/** 最近一次失败的原因(空串 = 最近一次是成功的) */
|
||||||
|
lastError: string;
|
||||||
|
/** 已下载待安装的版本(空串 = 无) */
|
||||||
|
pendingUpdateVersion: string;
|
||||||
|
/** 服务端最近一次要求升到的版本(空串 = 没要求) */
|
||||||
|
updateTargetVersion: string;
|
||||||
|
/** 是否接受服务端统一下发的自动更新(配置值,供设置页回显) */
|
||||||
|
autoUpdate: boolean;
|
||||||
|
/** 最近 90 秒内上报成功过(与服务端的在线口径一致) */
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentState {
|
||||||
|
running: boolean;
|
||||||
|
/** 当前心跳定时器(退避期间与正常节奏共用它) */
|
||||||
|
timer: ReturnType<typeof setTimeout> | null;
|
||||||
|
/** 正在上报:避免手动「立即上报」与定时器撞在一起 */
|
||||||
|
beating: boolean;
|
||||||
|
lastHeartbeatAt: string | null;
|
||||||
|
lastError: string;
|
||||||
|
updateTargetVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state: AgentState = {
|
||||||
|
running: false,
|
||||||
|
timer: null,
|
||||||
|
beating: false,
|
||||||
|
lastHeartbeatAt: null,
|
||||||
|
lastError: '',
|
||||||
|
updateTargetVersion: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 设备标识尾号:取后 8 位(足够对账,又不至于把完整 UUID 贴到界面上) */
|
||||||
|
export function deviceKeyTail(deviceKey: string): string {
|
||||||
|
const trimmed = deviceKey.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
return trimmed.length > 8 ? trimmed.slice(-8) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 冒烟模式且没有显式打开联动开关时,一律不上报(避免每次冒烟都在库里造设备记录) */
|
||||||
|
function isAgentSuppressed(): boolean {
|
||||||
|
return process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_CLIENT_AGENT !== '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上报里的"这台机器现在在干什么"。
|
||||||
|
*
|
||||||
|
* 取值就三种(服务端设备列表直接展示这段文字):
|
||||||
|
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(延后策略此时不安装更新);
|
||||||
|
* - `使用中` —— 主窗口可见,人正在用;
|
||||||
|
* - `待机` —— 窗口隐藏(最小化到托盘),机器空闲。
|
||||||
|
*/
|
||||||
|
function currentStateNote(): string {
|
||||||
|
if (island.getState().inClass) return '上课中';
|
||||||
|
const win = BrowserWindow.getAllWindows().find((item) => !item.isDestroyed());
|
||||||
|
return win?.isVisible() ? '使用中' : '待机';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已下载待安装的版本;没有则 null(服务端把它记进设备的 `pendingVersion`) */
|
||||||
|
function pendingVersionOrNull(): string | null {
|
||||||
|
const pending = getConfig().pendingUpdateVersion.trim();
|
||||||
|
return pending ? pending : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清掉待执行的心跳定时器(幂等) */
|
||||||
|
function clearTimer(): void {
|
||||||
|
if (state.timer !== null) {
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排下一次上报;`delayMs` 不传时用正常节奏 */
|
||||||
|
function scheduleNext(delayMs: number = CLIENT_HEARTBEAT_INTERVAL_MS): void {
|
||||||
|
if (!state.running) return;
|
||||||
|
clearTimer();
|
||||||
|
state.timer = setTimeout(() => {
|
||||||
|
state.timer = null;
|
||||||
|
void beat();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次上报(含响应处理)。
|
||||||
|
*
|
||||||
|
* 失败一律**不抛**:网络抖动是教室机的常态,只记一行日志并按原节奏重试;
|
||||||
|
* 只有 401/403 才退避(见文件头说明)。
|
||||||
|
*/
|
||||||
|
async function beat(): Promise<void> {
|
||||||
|
if (state.beating) return;
|
||||||
|
if (isAgentSuppressed()) {
|
||||||
|
// 冒烟模式:照原节奏空转(不碰服务端,也不在库里造设备记录)
|
||||||
|
state.lastError = '';
|
||||||
|
scheduleNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const config = getConfig();
|
||||||
|
const token = config.token?.trim() ?? '';
|
||||||
|
if (!token) {
|
||||||
|
// 未登录:不打服务端、不记错误,等下次心跳(登录后自然会带上令牌)
|
||||||
|
logger.debug('客户端上报:尚未登录(没有令牌),本次跳过');
|
||||||
|
scheduleNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.beating = true;
|
||||||
|
let backoff = false;
|
||||||
|
try {
|
||||||
|
const body: ClientHeartbeatRequest = {
|
||||||
|
agentProtocol: 2,
|
||||||
|
deviceKey: config.deviceKey,
|
||||||
|
name: os.hostname(),
|
||||||
|
clientVersion: app.getVersion(),
|
||||||
|
platform: `${process.platform}-${process.arch}`,
|
||||||
|
autoUpdate: config.autoUpdate !== false,
|
||||||
|
pendingVersion: pendingVersionOrNull(),
|
||||||
|
stateNote: currentStateNote(),
|
||||||
|
};
|
||||||
|
const response = await fetch(`${config.serverUrl}/api/clients/heartbeat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
// 令牌失效:退避到 5 分钟一次,直到用户重新登录
|
||||||
|
backoff = true;
|
||||||
|
state.lastError = `上报被拒绝(HTTP ${response.status}),可能登录已过期,请重新登录`;
|
||||||
|
logger.warn(`客户端上报:服务端返回 HTTP ${response.status},退避为 5 分钟一次`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
state.lastError = `服务端返回 HTTP ${response.status}`;
|
||||||
|
logger.warn(`客户端上报失败:HTTP ${response.status}(${HEARTBEAT_INTERVAL_TEXT})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = (await response.json()) as ApiResponse<ClientHeartbeatResponse>;
|
||||||
|
if (!envelope.success || !envelope.data?.deviceId) throw new Error('心跳响应格式不正确');
|
||||||
|
const payload = envelope.data;
|
||||||
|
state.lastHeartbeatAt = new Date().toISOString();
|
||||||
|
state.lastError = '';
|
||||||
|
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
|
||||||
|
logger.debug(
|
||||||
|
`客户端上报成功(设备尾号 ${deviceKeyTail(config.deviceKey)},状态 ${body.stateNote ?? '-'}` +
|
||||||
|
`${state.updateTargetVersion ? `,服务端要求升到 ${state.updateTargetVersion}` : ''})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
void handleCommand(payload.command).catch((error) => logger.error(`远控处理失败:${String(error)}`));
|
||||||
|
// 下载不能阻塞心跳,否则慢速下载期间远控和在线状态都会失效。
|
||||||
|
syncUpdatePolicy(payload);
|
||||||
|
void handleUpdate(payload.update, payload.updateTargetVersion).catch((error) =>
|
||||||
|
logger.error(`更新处理失败:${String(error)}`),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// 网络错误 / 超时 / JSON 解析失败:按原节奏重试
|
||||||
|
state.lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.warn(`客户端上报失败(将于 ${HEARTBEAT_INTERVAL_TEXT} 后重试):${state.lastError}`);
|
||||||
|
} finally {
|
||||||
|
state.beating = false;
|
||||||
|
// 每次心跳后检查已下载的更新是否满足安装策略。
|
||||||
|
void maybeInstallPendingUpdate();
|
||||||
|
scheduleNext(backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 1000)} 秒`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端下发的远控命令(关机 / 重启 / 注销 / 锁屏)。
|
||||||
|
*
|
||||||
|
* 两道护栏缺一不可(用户要求「必须二次确认」):
|
||||||
|
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
|
||||||
|
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
|
||||||
|
* 2. **同一条命令只执行一次**:等待确认时内存去重,处理结果落盘,重启后也只重试回执。
|
||||||
|
*
|
||||||
|
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
|
||||||
|
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
|
||||||
|
* 下一次心跳只重试回执、不重复弹窗。
|
||||||
|
*/
|
||||||
|
const inFlightCommands = new Set<string>();
|
||||||
|
type CommandOutcome = { ok: boolean; result: string };
|
||||||
|
const commandResults = new Map<string, CommandOutcome>();
|
||||||
|
let resultsLoaded = false;
|
||||||
|
function commandResultsPath(): string {
|
||||||
|
return path.join(app.getPath('userData'), 'command-results.json');
|
||||||
|
}
|
||||||
|
function readCommandResults(): void {
|
||||||
|
if (resultsLoaded) return;
|
||||||
|
try {
|
||||||
|
const entries: unknown = JSON.parse(fs.readFileSync(commandResultsPath(), 'utf8'));
|
||||||
|
if (Array.isArray(entries))
|
||||||
|
for (const entry of entries.slice(-200)) {
|
||||||
|
if (
|
||||||
|
Array.isArray(entry) &&
|
||||||
|
typeof entry[0] === 'string' &&
|
||||||
|
typeof entry[1]?.ok === 'boolean' &&
|
||||||
|
typeof entry[1]?.result === 'string'
|
||||||
|
)
|
||||||
|
commandResults.set(entry[0], entry[1]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 首次运行尚无记录 */
|
||||||
|
}
|
||||||
|
resultsLoaded = true;
|
||||||
|
}
|
||||||
|
function rememberCommand(commandId: string, outcome: CommandOutcome): void {
|
||||||
|
readCommandResults();
|
||||||
|
commandResults.set(commandId, outcome);
|
||||||
|
while (commandResults.size > 200) commandResults.delete(commandResults.keys().next().value!);
|
||||||
|
fs.mkdirSync(app.getPath('userData'), { recursive: true });
|
||||||
|
fs.writeFileSync(commandResultsPath(), JSON.stringify([...commandResults]), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCommand(command: ClientCommandDto | null): Promise<void> {
|
||||||
|
if (!command) return;
|
||||||
|
const label = CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
|
||||||
|
|
||||||
|
const expired =
|
||||||
|
command.expiresAt !== null && Number.isFinite(Date.parse(command.expiresAt))
|
||||||
|
? Date.parse(command.expiresAt) < Date.now()
|
||||||
|
: false;
|
||||||
|
if (expired) {
|
||||||
|
logger.warn(`收到已过期的远控命令(${label}),已忽略:${command.payload || '无备注'}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 正在确认或执行时不能用预存的中间结果抢先回执。
|
||||||
|
if (inFlightCommands.has(command.id)) return;
|
||||||
|
// 处理过但回执没送达:只重试回执
|
||||||
|
readCommandResults();
|
||||||
|
const pending = commandResults.get(command.id);
|
||||||
|
if (pending) {
|
||||||
|
await ackCommand(command.id, pending.ok, pending.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inFlightCommands.add(command.id);
|
||||||
|
try {
|
||||||
|
const confirmed = await confirmCommand(label, command.payload);
|
||||||
|
if (!confirmed) {
|
||||||
|
const result = '本机用户取消(或 60 秒未确认)';
|
||||||
|
logger.warn(`远控命令「${label}」未执行:${result}`);
|
||||||
|
await ackCommand(command.id, false, result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 注销可能立刻结束本进程,先落盘,重启后不能再次执行同一条指令。
|
||||||
|
rememberCommand(command.id, {
|
||||||
|
ok: false,
|
||||||
|
result: '本机已确认并提交系统执行,最终结果未回传,请核对设备状态',
|
||||||
|
});
|
||||||
|
const outcome = await executeCommand(command.kind);
|
||||||
|
logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`);
|
||||||
|
await ackCommand(command.id, outcome.ok, outcome.result);
|
||||||
|
} finally {
|
||||||
|
inFlightCommands.delete(command.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */
|
||||||
|
async function confirmCommand(label: string, note: string): Promise<boolean> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), COMMAND_CONFIRM_TIMEOUT_MS);
|
||||||
|
const answered = dialog
|
||||||
|
.showMessageBox({
|
||||||
|
signal: controller.signal,
|
||||||
|
type: 'warning',
|
||||||
|
title: '来自管理员的远程指令',
|
||||||
|
message: `管理员要求本机「${label}」`,
|
||||||
|
detail: `${note ? `备注:${note}\n` : ''}确认后本机将立即${label};60 秒内未确认视为取消。`,
|
||||||
|
buttons: ['取消', '确认执行'],
|
||||||
|
defaultId: 0,
|
||||||
|
cancelId: 0,
|
||||||
|
noLink: true,
|
||||||
|
})
|
||||||
|
.then((result) => !controller.signal.aborted && result.response === 1)
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await answered;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */
|
||||||
|
async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; result: string }> {
|
||||||
|
if (process.platform !== 'win32') return { ok: false, result: '仅支持 Windows' };
|
||||||
|
|
||||||
|
const commands: Record<ClientCommandKind, { file: string; args: string[] }> = {
|
||||||
|
shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '5'] },
|
||||||
|
restart: { file: 'shutdown.exe', args: ['/r', '/t', '5'] },
|
||||||
|
logoff: { file: 'shutdown.exe', args: ['/l'] },
|
||||||
|
lock: { file: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
|
||||||
|
};
|
||||||
|
const plan = commands[kind];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 关机/重启预留 5 秒用于回执;检查系统命令进程的真实退出码。
|
||||||
|
const child = spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true });
|
||||||
|
/**
|
||||||
|
* **必须监听 `error` 事件**:`spawn` 找不到可执行文件(或没权限)时不是抛异常,而是在
|
||||||
|
* next tick 上发一个 `error` 事件 —— 只包 `try/catch` 的话,这种情况会一路走到
|
||||||
|
* `return { ok: true }`,回执写着"已下达系统命令",Web 端看起来一切正常,机器却纹丝不动。
|
||||||
|
* 同时检查非零退出码,系统拒绝指令时不能回报成功。
|
||||||
|
*/
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
child.once('error', (error) => resolve({ ok: false, result: error.message }));
|
||||||
|
child.once('exit', (code) =>
|
||||||
|
resolve({
|
||||||
|
ok: code === 0,
|
||||||
|
result: code === 0 ? '系统已接受指令' : `系统命令执行失败(退出码 ${code ?? '未知'})`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, result: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
|
||||||
|
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
|
||||||
|
rememberCommand(commandId, { ok, result });
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config.token) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${config.serverUrl}/api/clients/commands/${commandId}/ack`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${config.token}` },
|
||||||
|
body: JSON.stringify({ deviceKey: config.deviceKey, ok, result }),
|
||||||
|
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn(`远控命令回执失败(HTTP ${response.status}),将在下次心跳重试`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.info(`远控命令回执已送达(${ok ? '成功' : '失败'}):${result}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`远控命令回执失败(${error instanceof Error ? error.message : String(error)}),将在下次心跳重试`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 响应里的更新信息:服务端指定了目标版本、且该版本确实比本机新,才交给 auto-update */
|
||||||
|
async function handleUpdate(info: ClientUpdateInfoDto | null, targetVersion: string | null): Promise<void> {
|
||||||
|
const target = targetVersion?.trim() ?? '';
|
||||||
|
if (!target) return;
|
||||||
|
if (!info) {
|
||||||
|
// 服务端指定了目标版本但还没有这份安装包:只记一行,客户端只能提示手动更新
|
||||||
|
logger.warn(`服务端要求升到 ${target},但服务端还没有这个版本的安装包(无法自动下载)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isNewerVersion(app.getVersion(), target)) return;
|
||||||
|
await considerUpdate(info, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动心跳(重复调用无副作用) */
|
||||||
|
export function startClientAgent(): void {
|
||||||
|
if (state.running) return;
|
||||||
|
state.running = true;
|
||||||
|
logger.info(
|
||||||
|
isAgentSuppressed()
|
||||||
|
? '客户端上报代理:冒烟模式下不上报(需要时设 ELECTRON_SMOKE_CLIENT_AGENT=1)'
|
||||||
|
: `客户端上报代理已启动:${FIRST_BEAT_DELAY_MS / 1000} 秒后首次上报,之后每 ${HEARTBEAT_INTERVAL_TEXT} 一次`,
|
||||||
|
);
|
||||||
|
scheduleNext(FIRST_BEAT_DELAY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止心跳(退出清理调用;幂等) */
|
||||||
|
export function stopClientAgent(): void {
|
||||||
|
if (!state.running) return;
|
||||||
|
state.running = false;
|
||||||
|
clearTimer();
|
||||||
|
logger.info('客户端上报代理已停止');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动触发一次上报并返回最新状态(设置页「立即上报」按钮)。
|
||||||
|
* 返回的是**报告之后**的状态,界面直接照它刷新即可。
|
||||||
|
*/
|
||||||
|
export async function beatNow(): Promise<ClientAgentStatus> {
|
||||||
|
if (!state.running) startClientAgent();
|
||||||
|
if (isAgentSuppressed()) {
|
||||||
|
logger.info('客户端上报:冒烟模式下跳过手动上报');
|
||||||
|
return getClientAgentStatus();
|
||||||
|
}
|
||||||
|
clearTimer();
|
||||||
|
await beat();
|
||||||
|
return getClientAgentStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前联动状态(纯数据,可直接跨 contextBridge 传给渲染进程) */
|
||||||
|
export function getClientAgentStatus(): ClientAgentStatus {
|
||||||
|
const config = getConfig();
|
||||||
|
const lastAt = state.lastHeartbeatAt ? Date.parse(state.lastHeartbeatAt) : Number.NaN;
|
||||||
|
return {
|
||||||
|
deviceKeyTail: deviceKeyTail(config.deviceKey),
|
||||||
|
lastHeartbeatAt: state.lastHeartbeatAt,
|
||||||
|
lastError: state.lastError,
|
||||||
|
pendingUpdateVersion: config.pendingUpdateVersion ?? '',
|
||||||
|
updateTargetVersion: state.updateTargetVersion,
|
||||||
|
autoUpdate: config.autoUpdate !== false,
|
||||||
|
online: Number.isFinite(lastAt) && Date.now() - lastAt < ONLINE_WINDOW_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { app, safeStorage } from 'electron';
|
import { app, safeStorage } from 'electron';
|
||||||
import {
|
import {
|
||||||
CLASSISLAND_NOTIFICATION_CHANNELS,
|
|
||||||
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
|
||||||
DEFAULT_ISLAND_APPEARANCE,
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
DEFAULT_SERVER_URL,
|
DEFAULT_SERVER_URL,
|
||||||
HOMEWORK_PHRASE_DEFAULTS,
|
HOMEWORK_PHRASE_DEFAULTS,
|
||||||
HOMEWORK_PHRASE_MAX_COUNT,
|
HOMEWORK_PHRASE_MAX_COUNT,
|
||||||
HOMEWORK_PHRASE_MAX_LENGTH,
|
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||||
type ClassIslandNotificationChannel,
|
|
||||||
type IslandAppearance,
|
type IslandAppearance,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
@@ -32,8 +30,6 @@ interface PersistedConfig {
|
|||||||
island?: Partial<IslandAppearance>;
|
island?: Partial<IslandAppearance>;
|
||||||
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
||||||
homeworkBoard?: Partial<HomeworkBoardSettings>;
|
homeworkBoard?: Partial<HomeworkBoardSettings>;
|
||||||
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
|
|
||||||
notificationChannel?: ClassIslandNotificationChannel;
|
|
||||||
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
|
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
|
||||||
homeworkPhrases?: string[];
|
homeworkPhrases?: string[];
|
||||||
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
|
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
|
||||||
@@ -44,6 +40,20 @@ interface PersistedConfig {
|
|||||||
sidebarCollapsed?: boolean;
|
sidebarCollapsed?: boolean;
|
||||||
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
|
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
|
||||||
ignoredUpdateVersion?: string;
|
ignoredUpdateVersion?: string;
|
||||||
|
/** 本机设备标识(向后兼容:旧配置没有该字段时首次读取生成并落盘,之后永不变) */
|
||||||
|
deviceKey?: string;
|
||||||
|
/** 是否开机自启(向后兼容:旧配置没有该字段视为关闭 —— 由用户在设置页自己打开) */
|
||||||
|
autoLaunch?: boolean;
|
||||||
|
/** 是否接受服务端统一下发的自动更新(向后兼容:旧配置没有该字段视为开启) */
|
||||||
|
autoUpdate?: boolean;
|
||||||
|
/** 开发者模式:日志调到 debug 级 + 设置里多出「开发者」页(向后兼容:旧配置没有该字段视为关闭) */
|
||||||
|
developerMode?: boolean;
|
||||||
|
/** 关怀模式:整体放大正文与控件字号,供视力不佳的用户使用(向后兼容:旧配置没有该字段视为关闭) */
|
||||||
|
careMode?: boolean;
|
||||||
|
/** 已下载待安装的更新版本(空串 = 无;跨重启保留) */
|
||||||
|
pendingUpdateVersion?: string;
|
||||||
|
/** 已下载待安装的安装包绝对路径(空串 = 无;跨重启保留) */
|
||||||
|
pendingUpdateFile?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_CONFIG: PersistedConfig = {
|
const DEFAULT_CONFIG: PersistedConfig = {
|
||||||
@@ -53,12 +63,18 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
|||||||
tokenEncrypted: false,
|
tokenEncrypted: false,
|
||||||
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
||||||
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
||||||
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
|
||||||
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
|
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
|
||||||
onboardingDone: false,
|
onboardingDone: false,
|
||||||
theme: 'light',
|
theme: 'light',
|
||||||
sidebarCollapsed: false,
|
sidebarCollapsed: false,
|
||||||
ignoredUpdateVersion: '',
|
ignoredUpdateVersion: '',
|
||||||
|
deviceKey: '',
|
||||||
|
autoLaunch: false,
|
||||||
|
autoUpdate: true,
|
||||||
|
developerMode: false,
|
||||||
|
careMode: false,
|
||||||
|
pendingUpdateVersion: '',
|
||||||
|
pendingUpdateFile: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 主题白名单:非法值一律回落浅色 */
|
/** 主题白名单:非法值一律回落浅色 */
|
||||||
@@ -85,15 +101,6 @@ function normalizeHomeworkPhrases(input?: string[]): string[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */
|
|
||||||
function normalizeNotificationChannel(
|
|
||||||
input?: ClassIslandNotificationChannel,
|
|
||||||
): ClassIslandNotificationChannel {
|
|
||||||
return CLASSISLAND_NOTIFICATION_CHANNELS.includes(input as ClassIslandNotificationChannel)
|
|
||||||
? (input as ClassIslandNotificationChannel)
|
|
||||||
: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 作业页偏好的合法化(模式白名单、字号夹紧) */
|
/** 作业页偏好的合法化(模式白名单、字号夹紧) */
|
||||||
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
|
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
|
||||||
const mode = input?.mode === 'list' ? 'list' : 'board';
|
const mode = input?.mode === 'list' ? 'list' : 'board';
|
||||||
@@ -196,13 +203,21 @@ function readPersistedFromDisk(): PersistedConfig {
|
|||||||
tokenEncrypted: parsed.tokenEncrypted === true,
|
tokenEncrypted: parsed.tokenEncrypted === true,
|
||||||
island: normalizeIslandAppearance(parsed.island),
|
island: normalizeIslandAppearance(parsed.island),
|
||||||
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
||||||
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
|
|
||||||
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
|
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
|
||||||
onboardingDone: parsed.onboardingDone === true,
|
onboardingDone: parsed.onboardingDone === true,
|
||||||
theme: normalizeTheme(parsed.theme),
|
theme: normalizeTheme(parsed.theme),
|
||||||
sidebarCollapsed: parsed.sidebarCollapsed === true,
|
sidebarCollapsed: parsed.sidebarCollapsed === true,
|
||||||
ignoredUpdateVersion:
|
ignoredUpdateVersion:
|
||||||
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
|
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
|
||||||
|
deviceKey: typeof parsed.deviceKey === 'string' ? parsed.deviceKey : '',
|
||||||
|
autoLaunch: parsed.autoLaunch === true,
|
||||||
|
// 默认开启:老配置文件里没有这个字段时按"接受服务端统一下发的更新"处理
|
||||||
|
autoUpdate: parsed.autoUpdate !== false,
|
||||||
|
developerMode: parsed.developerMode === true,
|
||||||
|
careMode: parsed.careMode === true,
|
||||||
|
pendingUpdateVersion:
|
||||||
|
typeof parsed.pendingUpdateVersion === 'string' ? parsed.pendingUpdateVersion : '',
|
||||||
|
pendingUpdateFile: typeof parsed.pendingUpdateFile === 'string' ? parsed.pendingUpdateFile : '',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_CONFIG };
|
return { ...DEFAULT_CONFIG };
|
||||||
@@ -350,15 +365,57 @@ function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
|
|||||||
token: state.token,
|
token: state.token,
|
||||||
island: normalizeIslandAppearance(persisted.island),
|
island: normalizeIslandAppearance(persisted.island),
|
||||||
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
||||||
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
|
|
||||||
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
|
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
|
||||||
onboardingDone: persisted.onboardingDone === true,
|
onboardingDone: persisted.onboardingDone === true,
|
||||||
theme: normalizeTheme(persisted.theme),
|
theme: normalizeTheme(persisted.theme),
|
||||||
sidebarCollapsed: persisted.sidebarCollapsed === true,
|
sidebarCollapsed: persisted.sidebarCollapsed === true,
|
||||||
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
|
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
|
||||||
|
deviceKey: ensureDeviceKey(persisted),
|
||||||
|
autoLaunch: persisted.autoLaunch === true,
|
||||||
|
autoUpdate: persisted.autoUpdate !== false,
|
||||||
|
developerMode: persisted.developerMode === true,
|
||||||
|
careMode: persisted.careMode === true,
|
||||||
|
pendingUpdateVersion: persisted.pendingUpdateVersion ?? '',
|
||||||
|
pendingUpdateFile: persisted.pendingUpdateFile ?? '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `crypto.randomUUID()`:Node 18+ / Chromium 里都有,但**不能假设它一定存在**
|
||||||
|
* (打包后的运行时或某些受限环境里 `crypto.randomUUID` 为 undefined,直接调用会抛)。
|
||||||
|
* 兜底用时间戳 + 随机字节拼一个同形状的 UUID v4。
|
||||||
|
*/
|
||||||
|
function generateDeviceKey(): string {
|
||||||
|
try {
|
||||||
|
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||||
|
} catch {
|
||||||
|
// 落到下面的兜底
|
||||||
|
}
|
||||||
|
const hex = crypto.randomBytes(16).toString('hex');
|
||||||
|
return (
|
||||||
|
`${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(12, 15)}` +
|
||||||
|
`-a${hex.slice(15, 18)}-${hex.slice(18, 30)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备标识:**首次读取时生成并落盘,之后永不变**(它是这台机器在服务端的身份,
|
||||||
|
* 服务端按 `deviceKey` upsert 设备记录,换了它就等于换了一台机器)。
|
||||||
|
*
|
||||||
|
* 为什么放在这里生成:`getConfig()` 是所有读取路径的唯一出口(主进程各处 + 渲染进程 IPC),
|
||||||
|
* 且此时必然已经过了 `app.setPath('userData', …)`(见 `loadCache()` 的懒初始化说明),
|
||||||
|
* 写的就是当前 profile 的 config.json。落盘走既有的 120ms 合并窗口,退出时 `flushConfigSync()` 兜底。
|
||||||
|
*/
|
||||||
|
function ensureDeviceKey(persisted: PersistedConfig): string {
|
||||||
|
const existing = typeof persisted.deviceKey === 'string' ? persisted.deviceKey.trim() : '';
|
||||||
|
if (existing) return existing;
|
||||||
|
const created = generateDeviceKey();
|
||||||
|
persisted.deviceKey = created;
|
||||||
|
scheduleWrite(persisted);
|
||||||
|
logger.info(`已为本机生成设备标识(服务端据此识别这台电脑):${created}`);
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
/** 读取配置(token 已解密),供渲染进程使用 */
|
/** 读取配置(token 已解密),供渲染进程使用 */
|
||||||
export function getConfig(): DesktopStoredConfig {
|
export function getConfig(): DesktopStoredConfig {
|
||||||
return toDesktopConfig(loadCache());
|
return toDesktopConfig(loadCache());
|
||||||
@@ -389,9 +446,6 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
|||||||
if (patch.homeworkPhrases !== undefined) {
|
if (patch.homeworkPhrases !== undefined) {
|
||||||
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
|
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
|
||||||
}
|
}
|
||||||
if (patch.notificationChannel !== undefined) {
|
|
||||||
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
|
|
||||||
}
|
|
||||||
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
|
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
|
||||||
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
|
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
|
||||||
if (patch.onboardingDone === true) {
|
if (patch.onboardingDone === true) {
|
||||||
@@ -407,6 +461,30 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
|||||||
if (typeof patch.ignoredUpdateVersion === 'string') {
|
if (typeof patch.ignoredUpdateVersion === 'string') {
|
||||||
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
|
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
|
||||||
}
|
}
|
||||||
|
if (patch.autoLaunch !== undefined) {
|
||||||
|
persisted.autoLaunch = patch.autoLaunch === true;
|
||||||
|
}
|
||||||
|
if (patch.autoUpdate !== undefined) {
|
||||||
|
persisted.autoUpdate = patch.autoUpdate === true;
|
||||||
|
}
|
||||||
|
if (patch.developerMode !== undefined) {
|
||||||
|
persisted.developerMode = patch.developerMode === true;
|
||||||
|
}
|
||||||
|
if (patch.careMode !== undefined) {
|
||||||
|
persisted.careMode = patch.careMode === true;
|
||||||
|
}
|
||||||
|
// 仅供主进程更新模块写入;IPC 层会剔除这两个字段。
|
||||||
|
if (typeof patch.pendingUpdateVersion === 'string') {
|
||||||
|
persisted.pendingUpdateVersion = patch.pendingUpdateVersion;
|
||||||
|
}
|
||||||
|
if (typeof patch.pendingUpdateFile === 'string') {
|
||||||
|
persisted.pendingUpdateFile = patch.pendingUpdateFile;
|
||||||
|
}
|
||||||
|
// deviceKey **不接受外部改写**:它是这台机器在服务端的身份,改了服务端就会当成一台新机器
|
||||||
|
// (旧记录永远停在上一次心跳、新记录从零开始)。要重置身份只能手工删掉 config.json 里的该字段。
|
||||||
|
if (typeof patch.deviceKey === 'string' && patch.deviceKey && patch.deviceKey !== persisted.deviceKey) {
|
||||||
|
logger.warn('忽略对设备标识(deviceKey)的改写:它是这台机器在服务端的身份,不允许变更');
|
||||||
|
}
|
||||||
if (patch.token !== undefined) {
|
if (patch.token !== undefined) {
|
||||||
const encrypted = encryptToken(patch.token);
|
const encrypted = encryptToken(patch.token);
|
||||||
persisted.token = encrypted.token;
|
persisted.token = encrypted.token;
|
||||||
@@ -419,9 +497,27 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
|||||||
return toDesktopConfig(state);
|
return toDesktopConfig(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */
|
/**
|
||||||
|
* 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟。
|
||||||
|
*
|
||||||
|
* **保留"这台机器"的身份与本地开关**:`deviceKey`(服务端按它识别设备,换了就等于换一台机器)、
|
||||||
|
* 开机自启 / 自动更新 / 开发者模式 / 关怀模式,以及已下载待安装的更新记录 —— 退出登录不该在服务端
|
||||||
|
* 多出一台陌生设备,也不该把用户刚打开的开关悄悄关回默认值。
|
||||||
|
*/
|
||||||
export function clearConfig(): DesktopStoredConfig {
|
export function clearConfig(): DesktopStoredConfig {
|
||||||
const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null };
|
const current = cache?.config ?? DEFAULT_CONFIG;
|
||||||
|
const kept: PersistedConfig = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
deviceKey: current.deviceKey ?? '',
|
||||||
|
autoLaunch: current.autoLaunch === true,
|
||||||
|
autoUpdate: current.autoUpdate !== false,
|
||||||
|
developerMode: current.developerMode === true,
|
||||||
|
careMode: current.careMode === true,
|
||||||
|
pendingUpdateVersion: current.pendingUpdateVersion ?? '',
|
||||||
|
pendingUpdateFile: current.pendingUpdateFile ?? '',
|
||||||
|
};
|
||||||
|
ensureDeviceKey(kept);
|
||||||
|
const state: ConfigCache = { config: kept, token: null };
|
||||||
cache = state;
|
cache = state;
|
||||||
scheduleWrite(state.config, true);
|
scheduleWrite(state.config, true);
|
||||||
return toDesktopConfig(state);
|
return toDesktopConfig(state);
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { app, type BrowserWindow, type RenderProcessGoneDetails } from 'electron';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 崩溃自愈(主窗口)。
|
||||||
|
*
|
||||||
|
* 教室机的运行环境很不友好:显卡驱动老旧、长期不重启、内存被其它教学软件挤占 ——
|
||||||
|
* 渲染进程偶发崩溃与无响应是常态。原先这种情况表现为"窗口还在、一片空白、点哪都没反应",
|
||||||
|
* 只能人工去重启,因此这里补三条自愈路径:
|
||||||
|
*
|
||||||
|
* 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**;
|
||||||
|
* 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消;
|
||||||
|
* 3. `uncaughtException` 只在本机空闲时重启;普通 `unhandledRejection` 仅记录堆栈。
|
||||||
|
*
|
||||||
|
* 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志):
|
||||||
|
* **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。
|
||||||
|
* 另外**冒烟模式下绝不 `app.relaunch()`**:验证实例重启自己会让父进程拿到一个莫名其妙的子进程。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 限流:窗口内最多几次 */
|
||||||
|
const RECOVER_MAX_TIMES = 3;
|
||||||
|
/** 限流窗口:10 分钟 */
|
||||||
|
const RECOVER_WINDOW_MS = 10 * 60_000;
|
||||||
|
/** 界面无响应多久算卡死(Electron 的 `unresponsive` 事件不会再给第二次机会) */
|
||||||
|
const UNRESPONSIVE_GRACE_MS = 10_000;
|
||||||
|
|
||||||
|
export interface CrashGuardOptions {
|
||||||
|
/** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */
|
||||||
|
recoverMainWindow: () => void | Promise<void>;
|
||||||
|
/** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */
|
||||||
|
isIdle: () => boolean;
|
||||||
|
/** 退出应用(复用既有的退出路径,保证配置落盘) */
|
||||||
|
quitApp: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowRecoverAt: number[] = [];
|
||||||
|
const restartAt: number[] = [];
|
||||||
|
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/** 正在重建:重建过程中窗口又会发 `closed` / `destroyed`,不能再触发一次 */
|
||||||
|
let recovering = false;
|
||||||
|
/** 当前主窗口(重建后会换成新实例) */
|
||||||
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
/** install 时拿到的回调集合(事件回调里复用) */
|
||||||
|
let options: CrashGuardOptions | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装崩溃自愈(在 `app.whenReady()` 里、主窗口建好之后调用一次)。
|
||||||
|
*/
|
||||||
|
export function installCrashGuard(guard: CrashGuardOptions): void {
|
||||||
|
options = guard;
|
||||||
|
|
||||||
|
app.on('render-process-gone', (_event, contents, details: RenderProcessGoneDetails) => {
|
||||||
|
// 灵动岛窗口有自己的 supervisor(island.ts),这里只管主窗口
|
||||||
|
if (!isMainWindowContents(contents.id)) return;
|
||||||
|
logger.error(
|
||||||
|
`主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`,
|
||||||
|
);
|
||||||
|
void recoverWindow(`渲染进程退出(${details.reason})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('child-process-gone', (_event, details) => {
|
||||||
|
logger.error(
|
||||||
|
`子进程异常退出:type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
logger.error('主进程未捕获异常:', error);
|
||||||
|
restartIfIdle('uncaughtException');
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
logger.error('主进程未处理的 Promise 拒绝:', reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录当前主窗口并挂上无响应监听。
|
||||||
|
* `createWindow()` 之后调用;窗口重建后要重新调用一次(新实例、新监听)。
|
||||||
|
*/
|
||||||
|
export function watchMainWindow(win: BrowserWindow): void {
|
||||||
|
mainWindow = win;
|
||||||
|
win.on('unresponsive', () => {
|
||||||
|
logger.error('主窗口界面无响应(10 秒内未恢复将重建窗口)');
|
||||||
|
armUnresponsiveTimer();
|
||||||
|
});
|
||||||
|
win.on('responsive', () => {
|
||||||
|
if (unresponsiveTimer !== null) {
|
||||||
|
clearTimeout(unresponsiveTimer);
|
||||||
|
unresponsiveTimer = null;
|
||||||
|
logger.info('主窗口界面已恢复响应,取消重建');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
win.on('closed', () => {
|
||||||
|
if (mainWindow === win) mainWindow = null;
|
||||||
|
if (unresponsiveTimer !== null) {
|
||||||
|
clearTimeout(unresponsiveTimer);
|
||||||
|
unresponsiveTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function armUnresponsiveTimer(): void {
|
||||||
|
if (unresponsiveTimer !== null) clearTimeout(unresponsiveTimer);
|
||||||
|
unresponsiveTimer = setTimeout(() => {
|
||||||
|
unresponsiveTimer = null;
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
void recoverWindow('界面无响应超过 10 秒');
|
||||||
|
}, UNRESPONSIVE_GRACE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMainWindowContents(id: number): boolean {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return false;
|
||||||
|
return mainWindow.webContents.id === id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 静默重建主窗口(限流) */
|
||||||
|
async function recoverWindow(reason: string): Promise<void> {
|
||||||
|
const recover = options?.recoverMainWindow;
|
||||||
|
if (recovering) return;
|
||||||
|
if (!recover) {
|
||||||
|
logger.error(`主窗口需要重建(${reason}),但没有可用的重建回调`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
while (windowRecoverAt.length > 0 && now - windowRecoverAt[0] > RECOVER_WINDOW_MS) {
|
||||||
|
windowRecoverAt.shift();
|
||||||
|
}
|
||||||
|
if (windowRecoverAt.length >= RECOVER_MAX_TIMES) {
|
||||||
|
logger.error(
|
||||||
|
`主窗口在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已重建 ${RECOVER_MAX_TIMES} 次,停止自动重建(${reason})` +
|
||||||
|
':请检查显卡驱动或教学软件冲突后手动重启应用',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
windowRecoverAt.push(now);
|
||||||
|
recovering = true;
|
||||||
|
try {
|
||||||
|
const win = mainWindow;
|
||||||
|
if (win && !win.isDestroyed()) {
|
||||||
|
// 先隐藏再销毁:跟灵动岛的 supervisor 一样,避免留住一帧残影/白屏
|
||||||
|
win.hide();
|
||||||
|
}
|
||||||
|
logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`);
|
||||||
|
await recover();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
recovering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主进程崩溃后按需重启自己(限流 + 空闲判定 + 冒烟豁免) */
|
||||||
|
function restartIfIdle(reason: string): void {
|
||||||
|
if (process.env.ELECTRON_SMOKE_TEST === '1') {
|
||||||
|
// 冒烟模式只记日志:验证实例重启自己会污染验证结果
|
||||||
|
logger.warn(`冒烟模式:不因 ${reason} 重启应用(仅记录日志)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let idle = false;
|
||||||
|
try {
|
||||||
|
idle = options?.isIdle() === true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`判断本机是否空闲时出错:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
if (!idle) {
|
||||||
|
logger.warn(`本机正在使用中(${reason}),不自动重启应用,以免打断课堂`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
while (restartAt.length > 0 && now - restartAt[0] > RECOVER_WINDOW_MS) {
|
||||||
|
restartAt.shift();
|
||||||
|
}
|
||||||
|
if (restartAt.length >= RECOVER_MAX_TIMES) {
|
||||||
|
logger.error(
|
||||||
|
`应用在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已自动重启 ${RECOVER_MAX_TIMES} 次,停止自动重启(${reason})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restartAt.push(now);
|
||||||
|
logger.warn(`本机空闲,因 ${reason} 自动重启应用(本窗口期第 ${restartAt.length} 次)`);
|
||||||
|
app.relaunch();
|
||||||
|
options?.quitApp();
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
import { BrowserWindow, app, shell } from 'electron';
|
import { BrowserWindow, app, shell } from 'electron';
|
||||||
|
import { setQuitHandler, watchMainWindow as watchAutoUpdateWindow } from './auto-update.js';
|
||||||
|
import { startClientAgent, stopClientAgent } from './client-agent.js';
|
||||||
import { flushConfigSync, getConfig } from './config.js';
|
import { flushConfigSync, getConfig } from './config.js';
|
||||||
|
import { installCrashGuard, watchMainWindow as watchCrashGuardWindow } from './crash-guard.js';
|
||||||
import { registerIpcHandlers } from './ipc.js';
|
import { registerIpcHandlers } from './ipc.js';
|
||||||
import { island, registerIslandIpc } from './island.js';
|
import { island, registerIslandIpc } from './island.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
@@ -15,6 +18,14 @@ const currentDir = __dirname;
|
|||||||
|
|
||||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||||
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
|
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
|
||||||
|
const isQuickSmoke = isSmokeTest && process.env.ELECTRON_SMOKE_MODE !== 'visual';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这次启动是否需要"直接进托盘、不弹窗"——开机自启专用(见 `applyAutoLaunch()`)。
|
||||||
|
* 判定依据是我们自己在登录项里注册的命令行参数 `--hidden`(Electron 44 的
|
||||||
|
* `setLoginItemSettings` 在 Windows 上**没有** `openAsHidden` 这个开关,只能自己带参数)。
|
||||||
|
*/
|
||||||
|
const startHidden = !isSmokeTest && process.argv.includes('--hidden');
|
||||||
|
|
||||||
// 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁,
|
// 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁,
|
||||||
// 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。
|
// 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。
|
||||||
@@ -50,7 +61,7 @@ function isAllowedNavigation(url: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow(): BrowserWindow {
|
function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
|
||||||
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
|
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
|
||||||
const theme = getConfig().theme;
|
const theme = getConfig().theme;
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
@@ -61,8 +72,9 @@ function createWindow(): BrowserWindow {
|
|||||||
minHeight: 540,
|
minHeight: 540,
|
||||||
center: true,
|
center: true,
|
||||||
title: 'ClassHelper',
|
title: 'ClassHelper',
|
||||||
// 冒烟验证时不弹窗,避免打扰使用者
|
// 冒烟验证时不弹窗,避免打扰使用者;开机自启(--hidden)时直接进托盘
|
||||||
show: !isSmokeTest,
|
show: !isSmokeTest && options.hidden !== true,
|
||||||
|
focusable: !isQuickSmoke,
|
||||||
autoHideMenuBar: true,
|
autoHideMenuBar: true,
|
||||||
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
|
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
@@ -71,6 +83,8 @@ function createWindow(): BrowserWindow {
|
|||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
|
backgroundThrottling: false,
|
||||||
|
offscreen: isQuickSmoke,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,12 +101,40 @@ function createWindow(): BrowserWindow {
|
|||||||
});
|
});
|
||||||
|
|
||||||
win.on('closed', () => {
|
win.on('closed', () => {
|
||||||
mainWindow = null;
|
if (mainWindow === win) mainWindow = null;
|
||||||
|
});
|
||||||
|
win.on('close', (event) => {
|
||||||
|
if (isQuitting) return;
|
||||||
|
event.preventDefault();
|
||||||
|
win.hide();
|
||||||
|
logger.info('主窗口已隐藏到系统托盘(后台继续运行)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 崩溃自愈与「空闲安装更新」都要盯着主窗口:窗口重建后换的是新实例,因此每次建窗都重挂一次
|
||||||
|
watchCrashGuardWindow(win);
|
||||||
|
watchAutoUpdateWindow(win);
|
||||||
|
|
||||||
return win;
|
return win;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取当前主窗口,必要时重建。
|
||||||
|
*
|
||||||
|
* 与 `createWindow()` 的区别:这里会把"窗口已经被崩溃自愈销毁 / 还没建"的情况一并处理掉
|
||||||
|
* (托盘、二次启动、`activate` 都通过它拿窗口),因此调用方拿到的要么是活着的窗口、要么是 null。
|
||||||
|
*/
|
||||||
|
function ensureMainWindow(options: { hidden?: boolean } = {}): BrowserWindow | null {
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
|
||||||
|
try {
|
||||||
|
mainWindow = createWindow(options);
|
||||||
|
void loadRenderer(mainWindow).catch((error) => logger.error('重建主窗口加载失败', error));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`创建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
mainWindow = null;
|
||||||
|
}
|
||||||
|
return mainWindow;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRenderer(win: BrowserWindow): Promise<void> {
|
async function loadRenderer(win: BrowserWindow): Promise<void> {
|
||||||
if (devServerUrl) {
|
if (devServerUrl) {
|
||||||
await win.loadURL(devServerUrl);
|
await win.loadURL(devServerUrl);
|
||||||
@@ -103,26 +145,80 @@ async function loadRenderer(win: BrowserWindow): Promise<void> {
|
|||||||
await win.loadFile(path.join(currentDir, '../renderer/index.html'));
|
await win.loadFile(path.join(currentDir, '../renderer/index.html'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按配置里的 `autoLaunch` 应用「开机自启」。
|
||||||
|
*
|
||||||
|
* 两个实测过的坑:
|
||||||
|
* 1. Electron 44 的 `setLoginItemSettings` 在 **Windows 上没有 `openAsHidden`**
|
||||||
|
* (那是旧的 macOS 选项),所以"登录后不弹主窗口"只能自己带参数 —— 启动时注册 `--hidden`,
|
||||||
|
* `createWindow()` 看到它就把窗口建成隐藏态(见 `startHidden`);
|
||||||
|
* 2. **冒烟验证绝不写登录项**:那会改开发机真实的注册表启动项,属于"验证污染环境"。
|
||||||
|
*/
|
||||||
|
function applyAutoLaunch(): void {
|
||||||
|
if (isSmokeTest) return;
|
||||||
|
const enabled = getConfig().autoLaunch === true;
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin: enabled,
|
||||||
|
path: process.execPath,
|
||||||
|
args: enabled ? ['--hidden'] : [],
|
||||||
|
});
|
||||||
|
logger.info(enabled ? '已开启开机自启(登录 Windows 后静默启动,不弹主窗口)' : '开机自启未开启');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`设置开机自启失败(不影响主功能,可在设置页重试):${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 单实例:重复启动时聚焦已有窗口
|
// 单实例:重复启动时聚焦已有窗口
|
||||||
const gotLock = app.requestSingleInstanceLock();
|
const gotLock = app.requestSingleInstanceLock();
|
||||||
if (!gotLock) {
|
if (!gotLock) {
|
||||||
app.quit();
|
app.quit();
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', () => {
|
||||||
if (!mainWindow) return;
|
showMainWindow();
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
||||||
mainWindow.focus();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
app
|
||||||
|
.whenReady()
|
||||||
|
.then(async () => {
|
||||||
|
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
|
||||||
|
logger.setVerbose(getConfig().developerMode === true);
|
||||||
registerIpcHandlers();
|
registerIpcHandlers();
|
||||||
registerIslandIpc();
|
registerIslandIpc();
|
||||||
|
|
||||||
mainWindow = createWindow();
|
// 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
|
||||||
|
applyAutoLaunch();
|
||||||
|
|
||||||
|
mainWindow = createWindow({ hidden: startHidden });
|
||||||
|
|
||||||
|
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
|
||||||
|
installCrashGuard({
|
||||||
|
recoverMainWindow: async () => {
|
||||||
|
const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
|
||||||
|
if (alive) {
|
||||||
|
alive.hide();
|
||||||
|
alive.destroy();
|
||||||
|
}
|
||||||
|
mainWindow = createWindow({ hidden: true });
|
||||||
await loadRenderer(mainWindow);
|
await loadRenderer(mainWindow);
|
||||||
|
},
|
||||||
|
isIdle: () =>
|
||||||
|
island.getState().inClass === false &&
|
||||||
|
mainWindow?.isVisible() !== true &&
|
||||||
|
!BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible()),
|
||||||
|
quitApp: () => quitApp(),
|
||||||
|
});
|
||||||
|
await loadRenderer(mainWindow).catch((error) => logger.error('主窗口启动加载失败', error));
|
||||||
|
// 空闲安装更新:先把手动退出的回调交给 auto-update(它不能再 import 本文件,否则循环导入)
|
||||||
|
setQuitHandler(() => quitApp());
|
||||||
|
|
||||||
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
|
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
|
||||||
try {
|
try {
|
||||||
|
if (!isQuickSmoke)
|
||||||
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
|
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
|
||||||
// 点击灵动岛里的"打开应用"时把主窗口拉到前台
|
// 点击灵动岛里的"打开应用"时把主窗口拉到前台
|
||||||
island.setOpenAppHandler(() => {
|
island.setOpenAppHandler(() => {
|
||||||
@@ -145,16 +241,8 @@ if (!gotLock) {
|
|||||||
logger.error('灵动岛初始化失败(不影响主功能)', error);
|
logger.error('灵动岛初始化失败(不影响主功能)', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭主窗口 → 隐藏到系统托盘(需求 5),只有托盘"退出"才真正结束进程
|
|
||||||
// 注意:必须在冒烟验证之前挂载,否则 close() 会真的销毁窗口
|
|
||||||
mainWindow?.on('close', (event) => {
|
|
||||||
if (isQuitting) return;
|
|
||||||
event.preventDefault();
|
|
||||||
mainWindow?.hide();
|
|
||||||
logger.info('主窗口已隐藏到系统托盘(后台继续运行,左键托盘图标可恢复)');
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (!isQuickSmoke)
|
||||||
createTray({
|
createTray({
|
||||||
onShow: () => showMainWindow(),
|
onShow: () => showMainWindow(),
|
||||||
onHide: () => mainWindow?.hide(),
|
onHide: () => mainWindow?.hide(),
|
||||||
@@ -167,6 +255,9 @@ if (!gotLock) {
|
|||||||
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
|
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
|
||||||
scheduleStartupUpdateCheck(() => mainWindow);
|
scheduleStartupUpdateCheck(() => mainWindow);
|
||||||
|
|
||||||
|
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
|
||||||
|
startClientAgent();
|
||||||
|
|
||||||
if (isSmokeTest) {
|
if (isSmokeTest) {
|
||||||
try {
|
try {
|
||||||
await runSmokeTest(mainWindow);
|
await runSmokeTest(mainWindow);
|
||||||
@@ -175,17 +266,19 @@ if (!gotLock) {
|
|||||||
app.exit(1);
|
app.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
logger.error('应用初始化失败', error);
|
||||||
|
if (isSmokeTest) app.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */
|
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动 / 灵动岛"打开应用") */
|
||||||
function showMainWindow(): void {
|
function showMainWindow(): void {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
const win = ensureMainWindow();
|
||||||
mainWindow = createWindow();
|
if (!win) return;
|
||||||
void loadRenderer(mainWindow);
|
if (win.isMinimized()) win.restore();
|
||||||
}
|
win.show();
|
||||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
win.focus();
|
||||||
mainWindow.show();
|
|
||||||
mainWindow.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,6 +287,8 @@ if (!gotLock) {
|
|||||||
*/
|
*/
|
||||||
function shutdownResources(): string {
|
function shutdownResources(): string {
|
||||||
const done: string[] = [];
|
const done: string[] = [];
|
||||||
|
// 上报代理先停:否则退出过程中它还会再排一次心跳(幂等,重复调用无副作用)
|
||||||
|
stopClientAgent();
|
||||||
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
|
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
|
||||||
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
|
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
|
||||||
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
|
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
|
||||||
@@ -241,10 +336,9 @@ if (!gotLock) {
|
|||||||
logger.info(`应用退出清理:${summary}`);
|
logger.info(`应用退出清理:${summary}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('activate', async () => {
|
app.on('activate', () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
mainWindow = createWindow();
|
ensureMainWindow();
|
||||||
await loadRenderer(mainWindow);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
import { app, ipcMain, shell } from 'electron';
|
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||||
import type { UpdateInfo } from '@classhelper/shared';
|
import type { UpdateInfo } from '@classhelper/shared';
|
||||||
|
import { IPC_CHANNELS } from './channels.js';
|
||||||
|
import { beatNow, getClientAgentStatus } from './client-agent.js';
|
||||||
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
|
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
import { checkForUpdates, ignoreUpdateVersion } from './update.js';
|
import { checkForUpdates, ignoreUpdateVersion } from './update.js';
|
||||||
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
|
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
|
||||||
|
|
||||||
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
|
// 通道名定义搬到了叶子模块 `channels.ts`(原因见那里的注释:preload 在沙箱里不能碰 node 内建模块);
|
||||||
export const IPC_CHANNELS = {
|
// 这里原样再导出,保持既有 `import { IPC_CHANNELS } from './ipc.js'` 的调用点不变。
|
||||||
getConfig: 'classhelper:config:get',
|
export { IPC_CHANNELS };
|
||||||
saveConfig: 'classhelper:config:save',
|
|
||||||
clearConfig: 'classhelper:config:clear',
|
|
||||||
appInfo: 'classhelper:app:info',
|
|
||||||
openExternal: 'classhelper:shell:open-external',
|
|
||||||
updateCheck: 'classhelper:update:check',
|
|
||||||
updateIgnore: 'classhelper:update:ignore',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function registerIpcHandlers(): void {
|
export function registerIpcHandlers(): void {
|
||||||
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
|
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
|
||||||
@@ -21,7 +17,11 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
IPC_CHANNELS.saveConfig,
|
IPC_CHANNELS.saveConfig,
|
||||||
(_event, patch: Partial<DesktopStoredConfig>): DesktopStoredConfig => {
|
(_event, patch: Partial<DesktopStoredConfig>): DesktopStoredConfig => {
|
||||||
return saveConfig(patch ?? {});
|
// 更新安装记录只允许主进程内部写入,渲染进程不得借配置通道指定可执行文件。
|
||||||
|
const allowed = { ...patch };
|
||||||
|
delete allowed.pendingUpdateFile;
|
||||||
|
delete allowed.pendingUpdateVersion;
|
||||||
|
return saveConfig(allowed);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -58,6 +58,84 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
|
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
|
||||||
if (typeof version === 'string') ignoreUpdateVersion(version);
|
if (typeof version === 'string') ignoreUpdateVersion(version);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** 开机自启:读的是系统登录项,不是配置文件里的值(两者可能被外部改过) */
|
||||||
|
ipcMain.handle(IPC_CHANNELS.getAutoLaunch, (): boolean => {
|
||||||
|
try {
|
||||||
|
return app.getLoginItemSettings().openAtLogin === true;
|
||||||
|
} catch {
|
||||||
|
return getConfig().autoLaunch === true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开机自启:写配置 + 同步系统登录项。
|
||||||
|
*
|
||||||
|
* 与 `main/index.ts` 的 `applyAutoLaunch()` 同一套参数(`--hidden` 是"登录后不弹主窗口"的实现方式,
|
||||||
|
* 因为 Electron 44 在 Windows 上没有 `openAsHidden`),返回**系统实际生效**的值。
|
||||||
|
*/
|
||||||
|
ipcMain.handle(IPC_CHANNELS.setAutoLaunch, (_event, enabled: boolean): boolean => {
|
||||||
|
const wanted = enabled === true;
|
||||||
|
saveConfig({ autoLaunch: wanted });
|
||||||
|
try {
|
||||||
|
app.setLoginItemSettings({
|
||||||
|
openAtLogin: wanted,
|
||||||
|
path: process.execPath,
|
||||||
|
args: wanted ? ['--hidden'] : [],
|
||||||
|
});
|
||||||
|
return app.getLoginItemSettings().openAtLogin === true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`写入开机自启失败(配置已保存,下次启动会重试):${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return wanted;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 打开日志目录(设置页「打开日志目录」;日志文件是 main.log,见 main/logger.ts) */
|
||||||
|
ipcMain.handle(IPC_CHANNELS.openLogDir, async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const message = await shell.openPath(app.getPath('logs'));
|
||||||
|
if (message) {
|
||||||
|
logger.warn(`打开日志目录失败:${message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`打开日志目录失败:${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开开发者工具(「开发者」页的按钮;detached 独立窗口)。
|
||||||
|
*
|
||||||
|
* 两处刻意的限制:
|
||||||
|
* - **冒烟模式下直接拒绝**。冒烟验证会给窗口注入合成事件、测量侧栏几何、整屏截图取样;
|
||||||
|
* 多出一个 DevTools 窗口会抢焦点、并且 detached 窗口会改变 `capturePage()` 的取样范围,
|
||||||
|
* 把既有断言打乱。返回 false 让页面提示"冒烟模式下不可用"。
|
||||||
|
* - 窗口取自 `event.sender` 而不是模块级变量:IPC 只可能由主窗口(或无关的岛窗口)发起,
|
||||||
|
* 按发送方取窗口既不需要在 ipc.ts 里持有 mainWindow 引用(避免与 main/index.ts 循环依赖),
|
||||||
|
* 也保证打开的是**发起调用的那个窗口**的 DevTools。
|
||||||
|
*/
|
||||||
|
ipcMain.handle(IPC_CHANNELS.openDevTools, (event): boolean => {
|
||||||
|
if (process.env.ELECTRON_SMOKE_TEST === '1') {
|
||||||
|
logger.warn('冒烟验证模式下不允许打开开发者工具(会打乱焦点与截图断言)');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const target = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (!target || target.isDestroyed()) return false;
|
||||||
|
target.webContents.openDevTools({ mode: 'detach' });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
|
||||||
|
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
|
||||||
|
|
||||||
|
/** 立即上报一次(设置页「立即上报」按钮) */
|
||||||
|
ipcMain.handle(IPC_CHANNELS.clientBeatNow, () => beatNow());
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
|
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
|
||||||
|
|||||||
@@ -101,6 +101,15 @@ let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 };
|
|||||||
*/
|
*/
|
||||||
let BBOX_SIZE = { width: 0, height: 0 };
|
let BBOX_SIZE = { width: 0, height: 0 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「收回动画」时长(毫秒)。
|
||||||
|
*
|
||||||
|
* 用户点了「知道了 / 标为已读」之后,主进程把形态切回胶囊、让渲染进程沿**既有的形变路径**
|
||||||
|
* 把卡片收回去,到点再淡出隐藏。取值与渲染进程的形变弹簧(约 300~500ms)同量级;
|
||||||
|
* 外观设置里的"动画速度"会同比缩放它(见 `beginClose()`)。
|
||||||
|
*/
|
||||||
|
const CLOSE_ANIMATION_MS = 360;
|
||||||
|
|
||||||
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
|
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
|
||||||
const TIMEOUTS = {
|
const TIMEOUTS = {
|
||||||
pill: 15_000,
|
pill: 15_000,
|
||||||
@@ -150,6 +159,11 @@ class IslandController {
|
|||||||
private ready = false;
|
private ready = false;
|
||||||
private pendingState: IslandState | null = null;
|
private pendingState: IslandState | null = null;
|
||||||
private collapseTimer: ReturnType<typeof setTimeout> | null = null;
|
private collapseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
/**
|
||||||
|
* 「收回动画」定时器:点知道了 / 标为已读之后,延时把窗口淡出隐藏。
|
||||||
|
* 任何让岛重新可见的路径(`activate()` 等)都会经 `clearTimers()` 取消它。
|
||||||
|
*/
|
||||||
|
private closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
/** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */
|
/** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */
|
||||||
private boundsToken = 0;
|
private boundsToken = 0;
|
||||||
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
|
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
|
||||||
@@ -327,9 +341,7 @@ class IslandController {
|
|||||||
if (this.previewId && this.state.active?.id === this.previewId) return;
|
if (this.previewId && this.state.active?.id === this.previewId) return;
|
||||||
const sinceActivated = Date.now() - this.state.updatedAt;
|
const sinceActivated = Date.now() - this.state.updatedAt;
|
||||||
if (this.isCursorOnIsland()) {
|
if (this.isCursorOnIsland()) {
|
||||||
logger.info(
|
logger.info(`灵动岛:忽略失焦(指针仍在岛上,判定为展开时的激活抖动)距上次状态=${sinceActivated}ms`);
|
||||||
`灵动岛:忽略失焦(指针仍在岛上,判定为展开时的激活抖动)距上次状态=${sinceActivated}ms`,
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖
|
// 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖
|
||||||
@@ -786,6 +798,51 @@ class IslandController {
|
|||||||
this.setState({ mode: 'hidden', reason: null });
|
this.setState({ mode: 'hidden', reason: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.beginClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收回动画:把形态切回胶囊,让渲染进程沿**既有的胶囊形变路径**把卡片收回去,
|
||||||
|
* `CLOSE_ANIMATION_MS` 之后再淡出隐藏。
|
||||||
|
*
|
||||||
|
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
|
||||||
|
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
|
||||||
|
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
|
||||||
|
* (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
|
||||||
|
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
|
||||||
|
*/
|
||||||
|
private beginClose(): void {
|
||||||
|
// 关掉动画(含"减少动态效果"):保持原行为,直接淡出
|
||||||
|
if (!this.appearance.animations) {
|
||||||
|
this.finishClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* **必须 `skipWindow`**:收回动画是"卡片沿既有的胶囊形变路径缩回去",
|
||||||
|
* 而窗口此刻还是展开/列表态的大包围盒 —— 若这里同步缩窗,卡片会在动画的 360ms 里
|
||||||
|
* 被窗口裁掉(冒烟用例「整批关闭过程中卡片不被窗口裁切」实测最大越界 554.9px,
|
||||||
|
* 用户看到的是一瞬间的硬裁边)。窗口要变小一律等**隐藏之后**由 `syncWindow()` 做,
|
||||||
|
* 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md §5 第 30 条)是同一条纪律。
|
||||||
|
*/
|
||||||
|
this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true });
|
||||||
|
const speed = clamp(this.appearance.speed, 0.5, 2, 1);
|
||||||
|
this.closeTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
this.closeTimer = null;
|
||||||
|
this.finishClose();
|
||||||
|
},
|
||||||
|
Math.round(CLOSE_ANIMATION_MS / speed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收回动画收尾:淡出窗口并把状态落成 hidden —— 与原来的 `hide()` 完全一致,
|
||||||
|
* 所以"窗口最终会隐藏"的既有断言(含轮询写法)不受影响。
|
||||||
|
*/
|
||||||
|
private finishClose(): void {
|
||||||
|
// 动画期间又来了新消息 / 被显式唤起:这次收回归属于旧的一批,放弃收尾
|
||||||
|
if (this.state.mode !== 'pill' || this.state.reason !== 'closing') return;
|
||||||
|
if (this.state.active || this.state.queued.length > 0) return;
|
||||||
this.fadeOut();
|
this.fadeOut();
|
||||||
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
|
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
|
||||||
}
|
}
|
||||||
@@ -820,6 +877,13 @@ class IslandController {
|
|||||||
this.win = null;
|
this.win = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 教师撤回通知后,同时移除正在展示和等待下课的副本。 */
|
||||||
|
removeNotification(id: string): void {
|
||||||
|
this.state.queued = this.state.queued.filter((item) => item.id !== id);
|
||||||
|
if (this.state.active?.id === id) this.dismissActive();
|
||||||
|
else this.setState({});
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 内部实现 */
|
/* ------------------------------------------------------------ 内部实现 */
|
||||||
|
|
||||||
private dismissActive(): void {
|
private dismissActive(): void {
|
||||||
@@ -852,7 +916,6 @@ class IslandController {
|
|||||||
this.hide();
|
this.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
|
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
|
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
|
||||||
@@ -908,6 +971,11 @@ class IslandController {
|
|||||||
clearTimeout(this.collapseTimer);
|
clearTimeout(this.collapseTimer);
|
||||||
this.collapseTimer = null;
|
this.collapseTimer = null;
|
||||||
}
|
}
|
||||||
|
// 收回动画定时器:岛一旦被重新唤起(新消息/显式展开/预览),必须取消这次收尾
|
||||||
|
if (this.closeTimer) {
|
||||||
|
clearTimeout(this.closeTimer);
|
||||||
|
this.closeTimer = null;
|
||||||
|
}
|
||||||
if (this.previewTimer) {
|
if (this.previewTimer) {
|
||||||
clearTimeout(this.previewTimer);
|
clearTimeout(this.previewTimer);
|
||||||
this.previewTimer = null;
|
this.previewTimer = null;
|
||||||
@@ -949,11 +1017,7 @@ class IslandController {
|
|||||||
? 'right'
|
? 'right'
|
||||||
: 'center';
|
: 'center';
|
||||||
const hValue =
|
const hValue =
|
||||||
hMode === 'left'
|
hMode === 'left' ? area.x + marginX : hMode === 'right' ? area.x + area.width - marginX : center;
|
||||||
? area.x + marginX
|
|
||||||
: hMode === 'right'
|
|
||||||
? area.x + area.width - marginX
|
|
||||||
: center;
|
|
||||||
return { hMode, hValue, vMode, vValue };
|
return { hMode, hValue, vMode, vValue };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1690,8 +1754,6 @@ export function getSliverSize(): { width: number; height: number } {
|
|||||||
return { ...SLIVER_SIZE };
|
return { ...SLIVER_SIZE };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
|
|
||||||
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
|
||||||
/**
|
/**
|
||||||
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
|
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
|
||||||
*
|
*
|
||||||
@@ -1700,11 +1762,18 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
|||||||
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
|
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
|
||||||
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
|
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
|
||||||
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
|
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
|
||||||
|
*
|
||||||
|
* 提到模块作用域:收回动画(`beginClose()`)也要用它按 `appearance.speed` 折算时长,
|
||||||
|
* 原先它只定义在 `normalizeAppearance()` 内部,`beginClose()` 引用不到(编译报
|
||||||
|
* `TS2304: Cannot find name 'clamp'`)。
|
||||||
*/
|
*/
|
||||||
const clamp = (value: number, min: number, max: number, fallback: number): number => {
|
const clamp = (value: number, min: number, max: number, fallback: number): number => {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||||
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
|
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
|
||||||
|
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
||||||
const fallbackOf = DEFAULT_ISLAND_APPEARANCE;
|
const fallbackOf = DEFAULT_ISLAND_APPEARANCE;
|
||||||
return {
|
return {
|
||||||
height: clamp(input.height, 36, 72, fallbackOf.height),
|
height: clamp(input.height, 36, 72, fallbackOf.height),
|
||||||
@@ -1768,7 +1837,6 @@ const PREVIEW_DURATION_MS = 30_000;
|
|||||||
*/
|
*/
|
||||||
const MIN_CARD_HEIGHT = 120;
|
const MIN_CARD_HEIGHT = 120;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`):
|
* 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`):
|
||||||
* 超过这个时间没上报就认为它已经卡死/不响应。
|
* 超过这个时间没上报就认为它已经卡死/不响应。
|
||||||
@@ -1800,6 +1868,9 @@ export const island = new IslandController();
|
|||||||
|
|
||||||
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
|
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
|
||||||
export function registerIslandIpc(): void {
|
export function registerIslandIpc(): void {
|
||||||
|
ipcMain.on('island:remove-notification', (_event, id: string) => {
|
||||||
|
if (typeof id === 'string') island.removeNotification(id);
|
||||||
|
});
|
||||||
ipcMain.on(
|
ipcMain.on(
|
||||||
'island:push',
|
'island:push',
|
||||||
(_event, payload: { notification: IslandNotification; context?: IslandPushContext }) => {
|
(_event, payload: { notification: IslandNotification; context?: IslandPushContext }) => {
|
||||||
@@ -1818,13 +1889,28 @@ export function registerIslandIpc(): void {
|
|||||||
island.noteRendererAlive();
|
island.noteRendererAlive();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('island:get-state', () => island.getState());
|
let appearancePatch: Partial<IslandAppearance> | null = null;
|
||||||
|
let appearanceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const flushAppearance = (): void => {
|
||||||
|
if (appearanceTimer) clearTimeout(appearanceTimer);
|
||||||
|
appearanceTimer = null;
|
||||||
|
if (appearancePatch) island.setAppearance(appearancePatch);
|
||||||
|
appearancePatch = null;
|
||||||
|
};
|
||||||
|
ipcMain.handle('island:get-state', () => {
|
||||||
|
flushAppearance();
|
||||||
|
return island.getState();
|
||||||
|
});
|
||||||
ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => {
|
ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => {
|
||||||
if (patch) island.setAppearance(patch);
|
if (!patch) return;
|
||||||
|
appearancePatch = { ...appearancePatch, ...patch };
|
||||||
|
if (!appearanceTimer) appearanceTimer = setTimeout(flushAppearance, 16);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('island:get-appearance', () => island.getAppearance());
|
ipcMain.handle('island:get-appearance', () => {
|
||||||
|
flushAppearance();
|
||||||
|
return island.getAppearance();
|
||||||
|
});
|
||||||
|
|
||||||
// 渲染进程命中测试结果 → **只用于立刻打开命中**(比 60ms 轮询更快)。
|
// 渲染进程命中测试结果 → **只用于立刻打开命中**(比 60ms 轮询更快)。
|
||||||
// 关闭命中一律由主进程按真实光标决定:两边都下发 true/false 会互相覆盖,
|
// 关闭命中一律由主进程按真实光标决定:两边都下发 true/false 会互相覆盖,
|
||||||
|
|||||||
@@ -1,25 +1,126 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { app } from 'electron';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主进程日志(控制台输出)。
|
* 主进程日志:控制台输出 + 落盘。
|
||||||
|
*
|
||||||
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
|
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
|
||||||
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
|
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
|
||||||
* - 主窗口右上角的连接状态 / 灵动岛状态
|
* - 主窗口右上角的连接状态 / 灵动岛状态
|
||||||
|
* - **本文件写出的日志文件**(设置页「打开日志目录」直达)
|
||||||
* 对外可见,避免"出了问题却看不到日志"。
|
* 对外可见,避免"出了问题却看不到日志"。
|
||||||
|
*
|
||||||
|
* 两条实现约束:
|
||||||
|
* 1. `app.getPath('logs')` **不能在 app ready 之前调用**,所以日志文件路径是**懒解析**的:
|
||||||
|
* 解析失败(或还没 ready)时只走 console,之后任意一次写入会重新尝试;
|
||||||
|
* 2. 单文件超过上限就改名成 `main.log.1`(只留一份旧的,更老的直接删除),
|
||||||
|
* 避免长期运行的教室机器把磁盘写满。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** 单文件大小上限:超过就轮转(1MB 足够放下很多次崩溃堆栈) */
|
||||||
|
const LOG_MAX_BYTES = 1024 * 1024;
|
||||||
|
/** 轮转后的旧文件名 */
|
||||||
|
const LOG_ROTATED_NAME = 'main.log.1';
|
||||||
|
|
||||||
|
/** 日志文件名(在 app ready 之后解析;null = 暂不可用) */
|
||||||
|
let filePath: string | null = null;
|
||||||
|
/** verbose(= 配置里的 developerMode)时 debug 级别才输出 */
|
||||||
|
let verbose = false;
|
||||||
|
|
||||||
|
/** 解析日志文件路径:失败只返回 null(不抛),调用方继续走 console */
|
||||||
|
function resolveLogFile(): string | null {
|
||||||
|
try {
|
||||||
|
return path.join(app.getPath('logs'), 'main.log');
|
||||||
|
} catch {
|
||||||
|
// app ready 之前调用会抛;此时不写文件即可,控制台输出不受影响
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件超过上限就轮转:main.log → main.log.1(覆盖上一份旧的) */
|
||||||
|
function rotateIfNeeded(target: string): void {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(target);
|
||||||
|
if (stat.size <= LOG_MAX_BYTES) return;
|
||||||
|
const rotated = path.join(path.dirname(target), LOG_ROTATED_NAME);
|
||||||
|
// 先删旧的再改名:只保留一份历史日志
|
||||||
|
fs.rmSync(rotated, { force: true });
|
||||||
|
fs.renameSync(target, rotated);
|
||||||
|
} catch {
|
||||||
|
// 文件还不存在(第一次写)/ 被占用:都无需处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 落盘一行。任何失败都静默忽略 —— 日志不能反过来把主进程搞崩 */
|
||||||
|
function write(line: string): void {
|
||||||
|
if (!filePath) filePath = resolveLogFile();
|
||||||
|
const target = filePath;
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||||
|
fs.appendFileSync(target, `${line}\n`, 'utf8');
|
||||||
|
rotateIfNeeded(target);
|
||||||
|
} catch {
|
||||||
|
// 忽略:磁盘满 / 权限不足等都不该影响应用运行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function timestamp(): string {
|
function timestamp(): string {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
|
/**
|
||||||
|
* 设置日志级别(由 `main/index.ts` 按 `config.developerMode` 调用)。
|
||||||
|
* 开启后 debug 级别的日志才会同时进控制台与日志文件。
|
||||||
|
*/
|
||||||
|
setVerbose(enabled: boolean): void {
|
||||||
|
verbose = enabled === true;
|
||||||
|
},
|
||||||
|
/** 当前是否 verbose(供设置页/冒烟断言) */
|
||||||
|
isVerbose(): boolean {
|
||||||
|
return verbose;
|
||||||
|
},
|
||||||
debug(message: string, ...args: unknown[]): void {
|
debug(message: string, ...args: unknown[]): void {
|
||||||
console.log(`[${timestamp()}] DEBUG ${message}`, ...args);
|
if (!verbose) return;
|
||||||
|
const line = `[${timestamp()}] DEBUG ${message}`;
|
||||||
|
console.log(line, ...args);
|
||||||
|
write(`${line}${formatArgs(args)}`);
|
||||||
},
|
},
|
||||||
info(message: string, ...args: unknown[]): void {
|
info(message: string, ...args: unknown[]): void {
|
||||||
console.log(`[${timestamp()}] INFO ${message}`, ...args);
|
const line = `[${timestamp()}] INFO ${message}`;
|
||||||
|
console.log(line, ...args);
|
||||||
|
write(`${line}${formatArgs(args)}`);
|
||||||
},
|
},
|
||||||
warn(message: string, ...args: unknown[]): void {
|
warn(message: string, ...args: unknown[]): void {
|
||||||
console.warn(`[${timestamp()}] WARN ${message}`, ...args);
|
const line = `[${timestamp()}] WARN ${message}`;
|
||||||
|
console.warn(line, ...args);
|
||||||
|
write(`${line}${formatArgs(args)}`);
|
||||||
},
|
},
|
||||||
error(message: string, ...args: unknown[]): void {
|
error(message: string, ...args: unknown[]): void {
|
||||||
console.error(`[${timestamp()}] ERROR ${message}`, ...args);
|
const line = `[${timestamp()}] ERROR ${message}`;
|
||||||
|
console.error(line, ...args);
|
||||||
|
write(`${line}${formatArgs(args)}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 额外参数转成一行文本(落盘用)。
|
||||||
|
*
|
||||||
|
* 崩溃自愈那边传进来的常常是 Error 对象,**必须把完整堆栈写进文件**,
|
||||||
|
* 否则事后再看 `main.log` 只有一个光秃秃的 message,等于没记。
|
||||||
|
*/
|
||||||
|
function formatArgs(args: unknown[]): string {
|
||||||
|
if (args.length === 0) return '';
|
||||||
|
const parts = args.map((item) => {
|
||||||
|
if (item instanceof Error) return item.stack ?? `${item.name}: ${item.message}`;
|
||||||
|
if (typeof item === 'string') return item;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(item);
|
||||||
|
} catch {
|
||||||
|
return String(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return ` ${parts.join(' ')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { app, BrowserWindow } from 'electron';
|
||||||
|
import { getConfig } from './config.js';
|
||||||
|
|
||||||
|
/** 后台冒烟只检启动、桥接、缓存与窗口生命周期;屏幕像素和动画由显式视觉验收覆盖。 */
|
||||||
|
export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const results: Array<{ name: string; ok: boolean; detail: string; durationMs: number }> = [];
|
||||||
|
async function check(name: string, action: () => Promise<boolean | string> | boolean): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const result = await action();
|
||||||
|
results.push({
|
||||||
|
name,
|
||||||
|
ok: result !== false,
|
||||||
|
detail: typeof result === 'string' ? result : '',
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
results.push({ name, ok: false, detail: String(error), durationMs: Date.now() - start });
|
||||||
|
}
|
||||||
|
const last = results.at(-1)!;
|
||||||
|
console.log(`[${last.ok ? 'PASS' : 'FAIL'}] ${name}(${last.durationMs}ms)${last.detail}`);
|
||||||
|
}
|
||||||
|
await check('后台启动并挂载界面', async () => {
|
||||||
|
const deadline = Date.now() + 10_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`Boolean(window.__classhelperSmoke__ && document.querySelector('input, .el-menu-item'))`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
await check('所有验证窗口保持隐藏且不获取焦点', () =>
|
||||||
|
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
|
||||||
|
);
|
||||||
|
await check('预加载桥接白名单', async () =>
|
||||||
|
win.webContents.executeJavaScript(`Boolean(window.desktop?.getConfig && !window.desktop.ipcRenderer)`),
|
||||||
|
);
|
||||||
|
await check('渲染进程不能改写安装记录或设备身份', async () => {
|
||||||
|
const before = getConfig();
|
||||||
|
const after = await win.webContents.executeJavaScript(
|
||||||
|
`window.desktop.saveConfig({ pendingUpdateFile: 'C:/unexpected.exe', pendingUpdateVersion: '999.0.0', deviceKey: 'unexpected' })`,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
after.pendingUpdateFile === before.pendingUpdateFile &&
|
||||||
|
after.pendingUpdateVersion === before.pendingUpdateVersion &&
|
||||||
|
after.deviceKey === before.deviceKey
|
||||||
|
);
|
||||||
|
});
|
||||||
|
for (const [name, method] of [
|
||||||
|
['IndexedDB 缓存读写', 'cacheSelfTest'],
|
||||||
|
['断网缓存回退', 'offlineScenario'],
|
||||||
|
['服务端与班级缓存隔离', 'cacheIsolationSelfTest'],
|
||||||
|
]) {
|
||||||
|
await check(name!, async () => {
|
||||||
|
const result = await win.webContents.executeJavaScript(`window.__classhelperSmoke__.${method}()`);
|
||||||
|
if (!result.ok) throw new Error(result.detail);
|
||||||
|
return result.detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
|
||||||
|
await check('后台登录与数据、实时通道集成', async () => {
|
||||||
|
const credentials = {
|
||||||
|
code: process.env.ELECTRON_SMOKE_CLASS_CODE ?? '',
|
||||||
|
password: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
|
||||||
|
serverUrl: (process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api').replace(/\/api\/?$/, ''),
|
||||||
|
};
|
||||||
|
const result = await win.webContents.executeJavaScript(
|
||||||
|
`window.__classhelperSmoke__.onlineScenario(${JSON.stringify(credentials)})`,
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new Error(result.detail);
|
||||||
|
await win.webContents.executeJavaScript('window.__classhelperSmoke__.sessionCleanup()');
|
||||||
|
return result.detail;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await check('检查结束时没有可见或聚焦的验证窗口', () =>
|
||||||
|
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
|
||||||
|
);
|
||||||
|
await check('关闭窗口仍隐藏而不销毁', () => {
|
||||||
|
win.close();
|
||||||
|
return !win.isDestroyed() && !win.isVisible() && app.isReady();
|
||||||
|
});
|
||||||
|
const failed = results.filter((result) => !result.ok);
|
||||||
|
const result = {
|
||||||
|
passed: results.length - failed.length,
|
||||||
|
total: results.length,
|
||||||
|
ok: !failed.length,
|
||||||
|
mode: 'quick',
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
results,
|
||||||
|
appVersion: app.getVersion(),
|
||||||
|
packaged: app.isPackaged,
|
||||||
|
};
|
||||||
|
if (process.env.ELECTRON_SMOKE_RESULT) {
|
||||||
|
fs.mkdirSync(path.dirname(process.env.ELECTRON_SMOKE_RESULT), { recursive: true });
|
||||||
|
fs.writeFileSync(process.env.ELECTRON_SMOKE_RESULT, JSON.stringify(result, null, 2));
|
||||||
|
}
|
||||||
|
console.log(`[SMOKE] 后台快速检查 ${result.passed}/${result.total} 项通过,${result.durationMs}ms`);
|
||||||
|
app.exit(failed.length ? 1 : 0);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import { runQuickSmoke } from './smoke-quick.js';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { app, BrowserWindow, desktopCapturer, screen } from 'electron';
|
import { app, BrowserWindow, desktopCapturer, screen } from 'electron';
|
||||||
import type { NativeImage } from 'electron';
|
import type { NativeImage } from 'electron';
|
||||||
@@ -1150,7 +1151,10 @@ async function runIslandChecks(
|
|||||||
const shotAppearance: Parameters<typeof island.setAppearance>[0] = {
|
const shotAppearance: Parameters<typeof island.setAppearance>[0] = {
|
||||||
accent: process.env.ELECTRON_SMOKE_ISLAND_ACCENT ?? '#e91e8c',
|
accent: process.env.ELECTRON_SMOKE_ISLAND_ACCENT ?? '#e91e8c',
|
||||||
};
|
};
|
||||||
if (process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' || process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass') {
|
if (
|
||||||
|
process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' ||
|
||||||
|
process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass'
|
||||||
|
) {
|
||||||
shotAppearance.style = process.env.ELECTRON_SMOKE_ISLAND_STYLE;
|
shotAppearance.style = process.env.ELECTRON_SMOKE_ISLAND_STYLE;
|
||||||
} else if (!process.env.ELECTRON_SMOKE_ISLAND_STYLE) {
|
} else if (!process.env.ELECTRON_SMOKE_ISLAND_STYLE) {
|
||||||
shotAppearance.style = 'tinted';
|
shotAppearance.style = 'tinted';
|
||||||
@@ -2029,6 +2033,39 @@ async function runIslandChecks(
|
|||||||
);
|
);
|
||||||
await sleep(120);
|
await sleep(120);
|
||||||
const duringClose = { state: island.getState() };
|
const duringClose = { state: island.getState() };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收回动画(用户要求:"点知道了 / 标为已读时也要有回收动画")。
|
||||||
|
*
|
||||||
|
* 主进程收到 dismiss / dismiss-all / mark-read / mark-all-read 之后:状态**同一帧就清空**,
|
||||||
|
* 形态先切回胶囊(`reason === 'closing'`)让渲染进程把卡片收回去,`CLOSE_ANIMATION_MS`(360ms,
|
||||||
|
* 会按外观里的"动画速度"缩放)之后才淡出隐藏。这里在点击后的小窗口内轮询采样,
|
||||||
|
* 断言"确实经过了这个阶段、且动画期间窗口仍然可见"。
|
||||||
|
*/
|
||||||
|
let closingSample: { mode: string; reason: string | null; visible: boolean } | null = null;
|
||||||
|
for (let attempt = 0; attempt < 20 && !closingSample; attempt += 1) {
|
||||||
|
const probe = island.getState();
|
||||||
|
if (probe.reason === 'closing') {
|
||||||
|
closingSample = {
|
||||||
|
mode: probe.mode,
|
||||||
|
reason: probe.reason,
|
||||||
|
visible: islandWindow?.isVisible() ?? false,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(25);
|
||||||
|
}
|
||||||
|
record(
|
||||||
|
'点「知道了」有收回动画(先收回胶囊再淡出,动画期间窗口可见)',
|
||||||
|
closingSample !== null &&
|
||||||
|
closingSample.mode === 'pill' &&
|
||||||
|
closingSample.visible &&
|
||||||
|
duringClose.state.active === null,
|
||||||
|
closingSample
|
||||||
|
? `采样=${closingSample.mode}/${closingSample.reason}/可见:${closingSample.visible}`
|
||||||
|
: '未捕捉到 closing 阶段(可能动画被关掉或耗时过短)',
|
||||||
|
);
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 26; attempt += 1) {
|
for (let attempt = 0; attempt < 26; attempt += 1) {
|
||||||
await probeClosingClip();
|
await probeClosingClip();
|
||||||
await sleep(40);
|
await sleep(40);
|
||||||
@@ -4092,67 +4129,17 @@ async function runSettingsPageMarginCheck(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> {
|
async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> {
|
||||||
const result = (await win.webContents
|
const text = await win.webContents.executeJavaScript(`(async () => {
|
||||||
.executeJavaScript(
|
|
||||||
`(async () => {
|
|
||||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
const before = (await window.desktop.getConfig()).notificationChannel;
|
|
||||||
location.hash = '#/settings/reminder';
|
location.hash = '#/settings/reminder';
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
// 找到「通知显示位置」那张卡片里的单选组
|
return document.body.innerText;
|
||||||
let group = null;
|
})()`);
|
||||||
for (let i = 0; i < 40 && !group; i += 1) {
|
|
||||||
await wait(100);
|
|
||||||
group = Array.from(document.querySelectorAll('.el-card')).find((card) =>
|
|
||||||
(card.querySelector('.el-card__header')?.textContent ?? '').includes('ClassIsland 联动'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!group) return { ok: false, detail: '设置页没有「ClassIsland 联动」卡片' };
|
|
||||||
const radios = Array.from(group.querySelectorAll('.el-radio'));
|
|
||||||
// 注意:这段脚本本身是外层模板字符串,里面不能再出现反引号(会截断外层字符串)
|
|
||||||
if (radios.length < 3) return { ok: false, detail: '单选项只有 ' + radios.length + ' 个' };
|
|
||||||
|
|
||||||
// 点第二个(只在 ClassHelper 客户端弹),断言配置真的写下去了
|
|
||||||
radios[1].click();
|
|
||||||
await wait(600);
|
|
||||||
const afterClient = (await window.desktop.getConfig()).notificationChannel;
|
|
||||||
const clientChecked = radios[1].classList.contains('is-checked');
|
|
||||||
|
|
||||||
// 再点回第一个(两端都弹),避免影响后续用例
|
|
||||||
radios[0].click();
|
|
||||||
await wait(600);
|
|
||||||
const afterBoth = (await window.desktop.getConfig()).notificationChannel;
|
|
||||||
|
|
||||||
location.hash = '#/schedule';
|
|
||||||
return { ok: true, before, afterClient, afterBoth, clientChecked, labels: radios.map((n) => n.textContent.trim()) };
|
|
||||||
})()`,
|
|
||||||
)
|
|
||||||
.catch((error: unknown) => ({
|
|
||||||
ok: false as const,
|
|
||||||
detail: `执行失败:${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
}))) as
|
|
||||||
| {
|
|
||||||
ok: true;
|
|
||||||
before: string;
|
|
||||||
afterClient: string;
|
|
||||||
afterBoth: string;
|
|
||||||
clientChecked: boolean;
|
|
||||||
labels: string[];
|
|
||||||
}
|
|
||||||
| { ok: false; detail: string }
|
|
||||||
| null;
|
|
||||||
|
|
||||||
if (result?.ok) {
|
|
||||||
return {
|
return {
|
||||||
ok: result.afterClient === 'client' && result.clientChecked && result.afterBoth === 'both',
|
ok: text.includes('通知提醒') && !text.includes('ClassIsland 联动'),
|
||||||
detail:
|
detail: '通知统一由客户端提醒',
|
||||||
`单选项=[${result.labels.join(' / ')}],` +
|
|
||||||
`选择「只在 ClassHelper 客户端弹」后配置 ${result.before} → ${result.afterClient}(选中=${result.clientChecked}),` +
|
|
||||||
`切回后 ${result.afterBoth}`,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { ok: false, detail: result?.detail ?? '未执行' };
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
|
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
|
||||||
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
||||||
@@ -4194,6 +4181,7 @@ async function runTrayAndShutdownChecks(mainWin: BrowserWindow, record: Recorder
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||||
|
if (process.env.ELECTRON_SMOKE_MODE !== 'visual') return runQuickSmoke(win);
|
||||||
const results: SmokeResult[] = [];
|
const results: SmokeResult[] = [];
|
||||||
const record = (name: string, ok: boolean, detail = ''): void => {
|
const record = (name: string, ok: boolean, detail = ''): void => {
|
||||||
results.push({ name, ok, detail });
|
results.push({ name, ok, detail });
|
||||||
@@ -4362,10 +4350,14 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
// 班级账号凭据由 scripts/smoke.mjs 通过管理端接口准备(渲染进程读不到 process.env)
|
// 班级账号凭据由 scripts/smoke.mjs 通过管理端接口准备(渲染进程读不到 process.env)
|
||||||
const classCode = process.env.ELECTRON_SMOKE_CLASS_CODE ?? '';
|
const classCode = process.env.ELECTRON_SMOKE_CLASS_CODE ?? '';
|
||||||
const classPassword = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '';
|
const classPassword = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '';
|
||||||
|
const serverUrl = (process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api').replace(
|
||||||
|
/\/api\/?$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
const online = await win.webContents.executeJavaScript(
|
const online = await win.webContents.executeJavaScript(
|
||||||
`(async () => {
|
`(async () => {
|
||||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||||
return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword })});
|
return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword, serverUrl })});
|
||||||
})()`,
|
})()`,
|
||||||
);
|
);
|
||||||
record(
|
record(
|
||||||
@@ -4380,9 +4372,9 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
|||||||
const settingsAppearance = await runSettingsPageAppearanceCheck(win);
|
const settingsAppearance = await runSettingsPageAppearanceCheck(win);
|
||||||
record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail);
|
record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail);
|
||||||
|
|
||||||
// 通知显示位置(客户端自己选):提醒弹在 ClassHelper 还是 ClassIsland
|
// 提醒设置:仅保留本机通知与教师管理的成绩查询状态
|
||||||
const settingsChannel = await runSettingsPageChannelCheck(win);
|
const settingsChannel = await runSettingsPageChannelCheck(win);
|
||||||
record('设置页真实 UI:切换「通知显示位置」写回本地配置', settingsChannel.ok, settingsChannel.detail);
|
record('设置页真实 UI:提醒规则展示且无退役联动入口', settingsChannel.ok, settingsChannel.detail);
|
||||||
|
|
||||||
// 改密入口(2026-10-01 用户要求移除:班级密码只由管理员在 Web 端维护)——负断言防退化
|
// 改密入口(2026-10-01 用户要求移除:班级密码只由管理员在 Web 端维护)——负断言防退化
|
||||||
const settingsPassword = await runSettingsPagePasswordEntryCheck(win);
|
const settingsPassword = await runSettingsPagePasswordEntryCheck(win);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type UpdateAssetDto,
|
type UpdateAssetDto,
|
||||||
type UpdateInfo,
|
type UpdateInfo,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
|
import { UPDATE_AVAILABLE_CHANNEL } from './channels.js';
|
||||||
import { getConfig, saveConfig } from './config.js';
|
import { getConfig, saveConfig } from './config.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
|
||||||
@@ -25,8 +26,8 @@ import { logger } from './logger.js';
|
|||||||
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
|
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */
|
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示);定义在叶子模块 `channels.ts` */
|
||||||
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
|
export { UPDATE_AVAILABLE_CHANNEL };
|
||||||
|
|
||||||
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
|
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
|
||||||
const STARTUP_DELAY_MS = 5_000;
|
const STARTUP_DELAY_MS = 5_000;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
import { IPC_CHANNELS } from '../main/ipc.js';
|
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
|
||||||
import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js';
|
import type { ClientAgentStatus } from '../main/client-agent.js';
|
||||||
import type {
|
import type {
|
||||||
DesktopAppInfo,
|
DesktopAppInfo,
|
||||||
DesktopBridge,
|
DesktopBridge,
|
||||||
@@ -50,6 +50,9 @@ const bridge: DesktopBridge = {
|
|||||||
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
|
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
|
||||||
ipcRenderer.send('island:push', payload);
|
ipcRenderer.send('island:push', payload);
|
||||||
},
|
},
|
||||||
|
islandRemoveNotification: (id: string): void => {
|
||||||
|
ipcRenderer.send('island:remove-notification', id);
|
||||||
|
},
|
||||||
islandSetClassState: (payload: IslandClassStatePayload): void => {
|
islandSetClassState: (payload: IslandClassStatePayload): void => {
|
||||||
ipcRenderer.send('island:class-state', payload);
|
ipcRenderer.send('island:class-state', payload);
|
||||||
},
|
},
|
||||||
@@ -66,6 +69,15 @@ const bridge: DesktopBridge = {
|
|||||||
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
|
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
|
||||||
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
|
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/* 开机自启 / 日志 / 服务端联动 */
|
||||||
|
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.getAutoLaunch),
|
||||||
|
setAutoLaunch: (enabled: boolean): Promise<boolean> =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.setAutoLaunch, enabled === true),
|
||||||
|
openLogDir: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openLogDir),
|
||||||
|
openDevTools: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openDevTools),
|
||||||
|
getClientAgentStatus: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientStatus),
|
||||||
|
beatClientNow: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientBeatNow),
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('desktop', bridge);
|
contextBridge.exposeInMainWorld('desktop', bridge);
|
||||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { ElButton, ElNotification } from 'element-plus';
|
import { ElButton, ElNotification } from 'element-plus';
|
||||||
import type { UpdateInfo } from '@classhelper/shared';
|
import type { UpdateInfo } from '@classhelper/shared';
|
||||||
import {
|
import {
|
||||||
|
replayMissedNotifications,
|
||||||
startIslandBridge,
|
startIslandBridge,
|
||||||
stopIslandBridge,
|
stopIslandBridge,
|
||||||
subscribeIslandMarkAllRead,
|
subscribeIslandMarkAllRead,
|
||||||
@@ -72,6 +73,20 @@ function showUpdateAvailable(info: UpdateInfo): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上线补弹「离线期间收到的消息」(需求 11)。
|
||||||
|
*
|
||||||
|
* **为什么订阅放在 App.vue**:与 `onUpdateAvailable` 同一条理由 —— App.vue 整个应用生命周期
|
||||||
|
* 只挂载一次,而 `ClientLayout` 会随"登出 → 登录"反复挂载。而且恢复事件本身是"可达性由不可达
|
||||||
|
* 变为可达"的那一刻,一次会话里可能发生多次,处理器必须是**幂等**的(`replayMissedNotifications`
|
||||||
|
* 推完就清掉离线起点,重复触发时第二次读到空值、直接返回 0)。
|
||||||
|
*
|
||||||
|
* 处理器是具名函数:`stores/app.ts` 的订阅表按函数引用 off,匿名箭头函数在 onUnmounted 里摘不掉。
|
||||||
|
*/
|
||||||
|
function handleServerRecovered(): void {
|
||||||
|
void replayMissedNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动流程:
|
* 启动流程:
|
||||||
* 1) 从主进程配置恢复服务器地址与登录令牌
|
* 1) 从主进程配置恢复服务器地址与登录令牌
|
||||||
@@ -87,14 +102,6 @@ onMounted(async () => {
|
|||||||
bootText.value = '正在恢复登录状态...';
|
bootText.value = '正在恢复登录状态...';
|
||||||
const session = await auth.restore();
|
const session = await auth.restore();
|
||||||
|
|
||||||
// 通知显示位置:先用本地配置兜底(断网也要按上次的选择决定本机弹不弹),
|
|
||||||
// 登录态就绪后再向服务器对齐一次(服务端才是单一事实来源)
|
|
||||||
const storedConfig = await window.desktop?.getConfig?.();
|
|
||||||
if (storedConfig?.notificationChannel) {
|
|
||||||
appStore.applyLocalNotificationChannel(storedConfig.notificationChannel);
|
|
||||||
}
|
|
||||||
void appStore.loadNotificationChannel(auth.classId);
|
|
||||||
|
|
||||||
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
|
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
|
||||||
void appStore.init(session.serverUrl);
|
void appStore.init(session.serverUrl);
|
||||||
|
|
||||||
@@ -107,6 +114,9 @@ onMounted(async () => {
|
|||||||
subscribeIslandMarkRead();
|
subscribeIslandMarkRead();
|
||||||
subscribeIslandMarkAllRead();
|
subscribeIslandMarkAllRead();
|
||||||
|
|
||||||
|
// 离线期间收到的消息:可达性恢复的那一刻补弹到灵动岛(App.vue 常驻,所以每次恢复只跑一次)
|
||||||
|
appStore.onServerRecovered(handleServerRecovered);
|
||||||
|
|
||||||
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
|
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
|
||||||
window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
|
window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
|
||||||
|
|
||||||
@@ -129,6 +139,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
appStore.offServerRecovered(handleServerRecovered);
|
||||||
stopIslandBridge();
|
stopIslandBridge();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import {
|
|||||||
API_PATHS,
|
API_PATHS,
|
||||||
type ClassDetailDto,
|
type ClassDetailDto,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
type ClassIslandNotificationChannel,
|
|
||||||
type CourseDto,
|
type CourseDto,
|
||||||
|
type ExamDto,
|
||||||
|
type ExamRankingDto,
|
||||||
type HomeworkDaysDto,
|
type HomeworkDaysDto,
|
||||||
type DashboardSummary,
|
type DashboardSummary,
|
||||||
type GradeDto,
|
type GradeDto,
|
||||||
@@ -45,36 +46,6 @@ export const classApi = {
|
|||||||
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
|
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* 通知显示位置(both / client / classisland)。
|
|
||||||
*
|
|
||||||
* 由**这台教室机器自己**决定"提醒弹在 ClassHelper 还是 ClassIsland":
|
|
||||||
* 本地存一份用于本机的弹窗/上岛判断,同时写回班级记录,服务端据此决定要不要推 ClassIsland。
|
|
||||||
*/
|
|
||||||
export const classChannelApi = {
|
|
||||||
get: (classId: string): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
|
|
||||||
api.get(`${API_PATHS.classes}/${classId}/notification-channel`),
|
|
||||||
set: (
|
|
||||||
classId: string,
|
|
||||||
notificationChannel: ClassIslandNotificationChannel,
|
|
||||||
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
|
|
||||||
api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }),
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 本班 ClassIsland 联动状态(教室客户端「设置 → ClassIsland 联动」展示) */
|
|
||||||
export const classIslandStatusApi = {
|
|
||||||
get: (
|
|
||||||
classId: string,
|
|
||||||
): Promise<{
|
|
||||||
connected: boolean;
|
|
||||||
deviceName: string | null;
|
|
||||||
deviceCount: number;
|
|
||||||
lastSeenAt: string | null;
|
|
||||||
pluginVersion: string | null;
|
|
||||||
classIslandVersion: string | null;
|
|
||||||
}> => api.get(`${API_PATHS.classes}/${classId}/classisland-status`),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const scheduleApi = {
|
export const scheduleApi = {
|
||||||
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
|
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
|
||||||
api.get(API_PATHS.schedules, params),
|
api.get(API_PATHS.schedules, params),
|
||||||
@@ -136,17 +107,24 @@ 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 }),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ClassHelper 班级端设置:「是否允许按学号查询本班学生成绩明细」。
|
* ClassHelper 班级端设置:「是否允许按学号查询本班学生成绩明细」。
|
||||||
* 与通知显示位置同一套模式 —— 由教室机器自己决定,教师/管理员在 Web 端也能改。
|
* 教师/管理员在 Web 端控制,教室机器只读取当前状态。
|
||||||
*/
|
*/
|
||||||
export const classGradeQueryApi = {
|
export const classGradeQueryApi = {
|
||||||
get: (classId: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
get: (classId: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||||||
api.get(`${API_PATHS.classes}/${classId}/student-grade-query`),
|
api.get(`${API_PATHS.classes}/${classId}/student-grade-query`),
|
||||||
set: (classId: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
|
||||||
api.patch(`${API_PATHS.classes}/${classId}/student-grade-query`, { enabled }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
|
|||||||
+55
-12
@@ -1,3 +1,5 @@
|
|||||||
|
import { getCacheScope, scopedKey } from './scope.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IndexedDB 缓存层(无第三方依赖)。
|
* IndexedDB 缓存层(无第三方依赖)。
|
||||||
*
|
*
|
||||||
@@ -37,7 +39,8 @@ let databasePromise: Promise<IDBDatabase> | null = null;
|
|||||||
function openDatabase(): Promise<IDBDatabase> {
|
function openDatabase(): Promise<IDBDatabase> {
|
||||||
if (databasePromise) return databasePromise;
|
if (databasePromise) return databasePromise;
|
||||||
|
|
||||||
databasePromise = new Promise<IDBDatabase>((resolve, reject) => {
|
let failed = false;
|
||||||
|
const opening = new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
request.onupgradeneeded = () => {
|
request.onupgradeneeded = () => {
|
||||||
@@ -49,12 +52,36 @@ function openDatabase(): Promise<IDBDatabase> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
request.onsuccess = () => resolve(request.result);
|
request.onsuccess = () => {
|
||||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB 打开失败'));
|
const db = request.result;
|
||||||
request.onblocked = () => reject(new Error('IndexedDB 被其它窗口占用'));
|
if (failed) {
|
||||||
|
db.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reset = (): void => {
|
||||||
|
if (databasePromise === opening) databasePromise = null;
|
||||||
|
};
|
||||||
|
db.onversionchange = () => {
|
||||||
|
db.close();
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
db.onclose = reset;
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
|
request.onerror = () => {
|
||||||
|
failed = true;
|
||||||
|
reject(request.error ?? new Error('IndexedDB 打开失败'));
|
||||||
|
};
|
||||||
|
request.onblocked = () => {
|
||||||
|
failed = true;
|
||||||
|
reject(new Error('IndexedDB 被其它窗口占用'));
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
databasePromise = opening;
|
||||||
return databasePromise;
|
void opening.catch(() => {
|
||||||
|
if (databasePromise === opening) databasePromise = null;
|
||||||
|
});
|
||||||
|
return opening;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runRequest<T>(
|
function runRequest<T>(
|
||||||
@@ -67,7 +94,8 @@ function runRequest<T>(
|
|||||||
new Promise<T>((resolve, reject) => {
|
new Promise<T>((resolve, reject) => {
|
||||||
const transaction = db.transaction(store, mode);
|
const transaction = db.transaction(store, mode);
|
||||||
const request = action(transaction.objectStore(store));
|
const request = action(transaction.objectStore(store));
|
||||||
request.onsuccess = () => resolve(request.result as T);
|
transaction.oncomplete = () => resolve(request.result as T);
|
||||||
|
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB 事务已中止'));
|
||||||
request.onerror = () => reject(request.error ?? new Error('IndexedDB 操作失败'));
|
request.onerror = () => reject(request.error ?? new Error('IndexedDB 操作失败'));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -89,14 +117,28 @@ function toPlain<T>(value: T): T {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cacheSet<T>(store: CacheStore, key: string, value: T): Promise<void> {
|
export async function cacheSet<T>(
|
||||||
const record: CacheRecord<T> = { key, value: toPlain(value), updatedAt: Date.now() };
|
store: CacheStore,
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
scope = getCacheScope(),
|
||||||
|
): Promise<void> {
|
||||||
|
const record: CacheRecord<T> = {
|
||||||
|
key: store === 'profile' ? key : scopedKey(key, scope),
|
||||||
|
value: toPlain(value),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
await runRequest<IDBValidKey>(store, 'readwrite', (objectStore) => objectStore.put(record));
|
await runRequest<IDBValidKey>(store, 'readwrite', (objectStore) => objectStore.put(record));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cacheGet<T>(store: CacheStore, key: string): Promise<CacheRecord<T> | null> {
|
export function cacheGet<T>(
|
||||||
|
store: CacheStore,
|
||||||
|
key: string,
|
||||||
|
scope = getCacheScope(),
|
||||||
|
): Promise<CacheRecord<T> | null> {
|
||||||
|
const resolvedKey = store === 'profile' ? key : scopedKey(key, scope);
|
||||||
return runRequest<CacheRecord<T> | undefined>(store, 'readonly', (objectStore) =>
|
return runRequest<CacheRecord<T> | undefined>(store, 'readonly', (objectStore) =>
|
||||||
objectStore.get(key),
|
objectStore.get(resolvedKey),
|
||||||
).then((record) => record ?? null);
|
).then((record) => record ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +147,8 @@ export function cacheList<T>(store: CacheStore): Promise<Array<CacheRecord<T>>>
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function cacheRemove(store: CacheStore, key: string): Promise<void> {
|
export async function cacheRemove(store: CacheStore, key: string): Promise<void> {
|
||||||
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(key));
|
const resolvedKey = store === 'profile' ? key : scopedKey(key);
|
||||||
|
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(resolvedKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cacheClearStore(store: CacheStore): Promise<void> {
|
export async function cacheClearStore(store: CacheStore): Promise<void> {
|
||||||
|
|||||||
+9
-2
@@ -1,4 +1,5 @@
|
|||||||
import { cacheGet, cacheSet, type CacheStore } from './db.js';
|
import { cacheGet, cacheSet, type CacheStore } from './db.js';
|
||||||
|
import { getCacheScope } from './scope.js';
|
||||||
|
|
||||||
export interface CachedFetchResult<T> {
|
export interface CachedFetchResult<T> {
|
||||||
data: T;
|
data: T;
|
||||||
@@ -23,13 +24,19 @@ export async function fetchWithCache<T>(
|
|||||||
loader: () => Promise<T>,
|
loader: () => Promise<T>,
|
||||||
fallback: T,
|
fallback: T,
|
||||||
): Promise<CachedFetchResult<T>> {
|
): Promise<CachedFetchResult<T>> {
|
||||||
|
const scope = getCacheScope();
|
||||||
try {
|
try {
|
||||||
const data = await loader();
|
const data = await loader();
|
||||||
await cacheSet(store, key, data);
|
if (scope !== getCacheScope())
|
||||||
|
return { data: fallback, fromCache: false, updatedAt: null, error: '会话已切换' };
|
||||||
|
// 网络成功但本地磁盘不可用时,仍返回刚获取的数据。
|
||||||
|
await cacheSet(store, key, data, scope).catch(() => undefined);
|
||||||
return { data, fromCache: false, updatedAt: Date.now(), error: null };
|
return { data, fromCache: false, updatedAt: Date.now(), error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const cached = await cacheGet<T>(store, key).catch(() => null);
|
const cached = await cacheGet<T>(store, key, scope).catch(() => null);
|
||||||
|
if (scope !== getCacheScope())
|
||||||
|
return { data: fallback, fromCache: false, updatedAt: null, error: '会话已切换' };
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: message };
|
return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
let activeScope = 'anonymous';
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
/** 同一台机器可切换服务端与班级,两者都必须纳入缓存命名空间。 */
|
||||||
|
export function setCacheScope(server: string, identity: string): void {
|
||||||
|
const next = `${encodeURIComponent(server.replace(/\/+$/, ''))}@${encodeURIComponent(identity)}`;
|
||||||
|
if (next === activeScope) return;
|
||||||
|
activeScope = next;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
export function getCacheScope(): string {
|
||||||
|
return activeScope;
|
||||||
|
}
|
||||||
|
export function scopedKey(key: string, scope = activeScope): string {
|
||||||
|
return `${scope}::${key}`;
|
||||||
|
}
|
||||||
|
export function onCacheScopeChanged(listener: () => void): void {
|
||||||
|
listeners.add(listener);
|
||||||
|
}
|
||||||
|
export function profileCacheKey(server: string, username: string): string {
|
||||||
|
return `${encodeURIComponent(server.replace(/\/+$/, ''))}@${encodeURIComponent(username)}::profile`;
|
||||||
|
}
|
||||||
+40
-6
@@ -1,5 +1,27 @@
|
|||||||
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
|
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
|
||||||
import { fetchWithCache } from './index.js';
|
import { fetchWithCache } from './index.js';
|
||||||
|
import { setCacheScope } from './scope.js';
|
||||||
|
|
||||||
|
export async function cacheIsolationSelfTest(): Promise<SmokeCheckResult> {
|
||||||
|
const key = `scope-check-${Date.now()}`;
|
||||||
|
try {
|
||||||
|
setCacheScope('http://scope-a.invalid', 'class-a');
|
||||||
|
await cacheSet('grades', key, ['班级甲']);
|
||||||
|
setCacheScope('http://scope-a.invalid', 'class-b');
|
||||||
|
if (await cacheGet('grades', key)) return { ok: false, detail: '跨班级读到了缓存' };
|
||||||
|
setCacheScope('http://scope-b.invalid', 'class-a');
|
||||||
|
if (await cacheGet('grades', key)) return { ok: false, detail: '跨服务端读到了缓存' };
|
||||||
|
setCacheScope('http://scope-a.invalid', 'class-a');
|
||||||
|
const own = await cacheGet<string[]>('grades', key);
|
||||||
|
await cacheRemove('grades', key);
|
||||||
|
return { ok: own?.value[0] === '班级甲', detail: '换班和换服务端均隔离,原班缓存仍可读取' };
|
||||||
|
} finally {
|
||||||
|
setCacheScope('', 'anonymous');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 联网自检与造数、清理始终使用同一服务端。 */
|
||||||
|
let smokeServerUrl = 'http://127.0.0.1:4000';
|
||||||
|
|
||||||
export interface SmokeCheckResult {
|
export interface SmokeCheckResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -208,7 +230,7 @@ async function seedSmokeContent(
|
|||||||
need: { homework: boolean; notification: boolean },
|
need: { homework: boolean; notification: boolean },
|
||||||
): Promise<SmokeCheckResult> {
|
): Promise<SmokeCheckResult> {
|
||||||
if (!classId) return { ok: false, detail: '缺少班级,无法自建数据' };
|
if (!classId) return { ok: false, detail: '缺少班级,无法自建数据' };
|
||||||
const serverUrl = 'http://127.0.0.1:4000';
|
const serverUrl = smokeServerUrl;
|
||||||
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
|
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -275,7 +297,7 @@ async function cleanupTemporaryContent(): Promise<string> {
|
|||||||
if (!temporaryContent) return '';
|
if (!temporaryContent) return '';
|
||||||
const { token, homeworks, notifications } = temporaryContent;
|
const { token, homeworks, notifications } = temporaryContent;
|
||||||
temporaryContent = null;
|
temporaryContent = null;
|
||||||
const serverUrl = 'http://127.0.0.1:4000';
|
const serverUrl = smokeServerUrl;
|
||||||
const removals = [
|
const removals = [
|
||||||
...homeworks.map((id) => `/api/homeworks/${id}`),
|
...homeworks.map((id) => `/api/homeworks/${id}`),
|
||||||
...notifications.map((id) => `/api/notifications/${id}`),
|
...notifications.map((id) => `/api/notifications/${id}`),
|
||||||
@@ -298,6 +320,7 @@ async function cleanupTemporaryContent(): Promise<string> {
|
|||||||
export async function onlineScenario(credentials?: {
|
export async function onlineScenario(credentials?: {
|
||||||
code: string;
|
code: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
serverUrl?: string;
|
||||||
}): Promise<SmokeCheckResult> {
|
}): Promise<SmokeCheckResult> {
|
||||||
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
|
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
|
||||||
import('../stores/auth.js'),
|
import('../stores/auth.js'),
|
||||||
@@ -311,7 +334,8 @@ export async function onlineScenario(credentials?: {
|
|||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
const serverUrl = 'http://127.0.0.1:4000';
|
const serverUrl = credentials?.serverUrl ?? smokeServerUrl;
|
||||||
|
smokeServerUrl = serverUrl;
|
||||||
const detail: string[] = [];
|
const detail: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -441,7 +465,7 @@ export async function islandRealtimeScenario(): Promise<{
|
|||||||
import('../island/bridge.js'),
|
import('../island/bridge.js'),
|
||||||
]);
|
]);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const serverUrl = 'http://127.0.0.1:4000';
|
const serverUrl = smokeServerUrl;
|
||||||
const classId = auth.classId ?? null;
|
const classId = auth.classId ?? null;
|
||||||
if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' };
|
if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' };
|
||||||
|
|
||||||
@@ -499,7 +523,7 @@ export async function islandRealtimeCleanup(
|
|||||||
teacherToken: string,
|
teacherToken: string,
|
||||||
): Promise<SmokeCheckResult> {
|
): Promise<SmokeCheckResult> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://127.0.0.1:4000/api/notifications/${notificationId}`, {
|
const response = await fetch(`${smokeServerUrl}/api/notifications/${notificationId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { authorization: `Bearer ${teacherToken}` },
|
headers: { authorization: `Bearer ${teacherToken}` },
|
||||||
});
|
});
|
||||||
@@ -544,7 +568,7 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
import('../router/index.js'),
|
import('../router/index.js'),
|
||||||
]);
|
]);
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const serverUrl = 'http://127.0.0.1:4000';
|
const serverUrl = smokeServerUrl;
|
||||||
const classId = auth.classId ?? null;
|
const classId = auth.classId ?? null;
|
||||||
if (!classId) return { ok: false, detail: '当前学生没有班级,无法构造课表' };
|
if (!classId) return { ok: false, detail: '当前学生没有班级,无法构造课表' };
|
||||||
|
|
||||||
@@ -1048,6 +1072,15 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
|
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
|
||||||
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
|
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
|
||||||
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
|
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
|
||||||
|
//
|
||||||
|
// ⚠️ 量之前必须等页脚真的收成 0 高:宽度到 64px 与页脚收拢各有一条 260ms 过渡,
|
||||||
|
// 而 `--nav-ease`(Material 曲线)在 74% 处就越过"宽度 ≤ 80px"这个等待条件,
|
||||||
|
// 此时页脚还剩十几像素 —— 直接量 `.menu` 会读出"溢出 16px"的**假红**
|
||||||
|
// (实测出现过一次:同一份产物上一次全绿、下一次红)。这里等的是**稳态几何**,
|
||||||
|
// 与这条断言本来的意图一致(CSS 里那句"量到的是稳态")。
|
||||||
|
const railFooter = document.querySelector<HTMLElement>('.aside-footer');
|
||||||
|
await waitUntil(() => (railFooter?.offsetHeight ?? 0) <= 1, 2000);
|
||||||
|
|
||||||
const settingsItem = Array.from(
|
const settingsItem = Array.from(
|
||||||
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
|
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
|
||||||
).find((element) =>
|
).find((element) =>
|
||||||
@@ -1163,6 +1196,7 @@ export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
|
|||||||
export function registerSmokeHooks(): void {
|
export function registerSmokeHooks(): void {
|
||||||
window.__classhelperSmoke__ = {
|
window.__classhelperSmoke__ = {
|
||||||
cacheSelfTest,
|
cacheSelfTest,
|
||||||
|
cacheIsolationSelfTest,
|
||||||
offlineScenario,
|
offlineScenario,
|
||||||
layoutNavigationSelfTest,
|
layoutNavigationSelfTest,
|
||||||
onlineScenario,
|
onlineScenario,
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ function finish(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent): void {
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
if (event.key === 'Escape') finish();
|
if (onboarding.visible && event.key === 'Escape') finish();
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.addEventListener('keydown', onKeydown);
|
window.addEventListener('keydown', onKeydown);
|
||||||
@@ -154,9 +154,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
|
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
|
||||||
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">
|
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">下一步</el-button>
|
||||||
下一步
|
|
||||||
</el-button>
|
|
||||||
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
|
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,7 +248,7 @@ onUnmounted(() => {
|
|||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: var(--ch-text-regular, #606266);
|
color: var(--ch-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-foot {
|
.onboarding-foot {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { ElMessageBox } from 'element-plus';
|
||||||
|
|
||||||
|
/** 取消是正常交互;真实异常继续交给调用方处理。 */
|
||||||
|
export async function confirmAction(...args: Parameters<typeof ElMessageBox.confirm>): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(...args);
|
||||||
|
return true;
|
||||||
|
} catch (reason) {
|
||||||
|
if (reason === 'cancel' || reason === 'close') return false;
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { watch, type WatchStopHandle } from 'vue';
|
||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
resolveClassStatus,
|
resolveClassStatus,
|
||||||
@@ -6,9 +7,10 @@ import {
|
|||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { scheduleApi } from '../api/index.js';
|
import { notificationApi, scheduleApi } from '../api/index.js';
|
||||||
import { fetchWithCache } from '../cache/index.js';
|
import { fetchWithCache } from '../cache/index.js';
|
||||||
import { useAppStore } from '../stores/app.js';
|
import { getCacheScope } from '../cache/scope.js';
|
||||||
|
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
|
||||||
import { useAuthStore } from '../stores/auth.js';
|
import { useAuthStore } from '../stores/auth.js';
|
||||||
import { useNotificationStore } from '../stores/notifications.js';
|
import { useNotificationStore } from '../stores/notifications.js';
|
||||||
import { useRealtimeStore } from '../stores/realtime.js';
|
import { useRealtimeStore } from '../stores/realtime.js';
|
||||||
@@ -30,6 +32,9 @@ let currentPeriod: ClassPeriod | null = null;
|
|||||||
let inClass = false;
|
let inClass = false;
|
||||||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let stopWeekWatch: WatchStopHandle | null = null;
|
||||||
|
let scheduleRequest = 0;
|
||||||
|
let scheduleWeek = 1;
|
||||||
|
|
||||||
/** 供通知投递时读取:当前是否上课 + 本节课结束时间 */
|
/** 供通知投递时读取:当前是否上课 + 本节课结束时间 */
|
||||||
export function getIslandClassContext(): { inClass: boolean; currentPeriodEnd: string | null } {
|
export function getIslandClassContext(): { inClass: boolean; currentPeriodEnd: string | null } {
|
||||||
@@ -45,21 +50,28 @@ export async function refreshIslandSchedules(): Promise<void> {
|
|||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
if (!auth.token) return;
|
if (!auth.token) return;
|
||||||
|
const scope = getCacheScope();
|
||||||
|
const classId = auth.classId ?? undefined;
|
||||||
|
await appStore.refreshTerm(classId);
|
||||||
|
if (scope !== getCacheScope() || !auth.token) return;
|
||||||
|
const request = ++scheduleRequest;
|
||||||
const week = appStore.currentWeek;
|
const week = appStore.currentWeek;
|
||||||
const result = await fetchWithCache<ScheduleDto[]>(
|
const result = await fetchWithCache<ScheduleDto[]>(
|
||||||
'schedules',
|
'schedules',
|
||||||
`week:${week}`,
|
`week:${week}`,
|
||||||
() => scheduleApi.list({ classId: auth.classId ?? undefined, week }),
|
() => scheduleApi.list({ classId, week }),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
if (scope !== getCacheScope() || request !== scheduleRequest) return;
|
||||||
|
if (result.error && !result.updatedAt) return;
|
||||||
schedules = result.data;
|
schedules = result.data;
|
||||||
|
scheduleWeek = week;
|
||||||
recomputeClassState(true);
|
recomputeClassState(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function recomputeClassState(force = false): void {
|
function recomputeClassState(force = false): void {
|
||||||
const appStore = useAppStore();
|
const appStore = useAppStore();
|
||||||
const status = resolveClassStatus(schedules, new Date(), appStore.currentWeek);
|
const status = resolveClassStatus(schedules, new Date(), scheduleWeek);
|
||||||
const nextInClass = status.inClass;
|
const nextInClass = status.inClass;
|
||||||
currentPeriod = status.current;
|
currentPeriod = status.current;
|
||||||
|
|
||||||
@@ -190,6 +202,81 @@ export function pushCallToIsland(call: NotificationDto): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 补弹离线期间收到的消息(需求 11) */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补弹条数上限。
|
||||||
|
*
|
||||||
|
* 灵动岛的多条通知列表默认只显示 `ISLAND_LIST_METRICS.defaultVisibleRows`(3)条 +
|
||||||
|
* 「展开更多(还有 N 条)」。所以一条不补、和一次补十几条,都是同样的阅读体验 ——
|
||||||
|
* 封顶 5 条 = 3 条可见 + 2 条藏在"展开更多"里,正好是打开就能读完的量;
|
||||||
|
* 补太多只会让"展开更多"变成一个没有意义的按钮。
|
||||||
|
*/
|
||||||
|
const REPLAY_MAX_COUNT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上线后补弹「离线期间收到的消息」(需求 11)。
|
||||||
|
*
|
||||||
|
* 调用时机:`App.vue` 订阅 `useAppStore().onServerRecovered`,也就是**可达性由不可达变为可达**
|
||||||
|
* 的那一刻(断网期间的实时通道是断的,那些通知既没弹过、也没进过用户的视野)。
|
||||||
|
*
|
||||||
|
* 数据来源复用既有的通知接口(`notificationApi.list({ unreadOnly: true })`):
|
||||||
|
* 客户端本来就是离线优先的(`fetchWithCache` 会把数据落 IndexedDB),**不为此新建数据源** ——
|
||||||
|
* 补弹只需要"服务端现在有哪些未读",而通知列表本来就是这个问题的答案。
|
||||||
|
*
|
||||||
|
* 实现上有四条约定:
|
||||||
|
* 1. **没有离线起点就直接返回 0**(首次启动 / 一直在线):离线起点只由
|
||||||
|
* `stores/app.ts` 在"由可达变不可达"的那一刻写入,没有它就说明这段时间根本没断过网,
|
||||||
|
* 此时补弹等于把**历史未读**全弹一遍,那不是「离线期间收到的」。
|
||||||
|
* 2. **只补离线起点之后创建的**(`createdAt > offlineSince`),并且在服务端排序之外再排一次
|
||||||
|
* **按时间升序**投递 —— 顺序投递才能让灵动岛的队列保持时间顺序。
|
||||||
|
* 3. **取最近的 5 条**(升序列表的尾部):断网一天可能积压几十条,取最早的那 5 条等于
|
||||||
|
* 让用户先看最旧的、最新的反而被"展开更多"挡住。取最近的 5 条更符合"我刚错过了什么"。
|
||||||
|
* 4. **推完就清掉离线起点**:同一次离线只补一次,否则每次可达性抖动都会重复弹同一批。
|
||||||
|
*
|
||||||
|
* @returns 实际补弹的条数(0 = 没补)
|
||||||
|
*/
|
||||||
|
export async function replayMissedNotifications(): Promise<number> {
|
||||||
|
// 冒烟保护:`verify:desktop` / `verify:packaged` 有一条「灵动岛队列」的断言链
|
||||||
|
// (100+ 条断言量的是卡片的条数、顺序、高度与"展开更多"的分页),
|
||||||
|
// 冒烟脚本会反复断开/恢复网络,注入的合成通知与这里的补弹会互相打乱队列,
|
||||||
|
// 让那些断言随机变红。冒烟模式下**直接跳过补弹**(也不动离线起点),
|
||||||
|
// 补弹本身由人工/单元路径覆盖,不进冒烟。
|
||||||
|
if (window.desktop?.smokeTest === true) return 0;
|
||||||
|
|
||||||
|
const offlineSince = readOfflineSince();
|
||||||
|
if (!offlineSince) return 0;
|
||||||
|
const sinceMs = Date.parse(offlineSince);
|
||||||
|
if (Number.isNaN(sinceMs)) {
|
||||||
|
// 存储里是坏值(手工改过 / 旧版本写入格式不同):清掉,别让它永远挡住补弹
|
||||||
|
clearOfflineSince();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let unread: NotificationDto[];
|
||||||
|
try {
|
||||||
|
unread = await notificationApi.list({ unreadOnly: true });
|
||||||
|
} catch {
|
||||||
|
// 刚恢复就又断了(或服务端还没真正就绪):保留离线起点,等下一次恢复再补
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missed = unread
|
||||||
|
.filter((item) => {
|
||||||
|
const at = Date.parse(item.createdAt);
|
||||||
|
return !Number.isNaN(at) && at > sinceMs;
|
||||||
|
})
|
||||||
|
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||||||
|
|
||||||
|
const picked = missed.slice(-REPLAY_MAX_COUNT);
|
||||||
|
for (const item of picked) pushNotificationToIsland(item);
|
||||||
|
|
||||||
|
// 无论补到几条都清掉:这一轮离线已经被处理过了
|
||||||
|
clearOfflineSince();
|
||||||
|
console.info(`[离线补弹] 补弹离线消息 ${picked.length} 条(离线起点 ${offlineSince})`);
|
||||||
|
return picked.length;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
|
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
|
||||||
* 由 App.vue 在挂载时订阅一次。
|
* 由 App.vue 在挂载时订阅一次。
|
||||||
@@ -238,6 +325,12 @@ function onScheduleUpdated(): void {
|
|||||||
|
|
||||||
export function startIslandBridge(): void {
|
export function startIslandBridge(): void {
|
||||||
if (tickTimer) return;
|
if (tickTimer) return;
|
||||||
|
stopWeekWatch = watch(
|
||||||
|
() => useAppStore().currentWeek,
|
||||||
|
() => {
|
||||||
|
void refreshIslandSchedules();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
void refreshIslandSchedules().catch(() => undefined);
|
void refreshIslandSchedules().catch(() => undefined);
|
||||||
|
|
||||||
@@ -263,6 +356,9 @@ export function stopIslandBridge(): void {
|
|||||||
if (refreshTimer) clearInterval(refreshTimer);
|
if (refreshTimer) clearInterval(refreshTimer);
|
||||||
tickTimer = null;
|
tickTimer = null;
|
||||||
refreshTimer = null;
|
refreshTimer = null;
|
||||||
|
scheduleRequest += 1;
|
||||||
|
stopWeekWatch?.();
|
||||||
|
stopWeekWatch = null;
|
||||||
|
|
||||||
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
|
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
|
||||||
schedules = [];
|
schedules = [];
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { confirmAction } from '../composables/confirm.js';
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
|
import { ElMessage, type MenuInstance } from 'element-plus';
|
||||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||||
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
|
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
|
||||||
import { useThemeReveal } from '../composables/motion.js';
|
import { useThemeReveal } from '../composables/motion.js';
|
||||||
@@ -25,9 +26,9 @@ const ui = useUiStore();
|
|||||||
*
|
*
|
||||||
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
|
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
|
||||||
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
|
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
|
||||||
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。
|
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由;
|
||||||
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨,
|
* 开发者模式下再多一个「开发者」)。6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。
|
||||||
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
|
* 折叠后是图标导轨,分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
|
||||||
*
|
*
|
||||||
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
|
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
|
||||||
*/
|
*/
|
||||||
@@ -38,21 +39,39 @@ const primaryItems = [
|
|||||||
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const menuGroups = [
|
/** 「设置」分组的常规子项(顺序即侧栏顺序) */
|
||||||
{
|
const settingItems = [
|
||||||
title: '设置',
|
|
||||||
icon: 'Setting',
|
|
||||||
items: [
|
|
||||||
{ path: '/settings/general', title: '通用', icon: 'Tools' },
|
{ path: '/settings/general', title: '通用', icon: 'Tools' },
|
||||||
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
|
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
|
||||||
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
|
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
|
||||||
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
|
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
|
||||||
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
|
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
|
||||||
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
|
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单分组(**computed**:子项随开发者模式变化,见下)。
|
||||||
|
*
|
||||||
|
* 「开发者」(需求 10)只在 `ui.developerMode === true` 时出现 —— 它是个排查问题的入口
|
||||||
|
* (版本/日志/更新/DevTools),常驻只会让教室机器上的使用者点进一个看不懂的页面。
|
||||||
|
*
|
||||||
|
* 图标用 `Connection`:新增图标必须同时进 `renderer/icons.ts` 的注册表,
|
||||||
|
* 而那是本任务范围外的文件 —— 所以**只用已注册图标**(`scripts/check-icons.mjs`
|
||||||
|
* 会在构建前拦下未注册的名字)。
|
||||||
|
*
|
||||||
|
* 折叠成图标导轨后多一行图标:开启开发者模式是用户自己的选择,默认关闭 ⇒
|
||||||
|
* 冒烟(默认配置)里的导轨几何与"不出现滚动条"断言不受影响。
|
||||||
|
*/
|
||||||
|
const menuGroups = computed(() => [
|
||||||
|
{
|
||||||
|
title: '设置',
|
||||||
|
icon: 'Setting',
|
||||||
|
items: ui.developerMode
|
||||||
|
? [...settingItems, { path: '/settings/developer', title: '开发者', icon: 'Connection' }]
|
||||||
|
: settingItems,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
/* -------------------------------------------------------------- 导航指示器 */
|
/* -------------------------------------------------------------- 导航指示器 */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,21 +89,64 @@ const menuGroups = [
|
|||||||
*/
|
*/
|
||||||
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
|
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 底片的**裁剪框**:与 `.menu` 的滚动视口逐像素重合。
|
||||||
|
*
|
||||||
|
* 底片挂在 `.aside` 上,而菜单项是在 `.menu` 里滚动的(`.menu` 是 `flex:1 + min-height:0 +
|
||||||
|
* overflow-y:auto`,设置组展开时内容约 606px 会超过窗口高度)。激活项被滚出视口时,
|
||||||
|
* 底片会跟着它跑到品牌区或页脚上 —— 套一层视口大小的 `overflow:hidden`,
|
||||||
|
* 就是让底片"只在菜单看得见的地方存在"。
|
||||||
|
*/
|
||||||
|
const clipStyle = ref<Record<string, string>>({
|
||||||
|
top: '0px',
|
||||||
|
left: '0px',
|
||||||
|
width: '0px',
|
||||||
|
height: '0px',
|
||||||
|
});
|
||||||
|
|
||||||
function syncIndicator(): void {
|
function syncIndicator(): void {
|
||||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||||
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
|
const scroller = aside?.querySelector<HTMLElement>('.menu') ?? null;
|
||||||
if (!aside || !active) {
|
if (!aside || !scroller) {
|
||||||
indicator.value = { ...indicator.value, visible: false };
|
indicator.value = { ...indicator.value, visible: false };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const asideRect = aside.getBoundingClientRect();
|
const asideRect = aside.getBoundingClientRect();
|
||||||
|
const boxRect = scroller.getBoundingClientRect();
|
||||||
|
// 底片的坐标一律相对「菜单视口」,不再相对 `.aside` —— 裁剪框把两者的原点对齐了
|
||||||
|
clipStyle.value = {
|
||||||
|
top: `${boxRect.top - asideRect.top}px`,
|
||||||
|
left: `${boxRect.left - asideRect.left}px`,
|
||||||
|
width: `${boxRect.width}px`,
|
||||||
|
height: `${boxRect.height}px`,
|
||||||
|
};
|
||||||
|
const active = scroller.querySelector<HTMLElement>('.el-menu-item.is-active');
|
||||||
|
if (!active) {
|
||||||
|
indicator.value = { ...indicator.value, visible: false };
|
||||||
|
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 - asideRect.left,
|
x: rect.left - boxRect.left,
|
||||||
y: rect.top - asideRect.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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +158,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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +186,32 @@ function scheduleIndicatorSync(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 菜单内部滚动:底片要**当帧**跟上,而且这一路上不能走过渡动画。
|
||||||
|
*
|
||||||
|
* 两点都和"滚动是连续手势"有关:
|
||||||
|
* 1. 不能用 `scheduleIndicatorSync()`(rAF 推后一帧)—— 快速滚动时底片会肉眼可见地落后;
|
||||||
|
* 滚动事件本身在布局之后派发,在这里同步量一次不会再插入一次强制布局。
|
||||||
|
* 2. 不能走 `.nav-indicator` 的 LAYOUT 弹簧(397ms)—— 那是"切换菜单项"的动画参数,
|
||||||
|
* 用在滚动上就成了"底片慢吞吞地追菜单项"。所以滚动期间挂 `is-following` 关掉过渡,
|
||||||
|
* 停手 140ms 后恢复,下一次点菜单项照样有滑动动画。
|
||||||
|
*/
|
||||||
|
const SCROLL_SETTLE_MS = 140;
|
||||||
|
|
||||||
|
let menuScroller: HTMLElement | null = null;
|
||||||
|
let scrollSettleTimer = 0;
|
||||||
|
const indicatorFollowing = ref(false);
|
||||||
|
|
||||||
|
function handleMenuScroll(): void {
|
||||||
|
if (!indicatorFollowing.value) indicatorFollowing.value = true;
|
||||||
|
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
|
||||||
|
scrollSettleTimer = window.setTimeout(() => {
|
||||||
|
scrollSettleTimer = 0;
|
||||||
|
indicatorFollowing.value = false;
|
||||||
|
}, SCROLL_SETTLE_MS);
|
||||||
|
syncIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active`
|
* 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active`
|
||||||
* 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。
|
* 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。
|
||||||
@@ -125,17 +223,37 @@ function scheduleIndicatorSync(): void {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncIndicator();
|
syncIndicator();
|
||||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||||
|
if (aside) {
|
||||||
|
// 菜单自己的滚动容器:翻菜单时底片必须跟着走(用户反馈的「上下滑动时项目选择块不移动」)
|
||||||
|
menuScroller = aside.querySelector<HTMLElement>('.menu');
|
||||||
|
menuScroller?.addEventListener('scroll', handleMenuScroll, { passive: true });
|
||||||
|
}
|
||||||
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));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
|
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
|
||||||
indicatorFrame = 0;
|
indicatorFrame = 0;
|
||||||
|
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
|
||||||
|
scrollSettleTimer = 0;
|
||||||
|
menuScroller?.removeEventListener('scroll', handleMenuScroll);
|
||||||
|
menuScroller = null;
|
||||||
asideObserver?.disconnect();
|
asideObserver?.disconnect();
|
||||||
asideObserver = null;
|
asideObserver = null;
|
||||||
|
submenuObserver?.disconnect();
|
||||||
|
submenuObserver = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
|
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
|
||||||
@@ -172,7 +290,7 @@ const activeMenu = computed(() => route.path);
|
|||||||
|
|
||||||
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
|
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
|
||||||
const activeGroupTitle = computed(() => {
|
const activeGroupTitle = computed(() => {
|
||||||
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
|
const group = menuGroups.value.find((item) => item.items.some((entry) => route.path === entry.path));
|
||||||
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
|
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
|
||||||
return group?.title ?? null;
|
return group?.title ?? null;
|
||||||
});
|
});
|
||||||
@@ -190,7 +308,7 @@ const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.va
|
|||||||
watch(
|
watch(
|
||||||
() => route.path,
|
() => route.path,
|
||||||
async (path) => {
|
async (path) => {
|
||||||
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
|
const group = menuGroups.value.find((item) => item.items.some((entry) => path === entry.path));
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
// 展开当前组(不主动关闭用户已展开的其它组)
|
// 展开当前组(不主动关闭用户已展开的其它组)
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@@ -215,7 +333,7 @@ function go(name: string): void {
|
|||||||
/** 开发期自检:菜单路径必须存在于路由表中 */
|
/** 开发期自检:菜单路径必须存在于路由表中 */
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
||||||
for (const group of menuGroups) {
|
for (const group of menuGroups.value) {
|
||||||
for (const item of group.items) {
|
for (const item of group.items) {
|
||||||
if (!knownPaths.has(item.path)) {
|
if (!knownPaths.has(item.path)) {
|
||||||
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
||||||
@@ -237,7 +355,7 @@ const connectionText = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function handleLogout(): Promise<void> {
|
async function handleLogout(): Promise<void> {
|
||||||
await ElMessageBox.confirm('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' });
|
if (!(await confirmAction('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' }))) return;
|
||||||
realtime.disconnect();
|
realtime.disconnect();
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
ElMessage.success('已退出登录');
|
ElMessage.success('已退出登录');
|
||||||
@@ -253,6 +371,10 @@ async function handleLogout(): Promise<void> {
|
|||||||
function onNotification(payload: unknown): void {
|
function onNotification(payload: unknown): void {
|
||||||
void notifications.pushRealtime(payload as NotificationDto);
|
void notifications.pushRealtime(payload as NotificationDto);
|
||||||
}
|
}
|
||||||
|
function onNotificationDeleted(payload: unknown): void {
|
||||||
|
void notifications.removeRealtime((payload as { id: string }).id);
|
||||||
|
window.desktop?.islandRemoveNotification((payload as { id: string }).id);
|
||||||
|
}
|
||||||
|
|
||||||
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
|
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
|
||||||
function onRecovered(): void {
|
function onRecovered(): void {
|
||||||
@@ -261,9 +383,10 @@ function onRecovered(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
|
||||||
|
realtime.on(SOCKET_EVENTS.notificationDeleted, onNotificationDeleted);
|
||||||
await notifications.load().catch(() => undefined);
|
await notifications.load().catch(() => undefined);
|
||||||
await notifications.refreshUnreadCount();
|
await notifications.refreshUnreadCount();
|
||||||
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
|
|
||||||
appStore.onServerRecovered(onRecovered);
|
appStore.onServerRecovered(onRecovered);
|
||||||
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
|
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
|
||||||
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
|
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
|
||||||
@@ -272,6 +395,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
|
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
|
||||||
|
realtime.off(SOCKET_EVENTS.notificationDeleted, onNotificationDeleted);
|
||||||
appStore.offServerRecovered(onRecovered);
|
appStore.offServerRecovered(onRecovered);
|
||||||
stopIslandBridge();
|
stopIslandBridge();
|
||||||
});
|
});
|
||||||
@@ -293,7 +417,10 @@ onUnmounted(() => {
|
|||||||
data-test="sidebar-toggle"
|
data-test="sidebar-toggle"
|
||||||
@click="ui.toggleSidebar()"
|
@click="ui.toggleSidebar()"
|
||||||
>
|
>
|
||||||
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
|
<el-icon :size="16">
|
||||||
|
<Expand v-if="ui.sidebarCollapsed" />
|
||||||
|
<Fold v-else />
|
||||||
|
</el-icon>
|
||||||
</button>
|
</button>
|
||||||
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
|
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
|
||||||
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
|
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
|
||||||
@@ -301,8 +428,12 @@ onUnmounted(() => {
|
|||||||
<span class="brand-text">ClassHelper</span>
|
<span class="brand-text">ClassHelper</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
|
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
|
||||||
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
|
外层 .nav-indicator-clip 与 .menu 的视口重合(overflow:hidden),
|
||||||
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
|
把"跟着菜单滚出视口"的底片裁掉,不让它盖到品牌区/页脚上。
|
||||||
|
两层都 pointer-events:none —— 底片盖在菜单项上,否则会把点击吃掉。 -->
|
||||||
|
<div class="nav-indicator-clip" :style="clipStyle" aria-hidden="true">
|
||||||
|
<div class="nav-indicator" :class="{ 'is-following': indicatorFollowing }" :style="indicatorStyle" />
|
||||||
|
</div>
|
||||||
<el-menu
|
<el-menu
|
||||||
ref="menuRef"
|
ref="menuRef"
|
||||||
:default-active="activeMenu"
|
:default-active="activeMenu"
|
||||||
@@ -370,7 +501,10 @@ onUnmounted(() => {
|
|||||||
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
|
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
|
||||||
@click="handleThemeToggle"
|
@click="handleThemeToggle"
|
||||||
>
|
>
|
||||||
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
|
<el-icon>
|
||||||
|
<Sunny v-if="ui.theme === 'dark'" />
|
||||||
|
<Moon v-else />
|
||||||
|
</el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
<el-dropdown trigger="click">
|
<el-dropdown trigger="click">
|
||||||
@@ -444,6 +578,18 @@ onUnmounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 指示器的裁剪框:位置与尺寸每帧由 syncIndicator() 量成"与 .menu 视口重合"。
|
||||||
|
* 少了它,激活项被滚出菜单视口时底片会跑到品牌区/页脚上(用户反馈「上下滑动时
|
||||||
|
* 项目选择块不移动」的那次修复里一起加的)。
|
||||||
|
*/
|
||||||
|
.nav-indicator-clip {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。
|
* 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。
|
||||||
*
|
*
|
||||||
@@ -459,7 +605,6 @@ onUnmounted(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 0;
|
|
||||||
border-radius: var(--ch-radius-control);
|
border-radius: var(--ch-radius-control);
|
||||||
background: var(--ch-accent-soft);
|
background: var(--ch-accent-soft);
|
||||||
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
|
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
|
||||||
@@ -472,6 +617,14 @@ onUnmounted(() => {
|
|||||||
opacity var(--ch-dur-fast) var(--ch-ease-out);
|
opacity var(--ch-dur-fast) var(--ch-ease-out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 菜单滚动期间(含停手后的 140ms)关掉过渡:滚动是连续手势,底片要"贴在"菜单项上一起走,
|
||||||
|
* 而不是每帧都启动一次 397ms 的弹簧去追。松手后恢复,点菜单项照样有滑动动画。
|
||||||
|
*/
|
||||||
|
.nav-indicator.is-following {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* 左侧强调条(原有观感,现在跟着底片一起滑) */
|
/* 左侧强调条(原有观感,现在跟着底片一起滑) */
|
||||||
.nav-indicator::before {
|
.nav-indicator::before {
|
||||||
content: '';
|
content: '';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
|
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
|
||||||
import { useAuthStore } from '../stores/auth.js';
|
import { useAuthStore } from '../stores/auth.js';
|
||||||
|
import { useUiStore } from '../stores/ui.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Electron 生产环境通过 file:// 加载页面,无法使用 history 模式,
|
* Electron 生产环境通过 file:// 加载页面,无法使用 history 模式,
|
||||||
@@ -82,6 +83,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('../views/settings/AboutView.vue'),
|
component: () => import('../views/settings/AboutView.vue'),
|
||||||
meta: { title: '关于' },
|
meta: { title: '关于' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 开发者(需求 10)。入口只在 `config.developerMode === true` 时出现在侧栏上,
|
||||||
|
* 但**路由始终注册**:关掉开关后侧栏项消失,已打开的页面靠一条守卫弹回「通用」,
|
||||||
|
* 不做成条件注册 —— 条件注册会让 `router.getRoutes()` 随配置变化,
|
||||||
|
* ClientLayout 的开发期自检与直接输 URL 的行为都会变得难以预期。
|
||||||
|
*/
|
||||||
|
path: 'settings/developer',
|
||||||
|
name: 'settings-developer',
|
||||||
|
component: () => import('../views/settings/DeveloperSettingsView.vue'),
|
||||||
|
meta: { title: '开发者' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/schedule' },
|
{ path: '/:pathMatch(.*)*', redirect: '/schedule' },
|
||||||
@@ -99,6 +112,16 @@ router.beforeEach((to) => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!auth.isAuthenticated) return { name: 'login' };
|
if (!auth.isAuthenticated) return { name: 'login' };
|
||||||
|
/**
|
||||||
|
* 开发者页(需求 10)只在开发者模式开启时可进:侧栏项本身也是条件显示的,
|
||||||
|
* 但配置可能是在**停留在这个页面时**被关掉的(「通用」页的开关),
|
||||||
|
* 所以需要这条守卫把它弹回「通用」,不让用户在看一个"已经不该存在"的页面。
|
||||||
|
*
|
||||||
|
* 这里读的是 ui store 里的状态(由 `ui.init()` 在首屏前从主进程配置恢复),
|
||||||
|
* 不额外调 `window.desktop.getConfig()` —— 导航守卫是同步高频路径,
|
||||||
|
* 每次跳转都跨一次 contextBridge 不值得。
|
||||||
|
*/
|
||||||
|
if (to.name === 'settings-developer' && !useUiStore().developerMode) return { name: 'settings-general' };
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,60 @@
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import {
|
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
|
||||||
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
|
||||||
STORAGE_KEYS,
|
|
||||||
formatDate,
|
|
||||||
type ClassIslandNotificationChannel,
|
|
||||||
} from '@classhelper/shared';
|
|
||||||
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
|
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
|
||||||
import { classChannelApi } from '../api/index.js';
|
|
||||||
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
|
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
|
||||||
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
||||||
|
import { dashboardApi } from '../api/index.js';
|
||||||
|
import { fetchWithCache } from '../cache/index.js';
|
||||||
|
import { getCacheScope, onCacheScopeChanged } from '../cache/scope.js';
|
||||||
|
|
||||||
type RecoveryHandler = () => void;
|
type RecoveryHandler = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「本次离线从什么时候开始」的本地时间戳键(需求 11)。
|
||||||
|
*
|
||||||
|
* 为什么用 localStorage 而不是主进程配置(config.json):
|
||||||
|
* 它是一个**瞬时状态**,只描述"当前这一轮断网",一旦补弹完成(或进程重启后重新联网)就作废,
|
||||||
|
* 不需要跨进程读、也不需要跟着设备身份走。放 localStorage 有三点好处:
|
||||||
|
* - 写入发生在每次请求的**可达性回调**里(高频路径),localStorage 是同步内存写,
|
||||||
|
* 不会像 saveConfig 那样排一次主进程 IPC + 120ms 合并窗口;
|
||||||
|
* - 它是**渲染进程自己**的读-改-清闭环(bridge.ts 读它 → 补弹 → 清掉),不跨越 contextBridge;
|
||||||
|
* - 跨重启也够用:网页存储随 profile 持久化,补弹窗口本来就只关心"最近一次离线"。
|
||||||
|
*/
|
||||||
|
const OFFLINE_SINCE_KEY = 'classhelper.offlineSince';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记下"本次离线从什么时候开始"(ISO 字符串)。
|
||||||
|
* 读不到 / 写不进(隐私模式、存储被清)时静默跳过 —— 补弹只是锦上添花,不该影响可达性上报。
|
||||||
|
*/
|
||||||
|
function markOfflineSince(): void {
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
localStorage.setItem(OFFLINE_SINCE_KEY, now);
|
||||||
|
console.info(`[离线] 已记录本次离线起点:${now}`);
|
||||||
|
} catch {
|
||||||
|
// 存储不可用:这次不补弹,但联网后的恢复流程照常
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读本次离线起点(ISO 字符串);没有记录(首次启动 / 一直在线)返回 null */
|
||||||
|
export function readOfflineSince(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(OFFLINE_SINCE_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清掉离线起点:补弹完成后调用,避免同一次离线被重复补弹 */
|
||||||
|
export function clearOfflineSince(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(OFFLINE_SINCE_KEY);
|
||||||
|
} catch {
|
||||||
|
// 清不掉也不影响功能:下一次"由可达变不可达"会覆盖它
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。
|
* 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。
|
||||||
* 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。
|
* 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。
|
||||||
@@ -25,13 +67,26 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
const currentWeek = ref(1);
|
const currentWeek = ref(1);
|
||||||
const maxWeek = ref(20);
|
const maxWeek = ref(20);
|
||||||
const initialized = ref(false);
|
const initialized = ref(false);
|
||||||
/**
|
let termRequest = 0;
|
||||||
* 通知显示位置:这台教室机器把提醒弹在哪个端。
|
onCacheScopeChanged(() => {
|
||||||
*
|
currentWeek.value = 1;
|
||||||
* 单一事实来源是**班级记录**(服务端),本地这份只是缓存:断网时也要能按上次的选择
|
maxWeek.value = 20;
|
||||||
* 决定本机弹不弹,所以启动时先读本地配置、登录后再向服务器对齐一次。
|
termRequest += 1;
|
||||||
*/
|
clearOfflineSince();
|
||||||
const notificationChannel = ref<ClassIslandNotificationChannel>(DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL);
|
});
|
||||||
|
|
||||||
|
/** 当前教学周的单一写入入口,课表页与灵动岛共用。 */
|
||||||
|
async function refreshTerm(classId?: string): Promise<void> {
|
||||||
|
const request = ++termRequest;
|
||||||
|
const scope = getCacheScope();
|
||||||
|
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(classId), {
|
||||||
|
currentWeek: 1,
|
||||||
|
maxWeek: 20,
|
||||||
|
});
|
||||||
|
if (request !== termRequest || scope !== getCacheScope() || (result.error && !result.updatedAt)) return;
|
||||||
|
currentWeek.value = result.data.currentWeek;
|
||||||
|
maxWeek.value = result.data.maxWeek;
|
||||||
|
}
|
||||||
|
|
||||||
const offline = computed(() => !serverReachable.value);
|
const offline = computed(() => !serverReachable.value);
|
||||||
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
|
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
|
||||||
@@ -48,13 +103,22 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
recoveryHandlers.delete(handler);
|
recoveryHandlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性 */
|
/**
|
||||||
|
* 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性。
|
||||||
|
*
|
||||||
|
* 「由可达变不可达」的那一刻顺手记下离线起点(需求 11 的补弹基准):
|
||||||
|
* 只在**边界**上写,不在"一直不可达"的每次请求上重复覆盖,否则补弹窗口会被不停地
|
||||||
|
* 推迟到"最后一次失败",离线期间收到的通知就永远落在窗口之外了。
|
||||||
|
*/
|
||||||
setReachabilityReporter((reachable) => {
|
setReachabilityReporter((reachable) => {
|
||||||
if (reachable && !serverReachable.value && initialized.value) {
|
if (reachable && !serverReachable.value && initialized.value) {
|
||||||
serverReachable.value = true;
|
serverReachable.value = true;
|
||||||
notifyRecovered();
|
notifyRecovered();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!reachable && serverReachable.value && initialized.value) {
|
||||||
|
markOfflineSince();
|
||||||
|
}
|
||||||
serverReachable.value = reachable;
|
serverReachable.value = reachable;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,6 +132,9 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
if (!serverReachable.value && initialized.value) notifyRecovered();
|
if (!serverReachable.value && initialized.value) notifyRecovered();
|
||||||
serverReachable.value = true;
|
serverReachable.value = true;
|
||||||
} else {
|
} else {
|
||||||
|
// 健康探针是机器**空闲时唯一**的可达性信号(没有任何业务请求在跑),
|
||||||
|
// 所以这条路径也要记离线起点,否则空闲断网期间收到的通知补不回来。
|
||||||
|
if (serverReachable.value && initialized.value) markOfflineSince();
|
||||||
serverReachable.value = false;
|
serverReachable.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,53 +188,6 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
void refreshCacheStats();
|
void refreshCacheStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 通知显示位置 */
|
|
||||||
|
|
||||||
/** 启动时用本地配置兜底(此时可能还没登录/断网) */
|
|
||||||
function applyLocalNotificationChannel(value: ClassIslandNotificationChannel): void {
|
|
||||||
notificationChannel.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 向服务器对齐显示位置。
|
|
||||||
* 登录后调用:以服务端为准(多台机器/老师改过之后,本地缓存可能已经过期)。
|
|
||||||
*/
|
|
||||||
async function loadNotificationChannel(classId: string | null): Promise<void> {
|
|
||||||
if (!classId) return;
|
|
||||||
try {
|
|
||||||
const result = await classChannelApi.get(classId);
|
|
||||||
notificationChannel.value = result.notificationChannel;
|
|
||||||
await window.desktop?.saveConfig({ notificationChannel: result.notificationChannel });
|
|
||||||
} catch {
|
|
||||||
// 离线/无权限:保留本地值,不影响展示
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 改显示位置:先落本地(立刻生效),再写回班级记录。
|
|
||||||
* 服务端写失败时返回 false,由设置页提示"已保存在本机,联网后请重试"。
|
|
||||||
*/
|
|
||||||
async function setNotificationChannel(
|
|
||||||
classId: string | null,
|
|
||||||
value: ClassIslandNotificationChannel,
|
|
||||||
): Promise<boolean> {
|
|
||||||
notificationChannel.value = value;
|
|
||||||
await window.desktop?.saveConfig({ notificationChannel: value });
|
|
||||||
if (!classId) return false;
|
|
||||||
try {
|
|
||||||
const result = await classChannelApi.set(classId, value);
|
|
||||||
notificationChannel.value = result.notificationChannel;
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 本次提醒是否要在本机弹(弹窗 + 灵动岛);"只在 ClassIsland 上弹"时为 false */
|
|
||||||
function shouldPopupLocally(): boolean {
|
|
||||||
return notificationChannel.value !== 'classisland';
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverUrl,
|
serverUrl,
|
||||||
serverReachable,
|
serverReachable,
|
||||||
@@ -179,17 +199,13 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
currentWeek,
|
currentWeek,
|
||||||
maxWeek,
|
maxWeek,
|
||||||
initialized,
|
initialized,
|
||||||
notificationChannel,
|
|
||||||
init,
|
init,
|
||||||
ping,
|
ping,
|
||||||
applyServerUrl,
|
applyServerUrl,
|
||||||
applyLocalNotificationChannel,
|
|
||||||
loadNotificationChannel,
|
|
||||||
setNotificationChannel,
|
|
||||||
shouldPopupLocally,
|
|
||||||
refreshCacheStats,
|
refreshCacheStats,
|
||||||
clearCache,
|
clearCache,
|
||||||
markSynced,
|
markSynced,
|
||||||
|
refreshTerm,
|
||||||
onServerRecovered,
|
onServerRecovered,
|
||||||
offServerRecovered,
|
offServerRecovered,
|
||||||
stopHealthPolling,
|
stopHealthPolling,
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { authApi } from '../api/index.js';
|
|||||||
import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
|
import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
|
||||||
import { cacheGet, cacheSet } from '../cache/db.js';
|
import { cacheGet, cacheSet } from '../cache/db.js';
|
||||||
import { normalizeServerUrl } from '../config.js';
|
import { normalizeServerUrl } from '../config.js';
|
||||||
|
import { profileCacheKey, setCacheScope } from '../cache/scope.js';
|
||||||
const PROFILE_KEY = 'me';
|
let profileKey = '';
|
||||||
|
let sessionServer = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录态。
|
* 登录态。
|
||||||
@@ -58,10 +59,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const payload: LoginResponse = await authApi.classLogin({ code, password });
|
const payload: LoginResponse = await authApi.classLogin({ code, password });
|
||||||
|
profileKey = profileCacheKey(normalized, code);
|
||||||
|
sessionServer = normalized;
|
||||||
|
setCacheScope(normalized, payload.user.classId ?? payload.user.id);
|
||||||
applyToken(payload.token);
|
applyToken(payload.token);
|
||||||
user.value = payload.user;
|
user.value = payload.user;
|
||||||
offlineSession.value = false;
|
offlineSession.value = false;
|
||||||
await cacheSet('profile', PROFILE_KEY, user.value);
|
await cacheSet('profile', profileKey, user.value).catch(() => undefined);
|
||||||
await persistSession(normalized, code, payload.token);
|
await persistSession(normalized, code, payload.token);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
@@ -90,8 +94,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedProfile = await cacheGet<SessionUser>('profile', PROFILE_KEY).catch(() => null);
|
profileKey = profileCacheKey(serverUrl, username);
|
||||||
|
sessionServer = serverUrl;
|
||||||
|
const cachedProfile = await cacheGet<SessionUser>('profile', profileKey).catch(() => null);
|
||||||
if (cachedProfile) user.value = cachedProfile.value;
|
if (cachedProfile) user.value = cachedProfile.value;
|
||||||
|
setCacheScope(serverUrl, user.value?.classId ?? user.value?.id ?? `unresolved:${username}`);
|
||||||
|
|
||||||
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
|
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
|
||||||
if (token.value) void refreshProfile();
|
if (token.value) void refreshProfile();
|
||||||
@@ -102,11 +109,14 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
/** 后台刷新个人资料;服务器不可达时保持缓存资料与离线会话标记 */
|
/** 后台刷新个人资料;服务器不可达时保持缓存资料与离线会话标记 */
|
||||||
async function refreshProfile(): Promise<void> {
|
async function refreshProfile(): Promise<void> {
|
||||||
if (!token.value) return;
|
if (!token.value) return;
|
||||||
|
const requestedToken = token.value;
|
||||||
try {
|
try {
|
||||||
const profile = await authApi.me();
|
const profile = await authApi.me();
|
||||||
|
if (token.value !== requestedToken) return;
|
||||||
user.value = profile;
|
user.value = profile;
|
||||||
|
setCacheScope(sessionServer, profile.classId ?? profile.id);
|
||||||
offlineSession.value = false;
|
offlineSession.value = false;
|
||||||
await cacheSet('profile', PROFILE_KEY, profile);
|
await cacheSet('profile', profileKey, profile).catch(() => undefined);
|
||||||
} catch {
|
} catch {
|
||||||
offlineSession.value = Boolean(user.value);
|
offlineSession.value = Boolean(user.value);
|
||||||
}
|
}
|
||||||
@@ -120,6 +130,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
applyToken(null);
|
applyToken(null);
|
||||||
user.value = null;
|
user.value = null;
|
||||||
|
setCacheScope('', 'anonymous');
|
||||||
offlineSession.value = false;
|
offlineSession.value = false;
|
||||||
if (window.desktop) await window.desktop.saveConfig({ token: null });
|
if (window.desktop) await window.desktop.saveConfig({ token: null });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { notificationApi } from '../api/index.js';
|
|||||||
import { cacheSet } from '../cache/db.js';
|
import { cacheSet } from '../cache/db.js';
|
||||||
import { fetchWithCache } from '../cache/index.js';
|
import { fetchWithCache } from '../cache/index.js';
|
||||||
import { useAppStore } from './app.js';
|
import { useAppStore } from './app.js';
|
||||||
|
import { getCacheScope, onCacheScopeChanged } from '../cache/scope.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通知状态:列表 + 未读数。
|
* 通知状态:列表 + 未读数。
|
||||||
@@ -18,6 +19,23 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
const fromCache = ref(false);
|
const fromCache = ref(false);
|
||||||
const updatedAt = ref<number | null>(null);
|
const updatedAt = ref<number | null>(null);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
|
let requestId = 0;
|
||||||
|
let revision = 0;
|
||||||
|
const changes = new Map<string, { revision: number; value: NotificationDto | null }>();
|
||||||
|
onCacheScopeChanged(() => {
|
||||||
|
requestId += 1;
|
||||||
|
revision = 0;
|
||||||
|
changes.clear();
|
||||||
|
items.value = [];
|
||||||
|
unreadCount.value = 0;
|
||||||
|
loading.value = false;
|
||||||
|
fromCache.value = false;
|
||||||
|
updatedAt.value = null;
|
||||||
|
error.value = null;
|
||||||
|
});
|
||||||
|
function changed(id: string, value: NotificationDto | null): void {
|
||||||
|
changes.set(id, { revision: ++revision, value });
|
||||||
|
}
|
||||||
|
|
||||||
const unreadItems = computed(() => items.value.filter((item) => !item.read));
|
const unreadItems = computed(() => items.value.filter((item) => !item.read));
|
||||||
|
|
||||||
@@ -34,6 +52,9 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
async function load(): Promise<void> {
|
||||||
|
const request = ++requestId;
|
||||||
|
const scope = getCacheScope();
|
||||||
|
const startedAt = revision;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await fetchWithCache<NotificationDto[]>(
|
const result = await fetchWithCache<NotificationDto[]>(
|
||||||
@@ -42,14 +63,23 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
() => notificationApi.list(),
|
() => notificationApi.list(),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
items.value = result.data;
|
if (request !== requestId || scope !== getCacheScope()) return;
|
||||||
|
const merged = new Map(result.data.map((item) => [item.id, item]));
|
||||||
|
for (const [id, change] of changes) {
|
||||||
|
if (change.revision <= startedAt) continue;
|
||||||
|
if (change.value) merged.set(id, change.value);
|
||||||
|
else merged.delete(id);
|
||||||
|
}
|
||||||
|
items.value = [...merged.values()].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
||||||
|
changes.clear();
|
||||||
fromCache.value = result.fromCache;
|
fromCache.value = result.fromCache;
|
||||||
updatedAt.value = result.updatedAt;
|
updatedAt.value = result.updatedAt;
|
||||||
error.value = result.error;
|
error.value = result.error;
|
||||||
recount();
|
recount();
|
||||||
|
await persist();
|
||||||
if (!result.fromCache) useAppStore().markSynced();
|
if (!result.fromCache) useAppStore().markSynced();
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (request === requestId) loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +87,14 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
async function pushRealtime(payload: NotificationDto): Promise<void> {
|
async function pushRealtime(payload: NotificationDto): Promise<void> {
|
||||||
if (items.value.some((item) => item.id === payload.id)) return;
|
if (items.value.some((item) => item.id === payload.id)) return;
|
||||||
items.value = [{ ...payload, read: false }, ...items.value];
|
items.value = [{ ...payload, read: false }, ...items.value];
|
||||||
|
changed(payload.id, items.value[0]!);
|
||||||
|
recount();
|
||||||
|
await persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRealtime(id: string): Promise<void> {
|
||||||
|
items.value = items.value.filter((item) => item.id !== id);
|
||||||
|
changed(id, null);
|
||||||
recount();
|
recount();
|
||||||
await persist();
|
await persist();
|
||||||
}
|
}
|
||||||
@@ -67,6 +105,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
if (target) {
|
if (target) {
|
||||||
target.read = true;
|
target.read = true;
|
||||||
target.readAt = new Date().toISOString();
|
target.readAt = new Date().toISOString();
|
||||||
|
changed(id, { ...target });
|
||||||
}
|
}
|
||||||
recount();
|
recount();
|
||||||
await persist();
|
await persist();
|
||||||
@@ -76,6 +115,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
const result = await notificationApi.markAllRead();
|
const result = await notificationApi.markAllRead();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
items.value = items.value.map((item) => ({ ...item, read: true, readAt: item.readAt ?? now }));
|
items.value = items.value.map((item) => ({ ...item, read: true, readAt: item.readAt ?? now }));
|
||||||
|
for (const item of items.value) changed(item.id, item);
|
||||||
recount();
|
recount();
|
||||||
await persist();
|
await persist();
|
||||||
return result.marked;
|
return result.marked;
|
||||||
@@ -100,6 +140,7 @@ export const useNotificationStore = defineStore('notifications', () => {
|
|||||||
error,
|
error,
|
||||||
load,
|
load,
|
||||||
pushRealtime,
|
pushRealtime,
|
||||||
|
removeRealtime,
|
||||||
markRead,
|
markRead,
|
||||||
markAllRead,
|
markAllRead,
|
||||||
refreshUnreadCount,
|
refreshUnreadCount,
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { socketUrlOf } from '../config.js';
|
import { socketUrlOf } from '../config.js';
|
||||||
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
|
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
|
||||||
import { useAppStore } from './app.js';
|
|
||||||
|
|
||||||
type RealtimeEventName = keyof ServerToClientEvents;
|
type RealtimeEventName = keyof ServerToClientEvents;
|
||||||
type EventHandler = (payload: unknown) => void;
|
type EventHandler = (payload: unknown) => void;
|
||||||
@@ -23,18 +22,6 @@ type EventHandler = (payload: unknown) => void;
|
|||||||
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
|
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
|
||||||
*/
|
*/
|
||||||
export const useRealtimeStore = defineStore('realtime', () => {
|
export const useRealtimeStore = defineStore('realtime', () => {
|
||||||
const app = useAppStore();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 本机要不要弹这次提醒。
|
|
||||||
*
|
|
||||||
* 教室在设置页选了「只在 ClassIsland 上弹」时,客户端仍然把通知收进通知中心(列表里有记录、
|
|
||||||
* 未读红点照常),但**不再弹窗、也不上岛** —— 否则老师会看到两个地方同时炸出来。
|
|
||||||
*/
|
|
||||||
function popupLocally(): boolean {
|
|
||||||
return app.shouldPopupLocally();
|
|
||||||
}
|
|
||||||
|
|
||||||
const connected = ref(false);
|
const connected = ref(false);
|
||||||
const connecting = ref(false);
|
const connecting = ref(false);
|
||||||
const lastEventName = ref<string | null>(null);
|
const lastEventName = ref<string | null>(null);
|
||||||
@@ -76,9 +63,12 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
|||||||
dispatch('__connect_error__', error.message);
|
dispatch('__connect_error__', error.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
instance.on(SOCKET_EVENTS.notificationDeleted, (payload) => {
|
||||||
|
mark(SOCKET_EVENTS.notificationDeleted);
|
||||||
|
dispatch(SOCKET_EVENTS.notificationDeleted, payload);
|
||||||
|
});
|
||||||
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
|
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
|
||||||
mark(SOCKET_EVENTS.notificationNew);
|
mark(SOCKET_EVENTS.notificationNew);
|
||||||
if (popupLocally()) {
|
|
||||||
ElMessage({
|
ElMessage({
|
||||||
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
|
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
|
||||||
type: payload.priority === 'URGENT' ? 'error' : 'info',
|
type: payload.priority === 'URGENT' ? 'error' : 'info',
|
||||||
@@ -87,7 +77,6 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
|||||||
});
|
});
|
||||||
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
|
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
|
||||||
pushNotificationToIsland(payload);
|
pushNotificationToIsland(payload);
|
||||||
}
|
|
||||||
dispatch(SOCKET_EVENTS.notificationNew, payload);
|
dispatch(SOCKET_EVENTS.notificationNew, payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,7 +90,6 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
|||||||
|
|
||||||
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
|
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
|
||||||
mark(SOCKET_EVENTS.callNew);
|
mark(SOCKET_EVENTS.callNew);
|
||||||
if (popupLocally()) {
|
|
||||||
ElMessage({
|
ElMessage({
|
||||||
message: payload.title,
|
message: payload.title,
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
@@ -110,7 +98,6 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
|||||||
});
|
});
|
||||||
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
|
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
|
||||||
pushCallToIsland(payload);
|
pushCallToIsland(payload);
|
||||||
}
|
|
||||||
dispatch(SOCKET_EVENTS.callNew, payload);
|
dispatch(SOCKET_EVENTS.callNew, payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,47 @@ import { defineStore } from 'pinia';
|
|||||||
import type { DesktopStoredConfig } from '../../types/desktop.js';
|
import type { DesktopStoredConfig } from '../../types/desktop.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 界面偏好(主题 / 侧边栏折叠)。
|
* 界面偏好(主题 / 侧边栏折叠 / 关怀模式)。
|
||||||
*
|
*
|
||||||
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
|
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed / careMode)里,
|
||||||
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
|
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
|
||||||
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
|
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
|
||||||
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
|
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
|
||||||
|
*
|
||||||
|
* 关怀模式 = 在 <html> 上挂 .care-mode 类(styles/index.css 末尾那个集中覆盖块),
|
||||||
|
* 只放大正文与控件字号,不动任何几何令牌 —— 理由写在那个块的注释里。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const THEME_KEY = 'classhelper.theme';
|
const THEME_KEY = 'classhelper.theme';
|
||||||
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
|
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
|
||||||
|
const CARE_MODE_KEY = 'classhelper.careMode';
|
||||||
|
const DEVELOPER_MODE_KEY = 'classhelper.developerMode';
|
||||||
|
|
||||||
function applyThemeClass(theme: 'light' | 'dark'): void {
|
function applyThemeClass(theme: 'light' | 'dark'): void {
|
||||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关怀模式的类名开关。**只碰这一个类**:放大规则全在 CSS 里,
|
||||||
|
* JS 侧不逐个元素改字号(那样既漏元素又把主题与字号耦合在一起)。
|
||||||
|
*/
|
||||||
|
function applyCareModeClass(enabled: boolean): void {
|
||||||
|
document.documentElement.classList.toggle('care-mode', enabled);
|
||||||
|
}
|
||||||
|
|
||||||
export const useUiStore = defineStore('ui', () => {
|
export const useUiStore = defineStore('ui', () => {
|
||||||
const theme = ref<'light' | 'dark'>('light');
|
const theme = ref<'light' | 'dark'>('light');
|
||||||
const sidebarCollapsed = ref(false);
|
const sidebarCollapsed = ref(false);
|
||||||
|
const careMode = ref(false);
|
||||||
|
/**
|
||||||
|
* 开发者模式(需求 10)。
|
||||||
|
*
|
||||||
|
* 它既是主进程配置(`config.developerMode`,决定日志级别),也是**侧栏的一项** ——
|
||||||
|
* 所以必须放在 store 里:关掉开关时「开发者」菜单项要立刻消失、路由守卫也要立刻生效,
|
||||||
|
* 而「通用」页与 `ClientLayout.vue` 是两个不同的组件,靠各自的本地 ref 是传不过去的
|
||||||
|
* (此前"改完设置要重启客户端才看到"多半就是这个原因)。
|
||||||
|
*/
|
||||||
|
const developerMode = ref(false);
|
||||||
|
|
||||||
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
|
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<void> {
|
||||||
@@ -29,10 +52,14 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
if (config) {
|
if (config) {
|
||||||
theme.value = config.theme === 'dark' ? 'dark' : 'light';
|
theme.value = config.theme === 'dark' ? 'dark' : 'light';
|
||||||
sidebarCollapsed.value = config.sidebarCollapsed === true;
|
sidebarCollapsed.value = config.sidebarCollapsed === true;
|
||||||
|
applyCareMode(config.careMode === true);
|
||||||
|
developerMode.value = config.developerMode === true;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
|
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
|
||||||
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
|
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
|
||||||
|
applyCareMode(localStorage.getItem(CARE_MODE_KEY) === '1');
|
||||||
|
developerMode.value = localStorage.getItem(DEVELOPER_MODE_KEY) === '1';
|
||||||
}
|
}
|
||||||
applyThemeClass(theme.value);
|
applyThemeClass(theme.value);
|
||||||
}
|
}
|
||||||
@@ -66,8 +93,64 @@ export const useUiStore = defineStore('ui', () => {
|
|||||||
void setSidebarCollapsed(!sidebarCollapsed.value);
|
void setSidebarCollapsed(!sidebarCollapsed.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
|
/**
|
||||||
|
* 应用关怀模式(需求 14):只挂类名 + 记状态,**不落盘**。
|
||||||
|
* 启动时由 `init()` 在首屏渲染前调用,所以刷新页面不会看到"先正常、后放大"的跳变。
|
||||||
|
*/
|
||||||
|
function applyCareMode(enabled: boolean): void {
|
||||||
|
careMode.value = enabled;
|
||||||
|
applyCareModeClass(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换关怀模式(「通用」设置页的开关):立刻生效并写回配置。
|
||||||
|
*
|
||||||
|
* 注意:用户明确开启时**不放大侧栏导轨**(见 styles/index.css 的 care-mode 块),
|
||||||
|
* 也**不做**任何 AI 助手按钮的联动 —— 本客户端目前没有助手入口,那条联动只存在于
|
||||||
|
* Web 管理端;这里不为了"看起来对称"去造一个入口。
|
||||||
|
*/
|
||||||
|
async function setCareMode(enabled: boolean): Promise<void> {
|
||||||
|
applyCareMode(enabled);
|
||||||
|
if (window.desktop) {
|
||||||
|
await window.desktop.saveConfig({ careMode: enabled }).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(CARE_MODE_KEY, enabled ? '1' : '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换开发者模式(「通用」设置页的开关):立刻生效并写回配置。
|
||||||
|
*
|
||||||
|
* 打开之后有三件事同时发生,这里只负责**状态与落盘**:
|
||||||
|
* 1. 侧栏「设置」分组里出现「开发者」项(`ClientLayout.vue` 按 `ui.developerMode` 渲染);
|
||||||
|
* 2. `main/index.ts` 在下次启动时把日志级别调到 debug(`logger.setVerbose`)——
|
||||||
|
* 本次会话的日志级别**不重开**,避免开着日志复现问题时把级别改来改去;
|
||||||
|
* 3. 「通用」页上给出"debug 级日志已开启"的提示。
|
||||||
|
*/
|
||||||
|
async function setDeveloperMode(enabled: boolean): Promise<void> {
|
||||||
|
developerMode.value = enabled;
|
||||||
|
if (window.desktop) {
|
||||||
|
await window.desktop.saveConfig({ developerMode: enabled }).catch(() => undefined);
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(DEVELOPER_MODE_KEY, enabled ? '1' : '0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
sidebarCollapsed,
|
||||||
|
careMode,
|
||||||
|
developerMode,
|
||||||
|
init,
|
||||||
|
setTheme,
|
||||||
|
toggleTheme,
|
||||||
|
setSidebarCollapsed,
|
||||||
|
toggleSidebar,
|
||||||
|
applyCareMode,
|
||||||
|
setCareMode,
|
||||||
|
setDeveloperMode,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 供类型检查使用(仅引用类型) */
|
/** 供类型检查使用(仅引用类型) */
|
||||||
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
|
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed' | 'careMode' | 'developerMode'>;
|
||||||
@@ -95,6 +95,12 @@
|
|||||||
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
|
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
|
||||||
--el-fill-color-blank: transparent;
|
--el-fill-color-blank: transparent;
|
||||||
--el-font-size-base: 13px;
|
--el-font-size-base: 13px;
|
||||||
|
|
||||||
|
/* 关怀模式的字号倍率(需求 14)。
|
||||||
|
* 平时恒为 1:它只作为「当前有没有放大」的单一事实来源,方便 JS 侧读取与断言,
|
||||||
|
* 真正的放大规则集中在文件末尾的 `html.care-mode` 块里(那里才是覆盖点,
|
||||||
|
* 且**刻意只动字号**——圆角 / 阴影 / 侧栏宽度等几何令牌一律不碰)。 */
|
||||||
|
--ch-font-scale: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 深色主题 */
|
/* ---------------------------------------------------------------- 深色主题 */
|
||||||
@@ -946,3 +952,61 @@ textarea {
|
|||||||
::view-transition-new(root) {
|
::view-transition-new(root) {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 关怀模式(需求 14)
|
||||||
|
*
|
||||||
|
* 给视力不佳的用户把**正文与控件**整体放大一档(约 +15%)。由 `stores/ui.ts` 的
|
||||||
|
* `applyCareMode()` 在首屏渲染前按 `config.careMode` 给 `<html>` 挂/摘 `.care-mode` 类。
|
||||||
|
*
|
||||||
|
* 三条刻意的边界,改这个块之前先读:
|
||||||
|
*
|
||||||
|
* 1. **只放大字号,不动几何。** 圆角(`--ch-radius-*`)、阴影(`--ch-shadow-*`)、
|
||||||
|
* 卡片内边距、侧栏宽度一律不碰 —— 放大字号已经会让文字换行,再动几何就是"另一个主题",
|
||||||
|
* 而且 `verify:desktop` 的多条断言量的是像素几何。
|
||||||
|
* 2. **侧栏导轨(`.ch-nav`)的条目字号不在这里放大。** §5 第 49 条那套"折叠成图标导轨"
|
||||||
|
* 的硬要求(导轨宽度、条目高度、折叠态文字必须既无宽度又透明)是按固定几何量出来的,
|
||||||
|
* 动导航条目字号会让折叠态文字重新占宽而断言变红。导航的可见标签保持原字号。
|
||||||
|
* 3. **用 `html.care-mode` 而不是 `:root`**:特异性 (0,1,1) 高于普通类选择器,
|
||||||
|
* 能稳定压过上面那些 `.page-title` / `.el-card__header` 等单类规则,不必写 `!important`。
|
||||||
|
* 深色主题无关 —— 这里一条颜色都没写。
|
||||||
|
*/
|
||||||
|
html.care-mode {
|
||||||
|
/* 放大倍率放在这里,下面的覆盖全部由它算出来 —— 想再调一档只改这一个数 */
|
||||||
|
--ch-font-scale: 1.15;
|
||||||
|
/* 覆盖 Element Plus 的基准字号:按钮、输入框、下拉、分页、消息框等控件都跟着它走
|
||||||
|
(基准值是 :root 里的 13px,见上面的 Element Plus 变量对齐) */
|
||||||
|
--el-font-size-base: calc(13px * var(--ch-font-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 页面标题与副标题(`views/*` 的通用页头) */
|
||||||
|
html.care-mode .page-title {
|
||||||
|
font-size: calc(20px * var(--ch-font-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.care-mode .page-subtitle {
|
||||||
|
font-size: calc(12.5px * var(--ch-font-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片标题 / 折叠面板标题(这两处在上面被显式写成 13.5px,必须逐条覆盖) */
|
||||||
|
html.care-mode .el-card__header,
|
||||||
|
html.care-mode .el-collapse-item__header {
|
||||||
|
font-size: calc(13.5px * var(--ch-font-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格:表头与单元格一起放大,行高由内容自然撑开(不设固定 height) */
|
||||||
|
html.care-mode .el-table {
|
||||||
|
font-size: calc(13px * var(--ch-font-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.care-mode .el-table th.el-table__cell {
|
||||||
|
font-size: calc(12.5px * var(--ch-font-scale));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单里 Element Plus 自己的标签/说明文字(默认跟着 --el-font-size-base,
|
||||||
|
* 但 `.el-form-item__label` 在部分页面被行内样式压过,这里统一一档更稳) */
|
||||||
|
html.care-mode .el-form-item__label,
|
||||||
|
html.care-mode .el-descriptions__label,
|
||||||
|
html.care-mode .el-descriptions__content,
|
||||||
|
html.care-mode .el-message-box__message {
|
||||||
|
font-size: calc(13px * var(--ch-font-scale));
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
@@ -248,7 +384,7 @@ onUnmounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="等级" width="80">
|
<el-table-column label="等级" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ gradeLevel(gradePercent(row.score, row.totalScore)) }}
|
{{ row.level || gradeLevel(gradePercent(row.score, row.totalScore)) }}
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="发布时间" width="170">
|
<el-table-column label="发布时间" width="170">
|
||||||
@@ -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>
|
||||||
@@ -70,8 +70,6 @@ async function submit(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await auth.login(form.serverUrl, form.code.trim(), form.password);
|
await auth.login(form.serverUrl, form.code.trim(), form.password);
|
||||||
await appStore.init(form.serverUrl);
|
await appStore.init(form.serverUrl);
|
||||||
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
|
|
||||||
await appStore.loadNotificationChannel(auth.classId);
|
|
||||||
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
||||||
ElMessage.success(`已进入 ${auth.displayName}`);
|
ElMessage.success(`已进入 ${auth.displayName}`);
|
||||||
await router.replace('/schedule');
|
await router.replace('/schedule');
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
|
NOTIFICATION_PRIORITIES,
|
||||||
PRIORITY_LABELS,
|
PRIORITY_LABELS,
|
||||||
PRIORITY_TAG_TYPES,
|
PRIORITY_TAG_TYPES,
|
||||||
|
dayKeyLocal,
|
||||||
formatDate,
|
formatDate,
|
||||||
relativeTime,
|
relativeTime,
|
||||||
|
shiftDayKey,
|
||||||
type NotificationDto,
|
type NotificationDto,
|
||||||
type NotificationPriority,
|
type NotificationPriority,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
@@ -24,6 +27,85 @@ const visibleItems = computed(() =>
|
|||||||
filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items,
|
filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- 按天归档 / 按发布人 / 按类型 */
|
||||||
|
/**
|
||||||
|
* 三个筛选都在**已加载的列表**上做,不再请求服务端:
|
||||||
|
* 客户端是离线优先的(AGENTS.md §5 第 10 条),断网回退缓存时归档功能必须照常可用。
|
||||||
|
* 口径与服务端一致:按 `assignDate`(本地日期)分组 —— 用 createdAt 的 UTC 切片会把晚上
|
||||||
|
* 发出来的通知算到第二天(AGENTS.md §5 第 19 条)。
|
||||||
|
*/
|
||||||
|
const dayFilter = ref('');
|
||||||
|
const creatorFilter = ref('');
|
||||||
|
const priorityFilter = ref('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条通知归到哪一天。
|
||||||
|
* `assignDate` 是服务端新加的字段,但**离线缓存里可能还留着加字段之前的旧数据**,
|
||||||
|
* 所以这里按 createdAt 的本地日期兜底,避免分组头显示成 "undefined"。
|
||||||
|
*/
|
||||||
|
function assignDay(item: NotificationDto): string {
|
||||||
|
return item.assignDate || dayKeyLocal(new Date(item.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayOptions = computed(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const item of store.items) {
|
||||||
|
const day = assignDay(item);
|
||||||
|
map.set(day, (map.get(day) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||||
|
.map(([date, count]) => ({ date, count }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const creatorOptions = computed(() => {
|
||||||
|
const map = new Map<string, { name: string; count: number }>();
|
||||||
|
for (const item of store.items) {
|
||||||
|
const previous = map.get(item.createdBy);
|
||||||
|
map.set(item.createdBy, {
|
||||||
|
name: item.creator?.name ?? '未知',
|
||||||
|
count: (previous?.count ?? 0) + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [...map.entries()].map(([id, value]) => ({ id, ...value }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasFilter = computed(() => Boolean(dayFilter.value || creatorFilter.value || priorityFilter.value));
|
||||||
|
|
||||||
|
const filteredItems = computed(() =>
|
||||||
|
visibleItems.value.filter(
|
||||||
|
(item) =>
|
||||||
|
(!dayFilter.value || assignDay(item) === dayFilter.value) &&
|
||||||
|
(!creatorFilter.value || item.createdBy === creatorFilter.value) &&
|
||||||
|
(!priorityFilter.value || item.priority === priorityFilter.value),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 按所属日期分块(同一天一块,块头显示"今天/昨天 + 日期 + 条数") */
|
||||||
|
const groupedItems = computed(() => {
|
||||||
|
const groups = new Map<string, NotificationDto[]>();
|
||||||
|
for (const item of filteredItems.value) {
|
||||||
|
const day = assignDay(item);
|
||||||
|
const list = groups.get(day);
|
||||||
|
if (list) list.push(item);
|
||||||
|
else groups.set(day, [item]);
|
||||||
|
}
|
||||||
|
return [...groups.entries()].map(([date, items]) => ({ date, items }));
|
||||||
|
});
|
||||||
|
|
||||||
|
function dayLabel(date: string): string {
|
||||||
|
const today = dayKeyLocal(new Date());
|
||||||
|
if (date === today) return '今天';
|
||||||
|
if (date === shiftDayKey(today, -1)) return '昨天';
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters(): void {
|
||||||
|
dayFilter.value = '';
|
||||||
|
creatorFilter.value = '';
|
||||||
|
priorityFilter.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
async function openDetail(item: NotificationDto): Promise<void> {
|
async function openDetail(item: NotificationDto): Promise<void> {
|
||||||
current.value = item;
|
current.value = item;
|
||||||
detailVisible.value = true;
|
detailVisible.value = true;
|
||||||
@@ -76,6 +158,7 @@ onUnmounted(() => {
|
|||||||
<h2 class="page-title">通知</h2>
|
<h2 class="page-title">通知</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
|
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
|
||||||
|
<span v-if="hasFilter">· 筛选后 {{ filteredItems.length }} 条</span>
|
||||||
<el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
<el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,10 +175,59 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card v-loading="store.loading" shadow="never">
|
<el-card v-loading="store.loading" shadow="never">
|
||||||
<el-empty v-if="visibleItems.length === 0" description="暂无通知" />
|
<!-- 按天归档 + 按发布人 + 按类型;都在本地已加载的列表上筛(离线也能用) -->
|
||||||
<ul v-else class="notice-list">
|
<div class="notice-filters">
|
||||||
|
<el-select v-model="dayFilter" placeholder="按天归档" clearable size="small" style="width: 150px">
|
||||||
|
<el-option
|
||||||
|
v-for="item in dayOptions"
|
||||||
|
:key="item.date"
|
||||||
|
:label="`${item.date}(${item.count})`"
|
||||||
|
:value="item.date"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="creatorFilter"
|
||||||
|
placeholder="全部发布人"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
style="width: 150px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in creatorOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.name}(${item.count})`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="priorityFilter"
|
||||||
|
placeholder="全部类型"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
style="width: 130px"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in NOTIFICATION_PRIORITIES"
|
||||||
|
:key="item"
|
||||||
|
:label="PRIORITY_LABELS[item]"
|
||||||
|
:value="item"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button v-if="hasFilter" link type="primary" size="small" @click="resetFilters">
|
||||||
|
清除筛选
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="filteredItems.length === 0" description="暂无通知" />
|
||||||
|
<div v-else class="notice-groups">
|
||||||
|
<section v-for="group in groupedItems" :key="group.date" class="notice-group">
|
||||||
|
<div class="notice-group__header">
|
||||||
|
<span>{{ dayLabel(group.date) }}</span>
|
||||||
|
<span class="text-muted">{{ group.date }} · {{ group.items.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<ul class="notice-list">
|
||||||
<li
|
<li
|
||||||
v-for="item in visibleItems"
|
v-for="item in group.items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="notice-item"
|
class="notice-item"
|
||||||
:class="{ unread: !item.read }"
|
:class="{ unread: !item.read }"
|
||||||
@@ -123,6 +255,8 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'">
|
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'">
|
||||||
@@ -147,6 +281,30 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.notice-filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-group + .notice-group {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-group__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 6px 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ch-text-secondary);
|
||||||
|
border-bottom: 1px solid var(--ch-border);
|
||||||
|
}
|
||||||
|
|
||||||
.notice-list {
|
.notice-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type ScheduleDto,
|
type ScheduleDto,
|
||||||
type ScheduleWeekView,
|
type ScheduleWeekView,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { dashboardApi, scheduleApi } from '../api/index.js';
|
import { scheduleApi } from '../api/index.js';
|
||||||
import { fetchWithCache } from '../cache/index.js';
|
import { fetchWithCache } from '../cache/index.js';
|
||||||
import { useAppStore } from '../stores/app.js';
|
import { useAppStore } from '../stores/app.js';
|
||||||
import { useAuthStore } from '../stores/auth.js';
|
import { useAuthStore } from '../stores/auth.js';
|
||||||
@@ -172,14 +172,9 @@ function remainText(minutes: number): string {
|
|||||||
|
|
||||||
async function loadTerm(): Promise<void> {
|
async function loadTerm(): Promise<void> {
|
||||||
// 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周)
|
// 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周)
|
||||||
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(auth.classId ?? undefined), {
|
await appStore.refreshTerm(auth.classId ?? undefined);
|
||||||
currentWeek: 1,
|
weekOptions.value = buildWeekOptions(appStore.maxWeek);
|
||||||
maxWeek: 20,
|
if (!week.value || week.value === 1) week.value = appStore.currentWeek;
|
||||||
});
|
|
||||||
appStore.currentWeek = result.data.currentWeek;
|
|
||||||
appStore.maxWeek = result.data.maxWeek;
|
|
||||||
weekOptions.value = buildWeekOptions(result.data.maxWeek);
|
|
||||||
if (!week.value || week.value === 1) week.value = result.data.currentWeek;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSchedules(): Promise<void> {
|
async function loadSchedules(): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { confirmAction } from '../../composables/confirm.js';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { useAuthStore } from '../../stores/auth.js';
|
import { useAuthStore } from '../../stores/auth.js';
|
||||||
import { useRealtimeStore } from '../../stores/realtime.js';
|
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ const auth = useAuthStore();
|
|||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
async function logout(): Promise<void> {
|
||||||
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
|
if (!(await confirmAction('确认退出当前账号?', '退出登录', { type: 'warning' }))) return;
|
||||||
realtime.disconnect();
|
realtime.disconnect();
|
||||||
await auth.logout();
|
await auth.logout();
|
||||||
ElMessage.success('已退出登录');
|
ElMessage.success('已退出登录');
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { confirmAction } from '../../composables/confirm.js';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { formatDate, relativeTime, type UpdateInfo } from '@classhelper/shared';
|
||||||
|
import type { ClientAgentStatus, DesktopAppInfo } from '../../../types/desktop.js';
|
||||||
|
import { useAppStore } from '../../stores/app.js';
|
||||||
|
import { useUiStore } from '../../stores/ui.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者(需求 10)。
|
||||||
|
*
|
||||||
|
* 入口只在 `config.developerMode === true` 时出现在侧栏「设置」分组里(`ClientLayout.vue`),
|
||||||
|
* 路由守卫也会把"关掉开关后仍停留在这个页面"的用户弹回「通用」。
|
||||||
|
*
|
||||||
|
* 本页的定位是**把排查问题时需要的现场集中到一处**,所以刻意不做成"可操作的调试台":
|
||||||
|
* 每一项都是只读状态 + 一两个既有桥上的动作,数据全部来自 `window.desktop.*`
|
||||||
|
* 与既有 store,**不新增任何数据源、也不新增文件读写**。
|
||||||
|
*
|
||||||
|
* 几条取舍写在各区块的注释里,统一的原则是:
|
||||||
|
* 「关于」页面向的是所有用户(干净、能截图给管理员),本页面向维护者(原始值、路径、内部开关)。
|
||||||
|
*/
|
||||||
|
const appStore = useAppStore();
|
||||||
|
const ui = useUiStore();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const appInfo = ref<DesktopAppInfo | null>(null);
|
||||||
|
const agent = ref<ClientAgentStatus | null>(null);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 版本与平台 */
|
||||||
|
|
||||||
|
/** 版本、运行时与两个关键路径(用户数据目录 / 配置文件),排查"配置写哪去了"必看 */
|
||||||
|
async function loadAppInfo(): Promise<void> {
|
||||||
|
if (!window.desktop?.getAppInfo) return;
|
||||||
|
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一次性把本页的所有状态拉新(刷新按钮与首次进页面共用) */
|
||||||
|
async function loadAll(): Promise<void> {
|
||||||
|
if (!window.desktop) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
await loadAppInfo();
|
||||||
|
await refreshAgent();
|
||||||
|
await loadIgnoredVersion();
|
||||||
|
await appStore.refreshCacheStats();
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 设备与上报 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端联动状态:设备尾号 / 最近上报 / 最近错误 / 目标版本 / 待安装版本 / 在线。
|
||||||
|
*
|
||||||
|
* 这一块与「通用」页的「服务端联动状态」是同一份数据 —— 有意保留的重叠:
|
||||||
|
* 排查上报问题时人在「通用」页看现象、在「开发者」页看原因(最近错误原文 + 设备尾号),
|
||||||
|
* 跳来跳去比重复几个字段更烦。
|
||||||
|
*/
|
||||||
|
const beating = ref(false);
|
||||||
|
|
||||||
|
const lastHeartbeatText = computed(() => {
|
||||||
|
const value = agent.value?.lastHeartbeatAt;
|
||||||
|
return value ? relativeTime(value) : '还没有上报过';
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
|
||||||
|
|
||||||
|
async function refreshAgent(): Promise<void> {
|
||||||
|
if (!window.desktop?.getClientAgentStatus) return;
|
||||||
|
agent.value = await window.desktop.getClientAgentStatus().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beatNow(): Promise<void> {
|
||||||
|
if (!window.desktop?.beatClientNow) return;
|
||||||
|
beating.value = true;
|
||||||
|
try {
|
||||||
|
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
|
||||||
|
agent.value = await window.desktop.beatClientNow();
|
||||||
|
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
|
||||||
|
else ElMessage.success('已向服务端上报本机状态');
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
|
||||||
|
} finally {
|
||||||
|
beating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 日志 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日志文件路径。
|
||||||
|
*
|
||||||
|
* 桥上没有"读日志内容"的接口(有意不加:日志是主进程的文件,给渲染进程一个读任意文件的
|
||||||
|
* 入口在安全边界上没有必要),所以这里只**提示路径** + 一个「打开日志目录」。
|
||||||
|
* 路径由用户数据目录拼出来:主进程的 logger 固定写在 `<userData>/logs/main.log`,
|
||||||
|
* 单文件 1MB 轮转(写满即改名为 main.old.log),所以"日志丢了"通常是轮转覆盖,不是没写。
|
||||||
|
*/
|
||||||
|
const logPathText = computed(() => {
|
||||||
|
const base = appInfo.value?.userDataPath;
|
||||||
|
if (!base) return 'logs/main.log(用户数据目录下)';
|
||||||
|
const sep = (appInfo.value?.platform ?? '').startsWith('win') ? '\\' : '/';
|
||||||
|
return `${base}${sep}logs${sep}main.log`;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function openLogDir(): Promise<void> {
|
||||||
|
if (!window.desktop?.openLogDir) return;
|
||||||
|
const ok = await window.desktop.openLogDir();
|
||||||
|
if (!ok) ElMessage.warning('打开日志目录失败,请按上面的路径手动前往');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 更新 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新检查。
|
||||||
|
*
|
||||||
|
* 「立即检查」走 `checkForUpdates(true)`:**强制绕过主进程那 30 分钟缓存**。
|
||||||
|
* 这是本页与「关于」页的区别 —— 那边用户点检查也可能是缓存结果(省流量),
|
||||||
|
* 这边维护者要的是"现在到底拿得到什么",所以按钮默认就是强制的。
|
||||||
|
*/
|
||||||
|
const checking = ref(false);
|
||||||
|
const updateInfo = ref<UpdateInfo | null>(null);
|
||||||
|
const ignoredUpdateVersion = ref('');
|
||||||
|
|
||||||
|
async function checkUpdate(force: boolean): Promise<void> {
|
||||||
|
if (!window.desktop?.checkForUpdates) return;
|
||||||
|
checking.value = true;
|
||||||
|
try {
|
||||||
|
updateInfo.value = await window.desktop.checkForUpdates(force);
|
||||||
|
} finally {
|
||||||
|
checking.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadIgnoredVersion(): Promise<void> {
|
||||||
|
if (!window.desktop?.getConfig) return;
|
||||||
|
const stored = await window.desktop.getConfig().catch(() => null);
|
||||||
|
ignoredUpdateVersion.value = stored?.ignoredUpdateVersion ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ignoreVersion(): Promise<void> {
|
||||||
|
const version = updateInfo.value?.latestVersion;
|
||||||
|
if (!version || !window.desktop?.ignoreUpdateVersion) return;
|
||||||
|
await window.desktop.ignoreUpdateVersion(version);
|
||||||
|
ignoredUpdateVersion.value = version;
|
||||||
|
ElMessage.success(`已忽略 v${version}:启动时的自动检查不再提示它`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 忽略是可逆的(排查线上"用户说不再提示"时经常要把它清掉),顺手给一个恢复入口 */
|
||||||
|
async function clearIgnoredVersion(): Promise<void> {
|
||||||
|
if (!window.desktop?.saveConfig) return;
|
||||||
|
const saved = await window.desktop.saveConfig({ ignoredUpdateVersion: '' });
|
||||||
|
ignoredUpdateVersion.value = saved.ignoredUpdateVersion;
|
||||||
|
ElMessage.success('已恢复该版本的更新提示');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 调试工具 */
|
||||||
|
|
||||||
|
const openingDevTools = ref(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开 DevTools(主进程 detached 独立窗口)。
|
||||||
|
* 冒烟验证模式下主进程会拒绝(多一个窗口会把自动化断言打乱),返回 false —— 这里如实提示。
|
||||||
|
*/
|
||||||
|
async function openDevTools(): Promise<void> {
|
||||||
|
if (!window.desktop?.openDevTools) return;
|
||||||
|
openingDevTools.value = true;
|
||||||
|
try {
|
||||||
|
const ok = await window.desktop.openDevTools();
|
||||||
|
if (ok) ElMessage.success('已在独立窗口打开开发者工具');
|
||||||
|
else ElMessage.warning('当前是冒烟验证模式,主进程不允许打开 DevTools');
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '打开开发者工具失败');
|
||||||
|
} finally {
|
||||||
|
openingDevTools.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 离线缓存 */
|
||||||
|
|
||||||
|
/** 与「通用」页同一份缓存统计(这里再看一遍是为了"刷新/清空"不用跳页) */
|
||||||
|
const storeLabels: Record<string, string> = {
|
||||||
|
profile: '登录资料',
|
||||||
|
classes: '班级 / 学期',
|
||||||
|
schedules: '课表',
|
||||||
|
homeworks: '作业',
|
||||||
|
notifications: '通知',
|
||||||
|
grades: '成绩',
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheRows = computed(() =>
|
||||||
|
appStore.cacheStoreStats.map((item) => ({
|
||||||
|
label: storeLabels[item.store] ?? item.store,
|
||||||
|
store: item.store,
|
||||||
|
count: item.count,
|
||||||
|
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function clearCache(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!(await confirmAction('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||||
|
type: 'warning',
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await appStore.clearCache();
|
||||||
|
ElMessage.success('离线缓存已清空');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadAll();
|
||||||
|
// 带出启动自动检查的结果(这次命中主进程缓存,不会真的再发一次请求)
|
||||||
|
void checkUpdate(false);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="page-title">开发者</h2>
|
||||||
|
<p class="page-subtitle">版本与平台、设备上报、日志、更新与调试工具</p>
|
||||||
|
</div>
|
||||||
|
<el-button :loading="loading" data-test="dev-refresh" @click="loadAll">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
class="mb-12"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
title="仅供排查问题"
|
||||||
|
description="这一页显示的是原始状态与内部路径,不需要给日常使用者看。关闭「通用 → 开发者模式」后它就会从侧栏消失。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<!-- 版本与平台 -->
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>版本与平台</span></template>
|
||||||
|
<el-descriptions :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="客户端版本">
|
||||||
|
{{ appInfo?.appVersion ?? '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="运行时">
|
||||||
|
Electron {{ appInfo?.electron ?? '—' }} · Chromium {{ appInfo?.chrome ?? '—' }} · Node
|
||||||
|
{{ appInfo?.node ?? '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="用户数据目录">
|
||||||
|
<span class="mono">{{ appInfo?.userDataPath ?? '—' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="配置文件">
|
||||||
|
<span class="mono">{{ appInfo?.configPath ?? '—' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="冒烟验证模式">
|
||||||
|
<el-tag :type="appInfo?.smokeTest ? 'warning' : 'info'" size="small" effect="plain">
|
||||||
|
{{ appInfo?.smokeTest ? '是(自动化验证中)' : '否' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 设备与上报 -->
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>设备与上报</span></template>
|
||||||
|
<el-descriptions :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="连接状态">
|
||||||
|
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
|
||||||
|
{{ agent?.online ? '在线(近期上报成功)' : '离线(等待上报)' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="设备标识尾号">
|
||||||
|
<span class="mono">{{ agent?.deviceKeyTail || '—' }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务端目标版本">
|
||||||
|
{{ agent?.updateTargetVersion || '无(保持当前版本)' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="待安装版本">
|
||||||
|
{{ agent?.pendingUpdateVersion || '无' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近错误">
|
||||||
|
<span :class="{ 'text-danger': lastErrorText !== '无' }">{{ lastErrorText }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div class="toolbar mt-12">
|
||||||
|
<el-button type="primary" :loading="beating" data-test="dev-beat-now" @click="beatNow">
|
||||||
|
立即上报
|
||||||
|
</el-button>
|
||||||
|
<el-button :loading="loading" @click="loadAll">刷新状态</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<el-col :xs="24" :md="12">
|
||||||
|
<!-- 日志 -->
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header><span>日志</span></template>
|
||||||
|
<el-descriptions :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="日志文件">
|
||||||
|
<span class="mono">{{ logPathText }}</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="日志级别">
|
||||||
|
<el-tag :type="ui.developerMode ? 'success' : 'info'" size="small" effect="plain">
|
||||||
|
{{ ui.developerMode ? 'debug(开发者模式)' : 'info' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<p class="text-muted mt-12">
|
||||||
|
单个日志文件 1MB 轮转:写满后改名为 main.old.log,新内容写进 main.log。
|
||||||
|
所以"日志里找不到某次崩溃"多半是被轮转覆盖了,不是没写。
|
||||||
|
</p>
|
||||||
|
<!-- 不在这里用 <strong> 强调:中文整行的显示宽度是字符数的两倍,
|
||||||
|
而 Vue 模板里夹了行内元素的长句会被 Prettier 折行并插入空格,
|
||||||
|
渲染出来句子中间就多一个空格。要强调就单独起一行文字。 -->
|
||||||
|
<p v-if="ui.developerMode" class="text-muted">
|
||||||
|
debug 级日志已开启:级别在主进程启动时读取,改完开关后需重启客户端才生效。
|
||||||
|
</p>
|
||||||
|
<div class="toolbar mt-12">
|
||||||
|
<el-button data-test="dev-open-log-dir" @click="openLogDir">打开日志目录</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 更新 -->
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>更新</span></template>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button :loading="checking" data-test="dev-check-update" @click="checkUpdate(true)">
|
||||||
|
强制检查更新
|
||||||
|
</el-button>
|
||||||
|
<span class="text-muted">绕过主进程 30 分钟缓存,直接问 GitHub</span>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="1" border size="small" class="mt-12">
|
||||||
|
<el-descriptions-item label="本次结果">
|
||||||
|
<template v-if="checking">正在检查…</template>
|
||||||
|
<template v-else-if="!updateInfo">尚未检查</template>
|
||||||
|
<template v-else-if="!updateInfo.ok">
|
||||||
|
<span class="text-muted">{{ updateInfo.error ?? '暂时无法检查更新' }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="updateInfo.hasUpdate">
|
||||||
|
<el-tag size="small" type="success" effect="light">
|
||||||
|
有新版本 v{{ updateInfo.latestVersion }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="text-muted">当前 v{{ updateInfo.currentVersion }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-tag size="small" type="info" effect="plain">已是最新版本</el-tag>
|
||||||
|
<span class="text-muted">v{{ updateInfo.currentVersion }}</span>
|
||||||
|
</template>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="检查时间">
|
||||||
|
{{ updateInfo?.checkedAt ? formatDate(updateInfo.checkedAt, true) : '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="已忽略的版本">
|
||||||
|
<template v-if="ignoredUpdateVersion">
|
||||||
|
<span class="mono">v{{ ignoredUpdateVersion }}</span>
|
||||||
|
<el-button link type="primary" size="small" @click="clearIgnoredVersion">恢复提示</el-button>
|
||||||
|
</template>
|
||||||
|
<span v-else class="text-muted">无</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div class="toolbar mt-12">
|
||||||
|
<el-button
|
||||||
|
:disabled="!updateInfo?.latestVersion"
|
||||||
|
data-test="dev-ignore-version"
|
||||||
|
@click="ignoreVersion"
|
||||||
|
>
|
||||||
|
不再提示该版本
|
||||||
|
</el-button>
|
||||||
|
<span class="text-muted">写入配置后,启动时的自动检查会跳过它</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 调试工具 -->
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>调试工具</span></template>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button :loading="openingDevTools" data-test="dev-open-devtools" @click="openDevTools">
|
||||||
|
打开 DevTools
|
||||||
|
</el-button>
|
||||||
|
<span class="text-muted">独立窗口;冒烟验证模式下不允许打开</span>
|
||||||
|
</div>
|
||||||
|
<el-alert class="mt-12" type="info" :closable="false" show-icon title="灵动岛诊断(命令行)">
|
||||||
|
<template #default>
|
||||||
|
<p class="diag-copy">灵动岛开合"闪一下"这类问题要的是整屏逐帧取证,界面上给不出这个按钮。</p>
|
||||||
|
<p class="diag-copy">跑冒烟时带上环境变量即可:</p>
|
||||||
|
<p class="diag-copy mono">ELECTRON_SMOKE_ISLAND_DIAG=1 pnpm verify:desktop</p>
|
||||||
|
<p class="diag-copy">它会在常规断言之外,对触摸/鼠标两种模式各跑一次收回动画的逐帧取证,</p>
|
||||||
|
<p class="diag-copy">帧图与逐帧数据落在 `.cache/island-closing-frames/`。</p>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 离线缓存 -->
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>离线缓存(IndexedDB)</span></template>
|
||||||
|
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
|
||||||
|
<el-table-column prop="label" label="数据类型" width="130" />
|
||||||
|
<el-table-column label="条目数" width="90">
|
||||||
|
<template #default="{ row }">{{ row.count }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近更新" min-width="150">
|
||||||
|
<template #default="{ row }">{{ row.updatedAt }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="toolbar mt-12">
|
||||||
|
<el-button @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
||||||
|
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/*
|
||||||
|
* 路径与版本号这类"要抄下来发给别人"的值统一用等宽字体(避免 0/O、1/l 看混),
|
||||||
|
* 并单独放开文本选中 —— body 上关掉了整页选中(更像原生应用),
|
||||||
|
* 但这一页的路径就是要让人复制的。
|
||||||
|
*
|
||||||
|
* 字体栈写死而不新建 `--ch-font-mono` 令牌:全局只有这一处在用,
|
||||||
|
* 为它加一个主题令牌(还得在 html.dark 里镜像一份)不划算。
|
||||||
|
*/
|
||||||
|
.mono {
|
||||||
|
font-family: 'Cascadia Mono', Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-copy {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diag-copy:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { confirmAction } from '../../composables/confirm.js';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
HOMEWORK_PHRASE_DEFAULTS,
|
HOMEWORK_PHRASE_DEFAULTS,
|
||||||
HOMEWORK_PHRASE_MAX_COUNT,
|
HOMEWORK_PHRASE_MAX_COUNT,
|
||||||
HOMEWORK_PHRASE_MAX_LENGTH,
|
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||||
formatDate,
|
formatDate,
|
||||||
|
relativeTime,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
|
import type { ClientAgentStatus } from '../../../types/desktop.js';
|
||||||
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
|
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
|
||||||
import { normalizeServerUrl } from '../../config.js';
|
import { normalizeServerUrl } from '../../config.js';
|
||||||
import { useAppStore } from '../../stores/app.js';
|
import { useAppStore } from '../../stores/app.js';
|
||||||
@@ -14,6 +17,7 @@ import { useAuthStore } from '../../stores/auth.js';
|
|||||||
import { useNotificationStore } from '../../stores/notifications.js';
|
import { useNotificationStore } from '../../stores/notifications.js';
|
||||||
import { useOnboardingStore } from '../../stores/onboarding.js';
|
import { useOnboardingStore } from '../../stores/onboarding.js';
|
||||||
import { useRealtimeStore } from '../../stores/realtime.js';
|
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||||
|
import { useUiStore } from '../../stores/ui.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用设置:服务器连接、离线缓存与作业录入短语。
|
* 通用设置:服务器连接、离线缓存与作业录入短语。
|
||||||
@@ -24,6 +28,7 @@ const auth = useAuthStore();
|
|||||||
const onboarding = useOnboardingStore();
|
const onboarding = useOnboardingStore();
|
||||||
const realtime = useRealtimeStore();
|
const realtime = useRealtimeStore();
|
||||||
const notifications = useNotificationStore();
|
const notifications = useNotificationStore();
|
||||||
|
const ui = useUiStore();
|
||||||
|
|
||||||
const serverInput = ref(appStore.serverUrl);
|
const serverInput = ref(appStore.serverUrl);
|
||||||
const testing = ref(false);
|
const testing = ref(false);
|
||||||
@@ -89,9 +94,12 @@ async function manualSync(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearCache(): Promise<void> {
|
async function clearCache(): Promise<void> {
|
||||||
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
if (
|
||||||
|
!(await confirmAction('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
});
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
await appStore.clearCache();
|
await appStore.clearCache();
|
||||||
ElMessage.success('离线缓存已清空');
|
ElMessage.success('离线缓存已清空');
|
||||||
}
|
}
|
||||||
@@ -147,15 +155,151 @@ async function removePhrase(value: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resetPhrases(): Promise<void> {
|
async function resetPhrases(): Promise<void> {
|
||||||
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
|
if (!(await confirmAction('恢复为默认快捷短语?', '恢复默认', { type: 'warning' }))) return;
|
||||||
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
|
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
|
||||||
await savePhrases();
|
await savePhrases();
|
||||||
ElMessage.success('已恢复默认');
|
ElMessage.success('已恢复默认');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 启动与更新 / 服务端联动 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开机自启与自动更新。
|
||||||
|
*
|
||||||
|
* 开机自启的"是否生效"以**系统登录项**为准(新增的 `getAutoLaunch` 桥读的是
|
||||||
|
* `app.getLoginItemSettings()`,不是配置文件里的值)—— 用户可能在任务管理器的「启动」页
|
||||||
|
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
|
||||||
|
*/
|
||||||
|
const autoLaunch = ref(false);
|
||||||
|
const savingLaunch = ref(false);
|
||||||
|
|
||||||
|
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
|
||||||
|
const agent = ref<ClientAgentStatus | null>(null);
|
||||||
|
const agentLoading = ref(false);
|
||||||
|
/** 本机客户端版本(与服务端下发的目标版本对照着看) */
|
||||||
|
const appVersion = ref('—');
|
||||||
|
|
||||||
|
/** 最近上报时间的相对描述(没有上报过就显示占位符) */
|
||||||
|
const lastHeartbeatText = computed(() => {
|
||||||
|
const value = agent.value?.lastHeartbeatAt;
|
||||||
|
if (!value) return '还没有上报过';
|
||||||
|
return relativeTime(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 服务端对本机版本的期望:空串表示"不需要更新" */
|
||||||
|
const updateTargetText = computed(() => {
|
||||||
|
const target = agent.value?.updateTargetVersion ?? '';
|
||||||
|
if (!target) return '无(保持当前版本)';
|
||||||
|
return target;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingUpdateText = computed(() => {
|
||||||
|
const pending = agent.value?.pendingUpdateVersion ?? '';
|
||||||
|
if (!pending) return '无';
|
||||||
|
return `${pending}(已下载,等待按服务端指定的时机安装)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
|
||||||
|
|
||||||
|
async function loadLaunchSettings(): Promise<void> {
|
||||||
|
if (!window.desktop?.getConfig) return;
|
||||||
|
const config = await window.desktop.getConfig();
|
||||||
|
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
|
||||||
|
const info = await window.desktop.getAppInfo?.().catch(() => null);
|
||||||
|
if (info?.appVersion) appVersion.value = info.appVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAutoLaunch(value: string | number | boolean): Promise<void> {
|
||||||
|
if (!window.desktop?.setAutoLaunch) return;
|
||||||
|
const wanted = value === true;
|
||||||
|
autoLaunch.value = wanted;
|
||||||
|
savingLaunch.value = true;
|
||||||
|
try {
|
||||||
|
const effective = await window.desktop.setAutoLaunch(wanted);
|
||||||
|
// 以系统实际生效值为准:写注册表失败时会与用户点的不一致,必须回显真实状态
|
||||||
|
autoLaunch.value = effective;
|
||||||
|
if (effective !== wanted) ElMessage.warning('系统未接受开机自启设置,请检查任务管理器里的启动项');
|
||||||
|
else ElMessage.success(wanted ? '已开启开机自启' : '已关闭开机自启');
|
||||||
|
} catch (error) {
|
||||||
|
autoLaunch.value = !wanted;
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '设置开机自启失败');
|
||||||
|
} finally {
|
||||||
|
savingLaunch.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 开发者模式 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发者模式(需求 10):显示调试页面与详细日志,仅排查问题时打开。
|
||||||
|
*
|
||||||
|
* 打开后侧栏「设置」分组里**立刻**多出「开发者」页(`ClientLayout.vue` 读的是同一个
|
||||||
|
* ui store 状态),下次启动时主进程会把日志级别调到 debug。
|
||||||
|
*/
|
||||||
|
const savingDeveloperMode = ref(false);
|
||||||
|
|
||||||
|
async function toggleDeveloperMode(value: string | number | boolean): Promise<void> {
|
||||||
|
const wanted = value === true;
|
||||||
|
savingDeveloperMode.value = true;
|
||||||
|
try {
|
||||||
|
await ui.setDeveloperMode(wanted);
|
||||||
|
ElMessage.success(wanted ? '已开启开发者模式:侧栏出现「开发者」页' : '已关闭开发者模式');
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '保存开发者模式设置失败');
|
||||||
|
} finally {
|
||||||
|
savingDeveloperMode.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAgentStatus(): Promise<void> {
|
||||||
|
if (!window.desktop?.getClientAgentStatus) return;
|
||||||
|
agentLoading.value = true;
|
||||||
|
try {
|
||||||
|
agent.value = await window.desktop.getClientAgentStatus();
|
||||||
|
} finally {
|
||||||
|
agentLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function beatNow(): Promise<void> {
|
||||||
|
if (!window.desktop?.beatClientNow) return;
|
||||||
|
agentLoading.value = true;
|
||||||
|
try {
|
||||||
|
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
|
||||||
|
agent.value = await window.desktop.beatClientNow();
|
||||||
|
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
|
||||||
|
else ElMessage.success('已向服务端上报本机状态');
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
|
||||||
|
} finally {
|
||||||
|
agentLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLogDir(): Promise<void> {
|
||||||
|
if (!window.desktop?.openLogDir) return;
|
||||||
|
const ok = await window.desktop.openLogDir();
|
||||||
|
if (!ok) ElMessage.warning('打开日志目录失败,可在「关于」页复制诊断信息');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态里的相对时间要跟着走,否则挂着的页面会一直显示"刚刚" */
|
||||||
|
let statusTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await appStore.refreshCacheStats();
|
await appStore.refreshCacheStats();
|
||||||
await loadPhrases();
|
await loadPhrases();
|
||||||
|
await loadLaunchSettings();
|
||||||
|
await refreshAgentStatus();
|
||||||
|
statusTimer = setInterval(() => {
|
||||||
|
void refreshAgentStatus();
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (statusTimer !== null) {
|
||||||
|
clearInterval(statusTimer);
|
||||||
|
statusTimer = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -238,6 +382,69 @@ onMounted(async () => {
|
|||||||
<el-button @click="resetPhrases">恢复默认</el-button>
|
<el-button @click="resetPhrases">恢复默认</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>启动与更新</span></template>
|
||||||
|
<div class="switch-row">
|
||||||
|
<div class="switch-main">
|
||||||
|
<span>开机自启</span>
|
||||||
|
<p class="text-muted">登录 Windows 后自动启动 ClassHelper;教室机器建议打开。</p>
|
||||||
|
</div>
|
||||||
|
<el-switch
|
||||||
|
:model-value="autoLaunch"
|
||||||
|
:loading="savingLaunch"
|
||||||
|
@update:model-value="toggleAutoLaunch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="switch-row">
|
||||||
|
<div class="switch-main">
|
||||||
|
<span>自动更新</span>
|
||||||
|
<p class="text-muted">
|
||||||
|
安装只在
|
||||||
|
<strong>不上课且窗口已隐藏 5 分钟后</strong>
|
||||||
|
进行,下载随时可能发生。
|
||||||
|
</p>
|
||||||
|
<!--
|
||||||
|
这里**刻意没有开关**(需求:「客户端无权选择是否要自动更新」)。
|
||||||
|
教室机器是统一的投放终端,版本一致性由管理员在服务端「客户端设备」页决定;
|
||||||
|
留一个本地开关只会让"为什么这台还没升"变成无解的排查题。
|
||||||
|
客户端配置里的 `autoUpdate` 仍然照读(`autoUpdate !== false` 才自动装),
|
||||||
|
因为服务端下发的是一台机器的目标版本,不是"允许不允许更新"。
|
||||||
|
-->
|
||||||
|
<p class="text-muted">更新由管理员在服务端统一推送,教室机器不提供本地开关。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar mt-12">
|
||||||
|
<el-button @click="openLogDir">打开日志目录</el-button>
|
||||||
|
<span class="text-muted">日志文件 main.log,排查问题时可发给管理员</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>显示与调试</span></template>
|
||||||
|
<!--
|
||||||
|
关怀模式**只保留在 Web 管理端**(需求:「客户端无需关怀模式」)。
|
||||||
|
客户端是投在教室大屏上的固定界面,字号由「外观」页的看板/岛设置管,
|
||||||
|
再来一套全站放大只会和那些设置互相打架,所以这里不再提供开关。
|
||||||
|
`stores/ui.ts` 与 `styles/index.css` 里的那套管道暂时保留(配置里还有这个字段,
|
||||||
|
删掉会让老 config.json 反序列化时多一条归一化分支),但界面上不再暴露。
|
||||||
|
-->
|
||||||
|
<div class="switch-row">
|
||||||
|
<div class="switch-main">
|
||||||
|
<span>开发者模式</span>
|
||||||
|
<p class="text-muted">显示调试页面与详细日志,仅排查问题时打开。</p>
|
||||||
|
<p v-if="ui.developerMode" class="text-muted">
|
||||||
|
debug 级日志已开启;侧栏「设置 → 开发者」可查看版本、设备上报、更新检查、日志目录与 DevTools。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<el-switch
|
||||||
|
:model-value="ui.developerMode"
|
||||||
|
:loading="savingDeveloperMode"
|
||||||
|
data-test="developer-mode-switch"
|
||||||
|
@update:model-value="toggleDeveloperMode"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<el-col :xs="24" :md="12">
|
<el-col :xs="24" :md="12">
|
||||||
@@ -265,6 +472,35 @@ onMounted(async () => {
|
|||||||
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
||||||
/>
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="mt-12">
|
||||||
|
<template #header><span>服务端联动状态</span></template>
|
||||||
|
<p class="text-muted">
|
||||||
|
本机每 30 秒向服务端上报一次状态(是否在上课、当前版本、已下载的更新),
|
||||||
|
服务端据此下发统一更新与远控命令。本机标识尾号用于在服务端设备列表里核对这台电脑。
|
||||||
|
</p>
|
||||||
|
<el-descriptions class="mt-12" :column="1" border size="small">
|
||||||
|
<el-descriptions-item label="连接状态">
|
||||||
|
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
|
||||||
|
{{ agent?.online ? '已连接(近期上报成功)' : '未连接(等待上报)' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="本机版本">
|
||||||
|
{{ appVersion }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="设备标识尾号">
|
||||||
|
{{ agent?.deviceKeyTail || '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="服务端目标版本">{{ updateTargetText }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="已下载待安装">{{ pendingUpdateText }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="最近错误">{{ lastErrorText }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div class="toolbar mt-12">
|
||||||
|
<el-button type="primary" :loading="agentLoading" @click="beatNow">立即上报</el-button>
|
||||||
|
<el-button :loading="agentLoading" @click="refreshAgentStatus">刷新状态</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,4 +518,29 @@ onMounted(async () => {
|
|||||||
.ml-8 {
|
.ml-8 {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 开关行:左说明右开关,说明长时自动换行(「自动更新」的提示是两行) */
|
||||||
|
.switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row + .switch-row {
|
||||||
|
border-top: 1px solid var(--ch-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-main p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { confirmAction } from '../../composables/confirm.js';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
DEFAULT_ISLAND_APPEARANCE,
|
DEFAULT_ISLAND_APPEARANCE,
|
||||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||||
@@ -65,7 +66,7 @@ async function saveIslandAppearance(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resetIslandAppearance(): Promise<void> {
|
async function resetIslandAppearance(): Promise<void> {
|
||||||
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
|
if (!(await confirmAction('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' }))) return;
|
||||||
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
|
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||||
await saveIslandAppearance();
|
await saveIslandAppearance();
|
||||||
}
|
}
|
||||||
|
|||||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user