fix(clients,client,web): 远控指令的两个修复 —— 执行失败不再谎报成功 + 「上次指令」让"点了没反应"可见(1.3.2)
背景:用户报「下发锁屏等等指令不生效」。先把服务端那条链路单独验了一遍
(模拟客户端:登记 → 下发 → 心跳带回 → 设备列表可见 → 回执 → 清除),**整条是通的**;
真正的问题在两处。
客户端:执行失败会谎报成功
- `spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在下一个 tick 发一个异步 `error` 事件 ——
只包 `try/catch` 抓不到,代码一路走到 `return { ok: true }`,回执写着「已下达系统命令」,
Web 端看起来一切正常而机器纹丝不动
- 现在等一个 200ms 的窗口确认没有立刻报错,失败如实回执(`client-agent.ts` 的 `executeCommand`)
Web 端:点了没生效,界面上什么都不说
- 一条命令被**取消 / 过期**之后就从 `pendingCommand` 里消失,页面留白 ——
而最常见的两种"没反应"恰恰是「本机没人确认(客户端 60 秒自动按取消)」与「下达时机器离线」
- `GET /clients/devices` 增加 `lastCommand`(最近一条,不论状态);
「客户端」页的设备表多一列「上次指令」,由 `commandStateText()` 翻成一句人话
(已执行 / 本机用户取消 / 已过期 —— 下达时机器不在线 / 待执行)
- 下发成功的提示补上「60 秒内没人点确认就按取消处理」
文档
- AGENTS.md §5 第 69 条记下这两条,并写明排查同类反馈的顺序:**先看那台机器的客户端版本是否 ≥ 1.3.0**
(远控命令的处理 `client-agent.ts` 是 1.3.0 才有的,旧客户端连心跳都不发)
- docs/reference.md 4.15 的表格补上「二次确认」的准确口径与新增的「上次指令」列
验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿;
后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、
Web 真实点击 **34/34**、客户端冒烟 **90/90**;
远控链路另用模拟客户端跑通,并确认新增的「上次指令」在待执行与已回执两种状态下都显示正确。
This commit is contained in:
1 parent
227117ccb3
commit
b7cdc7c9ea
32 files changed
+188
-171
No files matched your search
@@ -1320,6 +1320,18 @@ term / dashboard / database / update / permissions / clients / system`。
|
|||||||
然后 `POST /api/clients/commands/:id/ack`(body 带 `deviceKey`,服务端据此校验归属,重复回执幂等)。
|
然后 `POST /api/clients/commands/:id/ack`(body 带 `deviceKey`,服务端据此校验归属,重复回执幂等)。
|
||||||
同一台机器上**同一条命令只弹一次**(内存去重),回执失败只暂存重试、不重复弹窗。
|
同一台机器上**同一条命令只弹一次**(内存去重),回执失败只暂存重试、不重复弹窗。
|
||||||
|
|
||||||
|
**`executeCommand()` 必须显式监听子进程的 `error` 事件**(2026-10-11 修):
|
||||||
|
`spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在 next tick 发一个异步 `error` 事件 ——
|
||||||
|
只包 `try/catch` 会一路走到 `return { ok: true }`,回执写着"已下达系统命令",机器却纹丝不动。
|
||||||
|
现在等 200ms 的窗口确认没立刻报错,失败如实回执。
|
||||||
|
|
||||||
|
**「点了没反应」必须看得出来**:命令被取消 / 过期后就从 `pendingCommand` 里消失,Web 端原本一片空白,
|
||||||
|
而最常见的两种失败恰恰是"本机没人确认(60 秒自动取消)"与"下达时机器离线"。
|
||||||
|
所以 `GET /clients/devices` 一并返回 `lastCommand`(最近一条,不论状态),
|
||||||
|
「客户端」页把它翻成一句人话(`ClientHubView` 的 `commandStateText`)。
|
||||||
|
排查这类反馈时先看这一列,再看那台机器的**客户端版本**是否 ≥ 1.3.0 ——
|
||||||
|
远控命令的处理(`client-agent.ts`)是 1.3.0 才有的,旧客户端连心跳都不发。
|
||||||
|
|
||||||
70. **客户端安装包由服务端下载并缓存,教室机不直连 GitHub。** 原因有两条:Web 端 CSP
|
70. **客户端安装包由服务端下载并缓存,教室机不直连 GitHub。** 原因有两条:Web 端 CSP
|
||||||
(`connect-src 'self' ws wss …`)不允许浏览器直连 GitHub,而教室机常常根本没有外网。
|
(`connect-src 'self' ws wss …`)不允许浏览器直连 GitHub,而教室机常常根本没有外网。
|
||||||
所以 `POST /api/update/client/fetch` 由**服务端**下载到 `<serverRoot>/../data/client-releases/<版本>/`
|
所以 `POST /api/update/client/fetch` 由**服务端**下载到 `<serverRoot>/../data/client-releases/<版本>/`
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ pnpm dev # 后端 4000 + Web 端 5
|
|||||||
| 教师 | `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) —— 架构总览
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-1.3.1}
|
image: classhelper-server:${APP_VERSION:-1.3.2}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-1.3.1}
|
image: classhelper-server:${APP_VERSION:-1.3.2}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "classhelper-server-runtime",
|
"name": "classhelper-server-runtime",
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ export const apiModules: ApiModule[] = [
|
|||||||
|
|
||||||
## 几条贯穿全局的硬约定
|
## 几条贯穿全局的硬约定
|
||||||
|
|
||||||
这些都是踩过坑之后固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
|
这些都是长期演进中固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
|
||||||
|
|
||||||
| 约定 | 为什么 |
|
| 约定 | 为什么 |
|
||||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
|
|||||||
@@ -112,14 +112,17 @@ pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需
|
|||||||
|
|
||||||
## 部署到教室机器
|
## 部署到教室机器
|
||||||
|
|
||||||
|
把编出来的 DLL 覆盖到这台机器的 ClassIsland 数据目录(默认 `%USERPROFILE%\ClassIsland\data`,
|
||||||
|
便携版则是安装目录下的 `data\`)。ClassIsland **运行时会锁住 DLL,必须先完全退出它**:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 1) 完全退出 ClassIsland(它会锁住 DLL)
|
# 1) 完全退出 ClassIsland(它会锁住 DLL)
|
||||||
Get-Process ClassIsland.Desktop | Stop-Process -Force
|
Get-Process ClassIsland.Desktop | Stop-Process -Force
|
||||||
# 2) 覆盖插件 DLL(**不要覆盖 Settings.json**,那里存着服务器地址与设备令牌)
|
# 2) 覆盖插件 DLL(**不要覆盖 Settings.json**,那里存着服务器地址与设备令牌)
|
||||||
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll `
|
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll `
|
||||||
D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
|
"$env:USERPROFILE\ClassIsland\data\Plugins\classhelper.classisland.bridge\" -Force
|
||||||
# 3) 重新启动
|
# 3) 重新启动
|
||||||
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
|
Start-Process "$env:USERPROFILE\ClassIsland\ClassIsland.exe"
|
||||||
```
|
```
|
||||||
|
|
||||||
改配置要在 **ClassIsland 退出时**改:运行中改会被内存里的副本随时回写覆盖。
|
改配置要在 **ClassIsland 退出时**改:运行中改会被内存里的副本随时回写覆盖。
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ add_permission_groups_and_clients
|
|||||||
## 开发时的模型改动
|
## 开发时的模型改动
|
||||||
|
|
||||||
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`
|
||||||
|
|||||||
+4
-4
@@ -48,8 +48,8 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
|||||||
|
|
||||||
## 仓库里另外两份文档
|
## 仓库里另外两份文档
|
||||||
|
|
||||||
- **[完整参考](../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 编码代理的工程约定与本机踩坑记录,**改代码前必读**。
|
工程约定与开发环境注意事项,**改代码前必读**。里面每一条硬约定都对应一处容易踩空的地方,
|
||||||
里面每一条硬约定都对应一次真实的翻车,比代码注释更值得先看一遍。
|
比代码注释更值得先看一遍。
|
||||||
+2
-2
@@ -191,8 +191,8 @@ gh workflow run release-linux-server.yml -f tag=v1.1.3 -f attach=true
|
|||||||
> **CI 是按 tag 的 ref 检出源码的。** 发完版又发现构建脚本(或任何影响出包的代码)要修时,
|
> **CI 是按 tag 的 ref 检出源码的。** 发完版又发现构建脚本(或任何影响出包的代码)要修时,
|
||||||
> **光推 master 不够** —— CI 仍会拿旧 tag 的源码跑,得把 tag 挪到修复后的提交:
|
> **光推 master 不够** —— CI 仍会拿旧 tag 的源码跑,得把 tag 挪到修复后的提交:
|
||||||
> `git tag -f v<版本> <提交> && git push --force origin v<版本>`,tag 一变会自动重跑。
|
> `git tag -f v<版本> <提交> && git push --force origin v<版本>`,tag 一变会自动重跑。
|
||||||
> 1.3.0 实测踩过:tag 指的那个提交里打包脚本有个 bug,CI 红了、Release 上少一个 Linux 包,
|
> 别拿「Windows 侧绿不绿」当判据:`pnpm dist:server` 走的是另一条分支,
|
||||||
> 而本地 `pnpm dist:server` 一直是绿的(Windows 侧走另一条分支)。
|
> Linux 那一步红的时候本地完全看不出来。
|
||||||
>
|
>
|
||||||
> 另外,本页第 8 步的「官网与文档站」是**另一条**流水线(master 推送触发)——
|
> 另外,本页第 8 步的「官网与文档站」是**另一条**流水线(master 推送触发)——
|
||||||
> 它绿不代表 Linux 包出来了,推完 tag 要去 `gh run list --limit 5` 看一眼。
|
> 它绿不代表 Linux 包出来了,推完 tag 要去 `gh run list --limit 5` 看一眼。
|
||||||
|
|||||||
+7
-6
@@ -53,21 +53,22 @@ pnpm build && pnpm verify:web
|
|||||||
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 |
|
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 |
|
||||||
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 |
|
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 |
|
||||||
| `verify:desktop`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 |
|
| `verify:desktop`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 |
|
||||||
| 岛交互 / 岛动画 / 岛截图留档 | **AI 会话里会间歇性失败**:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
|
| 岛交互 / 岛动画 / 岛截图留档 | 窗口被遮挡或跑在远程会话里时会间歇性失败:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
|
||||||
|
|
||||||
## 大面积报「登录失败 / 401」时
|
## 大面积报「登录失败 / 401」时
|
||||||
|
|
||||||
先核对本机 `teacher1` 的密码 —— 本机实测可能被改成了 `123456`,不是种子默认的 `teacher123`
|
先确认演示账号的**当前**口令。种子脚本硬编码的是 `teacher123`,但「修改密码」弹窗留空会落到
|
||||||
(原因是「修改密码」弹窗留空会落到默认初始密码)。**处理方式是用环境变量传实际凭据,不要去改数据。**
|
默认初始密码(`DEFAULT_TEACHER_PASSWORD`,默认 `123456`),任何一次误操作都会把它改掉 ——
|
||||||
|
而**两个演示账号不一定一起漂**,所以不能简单地全局替换。
|
||||||
|
|
||||||
两个演示账号的口令**不一定一起漂**,所以不能简单地全局替换。
|
**处理方式是用环境变量传实际凭据,不要去改数据。**
|
||||||
|
|
||||||
| 脚本 | 覆盖凭据的方式 |
|
| 脚本 | 覆盖凭据的方式 |
|
||||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` |
|
| `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` |
|
||||||
| `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) |
|
| `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) |
|
||||||
| `verify:web` | `set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web`(另有 `UI_SMOKE_ADMIN*`) |
|
| `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:e2e` | **不支持环境变量**(口令硬编码在脚本里),需自己用改过口令的临时副本跑;副本必须放在 `packages/server/scripts/` 里,否则 `import 'socket.io-client'` 会失败 |
|
||||||
|
|
||||||
## 冒烟会真的动你的屏幕
|
## 冒烟会真的动你的屏幕
|
||||||
|
|
||||||
|
|||||||
@@ -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)**,
|
||||||
|
|||||||
+2
-20
@@ -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 会被单实例锁秒退(看起来像没反应),
|
||||||
@@ -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 端顶栏 / 客户端「设置 → 关于」里都有)和日志。
|
||||||
@@ -23,9 +23,7 @@ description: 在教室机器上安装 ClassHelper桌面客户端,用班级码
|
|||||||
pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的解包目录)
|
pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的解包目录)
|
||||||
```
|
```
|
||||||
|
|
||||||
> **装不上 / 双击没反应 / 一闪就没**(报 `0x80000003`)通常不是产物坏了,而是交付物被打了
|
> **双击没反应?** 关窗只是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘右键退出。
|
||||||
> Low 完整性标签。打包脚本收尾已自动修正;从 Release 下载的包不受影响。
|
|
||||||
> 另外:关窗是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘退出。
|
|
||||||
|
|
||||||
## 2. 登录
|
## 2. 登录
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,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,7 +64,7 @@ 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。插件里对本地 / 内网地址做了强制直连,
|
||||||
> 但浏览器与客户端本身不会绕过。
|
> 但浏览器与客户端本身不会绕过。
|
||||||
|
|
||||||
|
|||||||
@@ -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 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已实测(163 项端到端) |
|
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已验证(端到端验收通过) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -101,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
|
||||||
@@ -392,7 +392,7 @@ pnpm --filter @classhelper/server start # 或 node packages/server/dist/ind
|
|||||||
|
|
||||||
## 八、性能与容量参考
|
## 八、性能与容量参考
|
||||||
|
|
||||||
- 单机 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),
|
||||||
|
|||||||
+51
-98
@@ -19,13 +19,12 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
|||||||
└──── 老师下发的提醒 ◄────────┘
|
└──── 老师下发的提醒 ◄────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
> 本文是 ClassHelper的**完整参考**:功能行为、接口契约、验收证据与已知问题都在这里,是功能口径的权威来源。
|
> 本文是 ClassHelper的**完整参考**:功能行为、接口契约与已知问题都在这里,是功能口径的权威来源。
|
||||||
> 面向「第一次打开仓库」的展示型介绍在 [README](https://github.com/LoveChengke/classhelper/blob/master/README.md);按读者分组的引导式文档在文档站(本目录)。
|
> 面向「第一次打开仓库」的展示型介绍在 [README](https://github.com/LoveChengke/classhelper/blob/master/README.md);按读者分组的引导式文档在文档站(本目录)。
|
||||||
> 工程约定(改代码前必读)与本机踩坑在 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md);生产部署在
|
> 贡献者需要的工程约定在 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md);生产部署在
|
||||||
> [production.md](management/production.md);切换数据库在 [mysql.md](management/mysql.md)。
|
> [production.md](management/production.md);切换数据库在 [mysql.md](management/mysql.md)。
|
||||||
> 文中的**文件路径均相对仓库根目录**(`packages/…`、`scripts/…`),只有目录链接是相对本文的。
|
> 文中的**文件路径均相对仓库根目录**(`packages/…`、`scripts/…`),只有目录链接是相对本文的。
|
||||||
> **改动交付形态 / 新增功能后请一并更新本文**;文中计数均标注实测日期,条目数由脚本自行统计,
|
> **改动交付形态 / 新增功能后请一并更新本文**;条目数由脚本自行统计,不要写死容易过期的总数。
|
||||||
> 不要写死容易过期的总数。
|
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
@@ -75,9 +74,9 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
|||||||
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#,装在教室的 ClassIsland 上(上报课表与上课状态、弹提醒) | 教室机器(独立于 pnpm workspace) |
|
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#,装在教室的 ClassIsland 上(上报课表与上课状态、弹提醒) | 教室机器(独立于 pnpm workspace) |
|
||||||
| 官网 | `website/` | 纯 HTML / CSS / JS 的静态页面,**没有构建步骤** | 展示与说明(`node website/serve.mjs` 本地预览) |
|
| 官网 | `website/` | 纯 HTML / CSS / JS 的静态页面,**没有构建步骤** | 展示与说明(`node website/serve.mjs` 本地预览) |
|
||||||
|
|
||||||
**设计取舍(与原始提示词的两处偏差,已与用户确认)**:
|
**两处技术选型取舍**:
|
||||||
|
|
||||||
1. 桌面客户端采用 **Electron + Vue 3**(而非 Avalonia/FluentAvalonia)——需求正文 90% 的内容
|
1. 桌面客户端采用 **Electron + Vue 3**(而非 Avalonia/FluentAvalonia)——需求里的绝大部分内容
|
||||||
(Socket.IO Client、Pinia、IndexedDB 离线缓存、electron-builder)都基于 Electron;
|
(Socket.IO Client、Pinia、IndexedDB 离线缓存、electron-builder)都基于 Electron;
|
||||||
2. 数据库**先用 SQLite(libSQL 内嵌)跑通,并支持切到 MySQL**:`docs/management/mysql.md` 给出切换步骤,
|
2. 数据库**先用 SQLite(libSQL 内嵌)跑通,并支持切到 MySQL**:`docs/management/mysql.md` 给出切换步骤,
|
||||||
`deploy/` 同时提供 MySQL 版与 SQLite 版 Compose。
|
`deploy/` 同时提供 MySQL 版与 SQLite 版 Compose。
|
||||||
@@ -95,8 +94,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
|||||||
|
|
||||||
> 这些产物**都不入库**(`releases/` 已 gitignore + eslint ignore)。三个打包脚本**直接输出**到
|
> 这些产物**都不入库**(`releases/` 已 gitignore + eslint ignore)。三个打包脚本**直接输出**到
|
||||||
> `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`(交付物)、`构建中间/`(可重新生成)分层,
|
> `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`(交付物)、`构建中间/`(可重新生成)分层,
|
||||||
> 历史版本原样保留。**没有免安装目录**:组装/解包出来的那一份只是中间物,脚本打完就删 ——
|
> 历史版本原样保留。**没有免安装目录**:组装/解包出来的那一份只是中间物,脚本打完就删。
|
||||||
> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §4。
|
|
||||||
|
|
||||||
四种部署形态按场景选(Linux 完整步骤见 [docs/management/linux-deploy.md](management/linux-deploy.md),
|
四种部署形态按场景选(Linux 完整步骤见 [docs/management/linux-deploy.md](management/linux-deploy.md),
|
||||||
其余见 [docs/management/production.md](management/production.md)):
|
其余见 [docs/management/production.md](management/production.md)):
|
||||||
@@ -119,7 +117,7 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
|
|||||||
> 参数接在最后即可,例如先只体检(不需要 root、不改动系统):把末尾换成
|
> 参数接在最后即可,例如先只体检(不需要 root、不改动系统):把末尾换成
|
||||||
> `sudo bash /tmp/classhelper-install.sh --check`。
|
> `sudo bash /tmp/classhelper-install.sh --check`。
|
||||||
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`
|
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`
|
||||||
> 而不是 `$1`,脚本收不到(实测踩到:想体检却走进了交互式安装)。
|
> 而不是 `$1`,脚本收不到(想体检却会走进交互式安装)。
|
||||||
|
|
||||||
## 3. 快速开始
|
## 3. 快速开始
|
||||||
|
|
||||||
@@ -167,7 +165,8 @@ pnpm build:desktop
|
|||||||
pnpm verify:desktop
|
pnpm verify:desktop
|
||||||
```
|
```
|
||||||
|
|
||||||
> Electron 二进制**不会**随 `pnpm install` 自动下载,缺失时先手动装一次(见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7 第 5 条)。
|
> Electron 二进制**不会**随 `pnpm install` 自动下载,缺失时先手动装一次
|
||||||
|
> (见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7 第 5 条)。
|
||||||
|
|
||||||
客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,输入**班级码 + 班级密码**
|
客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,输入**班级码 + 班级密码**
|
||||||
(种子数据:`G101` / `123456`)即可进入班级。想验证「断网可查看缓存」:登录并浏览若干页面后停掉后端
|
(种子数据:`G101` / `123456`)即可进入班级。想验证「断网可查看缓存」:登录并浏览若干页面后停掉后端
|
||||||
@@ -184,7 +183,7 @@ pnpm verify:desktop
|
|||||||
|
|
||||||
### 默认账号与种子数据
|
### 默认账号与种子数据
|
||||||
|
|
||||||
`pnpm db:seed` 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印,**2026-10-06 实测**:
|
`pnpm db:seed` 会清空业务表并写入一套完整演示数据(计数由种子脚本自己打印:
|
||||||
账号 3 / 在读班级 3 / 学生 15 / 课程 54(每班 18 科)/ 课表 93 / 作业 16 / 通知 13 / 成绩 90 /
|
账号 3 / 在读班级 3 / 学生 15 / 课程 54(每班 18 科)/ 课表 93 / 作业 16 / 通知 13 / 成绩 90 /
|
||||||
调班历史 2 / 已归档届别 1):
|
调班历史 2 / 已归档届别 1):
|
||||||
|
|
||||||
@@ -233,8 +232,8 @@ pnpm verify:desktop
|
|||||||
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
||||||
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查(`format:check` 在 master 基线上本就失败,只检查改过的文件) |
|
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查(`format:check` 在 master 基线上本就失败,只检查改过的文件) |
|
||||||
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
|
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
|
||||||
| `pnpm db:migrate` | 创建并应用迁移(`prisma migrate dev`;本机不可靠,用 db:deploy) |
|
| `pnpm db:migrate` | 创建并应用迁移(`prisma migrate dev`;在 Windows 上不可靠,用 db:deploy) |
|
||||||
| `pnpm --filter @classhelper/server db:deploy` | 应用已有迁移(**本机推荐**;根 scripts 里没有 `db:deploy`) |
|
| `pnpm --filter @classhelper/server db:deploy` | 应用已有迁移(**推荐**;根 scripts 里没有 `db:deploy`) |
|
||||||
| `pnpm db:seed` / `pnpm db:reset` | 写入种子数据 / 重置并重播种子 |
|
| `pnpm db:seed` / `pnpm db:reset` | 写入种子数据 / 重置并重播种子 |
|
||||||
| `pnpm db:studio` | 打开 Prisma Studio |
|
| `pnpm db:studio` | 打开 Prisma Studio |
|
||||||
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(无第二份 schema) |
|
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(无第二份 schema) |
|
||||||
@@ -809,8 +808,8 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
|
|||||||
> 打包末尾会做一次**运行时裁剪**(删掉 Prisma 为其它数据库方言准备的查询编译器副本等死载荷,
|
> 打包末尾会做一次**运行时裁剪**(删掉 Prisma 为其它数据库方言准备的查询编译器副本等死载荷,
|
||||||
> 见 `scripts/dist-server.mjs` 的 `pruneRuntime()`),组装出来的运行时 469MB → 335MB
|
> 见 `scripts/dist-server.mjs` 的 `pruneRuntime()`),组装出来的运行时 469MB → 335MB
|
||||||
> (这份运行时现在只是中间物,打完安装程序即删)。
|
> (这份运行时现在只是中间物,打完安装程序即删)。
|
||||||
> - 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致);e2e 只覆盖校验分支
|
> - e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`;
|
||||||
> (同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`。
|
> 真实切换走的子进程链路已完整验证(快照 728 行逐表一致)。
|
||||||
|
|
||||||
### 4.10 更新检查
|
### 4.10 更新检查
|
||||||
|
|
||||||
@@ -963,7 +962,8 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
|
|||||||
| 命令种类 | 关机 / 重启 / 注销 / 锁屏(`CLIENT_COMMAND_KINDS` + `CLIENT_COMMAND_LABELS`) |
|
| 命令种类 | 关机 / 重启 / 注销 / 锁屏(`CLIENT_COMMAND_KINDS` + `CLIENT_COMMAND_LABELS`) |
|
||||||
| 新命令取代旧命令 | 同一台机器未回执的旧命令会被自动撤销(先关机后重启不会排队执行) |
|
| 新命令取代旧命令 | 同一台机器未回执的旧命令会被自动撤销(先关机后重启不会排队执行) |
|
||||||
| 回执 | `POST /clients/commands/:id/ack`,body 里的 `deviceKey` 既是身份也是护栏(班级端令牌全班共用),且**幂等** |
|
| 回执 | `POST /clients/commands/:id/ack`,body 里的 `deviceKey` 既是身份也是护栏(班级端令牌全班共用),且**幂等** |
|
||||||
| 二次确认 | 客户端执行前必须在界面上二次确认;回执里带成功 / 失败原因 |
|
| 二次确认 | 客户端执行前必须在界面上二次确认(**默认选中「取消」**,**60 秒未确认按取消处理**);回执里带成功 / 失败原因 |
|
||||||
|
| 上次指令 | 「客户端」页的详情表显示最近一条命令的结果(已执行 / 本机取消 / 已过期 / 待执行)——排"点了没反应"先看它 |
|
||||||
|
|
||||||
**安装包由服务端中继下发**(教室机器常常没有外网,Web 端的 CSP 也不允许直连 GitHub):
|
**安装包由服务端中继下发**(教室机器常常没有外网,Web 端的 CSP 也不允许直连 GitHub):
|
||||||
|
|
||||||
@@ -1163,14 +1163,14 @@ class-helper/
|
|||||||
| --------- | -------------------------------------------------------------------- | ---------------------------------------------------------- |
|
| --------- | -------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||||
| 包管理 | pnpm workspace | 11.8.0 |
|
| 包管理 | pnpm workspace | 11.8.0 |
|
||||||
| 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) |
|
| 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) |
|
||||||
| 后端 | Node.js + Express | ≥ 20.19(本机 24.19) / 5.2 |
|
| 后端 | Node.js + Express | ≥ 20.19 / 5.2 |
|
||||||
| ORM | Prisma + driver adapter | 7.10(+ `@prisma/adapter-libsql`,切 MySQL 用 `-mariadb`) |
|
| ORM | Prisma + driver adapter | 7.10(+ `@prisma/adapter-libsql`,切 MySQL 用 `-mariadb`) |
|
||||||
| 实时 | Socket.IO | 4.8 |
|
| 实时 | Socket.IO | 4.8 |
|
||||||
| 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 |
|
| 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 |
|
||||||
| 校验 | 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(本机 SDK 8.0.425) |
|
| 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0 |
|
||||||
| 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 |
|
| 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 |
|
||||||
| Lint/格式 | ESLint 10 + typescript-eslint 8 + eslint-plugin-vue 10;Prettier 3 | — |
|
| Lint/格式 | ESLint 10 + typescript-eslint 8 + eslint-plugin-vue 10;Prettier 3 | — |
|
||||||
|
|
||||||
@@ -1467,7 +1467,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
|||||||
| 灵动岛 | **刻意只有单调缓动**(曲线取自同一套令牌,但不用弹簧):岛的形变由主进程缓动窗口尺寸,任何过冲都会让"开合"看起来在震动(冒烟逐帧断言)。悬停微交互关在 `@media (hover: hover)` 里,避免触摸屏上"幽灵 hover"粘住按钮 |
|
| 灵动岛 | **刻意只有单调缓动**(曲线取自同一套令牌,但不用弹簧):岛的形变由主进程缓动窗口尺寸,任何过冲都会让"开合"看起来在震动(冒烟逐帧断言)。悬停微交互关在 `@media (hover: hover)` 里,避免触摸屏上"幽灵 hover"粘住按钮 |
|
||||||
| 降低动态效果 | 全站 `prefers-reduced-motion` 降级(**加载指示旋除外** —— 停掉会让界面看着像卡死) |
|
| 降低动态效果 | 全站 `prefers-reduced-motion` 降级(**加载指示旋除外** —— 停掉会让界面看着像卡死) |
|
||||||
|
|
||||||
三条**别踩**的约定(都对应实测过的坑,详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §5 第 61 条):
|
三条**别踩**的约定(详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §5 第 61 条):
|
||||||
路由层**不要**包 `<transition mode="out-in">`;Vue 的 `<transition>` 一律显式给 `:duration`;
|
路由层**不要**包 `<transition mode="out-in">`;Vue 的 `<transition>` 一律显式给 `:duration`;
|
||||||
`position: fixed` 的整屏层与"自己量尺寸"的元素不要叠位移类动画。
|
`position: fixed` 的整屏层与"自己量尺寸"的元素不要叠位移类动画。
|
||||||
|
|
||||||
@@ -1492,7 +1492,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
|||||||
|
|
||||||
> ⚠️ 改令牌时**只改值**。别对样式表跑"把 `#303133` 全量替换成 `var(--ch-text)`"这类批量替换 ——
|
> ⚠️ 改令牌时**只改值**。别对样式表跑"把 `#303133` 全量替换成 `var(--ch-text)`"这类批量替换 ——
|
||||||
> 它会把令牌**定义本身**也换掉(`--ch-text: #303133` → `--ch-text: var(--ch-text)`),
|
> 它会把令牌**定义本身**也换掉(`--ch-text: #303133` → `--ch-text: var(--ch-text)`),
|
||||||
> 自定义属性一旦自引用就整条失效、解析成空串,全站文字颜色静默回落成继承色(实测踩过)。
|
> 自定义属性一旦自引用就整条失效、解析成空串,全站文字颜色静默回落成继承色。
|
||||||
|
|
||||||
## 6. 验收
|
## 6. 验收
|
||||||
|
|
||||||
@@ -1500,7 +1500,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
|||||||
|
|
||||||
| 验收项 | 结果 | 证据 |
|
| 验收项 | 结果 | 证据 |
|
||||||
| ------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 教师 Web 端发布通知,ClassHelper 班级端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` 实测 30–50ms 到达(要求 < 5 秒) |
|
| 教师 Web 端发布通知,ClassHelper 班级端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` 到达耗时 30–50ms(要求 < 5 秒) |
|
||||||
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` 实时到达,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
|
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` 实时到达,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
|
||||||
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` 实时到达,`/grades/my` 返回记录;批量录入与统计接口通过 |
|
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` 实时到达,`/grades/my` 返回记录;批量录入与统计接口通过 |
|
||||||
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回周视图条目,`week` 过滤 `weekStart ≤ week ≤ weekEnd`,单双周按第 1 周 = 单周过滤 |
|
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回周视图条目,`week` 过滤 `weekStart ≤ week ≤ weekEnd`,单双周按第 1 周 = 单周过滤 |
|
||||||
@@ -1517,7 +1517,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
|||||||
| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:收起后胶囊常驻、「收起态点击可再次展开」、「空闲细缝态保持可交互」 |
|
| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:收起后胶囊常驻、「收起态点击可再次展开」、「空闲细缝态保持可交互」 |
|
||||||
| **触摸屏触摸灵动岛可展开(希沃白板)** | ⏳ | 触摸模式下窗口贴合岛体 + 始终接收输入;`verify:desktop` 断言「窗口 = 岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(触摸屏)需人工复验** |
|
| **触摸屏触摸灵动岛可展开(希沃白板)** | ⏳ | 触摸模式下窗口贴合岛体 + 始终接收输入;`verify:desktop` 断言「窗口 = 岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(触摸屏)需人工复验** |
|
||||||
| 叫人(老师点名,分紧急 / 普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 |
|
| 叫人(老师点名,分紧急 / 普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 |
|
||||||
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗实测 |
|
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗走通 |
|
||||||
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍在、merge 覆盖与保留行为符合预期 |
|
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍在、merge 覆盖与保留行为符合预期 |
|
||||||
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 N 个迁移文件,跳过 M 个已存在对象`,新表自动建好 |
|
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 N 个迁移文件,跳过 M 个已存在对象`,新表自动建好 |
|
||||||
| ClassHelper 班级端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 |
|
| ClassHelper 班级端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 |
|
||||||
@@ -1532,9 +1532,9 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
|||||||
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文 / 占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
|
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文 / 占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
|
||||||
| **新手引导(首启弹出 + 随时重看)** | ✅ | Web 管理端:首次登录弹出 `el-tour` 聚焦引导,头像菜单「使用引导」重看(`verify:web`);客户端:首启六步向导,登录页 / 设置页可重看(`verify:desktop`) |
|
| **新手引导(首启弹出 + 随时重看)** | ✅ | Web 管理端:首次登录弹出 `el-tour` 聚焦引导,头像菜单「使用引导」重看(`verify:web`);客户端:首启六步向导,登录页 / 设置页可重看(`verify:desktop`) |
|
||||||
| **客户端设置改版(分组侧栏 / 深色模式 / 关于页)** | ✅ | `verify:desktop`:「侧边栏点击导航」适配「学习 / 设置」分组;「主题切换与侧边栏折叠(真实点击、写回配置)」——`html.dark` 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染」含 configPath |
|
| **客户端设置改版(分组侧栏 / 深色模式 / 关于页)** | ✅ | `verify:desktop`:「侧边栏点击导航」适配「学习 / 设置」分组;「主题切换与侧边栏折叠(真实点击、写回配置)」——`html.dark` 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染」含 configPath |
|
||||||
| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与全部数据表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 |
|
| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与全部数据表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路已完整走通**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 |
|
||||||
| **更新检查(服务端 / Web 端 / 客户端)** | ✅ | `verify:e2e` 4 项:未登录 401、管理员结构完整且本机版本取自 package.json、教师可查但 `force` 仅管理员生效、健康检查暴露版本号与模块清单(含 update 模块);`verify:desktop` 4 项:IPC 返回结构正确(无外网时降级 `ok=false` 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;`verify:web`:「顶栏用户菜单 → 弹窗给出结论并可关闭」。**真机联网实测**:`GET /api/update/check` → `ok=true`、`latestVersion=1.0.0`、5 个附件、更新说明 1358 字 |
|
| **更新检查(服务端 / Web 端 / 客户端)** | ✅ | `verify:e2e` 4 项:未登录 401、管理员结构完整且版本取自 package.json、教师可查但 `force` 仅管理员生效、健康检查暴露版本号与模块清单(含 update 模块);`verify:desktop` 4 项:IPC 返回结构正确(无外网时降级 `ok=false` 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;`verify:web`:「顶栏用户菜单 → 弹窗给出结论并可关闭」 |
|
||||||
| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;2026-09-27 在真机(ClassIsland 2.1.0.1 + 本插件)上实测过,当时的抓图留在 `docs/screenshots/classisland/` |
|
| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;抓图留在 `docs/screenshots/classisland/` |
|
||||||
| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 |
|
| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 |
|
||||||
| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 通用」管理短语 |
|
| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 通用」管理短语 |
|
||||||
| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 提醒」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:`client` 时通知与联动页下发都被跳过) |
|
| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 提醒」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:`client` 时通知与联动页下发都被跳过) |
|
||||||
@@ -1543,53 +1543,15 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
|||||||
| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe`(见 [7. 打包与交付](#7-打包与交付)) |
|
| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe`(见 [7. 打包与交付](#7-打包与交付)) |
|
||||||
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题 |
|
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题 |
|
||||||
|
|
||||||
### 6.2 最近一次实测
|
### 6.2 怎么自己跑一遍
|
||||||
|
|
||||||
| 验证 | 结果 |
|
上面那张表的证据全部来自仓库里的验收脚本 —— 五套脚本怎么跑、覆盖什么、哪些用例是脆弱的、
|
||||||
| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
失败了怎么判断是不是真回归,见[验收与测试](dev/testing.md)。条目数由脚本自行统计并打印,
|
||||||
| `pnpm verify:e2e`(后端 + 实时 + RBAC + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查) | **190/190** ✅(2026-10-05 实测) |
|
不要在文档里写死容易过期的总数。
|
||||||
| `pnpm verify:desktop`(客户端冒烟,不弹窗) | **107/107** ✅ + **89/89** ✅(2026-10-06 实测,0 失败) |
|
|
||||||
| `pnpm verify:web`(Web 管理端真实点击回归) | **32/33**(2026-10-06 实测;未过的 1 项是既有脆弱用例「课表科目为全校统一目录」,见下) |
|
|
||||||
| `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅(2026-10-05 实测) |
|
|
||||||
| `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ |
|
|
||||||
| `pnpm typecheck` / `pnpm lint` / `pnpm check:icons` | 全过 ✅(lint 0 error **0 warning**) |
|
|
||||||
| `pnpm build:desktop`(含产物凭据门禁 `check-bundle-secrets`) | 通过 ✅(61 个产物文件里无种子凭据字面量) |
|
|
||||||
| `pnpm verify:packaged --exe …`(对打包副本复验) | **105/105** ✅ + **89/89** ✅(2026-10-05 实测,0 失败) |
|
|
||||||
| 裁剪后的服务端运行时(起服务 → 登录查表 → `prisma generate` → `tsc` → `db push`) | 通过 ✅(2026-10-05 实测,建出 14 张表;详见 AGENTS.md §8) |
|
|
||||||
| 插件 `dotnet` 编译(`pnpm build:classisland-plugin`) | 0 错误 ✅(1 条预期内的 Avalonia `AVLN3001` 警告) |
|
|
||||||
| 数据库切换子进程链路(快照 → generate → db push → 写入目标库) | ✅ 实测:728 行逐表一致(users / schedules / grades 抽查一致) |
|
|
||||||
| `pnpm dist:server`(服务端安装包 + Web 端) | ✅ 打出 ~62MB 安装程序(随包内置切换链路所需的 prisma CLI 与 tsc,并在打包末尾做运行时裁剪) |
|
|
||||||
| 安装程序完整生命周期(静默安装 → 启动 → 卸载) | 通过 ✅(安装到 `%LOCALAPPDATA%\Programs`,卸载干净) |
|
|
||||||
| 便捷版(单文件)解包启动 | 通过 ✅(wrapper + 多个进程,主窗口正常) |
|
|
||||||
| Docker / Nginx 部署样例 | 文件已提供;本机无 Docker 未实测 ⚠️ |
|
|
||||||
| Linux 一键安装 + `classhelper` 运维命令(`deploy/install.sh` / `deploy/classhelper` / `pnpm dist:server:linux`) | 脚本与文档已交付,本地静态检查通过(`bash -n`、ESLint 0 error);**Linux 实机验收待做** ⚠️(清单见 [linux-deploy.md](management/linux-deploy.md) §10,`deploy/verify-linux.sh` 可自动跑)。2026-10-06 用交叉构建出了 1.1.2 的包并核对过内容(sha256 自洽、顶层目录与 `--strip-components=1` 对得上、`.cross-built` 标记在位、包内是新图标),但**没在 Linux 上跑过** —— 正式发布仍走 CI |
|
|
||||||
|
|
||||||
### 6.3 已知的时序 / 环境相关用例
|
|
||||||
|
|
||||||
这些用例**不是回归**,遇到时先看条件,别急着改代码(完整清单见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 的「已知脆弱用例」表):
|
|
||||||
|
|
||||||
- **`verify:desktop` 的「真实通知链路」「灵动岛标为已读」等 3 项**:要求**当前不在上课时段**。
|
|
||||||
若种子课表恰有课正在进行,客户端会**正确地**把普通通知暂存到下课(见 [4.6](#46-上课时段策略)),
|
|
||||||
这三项就会红(日志里会写 `客户端判定上课中=true` / `正在上 XXX`)。2026-10-02 那次 99/102 即为此因,
|
|
||||||
2026-10-06 09:27(种子里的「政治 08:55–09:40」正在上)那次 104/107 同因。
|
|
||||||
- **`verify:e2e` 的「上课状态接口(at=周一 08:10)」**:种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07`
|
|
||||||
当第 1 周 —— `.env` 的 `TERM_START_DATE` 对齐到 `2026-09-07` 即全绿。
|
|
||||||
- **`verify:web` 的「课表科目为全校统一目录」**:种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组。
|
|
||||||
- **灵动岛交互 / 动画 / 截图留档几个用例在 AI 会话里会间歇性失败**:置顶透明小窗的 rAF 被限流、形变采样抓不到帧,
|
|
||||||
属时序敏感;**先原样重跑一次再动手改代码**。
|
|
||||||
- **跑验证脚本大面积报「登录失败 / 401」**:先核对本机 `teacher1` 的密码 —— 本机实测被改成了 `123456`,
|
|
||||||
不是种子默认的 `teacher123`(原因见 AGENTS.md §7 第 51 条)。三套脚本各有各的传参方式:
|
|
||||||
|
|
||||||
| 脚本 | 覆盖凭据的方式 |
|
|
||||||
| ----------------- | --------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` |
|
|
||||||
| `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) |
|
|
||||||
| `verify:web` | `set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web`(另有 `UI_SMOKE_ADMIN*`) |
|
|
||||||
| `verify:e2e` | **不支持环境变量**(口令硬编码在脚本里),需先确认本机种子口令或用临时副本(见 AGENTS.md §7 第 51 条) |
|
|
||||||
|
|
||||||
## 7. 打包与交付
|
## 7. 打包与交付
|
||||||
|
|
||||||
### 体积与性能(2026-10-05 优化后实测)
|
### 体积与性能(优化后)
|
||||||
|
|
||||||
| 产物 / 指标 | 优化前 | 优化后 | 做了什么 |
|
| 产物 / 指标 | 优化前 | 优化后 | 做了什么 |
|
||||||
| ----------------------------------- | -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------------------- | -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -1606,7 +1568,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
|||||||
> 没有做的事,以及为什么:Element Plus 仍全量引入(按需引入要改所有视图的样式引入方式与 locale 配置,
|
> 没有做的事,以及为什么:Element Plus 仍全量引入(按需引入要改所有视图的样式引入方式与 locale 配置,
|
||||||
> 收益约 200 到 350KB JS,风险不成比例);服务端静态资源不做预压缩;客户端不再删 GPU 回退组件
|
> 收益约 200 到 350KB JS,风险不成比例);服务端静态资源不做预压缩;客户端不再删 GPU 回退组件
|
||||||
> (dxcompiler / swiftshader / d3dcompiler,共约 37MB 未压缩,教室老显卡与虚拟机靠它们)。
|
> (dxcompiler / swiftshader / d3dcompiler,共约 37MB 未压缩,教室老显卡与虚拟机靠它们)。
|
||||||
> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 的 §5 第 53 至 56 条与 §7 第 23 条。
|
> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 的 §5 第 53 至 56 条。
|
||||||
|
|
||||||
### 客户端(Windows EXE)
|
### 客户端(Windows EXE)
|
||||||
|
|
||||||
@@ -1619,8 +1581,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
|||||||
`ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`;
|
`ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`;
|
||||||
用 `--config.directories.output` 把输出目录覆盖成 `releases/client/<版本>/`,打包成功后再把
|
用 `--config.directories.output` 把输出目录覆盖成 `releases/client/<版本>/`,打包成功后再把
|
||||||
electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并把 `win-unpacked/`(约 323MB 的中间物)
|
electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并把 `win-unpacked/`(约 323MB 的中间物)
|
||||||
删掉;收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,
|
删掉。
|
||||||
见 [8. 常见问题](#8-常见问题))。
|
|
||||||
|
|
||||||
**只有两个交付物**,都落在 `releases/client/<版本>/安装包/`:`-x64-setup.exe`(NSIS 安装程序)
|
**只有两个交付物**,都落在 `releases/client/<版本>/安装包/`:`-x64-setup.exe`(NSIS 安装程序)
|
||||||
与 `-x64-portable.exe`(自解压单文件,双击即用)。解包目录 `win-unpacked/` 只是生成这两个 exe 的
|
与 `-x64-portable.exe`(自解压单文件,双击即用)。解包目录 `win-unpacked/` 只是生成这两个 exe 的
|
||||||
@@ -1628,14 +1589,14 @@ electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并
|
|||||||
`tidyArtifacts()` 就排在最后。
|
`tidyArtifacts()` 就排在最后。
|
||||||
|
|
||||||
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/` 里的两个安装包。
|
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/` 里的两个安装包。
|
||||||
> 若学生机上是**安装版**(例如装在 `D:\class\@classhelperdesktop-client`),只更新 release 而不同步安装目录,
|
> 若学生机上是**安装版**(例如装在 `%LOCALAPPDATA%\Programs\ClassHelper`),只更新 release 而不同步安装目录,
|
||||||
> 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。两个可行做法:
|
> 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。两个可行做法:
|
||||||
>
|
>
|
||||||
> ```powershell
|
> ```powershell
|
||||||
> # 做法一(推荐):直接跑新安装包覆盖升级。用户配置在 %APPDATA%,不受影响。
|
> # 做法一(推荐):直接跑新安装包覆盖升级。用户配置在 %APPDATA%,不受影响。
|
||||||
> # 静默:releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe /S
|
> # 静默:releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe /S
|
||||||
> # 再实测**装好那个副本**真的带上了修复(packaged=true)
|
> # 再对**装好那个副本**跑一次冒烟,确认真的带上了修复(packaged=true)
|
||||||
> pnpm verify:packaged --exe "D:\class\@classhelperdesktop-client\ClassHelper.exe"
|
> pnpm verify:packaged --exe "%LOCALAPPDATA%\Programs\ClassHelper\ClassHelper.exe"
|
||||||
>
|
>
|
||||||
> # 做法二:要按文件覆盖(例如只换 app.asar),先让 portable 自解压出一份再拷
|
> # 做法二:要按文件覆盖(例如只换 app.asar),先让 portable 自解压出一份再拷
|
||||||
> # 便携版双击后会解到 %TEMP% 下的一个随机目录,从那里 robocopy 即可。
|
> # 便携版双击后会解到 %TEMP% 下的一个随机目录,从那里 robocopy 即可。
|
||||||
@@ -1644,14 +1605,14 @@ electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并
|
|||||||
> 注意路径变了:`dist-win.mjs` 不再在 releases 里留解包目录,
|
> 注意路径变了:`dist-win.mjs` 不再在 releases 里留解包目录,
|
||||||
> 所以 `packages\desktop-client\release\win-unpacked` 这个旧路径已经不存在了。
|
> 所以 `packages\desktop-client\release\win-unpacked` 这个旧路径已经不存在了。
|
||||||
|
|
||||||
最近一次打包实测(产物不入库):
|
各产物的体积(产物不入库):
|
||||||
|
|
||||||
| 产物 | 大小 | 冒烟结果 |
|
| 产物 | 大小 |
|
||||||
| ------------------------------------------------- | ------ | -------------------------------------------------------------------- |
|
| ---------------------------------------------- | ----- |
|
||||||
| 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 |
|
| 服务端安装程序(内置 Node + Web 端) | ~62MB |
|
||||||
| `releases/client/<版本>/安装包/…-portable.exe` | ~99MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 |
|
| `releases/client/<版本>/安装包/…-portable.exe` | ~99MB |
|
||||||
| `releases/client/<版本>/安装包/…-setup.exe` | ~99MB | ✅ 构建成功,已嵌入自定义图标 |
|
| `releases/client/<版本>/安装包/…-setup.exe` | ~99MB |
|
||||||
| `releases/classisland-plugin/<版本>/…cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 |
|
| `releases/classisland-plugin/<版本>/…cipx` | ~88KB |
|
||||||
|
|
||||||
### ClassIsland 插件
|
### ClassIsland 插件
|
||||||
|
|
||||||
@@ -1727,8 +1688,8 @@ node website/serve.mjs # http://127.0.0.1:5180,只是本地预览用
|
|||||||
展示图统一放在 `docs/images/` —— 文档站、README 与官网吃的是**同一份文件**,
|
展示图统一放在 `docs/images/` —— 文档站、README 与官网吃的是**同一份文件**,
|
||||||
重新采集的完整命令(含目录与清理)写在[验收与测试](dev/testing.md)的「截图留档」一节。
|
重新采集的完整命令(含目录与清理)写在[验收与测试](dev/testing.md)的「截图留档」一节。
|
||||||
|
|
||||||
两个环境变量在 `smoke.ts` 里是**默认值为洋红**的开关:冒烟平时故意用洋红主题色把验证实例的岛
|
两个环境变量在 `smoke.ts` 里是**默认值为洋红**的开关:冒烟平时故意用洋红主题色,把验证实例的岛
|
||||||
和用户自己的岛区分开(见 AGENTS.md §5 第 38 条),采集留档图时才换成产品默认外观。
|
与真实客户端区分开(见 AGENTS.md §5 第 38 条),采集留档图时才换成产品默认外观。
|
||||||
|
|
||||||
> 采集脚本会把窗口**真的弹在屏幕上**(和 `verify:web` / `verify:desktop` 一样),跑的时候别动键鼠。
|
> 采集脚本会把窗口**真的弹在屏幕上**(和 `verify:web` / `verify:desktop` 一样),跑的时候别动键鼠。
|
||||||
> 客户端留档会**临时停掉 CSS 动画**再截图:窗口被遮挡时 Chromium 冻结动画,页面入场动画的起始帧是
|
> 客户端留档会**临时停掉 CSS 动画**再截图:窗口被遮挡时 Chromium 冻结动画,页面入场动画的起始帧是
|
||||||
@@ -1737,30 +1698,22 @@ node website/serve.mjs # http://127.0.0.1:5180,只是本地预览用
|
|||||||
|
|
||||||
## 8. 常见问题
|
## 8. 常见问题
|
||||||
|
|
||||||
**开发环境的坑请直接看 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7「本机环境已知坑」** —— 那里是本仓库唯一维护这些内容的
|
**开发与构建环境的坑不在本文维护** —— 统一记在仓库根的
|
||||||
地方(含命令坑、代理与 TLS、Low 完整性标签、makensis 临时目录、ClassIsland 路径与日志编码、AI 会话里跑
|
[AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7(命令坑、代理与 TLS、
|
||||||
Electron 验证等)。这里只列最常被问到的几条:
|
makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列最常被问到的几条:
|
||||||
|
|
||||||
1. **客户端装不上 / 便捷版双击没反应 / 应用一闪就没(0x80000003)**
|
1. **文件传输 / 上传报「服务器内部错误:request entity too large」?**
|
||||||
现象:安装程序报「拒绝访问」,便捷版双击毫无反应,装上了也启动即退且不写日志。
|
|
||||||
原因:**交付物被打了 Low 完整性标签**(本仓库目录树带此标签、可继承,在仓库内构建就会继承)——
|
|
||||||
带 Low 标签的 exe 会被 Windows 降级运行,写不进 `%LOCALAPPDATA%` / `%TEMP%`;即使装上,Chromium 的
|
|
||||||
子进程(AppContainer,完整性更低)也读不到这些文件而秒崩。
|
|
||||||
处置:`pnpm dist:win` 收尾已自动改成 Medium;手工补救与排查命令见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7 第 7 条。
|
|
||||||
**别把安装测试装进仓库目录里**(会在那儿又继承 Low,看起来像「装完还是坏的」)。
|
|
||||||
|
|
||||||
2. **文件传输 / 上传报「服务器内部错误:request entity too large」?**
|
|
||||||
表格导入的文件上限是 **8MB**(请求体 12MB),超限应返回 413 `IMPORT_TOO_LARGE` 与中文提示。
|
表格导入的文件上限是 **8MB**(请求体 12MB),超限应返回 413 `IMPORT_TOO_LARGE` 与中文提示。
|
||||||
若看到英文的英文兜底错误,说明该异常没被 `middleware/error.ts` 映射,属 bug。
|
若看到英文的英文兜底错误,说明该异常没被 `middleware/error.ts` 映射,属 bug。
|
||||||
|
|
||||||
3. **`pnpm db:deploy` 在根目录不存在**
|
2. **`pnpm db:deploy` 在根目录不存在**
|
||||||
根 `package.json` 只声明了 `db:generate / db:migrate / db:seed / db:reset / db:studio`。
|
根 `package.json` 只声明了 `db:generate / db:migrate / db:seed / db:reset / db:studio`。
|
||||||
正确写法:`pnpm --filter @classhelper/server db:deploy`。
|
正确写法:`pnpm --filter @classhelper/server db:deploy`。
|
||||||
|
|
||||||
4. **`pnpm db:seed` 报 `Cannot find module '.../@classhelper/shared/dist/index.js'`**
|
3. **`pnpm db:seed` 报 `Cannot find module '.../@classhelper/shared/dist/index.js'`**
|
||||||
`packages/shared` 还没构建(它产出 `dist`,三端与 seed 都依赖)。先 `pnpm build:shared`。
|
`packages/shared` 还没构建(它产出 `dist`,三端与 seed 都依赖)。先 `pnpm build:shared`。
|
||||||
|
|
||||||
5. **后端改了代码不生效?** `tsx src/index.ts` 不热载 —— 用 `pnpm dev:server`(watch),或重启服务端。
|
4. **后端改了代码不生效?** `tsx src/index.ts` 不热载 —— 用 `pnpm dev:server`(watch),或重启服务端。
|
||||||
切换数据库之后**必须重启**才会连新库。
|
切换数据库之后**必须重启**才会连新库。
|
||||||
|
|
||||||
## 9. 后续可选增强
|
## 9. 后续可选增强
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "class-helper",
|
"name": "class-helper",
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||||
"packageManager": "pnpm@11.8.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ description: |-
|
|||||||
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
|
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
|
||||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||||
url: https://github.com/LoveChengke/ClassHelper
|
url: https://github.com/LoveChengke/ClassHelper
|
||||||
version: 1.3.1.0
|
version: 1.3.2.0
|
||||||
apiVersion: 2.0.0.0
|
apiVersion: 2.0.0.0
|
||||||
author: ClassHelper
|
author: ClassHelper
|
||||||
@@ -514,7 +514,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
/* ---------------------------------------------------------------- 内部实现 */
|
/* ---------------------------------------------------------------- 内部实现 */
|
||||||
|
|
||||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||||
private const string PluginVersion = "1.3.1.0";
|
private const string PluginVersion = "1.3.2.0";
|
||||||
|
|
||||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||||
private static string SafeAppVersion()
|
private static string SafeAppVersion()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/desktop-client",
|
"name": "@classhelper/desktop-client",
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"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",
|
||||||
|
|||||||
@@ -317,7 +317,22 @@ async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; r
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
|
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
|
||||||
spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
const child = spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true });
|
||||||
|
/**
|
||||||
|
* **必须监听 `error` 事件**:`spawn` 找不到可执行文件(或没权限)时不是抛异常,而是在
|
||||||
|
* next tick 上发一个 `error` 事件 —— 只包 `try/catch` 的话,这种情况会一路走到
|
||||||
|
* `return { ok: true }`,回执写着"已下达系统命令",Web 端看起来一切正常,机器却纹丝不动。
|
||||||
|
* 等一个很短的窗口(200ms)确认没有立刻报错;真执行成功时子进程本来也要跑一会儿。
|
||||||
|
*/
|
||||||
|
const failure = await new Promise<string | null>((resolve) => {
|
||||||
|
child.once('error', (error) => resolve(error.message));
|
||||||
|
setTimeout(() => resolve(null), 200);
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
if (failure) {
|
||||||
|
logger.warn(`远控命令「${CLIENT_COMMAND_LABELS[kind] ?? kind}」无法执行:${failure}`);
|
||||||
|
return { ok: false, result: failure };
|
||||||
|
}
|
||||||
return { ok: true, result: '已下达系统命令' };
|
return { ok: true, result: '已下达系统命令' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { ok: false, result: error instanceof Error ? error.message : String(error) };
|
return { ok: false, result: error instanceof Error ? error.message : String(error) };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/server",
|
"name": "@classhelper/server",
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -209,9 +209,22 @@ export async function listDevices(options: { classId?: string } = {}): Promise<C
|
|||||||
if (!pendingByDevice.has(command.deviceId)) pendingByDevice.set(command.deviceId, command);
|
if (!pendingByDevice.has(command.deviceId)) pendingByDevice.set(command.deviceId, command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「最近一条命令」:待执行的那条在取消 / 超时之后就从 pendingCommand 里消失了,
|
||||||
|
// Web 端于是看不出"我刚点的锁屏到底怎么了"。取最近 500 条(够覆盖一次批量下发)按设备分组。
|
||||||
|
const recent = (await prisma.clientCommand.findMany({
|
||||||
|
where: { deviceId: { in: rows.map((row) => row.id) } },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 500,
|
||||||
|
})) as unknown as CommandRow[];
|
||||||
|
const lastByDevice = new Map<string, CommandRow>();
|
||||||
|
for (const command of recent) {
|
||||||
|
if (!lastByDevice.has(command.deviceId)) lastByDevice.set(command.deviceId, command);
|
||||||
|
}
|
||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const heartbeat = row.lastHeartbeatAt;
|
const heartbeat = row.lastHeartbeatAt;
|
||||||
const pending = pendingByDevice.get(row.id);
|
const pending = pendingByDevice.get(row.id);
|
||||||
|
const last = lastByDevice.get(row.id);
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
classId: row.classId,
|
classId: row.classId,
|
||||||
@@ -228,6 +241,7 @@ export async function listDevices(options: { classId?: string } = {}): Promise<C
|
|||||||
stateNote: row.stateNote,
|
stateNote: row.stateNote,
|
||||||
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
|
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
|
||||||
pendingCommand: pending ? toCommandDto(pending) : null,
|
pendingCommand: pending ? toCommandDto(pending) : null,
|
||||||
|
lastCommand: last ? toCommandDto(last) : null,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/shared",
|
"name": "@classhelper/shared",
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1901,6 +1901,14 @@ export interface ClientDeviceDto {
|
|||||||
online: boolean;
|
online: boolean;
|
||||||
/** 该设备当前待执行(未回执、未撤销、未过期)的命令;没有则 null */
|
/** 该设备当前待执行(未回执、未撤销、未过期)的命令;没有则 null */
|
||||||
pendingCommand: ClientCommandDto | null;
|
pendingCommand: ClientCommandDto | null;
|
||||||
|
/**
|
||||||
|
* **最近一条**远控命令(不管什么状态),用于回答「我刚点了锁屏,怎么没反应?」。
|
||||||
|
*
|
||||||
|
* 待执行的那条在取消 / 超时之后就从 `pendingCommand` 里消失了,Web 端于是一片空白 ——
|
||||||
|
* 而最常见的两种失败恰恰是"本机没人确认(60 秒自动取消)"和"设备当时离线"。
|
||||||
|
* 把结果摆出来,老师才知道该去哪儿补上那一下确认。
|
||||||
|
*/
|
||||||
|
lastCommand?: ClientCommandDto | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/web-admin",
|
"name": "@classhelper/web-admin",
|
||||||
"version": "1.3.1",
|
"version": "1.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
relativeTime,
|
relativeTime,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
type ClassHelperStatusDto,
|
type ClassHelperStatusDto,
|
||||||
|
type ClientCommandDto,
|
||||||
type ClientCommandKind,
|
type ClientCommandKind,
|
||||||
type ClientDeviceDto,
|
type ClientDeviceDto,
|
||||||
type IntegrationDeviceDto,
|
type IntegrationDeviceDto,
|
||||||
@@ -280,8 +281,27 @@ async function pushUpdateToClass(): Promise<void> {
|
|||||||
await loadAll();
|
await loadAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendCommand(kind: ClientCommandKind): Promise<void> {
|
/** 命令种类的中文名。包一层是因为表格插槽里的 `row` 是 any,直接索引常量表过不了类型检查 */
|
||||||
const current = detail.value;
|
function commandLabel(command: ClientCommandDto): string {
|
||||||
|
return CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一条远控命令"现在究竟怎么样了"。
|
||||||
|
*
|
||||||
|
* 待执行的那条会在**取消 / 超时**之后从 `pendingCommand` 里消失,界面上不留痕迹 ——
|
||||||
|
* 而最常见的两种"点了没反应"恰恰是:本机没人确认(客户端 60 秒自动按取消)与设备当时离线。
|
||||||
|
* 服务端把最近一条也返回(`lastCommand`),这里把它翻成一句人话。
|
||||||
|
*/
|
||||||
|
function commandStateText(command: ClientCommandDto): string { if (command.result) return command.result;
|
||||||
|
if (command.ackedAt) return '已执行';
|
||||||
|
if (command.expiresAt && Date.parse(command.expiresAt) < Date.now()) {
|
||||||
|
return '已过期 —— 下达时机器不在线(命令有效期 10 分钟)';
|
||||||
|
}
|
||||||
|
return '待执行(等这台机器的下一次心跳)';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommand(kind: ClientCommandKind): Promise<void> { const current = detail.value;
|
||||||
if (!current || current.clients.length === 0) return;
|
if (!current || current.clients.length === 0) return;
|
||||||
const label = CLIENT_COMMAND_LABELS[kind];
|
const label = CLIENT_COMMAND_LABELS[kind];
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
@@ -295,7 +315,10 @@ async function sendCommand(kind: ClientCommandKind): Promise<void> {
|
|||||||
kind,
|
kind,
|
||||||
note: `由 ${auth.user?.name ?? '管理员'} 下达`,
|
note: `由 ${auth.user?.name ?? '管理员'} 下达`,
|
||||||
});
|
});
|
||||||
ElMessage.success(`已下发:成功 ${result.succeeded} / 共 ${result.total} 台`);
|
ElMessage.success(
|
||||||
|
`已下发:成功 ${result.succeeded} / 共 ${result.total} 台 —— ` +
|
||||||
|
'机器上会弹出默认选中「取消」的确认框,60 秒内没人点「确认执行」就按取消处理(详情表里能看到结果)',
|
||||||
|
);
|
||||||
await loadAll();
|
await loadAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,6 +600,14 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="stateNote" label="状态说明" min-width="140" />
|
<el-table-column prop="stateNote" label="状态说明" min-width="140" />
|
||||||
|
<el-table-column label="上次指令" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span v-if="!row.lastCommand" class="row-value muted">—</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ commandLabel(row.lastCommand) }} · {{ commandStateText(row.lastCommand) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="90" fixed="right">
|
<el-table-column label="操作" width="90" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="danger" :disabled="!canManageDevices" @click="removeClient(row)">
|
<el-button link type="danger" :disabled="!canManageDevices" @click="removeClient(row)">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
|
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
|
||||||
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
|
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
|
||||||
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
|
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
|
||||||
<meta name="ch-version" content="1.3.1" />
|
<meta name="ch-version" content="1.3.2" />
|
||||||
<link rel="icon" href="assets/icon.png" />
|
<link rel="icon" href="assets/icon.png" />
|
||||||
<link rel="apple-touch-icon" href="assets/icon.png" />
|
<link rel="apple-touch-icon" href="assets/icon.png" />
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -44,7 +44,7 @@
|
|||||||
<a class="brand" href="#top" data-press="0.96">
|
<a class="brand" href="#top" data-press="0.96">
|
||||||
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
|
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
|
||||||
<span class="brand-name">ClassHelper</span>
|
<span class="brand-name">ClassHelper</span>
|
||||||
<span class="brand-ver">1.3.1</span>
|
<span class="brand-ver">1.3.2</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="topnav" aria-label="页内导航">
|
<nav class="topnav" aria-label="页内导航">
|
||||||
<span class="nav-pill" aria-hidden="true"></span>
|
<span class="nav-pill" aria-hidden="true"></span>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
|
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
|
||||||
<li>v1.3.1</li>
|
<li>v1.3.2</li>
|
||||||
<li>Windows 10/11</li>
|
<li>Windows 10/11</li>
|
||||||
<li>Linux · Docker</li>
|
<li>Linux · Docker</li>
|
||||||
<li>触摸屏可用</li>
|
<li>触摸屏可用</li>
|
||||||
@@ -746,7 +746,7 @@
|
|||||||
ClassHelper
|
ClassHelper
|
||||||
<span>Class Helper</span>
|
<span>Class Helper</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="foot-ver">版本 1.3.1 · 源码公开 · 装在你自己的机器上</p>
|
<p class="foot-ver">版本 1.3.2 · 源码公开 · 装在你自己的机器上</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="foot-nav" aria-label="相关链接">
|
<nav class="foot-nav" aria-label="相关链接">
|
||||||
|
|||||||
Reference in new issue
Block a user