fix(homework,island,verify): 全屏作业看板自适应 + 已安装副本覆盖升级 + 新增打包版冒烟

① 全屏作业看板自适应(用户反馈:全屏模式没有自适应,卡片挤在左上角)
- 全屏看板按「可用宽 × 可用高」枚举 1..N 列,取**单卡面积最大且宽高比最接近 1.15** 的方案,
  注入 `--board-cols`(不再靠 CSS auto-fit 猜);列数变了再量一次缩放系数,
  `transform-origin: top center` 保证整体居中、不溢出屏幕
- 两个全屏弹窗各自持有 ref(原来共用同名 ref,后挂载的会覆盖前一个,量到的是另一个弹窗);
  列表型「单个科目全屏」补上 `transform: scale()`(该规则原先只作用于 `.board`,列表全屏从未缩放)
- 弹窗 `@opened` 后轮询等待布局就绪(最多 20×60ms)再计算;窗口 resize 时自动重算;
  另加一条全局 `.board-fullscreen .el-dialog__body` 兜底,保证内容区铺满整屏
- 实测:6 个科目 → 3 列、排版宽 1155px(视口 1187px)、可视底 678px(视口高 765px)

② 「灵动岛设置没办法生效」的真正原因:用户机器上的副本一直没被更新
- 学生端实际运行的是安装目录 `D:\class\@classhelperdesktop-client` 里的 `resources/app.asar`,
  而 `pnpm dist:win` 只更新 `release/`;只发新版 release 不动安装目录,用户看到的还是旧界面
- 已就地覆盖升级该目录,并用同一套冒烟实测**那个副本**:`packaged: true`、76/76 全绿
- README 补充「把修复交付到已安装的客户端」步骤(robocopy 覆盖 + verify:packaged 实测)

③ 新增/加固自动化验证
- `verify:desktop` 新增「作业看板全屏自适应(铺满屏幕、内容不溢出)」:进作业页 → 切看板 →
  打开全屏 → 断言排版宽铺满视口、可视底不超出屏幕;再把卡片补到 6 张触发 resize,
  要求列数自动变多(1 → 3)且仍不溢出
- 点击/命中这类异步链路由"固定 sleep 后读一次"改为**轮询等待**(曾偶发误判为"点不开"),
  失败时把渲染进程当前类名、窗口可见性、岛体矩形写进结论,便于区分真 bug 与竞态
- 新增 `pnpm verify:packaged`:对**打包后/已安装**的 EXE 跑同一套冒烟(含二次启动校验文件锁),
  避免"开发产物全绿但交付给学生的 EXE 是旧的"这类问题
- 实测:`verify:desktop` 76/76(离线复跑 68/68);`verify:packaged` 对
  `release/win-unpacked` 与 `D:\class\@classhelperdesktop-client` 均 76/76、`packaged: true`;
  `verify:e2e` 149/149(安装实例);`typecheck` / `lint` / `format:check` 全绿
This commit is contained in:
LoveChengke committed 2026-09-13 09:26:52 +08:00
1 parent 11008928a1
commit 0ff2dc228b
7 files changed
+466 -67

No files matched your search

+68 -49
View File
@@ -20,7 +20,7 @@
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 75/75) |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 76/76) |
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
@@ -173,13 +173,16 @@ pnpm verify:desktop
# 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 班级账号(个人学生登录停用):149 项
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、多班批量发布、未交名单、课程表导入弹窗、手机端课表横向滚动):20 项
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/联合通知回归/胶囊类型汇总、未读红点位置、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化与设置页链路、托盘/退出无残留):75 项
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/联合通知回归/胶囊类型汇总、未读红点位置、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、作业看板全屏自适应、个性化与设置页链路、托盘/退出无残留):76 项
pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(证明 packaged=true,默认 release/win-unpacked)
```
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入、个人学生登录停用**等 149 项,
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
(**班级账号登录** + 四类数据 + Socket.IO),实测 75/75 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 68/68)。
(**班级账号登录** + 四类数据 + Socket.IO),实测 76/76 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 68/68)。
`pnpm verify:packaged --exe "<客户端路径>"` 对**打包后/已安装**的 EXE 跑同一套用例(`packaged: true`),
用于确认交付给学生的那个副本真的带上了本次修复 —— 开发产物通过**不等于**用户机器上的副本通过。
## 默认账号与种子数据
@@ -208,30 +211,31 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中
## 常用命令
| 命令 | 说明 |
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
| `pnpm verify:e2e` | 后端端到端验收(149 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(75 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
| `pnpm db:migrate` | 创建并应用迁移 |
| `pnpm db:seed` | 写入种子数据 |
| `pnpm db:reset` | 重置数据库并重新执行 seed |
| `pnpm db:studio` | 打开 Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
| 命令 | 说明 |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
| `pnpm verify:e2e` | 后端端到端验收(149 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(76 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、作业看板全屏自适应、个性化全参数) |
| `pnpm verify:packaged` | 对打包后/已安装的客户端 EXE 跑同一套冒烟(`--exe` 指定路径,实测 `packaged: true`) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
| `pnpm db:migrate` | 创建并应用迁移 |
| `pnpm db:seed` | 写入种子数据 |
| `pnpm db:reset` | 重置数据库并重新执行 seed |
| `pnpm db:studio` | 打开 Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
## Web 管理端(含手机小屏适配)
@@ -334,13 +338,14 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中
看板行为细节(按用户反馈逐条落地):
| 需求 | 实现 |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 只看当天的作业 | 工具栏「只看今天」开关(默认开):按 `createdAt` 的**本地日期**过滤,昨天的过期作业不再混进看板 |
| 作业要完整显示 | 卡片里同时渲染**标题 + 作业要求全文**(`white-space: pre-wrap`,不截断),列表模式也新增「作业要求」列 |
| 全部铺满屏幕、不用上下翻页 | 看板外层高度固定 + `overflow: hidden`,按「内容高度 / 可用高度」算缩放系数用 `transform: scale()` 整体缩到刚好放下(字号仍由滑块决定,工具栏显示当前缩放比例) |
| 当天时间显示在标题栏正中 | 全屏看板标题栏用 `#header` 插槽:标题左对齐、当天日期时间**绝对居中**显示(受「显示时间」开关控制,每分钟刷新) |
| 未交名单 | 作业列表/详情里点「未交名单」→ 勾选**没交作业**的同学 → 保存后其余同学自动标记为已完成(`PATCH /api/homeworks/:id/submissions`,`GET .../submissions` 读名单);Web 管理端作业详情抽屉里同样可看可改 |
| 需求 | 实现 |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 只看当天的作业 | 工具栏「只看今天」开关(默认开):按 `createdAt` 的**本地日期**过滤,昨天的过期作业不再混进看板 |
| 作业要完整显示 | 卡片里同时渲染**标题 + 作业要求全文**(`white-space: pre-wrap`,不截断),列表模式也新增「作业要求」列 |
| 全部铺满屏幕、不用上下翻页 | 看板外层高度固定 + `overflow: hidden`,按「内容高度 / 可用高度」算缩放系数用 `transform: scale()` 整体缩到刚好放下(字号仍由滑块决定,工具栏显示当前缩放比例) |
| 全屏看板要自适应 | 全屏弹窗打开后按「可用宽 × 可用高」枚举 1..N 列,取**单卡面积最大且宽高比最接近 1.15** 的方案注入 `--board-cols`(`fitFullscreen()` 轮询等待弹窗挂载后再算,最多 20×60ms);列数变了再量一次缩放系数,`transform-origin: top center` 保证整体居中不溢出;窗口 resize 时自动重算。实测 6 个科目 → 3 列、排版宽 1155px(视口 1187px)、可视底 677px(视口高 765px) |
| 当天时间显示在标题栏正中 | 全屏看板标题栏用 `#header` 插槽:标题左对齐、当天日期时间**绝对居中**显示(受「显示时间」开关控制,每分钟刷新) |
| 未交名单 | 作业列表/详情里点「未交名单」→ 勾选**没交作业**的同学 → 保存后其余同学自动标记为已完成(`PATCH /api/homeworks/:id/submissions`,`GET .../submissions` 读名单);Web 管理端作业详情抽屉里同样可看可改 |
**学生端主体 = 班级(个人学生账号不再允许登录)**:`POST /api/auth/login` 对 `role=STUDENT`
一律返回 **403**(提示改用「班级码 + 班级密码」);学生用户行仍保留,作为作业完成 / 通知已读 / 成绩
@@ -615,7 +620,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
已实测 `packaged: true`、75/75 通过、退出码 0)。灵动岛各状态的窗口截图会写到
已实测 `packaged: true`、76/76 通过、退出码 0)。灵动岛各状态的窗口截图会写到
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
@@ -1011,23 +1016,37 @@ pnpm db:migrate && pnpm db:seed
打包产物验证(对最终 EXE 实测,非仅开发产物):
| 产物 | 大小 | 冒烟结果 |
| -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
| 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 62/62 e2e + 15/15 UI** |
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ **28/28**,`packaged: true`,退出码 0(含灵动岛动画/作业/叫人/时间轴) |
| `release/…-x64-portable.exe`(单文件) | 106.9MB | ✅ 打包后冒烟 **28/28**,`packaged: true`,退出码 0(自解压单文件同样通过) |
| `release/…-x64-setup.exe`(客户端) | 107.2MB | ✅ 构建成功,已嵌入自定义图标;含灵动岛与本次行为修正 |
| 产物 | 大小 | 冒烟结果 |
| -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
| 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 149/149 e2e + 20/20 UI** |
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 打包版冒烟 **76/76**(离线复跑 68/68),`packaged: true`,退出码 0(含灵动岛动画/作业看板全屏自适应/叫人/时间轴) |
| `release/…-x64-portable.exe`(单文件) | 107.1MB | ✅ 打包后冒烟 **76/76**,`packaged: true`,退出码 0(自解压单文件同样通过) |
| `release/…-x64-setup.exe`(客户端) | 107.4MB | ✅ 构建成功,已嵌入自定义图标;含灵动岛与本次行为修正 |
> **把修复交付到"已安装的客户端"**:给学生的 EXE 安装在 `D:\class\@classhelperdesktop-client`(桌面/开始菜单快捷方式指向它),
> 而 `pnpm dist:win` 只产出 `packages/desktop-client/release/`。只更新 release 而不同步安装目录,
> 用户打开的还是旧 `resources/app.asar`,就会出现"改了但设置/界面还是不生效"。
> 覆盖升级(用户配置在 `%APPDATA%`,不受影响):
>
> ```powershell
> # 1) 先退出客户端(如有进程在跑,用任务管理器/PID 精确结束)
> # 2) 用新产物覆盖安装目录(不删 Uninstall 等安装器文件,所以不加 /MIR)
> robocopy "packages\desktop-client\release\win-unpacked" "D:\class\@classhelperdesktop-client" /E /NFL /NDL /NJH /NJS /R:2 /W:1
> # 3) 实测**那个副本**真的带上了修复(packaged=true)
> pnpm verify:packaged --exe "D:\class\@classhelperdesktop-client\班级小助手.exe"
> ```
当前实测:
| 验证 | 结果 |
| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **75/75** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
| 验证 | 结果 |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **149/149** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **20/20** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 作业看板全屏自适应 + 个性化全参数 + 托盘与退出无残留) | **76/76** ✅(离线复跑 68/68) |
| `pnpm verify:packaged`(对 `release/win-unpacked` 与 `D:\class\@classhelperdesktop-client` 实测) | **76/76**,`packaged: true` ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
## 常见问题(本机环境已知坑)
+1
View File
@@ -30,6 +30,7 @@
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
"verify:web": "node scripts/ui-smoke/run.mjs",
"verify:packaged": "node scripts/verify-packaged.mjs",
"icons": "node scripts/generate-icons.mjs",
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
+50 -10
View File
@@ -1220,6 +1220,11 @@ async function runIslandChecks(
island.setHitTestCursor(pillPoint);
await sleep(260);
const interactiveWhilePill = island.getInteractive();
// 点击前记录渲染进程当前形态:若渲染进程停在 expanded(状态推送滞后),
// 合成点击会被解释成"点空白处 → 收起",表现为"点了没反应",这里留证据便于定位。
const classBeforeClick = await islandWindow?.webContents
.executeJavaScript(`document.querySelector('.island-card')?.className ?? '-'`)
.catch(() => '-');
await islandWindow?.webContents
.executeJavaScript(
`(() => {
@@ -1228,16 +1233,24 @@ async function runIslandChecks(
})()`,
)
.catch(() => undefined);
await sleep(750);
const reopened = island.getState();
// 轮询等待主进程状态落到 expanded(不同环境 IPC/动画耗时不同,固定 sleep 会误判)
let reopened = island.getState();
for (let attempt = 0; attempt < 12 && reopened.mode !== 'expanded'; attempt += 1) {
await sleep(120);
reopened = island.getState();
}
const classAfterClick = await islandWindow?.webContents
.executeJavaScript(`document.querySelector('.island-card')?.className ?? '-'`)
.catch(() => '-');
record(
'收起态点击可再次展开(胶囊命中恢复 + 点击后展开)',
forcedThrough === false &&
interactiveWhilePill === true &&
reopened.mode === 'expanded' &&
reopened.active?.id === 'smoke-collapse-reopen',
`置穿透后=${forcedThrough} 光标在胶囊上时=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` +
`active=${reopened.active?.id ?? '-'}`,
`置穿透后=${forcedThrough} 光标在胶囊上时=${interactiveWhilePill} ` +
`点击前渲染类名=${classBeforeClick} 点击后 mode=${reopened.mode} ` +
`active=${reopened.active?.id ?? '-'} 点击后渲染类名=${classAfterClick}`,
);
const sliverForInteractive = getSliverSize();
@@ -1979,10 +1992,16 @@ async function runIslandChecks(
)
.catch(() => undefined);
await sleep(180);
// 兜底轮询读的是主进程光标坐标:注入到岛体中心,等价于"指针停在岛内"
// 兜底轮询读的是主进程光标坐标:注入到岛体中心,等价于"指针停在岛内"。
// 命中判定链路是异步的(渲染进程上报矩形 → 主进程 120ms 轮询校正),
// 因此这里**轮询等待**而不是固定 sleep 后读一次,避免偶发误判。
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
await sleep(260);
const interactiveInside = island.getInteractive();
let interactiveInside = island.getInteractive();
for (let index = 0; index < 10 && interactiveInside !== true; index += 1) {
await sleep(120);
island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5));
interactiveInside = island.getInteractive();
}
await islandWindow?.webContents
.executeJavaScript(
`(() => {
@@ -1993,13 +2012,20 @@ async function runIslandChecks(
.catch(() => undefined);
// 光标的权威判定在主进程(兜底轮询):这里把光标注入到屏幕左上角,等价于"指针离开岛体"
island.setHitTestCursor({ x: 1, y: 1 });
await sleep(260);
const interactiveOutside = island.getInteractive();
let interactiveOutside = island.getInteractive();
for (let index = 0; index < 10 && interactiveOutside !== false; index += 1) {
await sleep(120);
island.setHitTestCursor({ x: 1, y: 1 });
interactiveOutside = island.getInteractive();
}
const hitCheckGeometry = island.getHitRect();
island.setHitTestCursor(null);
record(
'岛外鼠标穿透(固定大窗口不吞桌面点击)',
interactiveInside === true && interactiveOutside === false,
`指针在岛内 interactive=${interactiveInside},指针在岛外 interactive=${interactiveOutside}(要求 true / false)`,
`指针在岛内 interactive=${interactiveInside},指针在岛外 interactive=${interactiveOutside}(要求 true / false)` +
` 窗口可见=${islandWindow?.isVisible() ?? false} 状态=${island.getState().mode}` +
` 岛体矩形=${hitCheckGeometry ? `${Math.round(hitCheckGeometry.x)},${Math.round(hitCheckGeometry.y)} ${Math.round(hitCheckGeometry.width)}x${Math.round(hitCheckGeometry.height)}` : 'null'}`,
);
island.setAppearance({ ...appearanceBefore, style: 'black' });
await sleep(200);
@@ -2464,6 +2490,20 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
String(timeline?.detail ?? ''),
);
// 作业看板全屏自适应(用户反馈:全屏看板没有铺满屏幕):
// 进作业页 → 切看板 → 打开全屏 → 量布局宽度/可视底部,要求铺满且不需要滚动
const boardFullscreen = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.homeworkBoardSelfTest) return { ok: false, detail: '缺少看板自适应自检钩子' };
return await window.__classhelperSmoke__.homeworkBoardSelfTest();
})()`,
);
record(
'作业看板全屏自适应(铺满屏幕、内容不溢出)',
Boolean(boardFullscreen?.ok),
String(boardFullscreen?.detail ?? ''),
);
// 侧边栏点击导航(回归测试:曾因把 index 当路由名导致点击无反应)
const navigation = await win.webContents.executeJavaScript(
`(async () => {
+108
View File
@@ -475,6 +475,113 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
}
}
}
/**
* 冒烟自检 8(需后端在线):作业看板**全屏自适应**。
*
* 用户反馈"全屏模式没有自适应":点开全屏后卡片挤在左上角、内容显示不全。
* 这里走真实界面链路:进入作业页 → 切到「看板」→ 点「全屏看板」→
* 断言 ①布局宽度铺满视口(列数由算法注入 `--board-cols`)②缩放后可视底部不超出屏幕(无需滚动)。
*/
export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
const { router } = await import('../router/index.js');
try {
await router.push('/homeworks');
if (!(await waitUntil(() => Boolean(document.querySelector('.homework-page')), 6000))) {
return { ok: false, detail: '作业页未挂载' };
}
// 必须是「看板」模式(列表模式没有全屏看板入口)
const boardTab = Array.from(document.querySelectorAll('.el-radio-button')).find((node) =>
(node.textContent ?? '').includes('看板'),
);
if (!(boardTab instanceof HTMLElement)) return { ok: false, detail: '找不到「看板」切换按钮' };
boardTab.click();
await waitUntil(() => Boolean(document.querySelector('.board-config')), 3000);
// 今天可能恰好没有作业:先关掉「只看今天」,保证看板一定有卡片(断言才有意义)
if (!document.querySelector('.board-host')) {
const todaySwitch = document.querySelector('.page-header .el-switch');
if (todaySwitch instanceof HTMLElement) todaySwitch.click();
}
const hasBoard = await waitUntil(() => Boolean(document.querySelector('.board-host')), 4000);
const fullscreenButton = Array.from(document.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').includes('全屏看板'),
);
if (!(fullscreenButton instanceof HTMLElement)) return { ok: false, detail: '找不到「全屏看板」按钮' };
fullscreenButton.click();
const dialogReady = await waitUntil(
() => Boolean(document.querySelector('.board-fullscreen .board-lg')),
6000,
);
if (!dialogReady) return { ok: false, detail: '全屏看板弹窗未出现' };
// 等自适应轮询跑完(最多 20 次 × 60ms)
await new Promise((resolve) => setTimeout(resolve, 1600));
const board = document.querySelector('.board-fullscreen .board-lg');
const host = document.querySelector('.board-fullscreen .board-host-lg');
if (!(board instanceof HTMLElement) || !(host instanceof HTMLElement)) {
return { ok: false, detail: '全屏看板容器缺失' };
}
const rect = board.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const layoutWidth = board.offsetWidth;
const columns = Number(board.style.getPropertyValue('--board-cols') || 0);
const cards = board.querySelectorAll('.board-card').length;
const scale = Number((getComputedStyle(board).transform.match(/matrix\(([-\d.]+)/) ?? [])[1] ?? 1);
const fillsWidth = layoutWidth >= viewportWidth * 0.9;
const fitsScreen = rect.bottom <= viewportHeight + 2 && rect.width <= viewportWidth + 2;
// 第二阶段:模拟"科目很多"(真实班级看板常有 5~8 个科目)。
// 克隆卡片到 6 张 → 触发 resize 让自适应算法重算 → 断言列数自动变多且仍然铺满、不溢出。
let manyCards = '';
let manyOk = false;
const sample = board.querySelector('.board-card');
if (sample instanceof HTMLElement) {
while (board.querySelectorAll('.board-card').length < 6) {
const clone = sample.cloneNode(true) as HTMLElement;
clone.setAttribute('data-smoke-filler', '1');
board.appendChild(clone);
}
window.dispatchEvent(new Event('resize'));
await new Promise((resolve) => setTimeout(resolve, 1500));
const cardsMany = board.querySelectorAll('.board-card').length;
const columnsMany = Number(board.style.getPropertyValue('--board-cols') || 0);
const layoutWidthMany = board.offsetWidth;
const rectMany = board.getBoundingClientRect();
manyOk =
cardsMany === 6 &&
columnsMany >= 2 &&
columnsMany <= cardsMany &&
layoutWidthMany >= viewportWidth * 0.9 &&
rectMany.bottom <= viewportHeight + 2;
manyCards =
`多科目:卡片=${cardsMany} 列数=${columnsMany} 排版宽=${layoutWidthMany} ` +
`可视底=${Math.round(rectMany.bottom)} 通过=${manyOk}`;
}
const closeButton = document.querySelector('.board-fullscreen .el-dialog__headerbtn');
if (closeButton instanceof HTMLElement) closeButton.click();
return {
ok: hasBoard && cards > 0 && columns >= 1 && columns <= cards && fillsWidth && fitsScreen && manyOk,
detail:
`卡片=${cards} 列数=${columns} 缩放=${Number.isFinite(scale) ? scale.toFixed(3) : '-'} ` +
`排版宽=${layoutWidth}(视口宽=${viewportWidth})可视底=${Math.round(rect.bottom)}(视口高=${viewportHeight}) ` +
`铺满宽=${fillsWidth} 不超出屏幕=${fitsScreen};${manyCards}`,
};
} catch (error) {
return {
ok: false,
detail: `看板自适应自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -501,6 +608,7 @@ export function registerSmokeHooks(): void {
islandRealtimeCleanup,
islandReadState,
scheduleTimelineSelfTest,
homeworkBoardSelfTest,
sessionCleanup,
};
}
@@ -148,24 +148,93 @@ const boardScale = ref(1);
const fullscreenHostRef = ref<HTMLElement | null>(null);
const fullscreenInnerRef = ref<HTMLElement | null>(null);
const fullscreenScale = ref(1);
/** 全屏单个科目视图(列表型):与整块看板分开持有 ref,避免两个全屏弹窗互相覆盖 */
const courseHostRef = ref<HTMLElement | null>(null);
const courseInnerRef = ref<HTMLElement | null>(null);
const fullscreenCourseScale = ref(1);
/** 全屏看板的列数(由"铺满屏幕"算法动态算出;见 computeColumns) */
const fullscreenColumns = ref(0);
function computeScale(host: HTMLElement | null, inner: HTMLElement | null): number {
function computeScale(host: HTMLElement | null, inner: HTMLElement | null, minScale = 0.45): number {
if (!host || !inner) return 1;
const available = host.clientHeight;
// 先按 1 倍量一次内容高度(transform 不影响 offsetHeight)
const content = inner.offsetHeight;
if (available <= 0 || content <= 0) return 1;
const scale = Math.min(1, available / content);
return Math.max(0.45, Math.round(scale * 1000) / 1000);
return Math.max(minScale, Math.round(scale * 1000) / 1000);
}
/**
* 全屏看板列数:在"可用宽 × 可用高"里枚举 1..count 列,
* 选出"单卡面积最大"的方案(面积越大越铺满屏幕),并用宽高比惩罚避免出现
* 又扁又长的卡片。这样无论 2 个科目还是 12 个科目,全屏都是一屏铺满。
*/
function computeColumns(count: number, host: HTMLElement | null): number {
if (count <= 1) return 1;
if (!host) return count;
const width = host.clientWidth || 0;
const height = host.clientHeight || 0;
if (width <= 0 || height <= 0) return Math.min(3, count);
const gap = Math.max(8, boardFontSize.value * 1.53);
const wantRatio = 1.15;
let bestCols = 1;
let bestScore = -Infinity;
for (let cols = 1; cols <= count; cols += 1) {
const rows = Math.ceil(count / cols);
const cardWidth = (width - gap * (cols - 1)) / cols;
const cardHeight = (height - gap * (rows - 1)) / rows;
if (cardWidth <= 0 || cardHeight <= 0) continue;
const ratio = cardWidth / cardHeight;
const ratioPenalty = Math.max(ratio / wantRatio, wantRatio / ratio);
const score = (cardWidth * cardHeight) / ratioPenalty;
if (score > bestScore) {
bestScore = score;
bestCols = cols;
}
}
return bestCols;
}
function fitBoards(): void {
void nextTick(() => {
boardScale.value = computeScale(boardHostRef.value, boardInnerRef.value);
fullscreenScale.value = computeScale(fullscreenHostRef.value, fullscreenInnerRef.value);
fullscreenCourseScale.value = computeScale(courseHostRef.value, courseInnerRef.value);
});
}
/**
* 全屏看板适配:弹窗是异步挂载的(有过渡),刚打开时容器尺寸可能是 0,
* 因此这里轮询等待布局就绪,再算列数与缩放,保证"一屏铺满、不用滚动"。
* 列数改变会重排,所以排完再量一次缩放系数。
*/
async function fitFullscreen(kind: 'board' | 'course' = 'board'): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
const host = kind === 'board' ? fullscreenHostRef.value : courseHostRef.value;
const inner = kind === 'board' ? fullscreenInnerRef.value : courseInnerRef.value;
if (host && inner && host.clientHeight > 60 && host.clientWidth > 60) {
if (kind === 'board') {
const count = inner.children.length || boardColumns.value.length || 1;
fullscreenColumns.value = computeColumns(count, host);
await nextTick();
fullscreenScale.value = computeScale(host, fullscreenInnerRef.value, 0.3);
} else {
fullscreenCourseScale.value = computeScale(host, inner, 0.3);
}
return;
}
await new Promise((resolve) => setTimeout(resolve, 60));
}
}
/** 视口/窗口尺寸变化:全屏看板重新自适应 */
function onResize(): void {
fitBoards();
if (fullscreenBoard.value) void fitFullscreen('board');
if (fullscreenCourse.value) void fitFullscreen('course');
}
/* ------------------------------------------------------------ 数据加载 */
async function loadHomework(): Promise<void> {
@@ -355,6 +424,7 @@ onMounted(async () => {
resizeObserver = new ResizeObserver(() => fitBoards());
if (boardHostRef.value) resizeObserver.observe(boardHostRef.value);
}
window.addEventListener('resize', onResize);
fitBoards();
realtime.on(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.on(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
@@ -364,6 +434,7 @@ onMounted(async () => {
onUnmounted(() => {
if (clockTimer) clearInterval(clockTimer);
resizeObserver?.disconnect();
window.removeEventListener('resize', onResize);
realtime.off(SOCKET_EVENTS.homeworkNew, onHomeworkEvent);
realtime.off(SOCKET_EVENTS.homeworkUpdated, onHomeworkEvent);
appStore.offServerRecovered(onRecovered);
@@ -512,6 +583,7 @@ onUnmounted(() => {
fullscreen
:title="`${fullscreenCourse ?? ''} · 作业`"
class="board-fullscreen"
@opened="fitFullscreen('course')"
>
<template #header="{ titleId, titleClass }">
<div class="board-dialog-head">
@@ -520,11 +592,11 @@ onUnmounted(() => {
<span v-if="boardShowTime" class="board-dialog-clock">{{ todayText }}</span>
</div>
</template>
<div ref="fullscreenHostRef" class="board-host board-host-lg">
<div ref="courseHostRef" class="board-host board-host-lg">
<ol
ref="fullscreenInnerRef"
ref="courseInnerRef"
class="board-list board-list-lg"
:style="{ ...fullscreenStyle, '--board-scale': fullscreenScale }"
:style="{ ...fullscreenStyle, '--board-scale': fullscreenCourseScale }"
>
<li
v-for="(item, index) in fullscreenItems"
@@ -547,7 +619,7 @@ onUnmounted(() => {
</el-dialog>
<!-- 全屏:整块看板放大 -->
<el-dialog v-model="fullscreenBoard" fullscreen class="board-fullscreen">
<el-dialog v-model="fullscreenBoard" fullscreen class="board-fullscreen" @opened="fitFullscreen('board')">
<template #header="{ titleId, titleClass }">
<div class="board-dialog-head">
<span :id="titleId" :class="titleClass">今日作业看板</span>
@@ -558,7 +630,11 @@ onUnmounted(() => {
<div
ref="fullscreenInnerRef"
class="board board-lg"
:style="{ ...fullscreenStyle, '--board-scale': fullscreenScale }"
:style="{
...fullscreenStyle,
'--board-scale': fullscreenScale,
'--board-cols': String(fullscreenColumns || 1),
}"
>
<section v-for="column in boardColumns" :key="column.course" class="board-card">
<header class="board-card-head">
@@ -738,9 +814,12 @@ onUnmounted(() => {
transform-origin: top center;
}
/* 全屏看板:列数由脚本按"铺满屏幕"算好后注入(--board-cols),不再靠 auto-fit 猜;
行高保持内容自然高度,交给 transform: scale() 适配,避免卡片被压扁截断内容。 */
.board-lg {
grid-template-columns: repeat(auto-fit, minmax(calc(var(--board-font, 15px) * 16), 1fr));
grid-template-columns: repeat(var(--board-cols, 2), minmax(0, 1fr));
gap: calc(var(--board-font, 15px) * 0.9);
align-content: start;
}
.board-card {
@@ -875,6 +954,12 @@ onUnmounted(() => {
color: #0a84ff;
}
.board-list-lg {
width: 100%;
transform: scale(var(--board-scale, 1));
transform-origin: top center;
}
.board-list-lg .board-item {
padding: 6px 8px;
}
@@ -904,6 +989,14 @@ onUnmounted(() => {
overflow: hidden;
}
/* 兜底:`class` 由 el-dialog 透传时可能不携带 scoped 属性,这里用全局选择器保证
全屏弹窗内容区一定铺满整屏(否则看板会缩在左上角)。 */
:global(.board-fullscreen .el-dialog__body) {
padding-top: 6px;
height: calc(100vh - 84px);
overflow: hidden;
}
.submission-toolbar {
display: flex;
align-items: center;
+2
View File
@@ -133,6 +133,8 @@ declare global {
}>;
/** ClassIsland 风格「今天」时间轴自检(造课 → 读 DOM → 清理) */
scheduleTimelineSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 作业看板全屏自适应自检(切看板 → 打开全屏 → 量尺寸) */
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
};
}
+136
View File
@@ -0,0 +1,136 @@
/**
* 打包版冒烟验证:对**已打包**的客户端 EXE 跑一遍与 `pnpm verify:desktop` 相同的自检。
*
* 为什么需要单独一条:开发产物(`dist/`)通过不代表打包产物通过 —— asar 打包后
* 资源路径、Electron 版本、`app.isPackaged` 都可能不同。交付前应实测 `packaged: true`。
*
* 用法:
* node scripts/verify-packaged.mjs
* node scripts/verify-packaged.mjs --exe "D:\class\@classhelperdesktop-client\班级小助手.exe"
* node scripts/verify-packaged.mjs --offline
* node scripts/verify-packaged.mjs --code G101 --password 123456
*
* 联网用例需要后端在 http://127.0.0.1:4000(默认自动探测;探测不到时退化为离线用例)。
* 退出码:0 = 两次启动都全绿且 `packaged: true`。
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveElectronEnv } from './lib/electron-env.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const argv = process.argv.slice(2);
const argOf = (name) => {
const index = argv.indexOf(name);
return index >= 0 ? argv[index + 1] : undefined;
};
const exe = path.resolve(
argOf('--exe') ?? path.join(repoRoot, 'packages/desktop-client/release/win-unpacked/班级小助手.exe'),
);
const classCode = argOf('--code') ?? process.env.ELECTRON_SMOKE_CLASS_CODE ?? 'G101';
const classPassword = argOf('--password') ?? process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '123456';
const forceOffline = argv.includes('--offline');
if (!fs.existsSync(exe)) {
console.error(`[packaged-smoke] 找不到打包后的 EXE:${exe}`);
console.error('[packaged-smoke] 先执行 pnpm dist:win,或用 --exe 指定已安装的客户端路径');
process.exit(2);
}
let online = forceOffline ? '0' : null;
if (online === null) {
online = '0';
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2500);
const response = await fetch('http://127.0.0.1:4000/api/health', { signal: controller.signal });
clearTimeout(timer);
online = response.ok ? '1' : '0';
} catch {
online = '0';
}
}
// 独立 userData:用户开着客户端也能跑验证(不抢单实例锁)
const profileDir = path.join(repoRoot, '.cache', `packaged-smoke-profile-${process.pid}`);
const resultFile = path.join(repoRoot, '.cache', `packaged-smoke-${process.pid}.json`);
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 });
console.log(`[packaged-smoke] EXE:${exe}`);
console.log(`[packaged-smoke] 联网用例:${online === '1' ? '启用' : '关闭'}`);
console.log(`[packaged-smoke] 独立配置目录:${profileDir}\n`);
/** 启动一次打包版冒烟(stdio 继承:EXE 是 GUI 进程,输出直接透传到终端) */
function launch(extraEnv) {
return new Promise((resolve) => {
const child = spawn(exe, [], {
cwd: path.dirname(exe),
stdio: 'inherit',
env: resolveElectronEnv({
ELECTRON_SMOKE_TEST: '1',
ELECTRON_SMOKE_PROFILE: profileDir,
ELECTRON_SMOKE_ONLINE: online,
ELECTRON_SMOKE_CLASS_CODE: classCode,
ELECTRON_SMOKE_CLASS_PASSWORD: classPassword,
...extraEnv,
}),
});
child.on('exit', (code) => resolve(code ?? 1));
});
}
const readResult = () => {
try {
return JSON.parse(fs.readFileSync(resultFile, 'utf8'));
} catch {
return null;
}
};
fs.rmSync(resultFile, { force: true });
const firstCode = await launch({ ELECTRON_SMOKE_RESULT: resultFile });
const first = readResult();
const firstPassed = first?.passed ?? -1;
const firstTotal = first?.total ?? -1;
// 退出后再启动一次:证明单实例锁与文件句柄都已释放(与开发版冒烟同一套判据)
let lockReleased = true;
try {
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 });
} catch (error) {
lockReleased = false;
console.error(`[packaged-smoke] [FAIL] 退出后配置文件锁未释放:${error?.message ?? error}`);
}
console.log('\n[packaged-smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
fs.rmSync(resultFile, { force: true });
const secondCode = await launch({ ELECTRON_SMOKE_ONLINE: '0', ELECTRON_SMOKE_RESULT: resultFile });
const second = readResult();
const ok =
firstCode === 0 &&
first?.ok === true &&
first?.packaged === true &&
firstPassed === firstTotal &&
lockReleased &&
secondCode === 0 &&
second?.ok === true &&
second?.packaged === true;
console.log(
`\n[packaged-smoke] 首次启动:${firstPassed}/${firstTotal} packaged=${first?.packaged === true} code=${firstCode}`,
);
console.log(
`[packaged-smoke] 二次启动:${second?.passed ?? -1}/${second?.total ?? -1} ` +
`packaged=${second?.packaged === true} code=${secondCode} 文件锁已释放=${lockReleased}`,
);
console.log(ok ? '[packaged-smoke] [PASS] 打包版全绿' : '[packaged-smoke] [FAIL] 打包版未全绿');
try {
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 2, retryDelay: 200 });
} catch {
// 清理失败不影响结论(第一次删除已证明锁已释放)
}
process.exit(ok ? 0 : 1);