From b7cdc7c9ea4af6e38198a2ada92fd5362cfa20bb Mon Sep 17 00:00:00 2001
From: LoveChengke
Date: Sun, 11 Oct 2026 17:00:11 +0800
Subject: [PATCH] =?UTF-8?q?fix(clients,client,web):=20=E8=BF=9C=E6=8E=A7?=
=?UTF-8?q?=E6=8C=87=E4=BB=A4=E7=9A=84=E4=B8=A4=E4=B8=AA=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=20=E2=80=94=E2=80=94=20=E6=89=A7=E8=A1=8C=E5=A4=B1=E8=B4=A5?=
=?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=B0=8E=E6=8A=A5=E6=88=90=E5=8A=9F=20+=20?=
=?UTF-8?q?=E3=80=8C=E4=B8=8A=E6=AC=A1=E6=8C=87=E4=BB=A4=E3=80=8D=E8=AE=A9?=
=?UTF-8?q?"=E7=82=B9=E4=BA=86=E6=B2=A1=E5=8F=8D=E5=BA=94"=E5=8F=AF?=
=?UTF-8?q?=E8=A7=81=EF=BC=881.3.2=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
背景:用户报「下发锁屏等等指令不生效」。先把服务端那条链路单独验了一遍
(模拟客户端:登记 → 下发 → 心跳带回 → 设备列表可见 → 回执 → 清除),**整条是通的**;
真正的问题在两处。
客户端:执行失败会谎报成功
- `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**;
远控链路另用模拟客户端跑通,并确认新增的「上次指令」在待执行与已回执两种状态下都显示正确。
---
AGENTS.md | 12 ++
README.md | 2 +-
deploy/docker-compose.sqlite.yml | 2 +-
deploy/docker-compose.yml | 2 +-
deploy/package.runtime.json | 2 +-
docs/dev/architecture.md | 2 +-
docs/dev/classisland-plugin.md | 7 +-
docs/dev/data-model.md | 2 +-
docs/dev/index.md | 8 +-
docs/dev/release.md | 4 +-
docs/dev/testing.md | 21 +--
docs/dev/winisland-design-tokens.md | 2 +-
docs/faq.md | 22 +--
docs/get-started/client.md | 4 +-
docs/get-started/index.md | 2 +-
docs/get-started/requirements.md | 2 +-
docs/management/linux-deploy.md | 6 +-
docs/management/production.md | 18 +--
docs/reference.md | 149 ++++++------------
package.json | 2 +-
packages/classisland-plugin/manifest.yml | 2 +-
.../src/Services/BridgeService.cs | 2 +-
packages/desktop-client/package.json | 2 +-
.../desktop-client/src/main/client-agent.ts | 17 +-
packages/server/package.json | 2 +-
.../src/modules/clients/clients.service.ts | 14 ++
packages/shared/package.json | 2 +-
packages/shared/src/types.ts | 8 +
packages/web-admin/package.json | 2 +-
.../web-admin/src/views/ClientHubView.vue | 37 ++++-
website/download.html | 2 +-
website/index.html | 6 +-
32 files changed, 192 insertions(+), 175 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index d0e3dd0..84acba1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1320,6 +1320,18 @@ term / dashboard / database / update / permissions / clients / system`。
然后 `POST /api/clients/commands/:id/ack`(body 带 `deviceKey`,服务端据此校验归属,重复回执幂等)。
同一台机器上**同一条命令只弹一次**(内存去重),回执失败只暂存重试、不重复弹窗。
+ **`executeCommand()` 必须显式监听子进程的 `error` 事件**(2026-10-11 修):
+ `spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在 next tick 发一个异步 `error` 事件 ——
+ 只包 `try/catch` 会一路走到 `return { ok: true }`,回执写着"已下达系统命令",机器却纹丝不动。
+ 现在等 200ms 的窗口确认没立刻报错,失败如实回执。
+
+ **「点了没反应」必须看得出来**:命令被取消 / 过期后就从 `pendingCommand` 里消失,Web 端原本一片空白,
+ 而最常见的两种失败恰恰是"本机没人确认(60 秒自动取消)"与"下达时机器离线"。
+ 所以 `GET /clients/devices` 一并返回 `lastCommand`(最近一条,不论状态),
+ 「客户端」页把它翻成一句人话(`ClientHubView` 的 `commandStateText`)。
+ 排查这类反馈时先看这一列,再看那台机器的**客户端版本**是否 ≥ 1.3.0 ——
+ 远控命令的处理(`client-agent.ts`)是 1.3.0 才有的,旧客户端连心跳都不发。
+
70. **客户端安装包由服务端下载并缓存,教室机不直连 GitHub。** 原因有两条:Web 端 CSP
(`connect-src 'self' ws wss …`)不允许浏览器直连 GitHub,而教室机常常根本没有外网。
所以 `POST /api/update/client/fetch` 由**服务端**下载到 `/../data/client-releases/<版本>/`
diff --git a/README.md b/README.md
index 4a608be..326ac51 100644
--- a/README.md
+++ b/README.md
@@ -224,7 +224,7 @@ pnpm dev # 后端 4000 + Web 端 5
| 教师 | `teacher1` | `teacher123` |
| ClassHelper 班级端(班级) | 班级码 `G101` | `123456` |
-- [AGENTS.md](AGENTS.md) —— 工程约定与本机踩坑,**改代码前先读它**
+- [AGENTS.md](AGENTS.md) —— 工程约定与开发环境注意事项,**改代码前先读它**
- [docs/reference.md](docs/reference.md) —— 完整参考(功能行为、接口、验收记录)
- [docs/dev/architecture.md](docs/dev/architecture.md) —— 架构总览
diff --git a/deploy/docker-compose.sqlite.yml b/deploy/docker-compose.sqlite.yml
index 1111a2d..6fc16d7 100644
--- a/deploy/docker-compose.sqlite.yml
+++ b/deploy/docker-compose.sqlite.yml
@@ -23,7 +23,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
- image: classhelper-server:${APP_VERSION:-1.3.1}
+ image: classhelper-server:${APP_VERSION:-1.3.2}
restart: unless-stopped
environment:
NODE_ENV: production
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index d16e8f6..bfe7b97 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -60,7 +60,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
- image: classhelper-server:${APP_VERSION:-1.3.1}
+ image: classhelper-server:${APP_VERSION:-1.3.2}
restart: unless-stopped
environment:
NODE_ENV: production
diff --git a/deploy/package.runtime.json b/deploy/package.runtime.json
index 83b0625..164766f 100644
--- a/deploy/package.runtime.json
+++ b/deploy/package.runtime.json
@@ -1,6 +1,6 @@
{
"name": "classhelper-server-runtime",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"type": "module",
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md
index 539ec0e..0d7c2b9 100644
--- a/docs/dev/architecture.md
+++ b/docs/dev/architecture.md
@@ -139,7 +139,7 @@ export const apiModules: ApiModule[] = [
## 几条贯穿全局的硬约定
-这些都是踩过坑之后固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
+这些都是长期演进中固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
| 约定 | 为什么 |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
diff --git a/docs/dev/classisland-plugin.md b/docs/dev/classisland-plugin.md
index 2d031f0..2f4838e 100644
--- a/docs/dev/classisland-plugin.md
+++ b/docs/dev/classisland-plugin.md
@@ -112,14 +112,17 @@ pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需
## 部署到教室机器
+把编出来的 DLL 覆盖到这台机器的 ClassIsland 数据目录(默认 `%USERPROFILE%\ClassIsland\data`,
+便携版则是安装目录下的 `data\`)。ClassIsland **运行时会锁住 DLL,必须先完全退出它**:
+
```powershell
# 1) 完全退出 ClassIsland(它会锁住 DLL)
Get-Process ClassIsland.Desktop | Stop-Process -Force
# 2) 覆盖插件 DLL(**不要覆盖 Settings.json**,那里存着服务器地址与设备令牌)
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll `
- D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
+ "$env:USERPROFILE\ClassIsland\data\Plugins\classhelper.classisland.bridge\" -Force
# 3) 重新启动
-Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
+Start-Process "$env:USERPROFILE\ClassIsland\ClassIsland.exe"
```
改配置要在 **ClassIsland 退出时**改:运行中改会被内存里的副本随时回写覆盖。
diff --git a/docs/dev/data-model.md b/docs/dev/data-model.md
index 0e15f8a..0800642 100644
--- a/docs/dev/data-model.md
+++ b/docs/dev/data-model.md
@@ -141,7 +141,7 @@ add_permission_groups_and_clients
## 开发时的模型改动
1. 改 `schema.prisma`;
-2. 在 `prisma/migrations/` 下加一个迁移目录(本机用
+2. 在 `prisma/migrations/` 下加一个迁移目录(用
`pnpm --filter @classhelper/server db:deploy` 应用,**别用 `pnpm db:migrate`** ——
它在 Windows 上不可靠)。要改已有表结构而不只是加列时,可以先用
`pnpm exec prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --script`
diff --git a/docs/dev/index.md b/docs/dev/index.md
index 88ccb9a..b05e7ff 100644
--- a/docs/dev/index.md
+++ b/docs/dev/index.md
@@ -48,8 +48,8 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
## 仓库里另外两份文档
-- **[完整参考](../reference.md)** —— 产品说明书 + 验收对照表(原 README 全文),
- 功能行为的**权威来源**;仓库首页那份 README 是精简后的门面页,细节都在这里。
+- **[完整参考](../reference.md)** —— 产品说明书(原 README 全文),功能行为的**权威来源**;
+ 仓库首页那份 README 是精简后的门面页,细节都在这里。
- **[`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md)** ——
- 写给 AI 编码代理的工程约定与本机踩坑记录,**改代码前必读**。
- 里面每一条硬约定都对应一次真实的翻车,比代码注释更值得先看一遍。
+ 工程约定与开发环境注意事项,**改代码前必读**。里面每一条硬约定都对应一处容易踩空的地方,
+ 比代码注释更值得先看一遍。
diff --git a/docs/dev/release.md b/docs/dev/release.md
index 7ad99c3..4989a6f 100644
--- a/docs/dev/release.md
+++ b/docs/dev/release.md
@@ -191,8 +191,8 @@ gh workflow run release-linux-server.yml -f tag=v1.1.3 -f attach=true
> **CI 是按 tag 的 ref 检出源码的。** 发完版又发现构建脚本(或任何影响出包的代码)要修时,
> **光推 master 不够** —— CI 仍会拿旧 tag 的源码跑,得把 tag 挪到修复后的提交:
> `git tag -f v<版本> <提交> && git push --force origin v<版本>`,tag 一变会自动重跑。
-> 1.3.0 实测踩过:tag 指的那个提交里打包脚本有个 bug,CI 红了、Release 上少一个 Linux 包,
-> 而本地 `pnpm dist:server` 一直是绿的(Windows 侧走另一条分支)。
+> 别拿「Windows 侧绿不绿」当判据:`pnpm dist:server` 走的是另一条分支,
+> Linux 那一步红的时候本地完全看不出来。
>
> 另外,本页第 8 步的「官网与文档站」是**另一条**流水线(master 推送触发)——
> 它绿不代表 Linux 包出来了,推完 tag 要去 `gh run list --limit 5` 看一眼。
diff --git a/docs/dev/testing.md b/docs/dev/testing.md
index f599dc3..cafb4c7 100644
--- a/docs/dev/testing.md
+++ b/docs/dev/testing.md
@@ -53,21 +53,22 @@ pnpm build && pnpm verify:web
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 |
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 |
| `verify:desktop`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 |
-| 岛交互 / 岛动画 / 岛截图留档 | **AI 会话里会间歇性失败**:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
+| 岛交互 / 岛动画 / 岛截图留档 | 窗口被遮挡或跑在远程会话里时会间歇性失败:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
## 大面积报「登录失败 / 401」时
-先核对本机 `teacher1` 的密码 —— 本机实测可能被改成了 `123456`,不是种子默认的 `teacher123`
-(原因是「修改密码」弹窗留空会落到默认初始密码)。**处理方式是用环境变量传实际凭据,不要去改数据。**
+先确认演示账号的**当前**口令。种子脚本硬编码的是 `teacher123`,但「修改密码」弹窗留空会落到
+默认初始密码(`DEFAULT_TEACHER_PASSWORD`,默认 `123456`),任何一次误操作都会把它改掉 ——
+而**两个演示账号不一定一起漂**,所以不能简单地全局替换。
-两个演示账号的口令**不一定一起漂**,所以不能简单地全局替换。
+**处理方式是用环境变量传实际凭据,不要去改数据。**
-| 脚本 | 覆盖凭据的方式 |
-| ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
-| `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` |
-| `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) |
-| `verify:web` | `set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web`(另有 `UI_SMOKE_ADMIN*`) |
-| `verify:e2e` | **不支持环境变量**,需用临时副本替换(且副本必须放在 `packages/server/scripts/` 里,否则 `import 'socket.io-client'` 会失败) |
+| 脚本 | 覆盖凭据的方式 |
+| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` |
+| `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) |
+| `verify:web` | `set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web`(另有 `UI_SMOKE_ADMIN*`) |
+| `verify:e2e` | **不支持环境变量**(口令硬编码在脚本里),需自己用改过口令的临时副本跑;副本必须放在 `packages/server/scripts/` 里,否则 `import 'socket.io-client'` 会失败 |
## 冒烟会真的动你的屏幕
diff --git a/docs/dev/winisland-design-tokens.md b/docs/dev/winisland-design-tokens.md
index b86f53b..ff77955 100644
--- a/docs/dev/winisland-design-tokens.md
+++ b/docs/dev/winisland-design-tokens.md
@@ -1,6 +1,6 @@
# WinIsland 视觉设计系统(Design Token 规格)
-来源:`D:\classhelper\.cache\winisland-ref\WinIsland-master`(只读 checkout)。
+来源:[WinIsland](https://github.com/WinIslandProject/WinIsland) 的源码(`WinIsland-master`,只读 checkout)。
所有数值均取自代码常量,标注了「常量名 = 值 @ 文件」。未在代码中找到的项明确写「未找到」。
关键前提:**岛内所有几何尺寸都乘以 `compact_scale`(默认 1.0)或 `expanded_scale`(默认 1.0)**,
diff --git a/docs/faq.md b/docs/faq.md
index d465545..9dbacc6 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -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 | findstr Mandatory` 里出现 `Low`。
-另外**别把安装测试装进仓库目录里** —— 在那里又会继承 Low。
-
### 双击客户端"点了没反应"
**关窗只是隐藏到托盘,进程还在。** 再双击 EXE 会被单实例锁秒退(看起来像没反应),
@@ -148,8 +130,8 @@ Linux 上跑 `sudo classhelper doctor` 有专门一条断言盯着这个软链
## 还没解决?
-- 开发环境的坑(命令、代理与 TLS、完整性标签、ClassIsland 路径与日志编码……)
- 都记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里;
+- **开发与构建环境**的问题(命令、代理与 TLS、ClassIsland 路径与日志编码……)
+ 记在仓库的 [`AGENTS.md`](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 里;
- 功能行为的权威说明在[完整参考](reference.md);
- 都不是的话,去 [Issues](https://github.com/LoveChengke/classhelper/issues) 提一个,
附上版本号(Web 端顶栏 / 客户端「设置 → 关于」里都有)和日志。
diff --git a/docs/get-started/client.md b/docs/get-started/client.md
index 4b6d000..f2f511d 100644
--- a/docs/get-started/client.md
+++ b/docs/get-started/client.md
@@ -23,9 +23,7 @@ description: 在教室机器上安装 ClassHelper桌面客户端,用班级码
pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的解包目录)
```
-> **装不上 / 双击没反应 / 一闪就没**(报 `0x80000003`)通常不是产物坏了,而是交付物被打了
-> Low 完整性标签。打包脚本收尾已自动修正;从 Release 下载的包不受影响。
-> 另外:关窗是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘退出。
+> **双击没反应?** 关窗只是隐藏到托盘,进程还在,再双击会因单实例锁秒退 —— 先从托盘右键退出。
## 2. 登录
diff --git a/docs/get-started/index.md b/docs/get-started/index.md
index a27ae83..89fae22 100644
--- a/docs/get-started/index.md
+++ b/docs/get-started/index.md
@@ -30,7 +30,7 @@ ClassHelper不是一个单机软件,它由四部分组成,**必须先有服
- **看界面**:本页文档里各处嵌的都是实机截图,比如[灵动岛](../app/island.md)、[课表](../app/schedules.md)。
- **本地跑一套演示数据**:装好 Node 20.19+ 与 pnpm 之后,
- `pnpm install && pnpm build:shared && pnpm db:seed && pnpm dev` 会在本机拉起一套带演示班级的后端与
+ `pnpm install && pnpm build:shared && pnpm db:seed && pnpm dev` 会在本地拉起一套带演示班级的后端与
Web 管理端(详见[安装服务端](server.md#从源码跑开发模式))。
## 装完之后呢
diff --git a/docs/get-started/requirements.md b/docs/get-started/requirements.md
index 3422dfd..d72ed45 100644
--- a/docs/get-started/requirements.md
+++ b/docs/get-started/requirements.md
@@ -64,7 +64,7 @@ ldd --version | head -1
| 教室机器 / 老师电脑也要用 | 客户端与管理端填服务器的**局域网 IP**(如 `http://192.168.1.10:4000`),防火墙放行 4000 |
| 公网访问 | 前面加 Nginx / Caddy 反代并启用 HTTPS,见[生产部署指南](../management/production.md) |
-> **别用代理**。本机自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`;
+> **别用代理**。自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`;
> 教室机器上如果开了系统代理,填服务端地址后可能拿到代理返回的 404。插件里对本地 / 内网地址做了强制直连,
> 但浏览器与客户端本身不会绕过。
diff --git a/docs/management/linux-deploy.md b/docs/management/linux-deploy.md
index a43a87e..2bd056d 100644
--- a/docs/management/linux-deploy.md
+++ b/docs/management/linux-deploy.md
@@ -23,8 +23,8 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
```
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`,
-> 不是 `$1` —— 脚本收不到它(实测踩到:本意是体检,结果走进了交互式安装)。
-> 先落盘再执行没这个坑,也不会因为下载中断而执行到半个脚本。
+> 不是 `$1` —— 脚本收不到它(想体检却会走进交互式安装)。
+> 先落盘再执行没这个问题,也不会因为下载中断而执行到半个脚本。
以下是"已有安装包 / 内网离线"的用法:
@@ -75,7 +75,7 @@ Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不
`pnpm dist:server:linux` 在非 Linux 上也**能跑**:脚本会用 npm 的 `--os=linux --cpu=x64`
按目标平台解析可选依赖,所以 `@libsql/linux-x64-gnu` 这些"按平台分发"的包会被正确装进来。
-适合本地快速打一个包去验证安装与运维流程。已核对过的结论:
+适合本地快速打一个包去验证安装与运维流程。逐项核对过的结论:
| 项 | 结论 |
| --- | --- |
diff --git a/docs/management/production.md b/docs/management/production.md
index f81b698..3017849 100644
--- a/docs/management/production.md
+++ b/docs/management/production.md
@@ -2,12 +2,12 @@
本文档覆盖三种上线形态,按适用场景选择:
-| 形态 | 适用场景 | 产物 | 本机验证情况 |
-| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
-| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
-| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
-| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
-| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已实测(163 项端到端) |
+| 形态 | 适用场景 | 产物 | 验证状态 |
+| --------------------- | -------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
+| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已验证(静默安装 / 启停 / 卸载 / UI) |
+| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,尚未实机验证 |
+| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 实机验收清单见 [linux-deploy.md](linux-deploy.md) §10 |
+| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已验证(端到端验收通过) |
---
@@ -101,8 +101,8 @@ uninstall-service.cmd :: 删除计划任务
## 二、Docker + MySQL(形态 B,云部署推荐)
-> 本机没有 Docker,因此这套文件未在本机实测;结构经过与已验证的产物对照(依赖清单、
-> 目录布局、迁移方式与服务端完全一致)。
+> 这套文件与已验证的产物结构一致(依赖清单、目录布局、迁移方式与服务端完全相同),
+> 但**尚未在真实 Docker 环境跑过**。第一次上线请先在测试机走一遍。
```bash
cp deploy/.env.example deploy/.env
@@ -392,7 +392,7 @@ pnpm --filter @classhelper/server start # 或 node packages/server/dist/ind
## 八、性能与容量参考
-- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),实测 62 项接口与实时推送全部达标。
+- 单机 SQLite 形态适合**单校规模**(数千用户、每天数百次写操作),接口与实时推送均已通过端到端验收。
- 多校区 / 高并发建议切 MySQL,并横向扩容服务端(Socket.IO 需要配置 Redis adapter 才能多实例广播,
当前为单实例广播;如需多实例请参考 `packages/server/src/realtime/socket.ts` 引入 `@socket.io/redis-adapter`)。
- 静态资源已设置 immutable 长缓存 + gzip;Web 管理端为 SPA,首屏包体约 900 KB(gzip 约 290 KB),
diff --git a/docs/reference.md b/docs/reference.md
index 4376f01..7d1fba0 100644
--- a/docs/reference.md
+++ b/docs/reference.md
@@ -19,13 +19,12 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
└──── 老师下发的提醒 ◄────────┘
```
-> 本文是 ClassHelper的**完整参考**:功能行为、接口契约、验收证据与已知问题都在这里,是功能口径的权威来源。
+> 本文是 ClassHelper的**完整参考**:功能行为、接口契约与已知问题都在这里,是功能口径的权威来源。
> 面向「第一次打开仓库」的展示型介绍在 [README](https://github.com/LoveChengke/classhelper/blob/master/README.md);按读者分组的引导式文档在文档站(本目录)。
-> 工程约定(改代码前必读)与本机踩坑在 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md);生产部署在
+> 贡献者需要的工程约定在 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md);生产部署在
> [production.md](management/production.md);切换数据库在 [mysql.md](management/mysql.md)。
> 文中的**文件路径均相对仓库根目录**(`packages/…`、`scripts/…`),只有目录链接是相对本文的。
-> **改动交付形态 / 新增功能后请一并更新本文**;文中计数均标注实测日期,条目数由脚本自行统计,
-> 不要写死容易过期的总数。
+> **改动交付形态 / 新增功能后请一并更新本文**;条目数由脚本自行统计,不要写死容易过期的总数。
## 目录
@@ -75,9 +74,9 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#,装在教室的 ClassIsland 上(上报课表与上课状态、弹提醒) | 教室机器(独立于 pnpm workspace) |
| 官网 | `website/` | 纯 HTML / CSS / JS 的静态页面,**没有构建步骤** | 展示与说明(`node website/serve.mjs` 本地预览) |
-**设计取舍(与原始提示词的两处偏差,已与用户确认)**:
+**两处技术选型取舍**:
-1. 桌面客户端采用 **Electron + Vue 3**(而非 Avalonia/FluentAvalonia)——需求正文 90% 的内容
+1. 桌面客户端采用 **Electron + Vue 3**(而非 Avalonia/FluentAvalonia)——需求里的绝大部分内容
(Socket.IO Client、Pinia、IndexedDB 离线缓存、electron-builder)都基于 Electron;
2. 数据库**先用 SQLite(libSQL 内嵌)跑通,并支持切到 MySQL**:`docs/management/mysql.md` 给出切换步骤,
`deploy/` 同时提供 MySQL 版与 SQLite 版 Compose。
@@ -95,8 +94,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
> 这些产物**都不入库**(`releases/` 已 gitignore + eslint ignore)。三个打包脚本**直接输出**到
> `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`(交付物)、`构建中间/`(可重新生成)分层,
-> 历史版本原样保留。**没有免安装目录**:组装/解包出来的那一份只是中间物,脚本打完就删 ——
-> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §4。
+> 历史版本原样保留。**没有免安装目录**:组装/解包出来的那一份只是中间物,脚本打完就删。
四种部署形态按场景选(Linux 完整步骤见 [docs/management/linux-deploy.md](management/linux-deploy.md),
其余见 [docs/management/production.md](management/production.md)):
@@ -119,7 +117,7 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
> 参数接在最后即可,例如先只体检(不需要 root、不改动系统):把末尾换成
> `sudo bash /tmp/classhelper-install.sh --check`。
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`
-> 而不是 `$1`,脚本收不到(实测踩到:想体检却走进了交互式安装)。
+> 而不是 `$1`,脚本收不到(想体检却会走进交互式安装)。
## 3. 快速开始
@@ -167,7 +165,8 @@ pnpm build:desktop
pnpm verify:desktop
```
-> Electron 二进制**不会**随 `pnpm install` 自动下载,缺失时先手动装一次(见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7 第 5 条)。
+> Electron 二进制**不会**随 `pnpm install` 自动下载,缺失时先手动装一次
+> (见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7 第 5 条)。
客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,输入**班级码 + 班级密码**
(种子数据:`G101` / `123456`)即可进入班级。想验证「断网可查看缓存」:登录并浏览若干页面后停掉后端
@@ -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 /
调班历史 2 / 已归档届别 1):
@@ -233,8 +232,8 @@ pnpm verify:desktop
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查(`format:check` 在 master 基线上本就失败,只检查改过的文件) |
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
-| `pnpm db:migrate` | 创建并应用迁移(`prisma migrate dev`;本机不可靠,用 db:deploy) |
-| `pnpm --filter @classhelper/server db:deploy` | 应用已有迁移(**本机推荐**;根 scripts 里没有 `db:deploy`) |
+| `pnpm db:migrate` | 创建并应用迁移(`prisma migrate dev`;在 Windows 上不可靠,用 db:deploy) |
+| `pnpm --filter @classhelper/server db:deploy` | 应用已有迁移(**推荐**;根 scripts 里没有 `db:deploy`) |
| `pnpm db:seed` / `pnpm db:reset` | 写入种子数据 / 重置并重播种子 |
| `pnpm db:studio` | 打开 Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(无第二份 schema) |
@@ -809,8 +808,8 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
> 打包末尾会做一次**运行时裁剪**(删掉 Prisma 为其它数据库方言准备的查询编译器副本等死载荷,
> 见 `scripts/dist-server.mjs` 的 `pruneRuntime()`),组装出来的运行时 469MB → 335MB
> (这份运行时现在只是中间物,打完安装程序即删)。
-> - 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致);e2e 只覆盖校验分支
-> (同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`。
+> - e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`;
+> 真实切换走的子进程链路已完整验证(快照 728 行逐表一致)。
### 4.10 更新检查
@@ -963,7 +962,8 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
| 命令种类 | 关机 / 重启 / 注销 / 锁屏(`CLIENT_COMMAND_KINDS` + `CLIENT_COMMAND_LABELS`) |
| 新命令取代旧命令 | 同一台机器未回执的旧命令会被自动撤销(先关机后重启不会排队执行) |
| 回执 | `POST /clients/commands/:id/ack`,body 里的 `deviceKey` 既是身份也是护栏(班级端令牌全班共用),且**幂等** |
-| 二次确认 | 客户端执行前必须在界面上二次确认;回执里带成功 / 失败原因 |
+| 二次确认 | 客户端执行前必须在界面上二次确认(**默认选中「取消」**,**60 秒未确认按取消处理**);回执里带成功 / 失败原因 |
+| 上次指令 | 「客户端」页的详情表显示最近一条命令的结果(已执行 / 本机取消 / 已过期 / 待执行)——排"点了没反应"先看它 |
**安装包由服务端中继下发**(教室机器常常没有外网,Web 端的 CSP 也不允许直连 GitHub):
@@ -1163,14 +1163,14 @@ class-helper/
| --------- | -------------------------------------------------------------------- | ---------------------------------------------------------- |
| 包管理 | pnpm workspace | 11.8.0 |
| 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) |
-| 后端 | Node.js + Express | ≥ 20.19(本机 24.19) / 5.2 |
+| 后端 | Node.js + Express | ≥ 20.19 / 5.2 |
| ORM | Prisma + driver adapter | 7.10(+ `@prisma/adapter-libsql`,切 MySQL 用 `-mariadb`) |
| 实时 | Socket.IO | 4.8 |
| 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 |
| 校验 | zod | 4.6 |
| Web 端 | Vue + Vite + Element Plus + Pinia + Vue Router + Axios | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 |
| 客户端 | Electron + Vue + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 |
-| 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0(本机 SDK 8.0.425) |
+| 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0 |
| 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 |
| Lint/格式 | ESLint 10 + typescript-eslint 8 + eslint-plugin-vue 10;Prettier 3 | — |
@@ -1467,7 +1467,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| 灵动岛 | **刻意只有单调缓动**(曲线取自同一套令牌,但不用弹簧):岛的形变由主进程缓动窗口尺寸,任何过冲都会让"开合"看起来在震动(冒烟逐帧断言)。悬停微交互关在 `@media (hover: hover)` 里,避免触摸屏上"幽灵 hover"粘住按钮 |
| 降低动态效果 | 全站 `prefers-reduced-motion` 降级(**加载指示旋除外** —— 停掉会让界面看着像卡死) |
-三条**别踩**的约定(都对应实测过的坑,详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §5 第 61 条):
+三条**别踩**的约定(详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §5 第 61 条):
路由层**不要**包 ``;Vue 的 `` 一律显式给 `:duration`;
`position: fixed` 的整屏层与"自己量尺寸"的元素不要叠位移类动画。
@@ -1492,7 +1492,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
> ⚠️ 改令牌时**只改值**。别对样式表跑"把 `#303133` 全量替换成 `var(--ch-text)`"这类批量替换 ——
> 它会把令牌**定义本身**也换掉(`--ch-text: #303133` → `--ch-text: var(--ch-text)`),
-> 自定义属性一旦自引用就整条失效、解析成空串,全站文字颜色静默回落成继承色(实测踩过)。
+> 自定义属性一旦自引用就整条失效、解析成空串,全站文字颜色静默回落成继承色。
## 6. 验收
@@ -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` |
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` 实时到达,`/grades/my` 返回记录;批量录入与统计接口通过 |
| 学生能查看课表,支持按周切换 | ✅ | `/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` 断言「窗口 = 岛体 + 2×阴影留白、`interactive=true`、退出后恢复固定包围盒」。**真机(触摸屏)需人工复验** |
| 叫人(老师点名,分紧急 / 普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 |
-| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗实测 |
+| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 覆盖 20 余项,`verify:web` 弹窗走通 |
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍在、merge 覆盖与保留行为符合预期 |
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 N 个迁移文件,跳过 M 个已存在对象`,新表自动建好 |
| ClassHelper 班级端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 |
@@ -1532,9 +1532,9 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文 / 占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
| **新手引导(首启弹出 + 随时重看)** | ✅ | Web 管理端:首次登录弹出 `el-tour` 聚焦引导,头像菜单「使用引导」重看(`verify:web`);客户端:首启六步向导,登录页 / 设置页可重看(`verify:desktop`) |
| **客户端设置改版(分组侧栏 / 深色模式 / 关于页)** | ✅ | `verify:desktop`:「侧边栏点击导航」适配「学习 / 设置」分组;「主题切换与侧边栏折叠(真实点击、写回配置)」——`html.dark` 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染」含 configPath |
-| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与全部数据表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 |
-| **更新检查(服务端 / Web 端 / 客户端)** | ✅ | `verify:e2e` 4 项:未登录 401、管理员结构完整且本机版本取自 package.json、教师可查但 `force` 仅管理员生效、健康检查暴露版本号与模块清单(含 update 模块);`verify:desktop` 4 项:IPC 返回结构正确(无外网时降级 `ok=false` 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;`verify:web`:「顶栏用户菜单 → 弹窗给出结论并可关闭」。**真机联网实测**:`GET /api/update/check` → `ok=true`、`latestVersion=1.0.0`、5 个附件、更新说明 1358 字 |
-| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;2026-09-27 在真机(ClassIsland 2.1.0.1 + 本插件)上实测过,当时的抓图留在 `docs/screenshots/classisland/` |
+| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与全部数据表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路已完整走通**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 |
+| **更新检查(服务端 / Web 端 / 客户端)** | ✅ | `verify:e2e` 4 项:未登录 401、管理员结构完整且版本取自 package.json、教师可查但 `force` 仅管理员生效、健康检查暴露版本号与模块清单(含 update 模块);`verify:desktop` 4 项:IPC 返回结构正确(无外网时降级 `ok=false` 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;`verify:web`:「顶栏用户菜单 → 弹窗给出结论并可关闭」 |
+| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;抓图留在 `docs/screenshots/classisland/` |
| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 |
| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 通用」管理短语 |
| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 提醒」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:`client` 时通知与联动页下发都被跳过) |
@@ -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-打包与交付)) |
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题 |
-### 6.2 最近一次实测
+### 6.2 怎么自己跑一遍
-| 验证 | 结果 |
-| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
-| `pnpm verify:e2e`(后端 + 实时 + RBAC + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查) | **190/190** ✅(2026-10-05 实测) |
-| `pnpm verify:desktop`(客户端冒烟,不弹窗) | **107/107** ✅ + **89/89** ✅(2026-10-06 实测,0 失败) |
-| `pnpm verify:web`(Web 管理端真实点击回归) | **32/33**(2026-10-06 实测;未过的 1 项是既有脆弱用例「课表科目为全校统一目录」,见下) |
-| `pnpm verify:classisland`(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约) | **42/42** ✅(2026-10-05 实测) |
-| `pnpm verify:classisland-plugin`(插件静态契约) | **68/68** ✅ |
-| `pnpm typecheck` / `pnpm lint` / `pnpm check:icons` | 全过 ✅(lint 0 error **0 warning**) |
-| `pnpm build:desktop`(含产物凭据门禁 `check-bundle-secrets`) | 通过 ✅(61 个产物文件里无种子凭据字面量) |
-| `pnpm verify:packaged --exe …`(对打包副本复验) | **105/105** ✅ + **89/89** ✅(2026-10-05 实测,0 失败) |
-| 裁剪后的服务端运行时(起服务 → 登录查表 → `prisma generate` → `tsc` → `db push`) | 通过 ✅(2026-10-05 实测,建出 14 张表;详见 AGENTS.md §8) |
-| 插件 `dotnet` 编译(`pnpm build:classisland-plugin`) | 0 错误 ✅(1 条预期内的 Avalonia `AVLN3001` 警告) |
-| 数据库切换子进程链路(快照 → generate → db push → 写入目标库) | ✅ 实测:728 行逐表一致(users / schedules / grades 抽查一致) |
-| `pnpm dist:server`(服务端安装包 + Web 端) | ✅ 打出 ~62MB 安装程序(随包内置切换链路所需的 prisma CLI 与 tsc,并在打包末尾做运行时裁剪) |
-| 安装程序完整生命周期(静默安装 → 启动 → 卸载) | 通过 ✅(安装到 `%LOCALAPPDATA%\Programs`,卸载干净) |
-| 便捷版(单文件)解包启动 | 通过 ✅(wrapper + 多个进程,主窗口正常) |
-| Docker / Nginx 部署样例 | 文件已提供;本机无 Docker 未实测 ⚠️ |
-| Linux 一键安装 + `classhelper` 运维命令(`deploy/install.sh` / `deploy/classhelper` / `pnpm dist:server:linux`) | 脚本与文档已交付,本地静态检查通过(`bash -n`、ESLint 0 error);**Linux 实机验收待做** ⚠️(清单见 [linux-deploy.md](management/linux-deploy.md) §10,`deploy/verify-linux.sh` 可自动跑)。2026-10-06 用交叉构建出了 1.1.2 的包并核对过内容(sha256 自洽、顶层目录与 `--strip-components=1` 对得上、`.cross-built` 标记在位、包内是新图标),但**没在 Linux 上跑过** —— 正式发布仍走 CI |
-
-### 6.3 已知的时序 / 环境相关用例
-
-这些用例**不是回归**,遇到时先看条件,别急着改代码(完整清单见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 的「已知脆弱用例」表):
-
-- **`verify:desktop` 的「真实通知链路」「灵动岛标为已读」等 3 项**:要求**当前不在上课时段**。
- 若种子课表恰有课正在进行,客户端会**正确地**把普通通知暂存到下课(见 [4.6](#46-上课时段策略)),
- 这三项就会红(日志里会写 `客户端判定上课中=true` / `正在上 XXX`)。2026-10-02 那次 99/102 即为此因,
- 2026-10-06 09:27(种子里的「政治 08:55–09:40」正在上)那次 104/107 同因。
-- **`verify:e2e` 的「上课状态接口(at=周一 08:10)」**:种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07`
- 当第 1 周 —— `.env` 的 `TERM_START_DATE` 对齐到 `2026-09-07` 即全绿。
-- **`verify:web` 的「课表科目为全校统一目录」**:种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组。
-- **灵动岛交互 / 动画 / 截图留档几个用例在 AI 会话里会间歇性失败**:置顶透明小窗的 rAF 被限流、形变采样抓不到帧,
- 属时序敏感;**先原样重跑一次再动手改代码**。
-- **跑验证脚本大面积报「登录失败 / 401」**:先核对本机 `teacher1` 的密码 —— 本机实测被改成了 `123456`,
- 不是种子默认的 `teacher123`(原因见 AGENTS.md §7 第 51 条)。三套脚本各有各的传参方式:
-
- | 脚本 | 覆盖凭据的方式 |
- | ----------------- | --------------------------------------------------------------------------------------------------------- |
- | `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` |
- | `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) |
- | `verify:web` | `set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web`(另有 `UI_SMOKE_ADMIN*`) |
- | `verify:e2e` | **不支持环境变量**(口令硬编码在脚本里),需先确认本机种子口令或用临时副本(见 AGENTS.md §7 第 51 条) |
+上面那张表的证据全部来自仓库里的验收脚本 —— 五套脚本怎么跑、覆盖什么、哪些用例是脆弱的、
+失败了怎么判断是不是真回归,见[验收与测试](dev/testing.md)。条目数由脚本自行统计并打印,
+不要在文档里写死容易过期的总数。
## 7. 打包与交付
-### 体积与性能(2026-10-05 优化后实测)
+### 体积与性能(优化后)
| 产物 / 指标 | 优化前 | 优化后 | 做了什么 |
| ----------------------------------- | -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
@@ -1606,7 +1568,7 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
> 没有做的事,以及为什么:Element Plus 仍全量引入(按需引入要改所有视图的样式引入方式与 locale 配置,
> 收益约 200 到 350KB JS,风险不成比例);服务端静态资源不做预压缩;客户端不再删 GPU 回退组件
> (dxcompiler / swiftshader / d3dcompiler,共约 37MB 未压缩,教室老显卡与虚拟机靠它们)。
-> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 的 §5 第 53 至 56 条与 §7 第 23 条。
+> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) 的 §5 第 53 至 56 条。
### 客户端(Windows EXE)
@@ -1619,8 +1581,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
`ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`;
用 `--config.directories.output` 把输出目录覆盖成 `releases/client/<版本>/`,打包成功后再把
electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并把 `win-unpacked/`(约 323MB 的中间物)
-删掉;收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,
-见 [8. 常见问题](#8-常见问题))。
+删掉。
**只有两个交付物**,都落在 `releases/client/<版本>/安装包/`:`-x64-setup.exe`(NSIS 安装程序)
与 `-x64-portable.exe`(自解压单文件,双击即用)。解包目录 `win-unpacked/` 只是生成这两个 exe 的
@@ -1628,14 +1589,14 @@ electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并
`tidyArtifacts()` 就排在最后。
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/` 里的两个安装包。
-> 若学生机上是**安装版**(例如装在 `D:\class\@classhelperdesktop-client`),只更新 release 而不同步安装目录,
+> 若学生机上是**安装版**(例如装在 `%LOCALAPPDATA%\Programs\ClassHelper`),只更新 release 而不同步安装目录,
> 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。两个可行做法:
>
> ```powershell
> # 做法一(推荐):直接跑新安装包覆盖升级。用户配置在 %APPDATA%,不受影响。
> # 静默:releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe /S
-> # 再实测**装好那个副本**真的带上了修复(packaged=true)
-> pnpm verify:packaged --exe "D:\class\@classhelperdesktop-client\ClassHelper.exe"
+> # 再对**装好那个副本**跑一次冒烟,确认真的带上了修复(packaged=true)
+> pnpm verify:packaged --exe "%LOCALAPPDATA%\Programs\ClassHelper\ClassHelper.exe"
>
> # 做法二:要按文件覆盖(例如只换 app.asar),先让 portable 自解压出一份再拷
> # 便携版双击后会解到 %TEMP% 下的一个随机目录,从那里 robocopy 即可。
@@ -1644,14 +1605,14 @@ electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并
> 注意路径变了:`dist-win.mjs` 不再在 releases 里留解包目录,
> 所以 `packages\desktop-client\release\win-unpacked` 这个旧路径已经不存在了。
-最近一次打包实测(产物不入库):
+各产物的体积(产物不入库):
-| 产物 | 大小 | 冒烟结果 |
-| ------------------------------------------------- | ------ | -------------------------------------------------------------------- |
-| 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 |
-| `releases/client/<版本>/安装包/…-portable.exe` | ~99MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 |
-| `releases/client/<版本>/安装包/…-setup.exe` | ~99MB | ✅ 构建成功,已嵌入自定义图标 |
-| `releases/classisland-plugin/<版本>/…cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 |
+| 产物 | 大小 |
+| ---------------------------------------------- | ----- |
+| 服务端安装程序(内置 Node + Web 端) | ~62MB |
+| `releases/client/<版本>/安装包/…-portable.exe` | ~99MB |
+| `releases/client/<版本>/安装包/…-setup.exe` | ~99MB |
+| `releases/classisland-plugin/<版本>/…cipx` | ~88KB |
### ClassIsland 插件
@@ -1727,8 +1688,8 @@ node website/serve.mjs # http://127.0.0.1:5180,只是本地预览用
展示图统一放在 `docs/images/` —— 文档站、README 与官网吃的是**同一份文件**,
重新采集的完整命令(含目录与清理)写在[验收与测试](dev/testing.md)的「截图留档」一节。
-两个环境变量在 `smoke.ts` 里是**默认值为洋红**的开关:冒烟平时故意用洋红主题色把验证实例的岛
-和用户自己的岛区分开(见 AGENTS.md §5 第 38 条),采集留档图时才换成产品默认外观。
+两个环境变量在 `smoke.ts` 里是**默认值为洋红**的开关:冒烟平时故意用洋红主题色,把验证实例的岛
+与真实客户端区分开(见 AGENTS.md §5 第 38 条),采集留档图时才换成产品默认外观。
> 采集脚本会把窗口**真的弹在屏幕上**(和 `verify:web` / `verify:desktop` 一样),跑的时候别动键鼠。
> 客户端留档会**临时停掉 CSS 动画**再截图:窗口被遮挡时 Chromium 冻结动画,页面入场动画的起始帧是
@@ -1737,30 +1698,22 @@ node website/serve.mjs # http://127.0.0.1:5180,只是本地预览用
## 8. 常见问题
-**开发环境的坑请直接看 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7「本机环境已知坑」** —— 那里是本仓库唯一维护这些内容的
-地方(含命令坑、代理与 TLS、Low 完整性标签、makensis 临时目录、ClassIsland 路径与日志编码、AI 会话里跑
-Electron 验证等)。这里只列最常被问到的几条:
+**开发与构建环境的坑不在本文维护** —— 统一记在仓库根的
+[AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7(命令坑、代理与 TLS、
+makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列最常被问到的几条:
-1. **客户端装不上 / 便捷版双击没反应 / 应用一闪就没(0x80000003)**
- 现象:安装程序报「拒绝访问」,便捷版双击毫无反应,装上了也启动即退且不写日志。
- 原因:**交付物被打了 Low 完整性标签**(本仓库目录树带此标签、可继承,在仓库内构建就会继承)——
- 带 Low 标签的 exe 会被 Windows 降级运行,写不进 `%LOCALAPPDATA%` / `%TEMP%`;即使装上,Chromium 的
- 子进程(AppContainer,完整性更低)也读不到这些文件而秒崩。
- 处置:`pnpm dist:win` 收尾已自动改成 Medium;手工补救与排查命令见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §7 第 7 条。
- **别把安装测试装进仓库目录里**(会在那儿又继承 Low,看起来像「装完还是坏的」)。
-
-2. **文件传输 / 上传报「服务器内部错误:request entity too large」?**
+1. **文件传输 / 上传报「服务器内部错误:request entity too large」?**
表格导入的文件上限是 **8MB**(请求体 12MB),超限应返回 413 `IMPORT_TOO_LARGE` 与中文提示。
若看到英文的英文兜底错误,说明该异常没被 `middleware/error.ts` 映射,属 bug。
-3. **`pnpm db:deploy` 在根目录不存在**
+2. **`pnpm db:deploy` 在根目录不存在**
根 `package.json` 只声明了 `db:generate / db:migrate / db:seed / db:reset / db:studio`。
正确写法:`pnpm --filter @classhelper/server db:deploy`。
-4. **`pnpm db:seed` 报 `Cannot find module '.../@classhelper/shared/dist/index.js'`**
+3. **`pnpm db:seed` 报 `Cannot find module '.../@classhelper/shared/dist/index.js'`**
`packages/shared` 还没构建(它产出 `dist`,三端与 seed 都依赖)。先 `pnpm build:shared`。
-5. **后端改了代码不生效?** `tsx src/index.ts` 不热载 —— 用 `pnpm dev:server`(watch),或重启服务端。
+4. **后端改了代码不生效?** `tsx src/index.ts` 不热载 —— 用 `pnpm dev:server`(watch),或重启服务端。
切换数据库之后**必须重启**才会连新库。
## 9. 后续可选增强
diff --git a/package.json b/package.json
index afbfb3a..4901c08 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "class-helper",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0",
diff --git a/packages/classisland-plugin/manifest.yml b/packages/classisland-plugin/manifest.yml
index 34457ac..f78a5d7 100644
--- a/packages/classisland-plugin/manifest.yml
+++ b/packages/classisland-plugin/manifest.yml
@@ -14,6 +14,6 @@ description: |-
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper
-version: 1.3.1.0
+version: 1.3.2.0
apiVersion: 2.0.0.0
author: ClassHelper
diff --git a/packages/classisland-plugin/src/Services/BridgeService.cs b/packages/classisland-plugin/src/Services/BridgeService.cs
index ae8d308..cc6ca94 100644
--- a/packages/classisland-plugin/src/Services/BridgeService.cs
+++ b/packages/classisland-plugin/src/Services/BridgeService.cs
@@ -514,7 +514,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */
/// 插件版本(与 manifest.yml / csproj 保持一致)。
- private const string PluginVersion = "1.3.1.0";
+ private const string PluginVersion = "1.3.2.0";
/// ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。
private static string SafeAppVersion()
diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json
index c78b044..64e8db6 100644
--- a/packages/desktop-client/package.json
+++ b/packages/desktop-client/package.json
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
diff --git a/packages/desktop-client/src/main/client-agent.ts b/packages/desktop-client/src/main/client-agent.ts
index 323f516..64628aa 100644
--- a/packages/desktop-client/src/main/client-agent.ts
+++ b/packages/desktop-client/src/main/client-agent.ts
@@ -317,7 +317,22 @@ async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; r
try {
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
- spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
+ const child = spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true });
+ /**
+ * **必须监听 `error` 事件**:`spawn` 找不到可执行文件(或没权限)时不是抛异常,而是在
+ * next tick 上发一个 `error` 事件 —— 只包 `try/catch` 的话,这种情况会一路走到
+ * `return { ok: true }`,回执写着"已下达系统命令",Web 端看起来一切正常,机器却纹丝不动。
+ * 等一个很短的窗口(200ms)确认没有立刻报错;真执行成功时子进程本来也要跑一会儿。
+ */
+ const failure = await new Promise((resolve) => {
+ child.once('error', (error) => resolve(error.message));
+ setTimeout(() => resolve(null), 200);
+ });
+ child.unref();
+ if (failure) {
+ logger.warn(`远控命令「${CLIENT_COMMAND_LABELS[kind] ?? kind}」无法执行:${failure}`);
+ return { ok: false, result: failure };
+ }
return { ok: true, result: '已下达系统命令' };
} catch (error) {
return { ok: false, result: error instanceof Error ? error.message : String(error) };
diff --git a/packages/server/package.json b/packages/server/package.json
index 7817856..0b8782a 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
- "version": "1.3.1",
+ "version": "1.3.2",
"private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
diff --git a/packages/server/src/modules/clients/clients.service.ts b/packages/server/src/modules/clients/clients.service.ts
index 2704921..13da383 100644
--- a/packages/server/src/modules/clients/clients.service.ts
+++ b/packages/server/src/modules/clients/clients.service.ts
@@ -209,9 +209,22 @@ export async function listDevices(options: { classId?: string } = {}): Promise row.id) } },
+ orderBy: { createdAt: 'desc' },
+ take: 500,
+ })) as unknown as CommandRow[];
+ const lastByDevice = new Map();
+ for (const command of recent) {
+ if (!lastByDevice.has(command.deviceId)) lastByDevice.set(command.deviceId, command);
+ }
+
return rows.map((row) => {
const heartbeat = row.lastHeartbeatAt;
const pending = pendingByDevice.get(row.id);
+ const last = lastByDevice.get(row.id);
return {
id: row.id,
classId: row.classId,
@@ -228,6 +241,7 @@ export async function listDevices(options: { classId?: string } = {}): Promise {
await loadAll();
}
-async function sendCommand(kind: ClientCommandKind): Promise {
- const current = detail.value;
+/** 命令种类的中文名。包一层是因为表格插槽里的 `row` 是 any,直接索引常量表过不了类型检查 */
+function commandLabel(command: ClientCommandDto): string {
+ return CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
+}
+
+/**
+ * 一条远控命令"现在究竟怎么样了"。
+ *
+ * 待执行的那条会在**取消 / 超时**之后从 `pendingCommand` 里消失,界面上不留痕迹 ——
+ * 而最常见的两种"点了没反应"恰恰是:本机没人确认(客户端 60 秒自动按取消)与设备当时离线。
+ * 服务端把最近一条也返回(`lastCommand`),这里把它翻成一句人话。
+ */
+function commandStateText(command: ClientCommandDto): string { if (command.result) return command.result;
+ if (command.ackedAt) return '已执行';
+ if (command.expiresAt && Date.parse(command.expiresAt) < Date.now()) {
+ return '已过期 —— 下达时机器不在线(命令有效期 10 分钟)';
+ }
+ return '待执行(等这台机器的下一次心跳)';
+}
+
+async function sendCommand(kind: ClientCommandKind): Promise { const current = detail.value;
if (!current || current.clients.length === 0) return;
const label = CLIENT_COMMAND_LABELS[kind];
await ElMessageBox.confirm(
@@ -295,7 +315,10 @@ async function sendCommand(kind: ClientCommandKind): Promise {
kind,
note: `由 ${auth.user?.name ?? '管理员'} 下达`,
});
- ElMessage.success(`已下发:成功 ${result.succeeded} / 共 ${result.total} 台`);
+ ElMessage.success(
+ `已下发:成功 ${result.succeeded} / 共 ${result.total} 台 —— ` +
+ '机器上会弹出默认选中「取消」的确认框,60 秒内没人点「确认执行」就按取消处理(详情表里能看到结果)',
+ );
await loadAll();
}
@@ -577,6 +600,14 @@ onUnmounted(() => {
+
+
+ —
+
+ {{ commandLabel(row.lastCommand) }} · {{ commandStateText(row.lastCommand) }}
+
+
+
diff --git a/website/download.html b/website/download.html
index c41e84d..90de555 100644
--- a/website/download.html
+++ b/website/download.html
@@ -18,7 +18,7 @@
-
+
diff --git a/website/index.html b/website/index.html
index 183f831..745e81a 100644
--- a/website/index.html
+++ b/website/index.html
@@ -44,7 +44,7 @@
ClassHelper
- 1.3.1
+ 1.3.2
-
+