diff --git a/README.md b/README.md index c11790c..772e759 100644 --- a/README.md +++ b/README.md @@ -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=` 写出 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 未实测 ⚠️ | ## 常见问题(本机环境已知坑) diff --git a/package.json b/package.json index ed1e499..d11b9b3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index a576788..1ec0e9c 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -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 { 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 () => { diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index dd26bca..e65cf95 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -475,6 +475,113 @@ export async function scheduleTimelineSelfTest(): Promise { } } } +/** + * 冒烟自检 8(需后端在线):作业看板**全屏自适应**。 + * + * 用户反馈"全屏模式没有自适应":点开全屏后卡片挤在左上角、内容显示不全。 + * 这里走真实界面链路:进入作业页 → 切到「看板」→ 点「全屏看板」→ + * 断言 ①布局宽度铺满视口(列数由算法注入 `--board-cols`)②缩放后可视底部不超出屏幕(无需滚动)。 + */ +export async function homeworkBoardSelfTest(): Promise { + 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 { const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([ @@ -501,6 +608,7 @@ export function registerSmokeHooks(): void { islandRealtimeCleanup, islandReadState, scheduleTimelineSelfTest, + homeworkBoardSelfTest, sessionCleanup, }; } diff --git a/packages/desktop-client/src/renderer/views/HomeworkView.vue b/packages/desktop-client/src/renderer/views/HomeworkView.vue index 9540db1..8bf26fa 100644 --- a/packages/desktop-client/src/renderer/views/HomeworkView.vue +++ b/packages/desktop-client/src/renderer/views/HomeworkView.vue @@ -148,24 +148,93 @@ const boardScale = ref(1); const fullscreenHostRef = ref(null); const fullscreenInnerRef = ref(null); const fullscreenScale = ref(1); +/** 全屏单个科目视图(列表型):与整块看板分开持有 ref,避免两个全屏弹窗互相覆盖 */ +const courseHostRef = ref(null); +const courseInnerRef = ref(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 { + 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 { @@ -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')" > -
+
  1. { - +