fix(island,server): 连续圆角四角修正 + 叫人分紧急/普通两级
灵动岛圆角(用户反馈"左上角和右下角缺一块 / 圆角边缘割裂"): - squircle.ts 改为"每个角独立圆心 + 角度参数"的超椭圆采样(原本四角共用同一个偏移向量, 左上/右下被切掉一块);四角几何与 WinIsland 的连续超椭圆一致,r 与 h/2、w/2 自动收敛。 - verify:desktop 新增像素级断言「连续圆角四角一致」:从四角沿对角线找岛体边界, 四角步进必须两两一致(展开 15/15/15/15、紧急 14/14/14/14、胶囊 5/5/5/5), 并保留路径绕行 |winding|≈2π 断言(抓"角上往回折")。该断言即本次回归的守卫。 - 截图留档同时写入裁剪图与整窗图,便于人工复核角形。 叫人分紧急 / 普通两级(用户要求"叫人也分为紧急和普通"): - shared:CreateCallRequest 新增 urgent;紧急叫人 → URGENT(无视上课时段立即展开), 普通叫人(默认)→ HIGH(按普通通知排队)。 - desktop:isImmediate 只认 priority=URGENT,不再把 kind=call 一律视为立即消息; 普通叫人回到与普通通知相同的排队 / 隐藏 / 下课弹出路径。 - web:通知发布 → 叫人、学生管理 → 叫人 两个弹窗新增"级别"单选(默认普通)+ 联动提示文案。 验证:verify:e2e 135/135(新增普通 / 紧急叫人级别断言)、verify:web 20/20(新增级别单选断言)、 verify:desktop 59/59 + 离线复跑 53/53、typecheck / lint / format 全绿。
This commit is contained in:
1 parent
1b4dd14e4a
commit
0690546307
13 files changed
+553
-205
No files matched your search
@@ -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 离线缓存 | ✅ 已完成(冒烟验证 54/54) |
|
||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 59/59) |
|
||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||
@@ -75,7 +75,7 @@ class-helper/
|
||||
│ ├── prisma/migrations/ # 迁移历史(已生成并应用)
|
||||
│ ├── prisma/seed.ts # 种子数据
|
||||
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
|
||||
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(133 项)
|
||||
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(135 项)
|
||||
│ └── src/
|
||||
│ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载)
|
||||
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
|
||||
@@ -171,15 +171,15 @@ pnpm verify:desktop
|
||||
|
||||
```bash
|
||||
# 另开一个终端先启动后端:pnpm dev:server
|
||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:133 项
|
||||
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 成绩/时间配置导入弹窗):19 项
|
||||
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、开合不震动、个性化 10 项参数与托盘/退出无残留):49 项
|
||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:135 项
|
||||
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项
|
||||
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/紧急与普通叫人、真实链路、课表时间轴、开合不震动、连续圆角四角一致、个性化 10 项参数与托盘/退出无残留):59 项
|
||||
```
|
||||
|
||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 133 项,
|
||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 135 项,
|
||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||
(**班级账号登录** + 四类数据 + Socket.IO),实测 54/54 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 48/48)。
|
||||
(**班级账号登录** + 四类数据 + Socket.IO),实测 59/59 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 53/53)。
|
||||
|
||||
## 默认账号与种子数据
|
||||
|
||||
@@ -208,30 +208,30 @@ 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` | 后端端到端验收(133 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
|
||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,19 项,含权限入口隐藏与导入弹窗) |
|
||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(49 项,含灵动岛开合不震动/已读/作业/叫人、课表时间轴、个性化全参数) |
|
||||
| `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` | 后端端到端验收(135 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
|
||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
|
||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(59 项,含灵动岛圆角/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) |
|
||||
| `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 管理端(含手机小屏适配)
|
||||
|
||||
@@ -267,7 +267,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
||||
实现位置:`src/composables/useResponsive.ts`(断点 + resize 监听)、`src/layouts/AdminLayout.vue`(抽屉/顶栏)、
|
||||
`src/styles/index.css`(全局响应式规则)、各视图表格卡片加 `.table-card`。
|
||||
|
||||
`pnpm verify:web` 的 19 项里,最后 11 项专门验证权限入口隐藏、手机端、叫人入口、成绩导入入口与 ClassIsland 时间配置导入弹窗(Electron 真实点击):
|
||||
`pnpm verify:web` 的 20 项里,后半段专门验证权限入口隐藏、手机端、叫人入口(含紧急/普通两级)、成绩导入入口与 ClassIsland 时间配置导入弹窗(Electron 真实点击):
|
||||
|
||||
| 校验项 | 实测结果 |
|
||||
| -------------------------------------- | -------------------------------------------------------------------- |
|
||||
@@ -332,22 +332,23 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
||||
> 渲染层做命中测试后只在指针进入岛体时打开命中——**大窗口不会吞掉桌面点击**(有专项断言)。
|
||||
> 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。
|
||||
|
||||
| 场景 | 灵动岛行为 |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 |
|
||||
| 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 |
|
||||
| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
|
||||
| **点击屏幕任意位置** | 同样回缩为胶囊:展开时窗口临时可聚焦,点到别处即失焦收起(WinUI 同款"点击外部关闭") |
|
||||
| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(此前只从灵动岛队列移除、列表仍显示未读) |
|
||||
| **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见"截止时间:…" |
|
||||
| **叫人(老师点名)** | **上课时段也立即展开**:琥珀金卡片、"叫人"徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到" |
|
||||
| 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 —— 收回时不会出现被拉伸的"方框",图标也不会上下跳 |
|
||||
| 点击右上角收起按钮 | 同上:回缩为胶囊 |
|
||||
| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情(14 秒)→ 收起为胶囊(25 秒) |
|
||||
| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 |
|
||||
| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 |
|
||||
| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" |
|
||||
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) |
|
||||
| 场景 | 灵动岛行为 |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 |
|
||||
| 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 |
|
||||
| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
|
||||
| **点击屏幕任意位置** | 同样回缩为胶囊:展开时窗口临时可聚焦,点到别处即失焦收起(WinUI 同款"点击外部关闭") |
|
||||
| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(此前只从灵动岛队列移除、列表仍显示未读) |
|
||||
| **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见"截止时间:…" |
|
||||
| **紧急叫人**(老师点名·紧急) | **上课时段也立即展开**:琥珀金卡片、`叫人` 徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到"(`priority=URGENT`) |
|
||||
| **普通叫人**(默认级别) | 课间先显示 `叫人` 胶囊、点击展开;**上课时段只进队列**(不打断课堂),下课后自动弹出详情(`priority=HIGH`) |
|
||||
| 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 —— 收回时不会出现被拉伸的"方框",图标也不会上下跳 |
|
||||
| 点击右上角收起按钮 | 同上:回缩为胶囊 |
|
||||
| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情(14 秒)→ 收起为胶囊(25 秒) |
|
||||
| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 |
|
||||
| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 |
|
||||
| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" |
|
||||
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) |
|
||||
|
||||
- 动画:窗口尺寸用逐帧缓动实现"形变",**单调不过冲**——展开 300ms、收回 220ms,统一 `easeOutCubic`
|
||||
(早期版本用 `easeOutBack` 弹簧缓动 + 卡片 `cubic-bezier(0.34, 1.56, 0.64, 1)` 缩放入场,
|
||||
@@ -498,66 +499,79 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
||||
|
||||
`pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出:
|
||||
|
||||
| 校验项 | 实测结果 |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
|
||||
| 登录页渲染(三个输入框) | ✅ 服务器地址 / **班级码** / 班级密码 |
|
||||
| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) |
|
||||
| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 |
|
||||
| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` |
|
||||
| IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store |
|
||||
| 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) |
|
||||
| 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `ready=true` |
|
||||
| 上课期间普通通知自动隐藏 | ✅ `mode=hidden queued=1 visible=false`(窗口真的隐藏,不是只改状态) |
|
||||
| 上课期间点击不展开(严格不显示) | ✅ `mode=hidden visible=false` |
|
||||
| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` |
|
||||
| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` |
|
||||
| **紧急通知带展开动画** | ✅ 16ms 采样 34 帧,出现 7 个中间尺寸,最终 `424x344`(窗口从胶囊尺寸单调展开,无过冲) |
|
||||
| 上课期间收起不回缩成胶囊 | ✅ `mode=hidden visible=false` |
|
||||
| **普通通知直接显示胶囊(无入场动画)** | ✅ 推送前已隐藏,60ms 内窗口即为 `268x44`、`mode=pill`(无"上岛"动画) |
|
||||
| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) |
|
||||
| **点击卡片空白处回缩为胶囊** | ✅ 真实点击 `.body` 空白区域 → `mode=pill` |
|
||||
| **点击右上角收起按钮回缩为胶囊** | ✅ 真实点击 `.icon-btn` → `mode=pill` |
|
||||
| **灵动岛截图留档 + 像素级校验** | ✅ 4 张(808×632 / 848×688 / 536×88 / 808×632);宽高比与状态机配置偏差 **0.000**;形态尺寸递增 pill < expanded < urgent;紧急态偏红 **20584 px**(卡片内部光晕) |
|
||||
| **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") |
|
||||
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ `login=高一(1)班 classSession=true` / 1 班级 / 30 节课 / Socket.IO 已连接 |
|
||||
| **个性化:尺寸联动不破坏布局** | ✅ 高 56 / 宽 300 / 圆角 26 / 字号 14 → 窗口 `300x56`,CSS 变量同步,展开态内容溢出 **0px** |
|
||||
| **开合不震动:展开** | ✅ 逐帧采样 57 帧:岛中心波动 **0.00px**、上边缘 **0.00px**;**窗口冻结**(全程 `476x282@482,-2`);岛最大 `440x246` = 目标无过冲 |
|
||||
| **开合不震动:收回** | ✅ 逐帧采样 56 帧:岛中心/上边缘波动 **0.00px**;窗口全程冻结;岛 `386x230→…→268x44` 单调收缩无回跳 |
|
||||
| **WinIsland 架构:窗口恒定 + 形状正确** | ✅ 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种);SVG 形状 bbox 与卡片框一致(±2px,可抓出 viewBox 错位/残留角) |
|
||||
| **岛外鼠标穿透** | ✅ 指针在岛内 `interactive=true`、在岛外 `interactive=false`(固定大包围盒窗口不吞桌面点击) |
|
||||
| **WinIsland 排版:字号即时生效** | ✅ 13px → 19px:胶囊标题 `11.96→17.48`、胶囊副题 `9.62→14.06`、标题 `14.04→20.52`、正文 `12.35→18.05`、按钮 `9.88→14.44`,**倍率全部 1.462 = 19/13** |
|
||||
| **WinIsland 风格:三种底即时切换** | ✅ 纯黑 `fill=rgb(0,0,0)`/材质 `none`;毛玻璃 `fill=rgba(10,10,14,.59)`/材质 **`acrylic`**;主题色 `fill=rgb(11,11,15)` |
|
||||
| **WinIsland 空闲细缝** | ✅ 空闲时岛收成 `6x22`(窗口固定,不再受 Windows 36px 最小窗口高度限制)→ 来消息自动回到 `268x44`;关闭细缝后空闲 `isVisible=false` |
|
||||
| **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` |
|
||||
| **个性化:停靠位置(6 个锚点)** | ✅ 左上 `8,8` / 右上 `1216,8` / 底部居中 `586,800` / 底部左 / 底部右 / 顶部居中 `586,8` |
|
||||
| **个性化:置顶开关** | ✅ 关闭后 `isAlwaysOnTop=false`,开启后 `true` |
|
||||
| **个性化:动画开关 / 速度** | ✅ 关闭动画后 60ms 内即到位(无过渡);speed=2 收回明显快于 speed=0.5 |
|
||||
| **个性化:持久化** | ✅ 写入客户端配置后重新读取完全一致(尺寸/圆角/透明度/主题色/字号/动画/速度/位置/置顶/风格/细缝) |
|
||||
| **退出清理:托盘 / 灵动岛 / 窗口** | ✅ `isTrayReady()=false`、`island.isReady()=false`、窗口全部销毁 |
|
||||
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
||||
| 校验项 | 实测结果 |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` |
|
||||
| 登录页渲染(三个输入框) | ✅ 服务器地址 / **班级码** / 班级密码 |
|
||||
| preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) |
|
||||
| IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 |
|
||||
| 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` |
|
||||
| IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store |
|
||||
| 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) |
|
||||
| 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `ready=true` |
|
||||
| 上课期间普通通知自动隐藏 | ✅ `mode=hidden queued=1 visible=false`(窗口真的隐藏,不是只改状态) |
|
||||
| 上课期间点击不展开(严格不显示) | ✅ `mode=hidden visible=false` |
|
||||
| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` |
|
||||
| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` |
|
||||
| **紧急通知带展开动画** | ✅ 16ms 采样 31 帧,出现 5 个中间尺寸,最终 `440x246`(岛在固定窗口内单调展开,无过冲) |
|
||||
| 上课期间收起紧急通知 | ✅ 回缩为**胶囊且保持可见** `mode=pill visible=true 岛=268x44`(不再整块隐藏) |
|
||||
| **上课期间紧急通知可再次展开(回归)** | ✅ 收起后点开 → `mode=expanded active=smoke-urgent-in-class 岛=440x246` |
|
||||
| **普通通知直接显示胶囊(无入场动画)** | ✅ 推送前已隐藏,60ms 内窗口即为 `268x44`、`mode=pill`(无"上岛"动画) |
|
||||
| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) |
|
||||
| **点击卡片空白处回缩为胶囊** | ✅ 真实点击 `.body` 空白区域 → `mode=pill` |
|
||||
| **点击右上角收起按钮回缩为胶囊** | ✅ 真实点击 `.icon-btn` → `mode=pill` |
|
||||
| **灵动岛截图留档 + 像素级校验** | ✅ 6 张(848×460 / 880×492 / 536×88 / 848×460 / 848×460 / 912×524);宽高比与状态机配置偏差 **0.000**;形态尺寸递增 pill < expanded < urgent < call |
|
||||
| **连续圆角四角一致(缺一块 / 角画反)** | ✅ 四角沿对角线边界步进两两一致:展开 `[15,15,15,15]`、紧急 `[14,14,14,14]`、胶囊 `[5,5,5,5]`、叫人 `[14,14,14,14]`,与超椭圆理论值(0.153r)吻合,离散 **0**(旧实现四角共用同一偏移向量 → 左上/右下被切掉一块,此断言即为该回归的守卫) |
|
||||
| **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") |
|
||||
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ `login=高一(1)班 classSession=true` / 1 班级 / 30 节课 / Socket.IO 已连接 |
|
||||
| **个性化:尺寸联动不破坏布局** | ✅ 高 56 / 宽 300 / 圆角 26 / 字号 14 → 窗口 `300x56`,CSS 变量同步,展开态内容溢出 **0px** |
|
||||
| **开合不震动:展开** | ✅ 逐帧采样 57 帧:岛中心波动 **0.00px**、上边缘 **0.00px**;**窗口冻结**(全程 `476x282@482,-2`);岛最大 `440x246` = 目标无过冲 |
|
||||
| **开合不震动:收回** | ✅ 逐帧采样 56 帧:岛中心/上边缘波动 **0.00px**;窗口全程冻结;岛 `386x230→…→268x44` 单调收缩无回跳 |
|
||||
| **WinIsland 架构:窗口恒定 + 形状正确** | ✅ 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种);SVG 形状 bbox 与卡片框一致(±2px);**路径绕行 \|winding\| ≈ 2π**(凸、不自交,可抓出"角上往回折") |
|
||||
| **岛外鼠标穿透** | ✅ 指针在岛内 `interactive=true`、在岛外 `interactive=false`(固定大包围盒窗口不吞桌面点击) |
|
||||
| **紧急叫人(URGENT + 叫人)** | ✅ 上课中也立即展开:`mode=expanded kind=call reason=call 徽标=叫人 按钮=收到` |
|
||||
| **普通叫人(HIGH + 叫人)** | ✅ 上课只进队列:`mode=hidden active≠smoke-call-normal queued=[…smoke-call-normal]`;下课后自动弹出 `active=smoke-call-normal kind=call` |
|
||||
| **WinIsland 排版:字号即时生效** | ✅ 13px → 19px:胶囊标题 `11.96→17.48`、胶囊副题 `9.62→14.06`、标题 `14.04→20.52`、正文 `12.35→18.05`、按钮 `9.88→14.44`,**倍率全部 1.462 = 19/13** |
|
||||
| **WinIsland 风格:三种底即时切换** | ✅ 纯黑 `fill=rgb(0,0,0)`/材质 `none`;毛玻璃 `fill=rgba(10,10,14,.59)`/材质 **`acrylic`**;主题色 `fill=rgb(11,11,15)` |
|
||||
| **WinIsland 空闲细缝** | ✅ 空闲时岛收成 `6x22`(窗口固定,不再受 Windows 36px 最小窗口高度限制)→ 来消息自动回到 `268x44`;关闭细缝后空闲 `isVisible=false` |
|
||||
| **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` |
|
||||
| **个性化:停靠位置(6 个锚点)** | ✅ 左上 `8,8` / 右上 `1216,8` / 底部居中 `586,800` / 底部左 / 底部右 / 顶部居中 `586,8` |
|
||||
| **个性化:置顶开关** | ✅ 关闭后 `isAlwaysOnTop=false`,开启后 `true` |
|
||||
| **个性化:动画开关 / 速度** | ✅ 关闭动画后 60ms 内即到位(无过渡);speed=2 收回明显快于 speed=0.5 |
|
||||
| **个性化:持久化** | ✅ 写入客户端配置后重新读取完全一致(尺寸/圆角/透明度/主题色/字号/动画/速度/位置/置顶/风格/细缝) |
|
||||
| **退出清理:托盘 / 灵动岛 / 窗口** | ✅ `isTrayReady()=false`、`island.isReady()=false`、窗口全部销毁 |
|
||||
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
||||
|
||||
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
|
||||
已实测 `packaged: true`、54/54 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
||||
已实测 `packaged: true`、59/59 通过、退出码 0)。灵动岛各状态的窗口截图会写到
|
||||
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
|
||||
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
||||
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
||||
|
||||
## 叫人(老师点名让学生过来)
|
||||
|
||||
老师在 **Web 管理端 → 学生管理 → 叫人** 选中学生,配合**快捷短语**或**自定义消息**发送;
|
||||
学生端桌面**立即浮出**灵动岛卡片,显示「请 XXX 同学找 XXX 老师」+ 具体事由,按钮为「收到」。
|
||||
老师在 **Web 管理端 → 学生管理 → 叫人**(或 **通知发布 → 叫人**)选中学生,配合**快捷短语**或**自定义消息**发送。
|
||||
叫人分**两档**,由弹窗里的「级别」单选决定(默认 **普通**):
|
||||
|
||||
| 位置 | 行为 |
|
||||
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 服务端 | `POST /api/calls`(教师/管理员)→ 落库一条 HIGH 级通知(进学生通知中心、可用已读状态) + 广播 `notification:new`(班级房间)与 `call:new`(定向到被叫学生 `user:{id}`) |
|
||||
| 快捷短语 | `CALL_QUICK_PHRASES`(共享常量):请到办公室/讲台/实验室/门卫处找我、请带上作业本/试卷找我、请到教室门口等我、请马上来一趟;点击选中,再点取消 |
|
||||
| 自定义消息 | 输入框内容优先于快捷短语(最长 200 字),例如"带上昨天的数学作业到办公室" |
|
||||
| 客户端 | `call:new` → 灵动岛 `kind=call`,**无视上课时段立即展开**(老师正在等这名学生),90 秒后自动收起 |
|
||||
| 权限 | 学生调用被拒 403;教师只能叫自己班级的学生(跨班/不在本班返回 400/403) |
|
||||
| 级别 | 落库级别 | 学生端行为 |
|
||||
| ------------ | -------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| **普通叫人** | `HIGH` | 课间:先显示"叫人"胶囊,点击展开;**上课时段:只进队列、不打断课堂**,下课后自动弹出详情(90 秒后收起) |
|
||||
| **紧急叫人** | `URGENT` | **无视上课时段立即展开**(与紧急通知同待遇),上课时被收起后仍保留胶囊、可再次点开 |
|
||||
|
||||
`verify:e2e` 覆盖 6 项(201 + 标题模板、自定义消息优先、上课时段允许、学生 403、学生通知中心可见且未读、清理);
|
||||
`verify:web` 覆盖弹窗与短语可选中;`verify:desktop` 覆盖"上课中也立即展开 + 徽标/按钮文案"。
|
||||
两档的学生端卡片都是「请 XXX 同学找 XXX 老师」+ 具体事由,徽标 `叫人`、按钮「收到」;点击「收到」复用通知已读机制。
|
||||
|
||||
| 位置 | 行为 |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 服务端 | `POST /api/calls`(教师/管理员)→ `urgent: true` 落库 `URGENT`,否则 `HIGH`(进学生通知中心、可用已读状态) + 广播 `notification:new`(班级房间)与 `call:new`(定向到被叫学生 `user:{id}`) |
|
||||
| 快捷短语 | `CALL_QUICK_PHRASES`(共享常量):请到办公室/讲台/实验室/门卫处找我、请带上作业本/试卷找我、请到教室门口等我、请马上来一趟;点击选中,再点取消 |
|
||||
| 自定义消息 | 输入框内容优先于快捷短语(最长 200 字),例如"带上昨天的数学作业到办公室" |
|
||||
| 客户端 | `call:new` → 灵动岛 `kind=call`;**是否立即展开只由 `priority` 决定**(`isImmediate` 只认 `URGENT`),与"普通通知"共用同一条排队/隐藏/下课弹出路径 |
|
||||
| 权限 | 学生调用被拒 403;教师只能叫自己班级的学生(跨班/不在本班返回 400/403) |
|
||||
|
||||
`verify:e2e` 覆盖 8 项(201 + 标题模板、**普通叫人 HIGH**、**紧急叫人 URGENT**、自定义消息优先、上课时段允许、
|
||||
学生 403、学生通知中心可见且未读、清理);
|
||||
`verify:web` 覆盖弹窗、级别单选与短语可选中;`verify:desktop` 覆盖「紧急叫人上课中也立即展开 + 徽标/按钮文案」
|
||||
与「普通叫人上课只进队列、下课自动弹出」。
|
||||
|
||||
## 上课时段策略(紧急通知二次确认)
|
||||
|
||||
@@ -859,28 +873,28 @@ pnpm db:migrate && pnpm db:seed
|
||||
|
||||
## 验收标准对照
|
||||
|
||||
| 验收项 | 结果 | 证据 |
|
||||
| ------------------------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
|
||||
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
|
||||
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
|
||||
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` |
|
||||
| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 |
|
||||
| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) |
|
||||
| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 |
|
||||
| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) |
|
||||
| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 |
|
||||
| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` |
|
||||
| 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" |
|
||||
| 叫人(老师点名 → 学生灵动岛立即跳出) | ✅ | `verify:e2e` 6 项 + `verify:desktop` 上课中也立即展开(徽标"叫人"、按钮"收到") |
|
||||
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 |
|
||||
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 |
|
||||
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 2 个迁移文件,跳过 34 个已存在对象`,`TimeLayout` 自动建表 |
|
||||
| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 | `n | 成绩与表格导入:老师端可用且不越权 | ✅ | 班主任预览/导入本班成绩 200、跨班 403、科任 403、名单导入与模板 403;Web 端班主任可见导入按钮并打开弹窗 |
|
||||
| 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入 6 条(全班 6 人)、教师端 `readCount=6`、`completedCount=6`、`/grades/my` 全班成绩 |
|
||||
| 客户端只保留班级登录入口 | ✅ | `verify:desktop`:`login=高一(1)班 classSession=true`,导航「成绩」标题变为「本班成绩」 |
|
||||
| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) |
|
||||
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
|
||||
| 验收项 | 结果 | 证据 |
|
||||
| ------------------------------------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
|
||||
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
|
||||
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
|
||||
| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` |
|
||||
| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 |
|
||||
| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) |
|
||||
| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 |
|
||||
| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) |
|
||||
| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 |
|
||||
| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` |
|
||||
| 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" |
|
||||
| 叫人(老师点名,分紧急/普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 |
|
||||
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 |
|
||||
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 |
|
||||
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 2 个迁移文件,跳过 34 个已存在对象`,`TimeLayout` 自动建表 |
|
||||
| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 | `n | 成绩与表格导入:老师端可用且不越权 | ✅ | 班主任预览/导入本班成绩 200、跨班 403、科任 403、名单导入与模板 403;Web 端班主任可见导入按钮并打开弹窗 |
|
||||
| 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入 6 条(全班 6 人)、教师端 `readCount=6`、`completedCount=6`、`/grades/my` 全班成绩 |
|
||||
| 客户端只保留班级登录入口 | ✅ | `verify:desktop`:`login=高一(1)班 classSession=true`,导航「成绩」标题变为「本班成绩」 |
|
||||
| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) |
|
||||
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
|
||||
|
||||
打包产物验证(对最终 EXE 实测,非仅开发产物):
|
||||
|
||||
@@ -893,14 +907,14 @@ pnpm db:migrate && pnpm db:seed
|
||||
|
||||
当前实测:
|
||||
|
||||
| 验证 | 结果 |
|
||||
| -------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
|
||||
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
|
||||
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/开合不震动 + 个性化全参数 + 托盘与退出无残留) | **54/54** ✅ |
|
||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||
| 验证 | 结果 |
|
||||
| ---------------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
|
||||
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
|
||||
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/开合不震动 + 个性化全参数 + 托盘与退出无残留) | **59/59** ✅ |
|
||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||
|
||||
## 常见问题(本机环境已知坑)
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@ compact 圆角:`IslandSprings::new` 里 `r = (base_height * compact_scale) / 2
|
||||
- 路径缓存 `PATH_CACHE_CAPACITY = 64`
|
||||
- **Web 复刻提示**:CSS `border-radius` 是正圆角,观感会偏"软";要还原请用 SVG path 或
|
||||
`corner-shape: squircle`(若可用)/ `clip-path` 近似。**未找到**等价的简单圆角公式——真实几何就是上面那张表。
|
||||
- **本仓库的落地实现**(`packages/desktop-client/src/island/squircle.ts`):不查表,用单参数超椭圆近似
|
||||
`point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`,`p = 2/n`、`n = 4.2`,
|
||||
四角分别以 `TR(w-r,r) → BR(w-r,h-r) → BL(r,h-r) → TL(r,r)`、起始角 `-π/2, 0, π/2, π` 各扫 `π/2`,
|
||||
每角 14 段。**四个角必须各用自己的圆心 + 角度参数**:早期版本四个角共用同一个"偏移向量",
|
||||
左上/右下被切成缺一块(用户反馈的"左上角和右下角怎么缺一块 / 圆角边缘割裂"),
|
||||
现已由 `verify:desktop` 的「连续圆角四角一致」断言(四角沿对角线边界步进两两一致 + 路径绕行 \|winding\|≈2π)长期守住。
|
||||
|
||||
### 1.8 窗口留白 / 裁切
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
/**
|
||||
* 连续圆角(squircle / Apple 连续曲率圆角)路径生成。
|
||||
*
|
||||
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表
|
||||
* 构造 `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡的半径取
|
||||
* 参考实现:WinIsland `src/utils/shape.rs` —— 它用八分体(Octant)+ 超椭圆参数表构造
|
||||
* `continuous_rounded_rect_path`,而不是普通圆弧圆角;展开卡半径取
|
||||
* `expanded_island_radius = min(48 * scale, w/2, h/2)`。
|
||||
*
|
||||
* 这里用等价的超椭圆角公式生成 SVG path:
|
||||
* 角点区域按 x = r * (1 - cos^p(t)), y = r * (1 - sin^p(t)) 采样,
|
||||
* 其中 p = 2/n、n 取 4.2 时最接近 Apple 的"连续曲率"观感(普通圆角相当于 n = 2)。
|
||||
* 直边部分保持完全笔直,因此与 WinIsland 的"直边 + 连续圆角"一致。
|
||||
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
|
||||
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
|
||||
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
|
||||
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、没有方向歧义。
|
||||
*
|
||||
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),所以圆角半径恒定、不被拉伸。
|
||||
* 早期版本用「相对角起点的偏移向量」拼角,而四个角需要的偏移向量**并不相同**
|
||||
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`)——
|
||||
* 于是左上与右下被画成了向内凹的形状,视觉上就是这两个角**缺一块**。
|
||||
*
|
||||
* 生成的是**像素坐标**路径(w/h 为卡片实际像素尺寸),因此圆角半径恒定、不被拉伸。
|
||||
*/
|
||||
|
||||
/** 超椭圆指数:2 = 标准圆弧圆角,越大越"方"但曲率越连续(Apple 观感约 4~5) */
|
||||
@@ -19,27 +23,20 @@ const SUPERELLIPSE_EXPONENT = 4.2;
|
||||
/** 每个角的采样段数(越多越平滑;4 个角共 4x 段) */
|
||||
const CORNER_SEGMENTS = 14;
|
||||
|
||||
function cornerPoints(radius: number): { x: number; y: number }[] {
|
||||
const p = 2 / SUPERELLIPSE_EXPONENT;
|
||||
const points: { x: number; y: number }[] = [];
|
||||
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
|
||||
const t = (index / CORNER_SEGMENTS) * (Math.PI / 2);
|
||||
const cos = Math.max(0, Math.cos(t));
|
||||
const sin = Math.max(0, Math.sin(t));
|
||||
// t=0 → 角外侧(贴直边),t=π/2 → 角内侧
|
||||
points.push({
|
||||
x: radius * (1 - Math.pow(cos, p)),
|
||||
y: radius * (1 - Math.pow(sin, p)),
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
const round = (value: number): number => Math.round(value * 100) / 100;
|
||||
|
||||
/**
|
||||
* 生成连续圆角矩形路径。
|
||||
*
|
||||
* 做法:把路径拆成「四条直边 + 四段圆角弧」,每段弧都用**自己那个角的圆心 + 角度**参数化:
|
||||
* `point = center + r·(sign(cos a)·|cos a|^p, sign(sin a)·|sin a|^p)`
|
||||
* 四个角的圆心依次是右上 `(w-r, r)` → 右下 `(w-r, h-r)` → 左下 `(r, h-r)` → 左上 `(r, r)`,
|
||||
* 起始角依次是 `-π/2, 0, π/2, π`(每个角扫 π/2),因此天然是顺时针一圈、不会出现方向歧义。
|
||||
*
|
||||
* 早期版本用「相对起点的偏移向量」拼角,而四个角需要的偏移向量并不相同
|
||||
* (右上/左下是 `(sin^p, 1-cos^p)`,左上/右下是 `(1-cos^p, sin^p)`),
|
||||
* 于是左上与右下被画成了"向内凹"的形状——视觉上就是这两个角**缺一块**。
|
||||
*
|
||||
* @param width 卡片宽度(px)
|
||||
* @param height 卡片高度(px)
|
||||
* @param radius 四角基准半径(px,会被裁剪到 min(w/2, h/2))
|
||||
@@ -51,23 +48,28 @@ export function squirclePath(width: number, height: number, radius: number): str
|
||||
|
||||
if (r <= 0.5) return `M0,0 H${round(w)} V${round(h)} H0 Z`;
|
||||
|
||||
const corner = cornerPoints(r);
|
||||
const parts: string[] = [];
|
||||
const p = 2 / SUPERELLIPSE_EXPONENT;
|
||||
const centers = [
|
||||
{ cx: w - r, cy: r }, // 右上
|
||||
{ cx: w - r, cy: h - r }, // 右下
|
||||
{ cx: r, cy: h - r }, // 左下
|
||||
{ cx: r, cy: r }, // 左上
|
||||
];
|
||||
const startAngles = [-Math.PI / 2, 0, Math.PI / 2, Math.PI];
|
||||
|
||||
// 从左上角的"上方直边起点"开始,顺时针绘制
|
||||
parts.push(`M${round(r)},0`);
|
||||
// 上边 → 右上角
|
||||
parts.push(`H${round(w - r)}`);
|
||||
for (const point of corner) parts.push(`L${round(w - r + point.x)},${round(point.y)}`);
|
||||
// 右边 → 右下角
|
||||
parts.push(`V${round(h - r)}`);
|
||||
for (const point of corner) parts.push(`L${round(w - point.y)},${round(h - r + point.x)}`);
|
||||
// 下边 → 左下角
|
||||
parts.push(`H${round(r)}`);
|
||||
for (const point of corner) parts.push(`L${round(r - point.x)},${round(h - point.y)}`);
|
||||
// 左边 → 左上角
|
||||
parts.push(`V${round(r)}`);
|
||||
for (const point of corner) parts.push(`L${round(point.y)},${round(r - point.x)}`);
|
||||
const parts: string[] = [];
|
||||
for (let corner = 0; corner < 4; corner += 1) {
|
||||
const center = centers[corner] as { cx: number; cy: number };
|
||||
const startAngle = startAngles[corner] as number;
|
||||
for (let index = 0; index <= CORNER_SEGMENTS; index += 1) {
|
||||
const angle = startAngle + (index / CORNER_SEGMENTS) * (Math.PI / 2);
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle);
|
||||
const px = center.cx + r * Math.sign(cos) * Math.pow(Math.abs(cos), p);
|
||||
const py = center.cy + r * Math.sign(sin) * Math.pow(Math.abs(sin), p);
|
||||
parts.push(`${index === 0 && corner === 0 ? 'M' : 'L'}${round(px)},${round(py)}`);
|
||||
}
|
||||
}
|
||||
parts.push('Z');
|
||||
|
||||
return parts.join(' ');
|
||||
|
||||
@@ -265,17 +265,18 @@ class IslandController {
|
||||
|
||||
/**
|
||||
* 是否需要"立即展开、无视上课时段"。
|
||||
* 紧急通知与"叫人"(老师点名)都属于:学生必须马上看到。
|
||||
* 只认 URGENT:紧急通知、以及"紧急叫人"(服务端 urgent=true 落库为 URGENT)。
|
||||
* 普通叫人(HIGH)与普通通知同等待遇 —— 上课时段只进队列,不打断课堂。
|
||||
*/
|
||||
private isImmediate(notification: IslandNotification): boolean {
|
||||
return notification.priority === 'URGENT' || notification.kind === 'call';
|
||||
return notification.priority === 'URGENT';
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到一条消息。
|
||||
* - 紧急 / 叫人:立刻展开(无视上课时段)
|
||||
* - 紧急(含紧急叫人):立刻展开(无视上课时段)
|
||||
* - 上课中:进队列并保持隐藏(下课后由 setClassState 触发弹出)
|
||||
* - 其它:显示"新消息/新作业"胶囊,等待点击展开
|
||||
* - 其它:显示"新消息/新作业/叫人"胶囊,等待点击展开
|
||||
*/
|
||||
pushNotification(notification: IslandNotification, context: IslandPushContext = {}): void {
|
||||
const inClass = context.inClass ?? this.state.inClass;
|
||||
@@ -293,7 +294,7 @@ class IslandController {
|
||||
this.activate(notification, reason, 'expanded');
|
||||
logger.info(
|
||||
notification.kind === 'call'
|
||||
? `灵动岛叫人消息:${notification.title}`
|
||||
? `灵动岛紧急叫人消息:${notification.title}`
|
||||
: `灵动岛紧急插播:${notification.title}`,
|
||||
);
|
||||
return;
|
||||
@@ -338,6 +339,14 @@ class IslandController {
|
||||
}
|
||||
|
||||
if (wasInClass && !payload.inClass) {
|
||||
const active = this.state.active;
|
||||
if (active && this.isImmediate(active) && this.state.mode === 'pill') {
|
||||
// 上课期间被收起的紧急消息(含紧急叫人):下课后自动展开,避免学生错过
|
||||
logger.info('灵动岛:下课,重新展开被收起的紧急消息');
|
||||
this.setState({ mode: 'expanded' });
|
||||
this.scheduleCollapse(active.kind === 'call' ? TIMEOUTS.call : TIMEOUTS.urgent);
|
||||
return;
|
||||
}
|
||||
logger.info('灵动岛:下课,弹出上课期间暂存的通知');
|
||||
this.flushQueueAfterClass();
|
||||
return;
|
||||
@@ -362,9 +371,10 @@ class IslandController {
|
||||
handleAction(payload: IslandActionPayload): void {
|
||||
switch (payload.action) {
|
||||
case 'expand': {
|
||||
// 上课时段一律不显示灵动岛(只有紧急通知 / 叫人才会自动展开)
|
||||
if (this.state.inClass) break;
|
||||
const active = this.state.active;
|
||||
// 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须
|
||||
// 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。
|
||||
if (this.state.inClass && !(active && this.isImmediate(active))) break;
|
||||
if (active) {
|
||||
this.setState({ mode: 'expanded' });
|
||||
this.scheduleCollapse(
|
||||
@@ -379,7 +389,8 @@ class IslandController {
|
||||
}
|
||||
case 'collapse':
|
||||
// 点击卡片空白处 / 右上角收起按钮 / 点击屏幕任意位置:回缩到灵动岛(胶囊)
|
||||
if (this.state.inClass) {
|
||||
// 上课时段:普通通知(含普通叫人)彻底隐藏;紧急消息回缩为**胶囊**(保持可见,允许再次展开)
|
||||
if (this.state.inClass && !(this.state.active && this.isImmediate(this.state.active))) {
|
||||
this.hideImmediately();
|
||||
break;
|
||||
}
|
||||
@@ -497,6 +508,13 @@ class IslandController {
|
||||
this.clearTimers();
|
||||
this.collapseTimer = setTimeout(() => {
|
||||
this.collapseTimer = null;
|
||||
// 上课时段 + 紧急消息(含紧急叫人):不自动隐藏(学生必须一直能看到、随时能再点开),
|
||||
// 只把展开卡回缩为胶囊;下课后再恢复为展开提示。
|
||||
const immediateActive = this.state.active;
|
||||
if (this.state.inClass && immediateActive && this.isImmediate(immediateActive)) {
|
||||
if (this.state.mode === 'expanded') this.setState({ mode: 'pill' });
|
||||
return;
|
||||
}
|
||||
if (this.state.mode === 'expanded') {
|
||||
const hasMore = this.state.queued.length > 1;
|
||||
if (hasMore) {
|
||||
@@ -574,8 +592,9 @@ class IslandController {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
|
||||
// 上课时间段:一律不显示灵动岛(只有"正在展示的紧急通知/叫人"允许出现在屏幕上)
|
||||
const immediateShowing =
|
||||
this.state.mode === 'expanded' && this.state.active !== null && this.isImmediate(this.state.active);
|
||||
// 上课时段:只有"紧急通知 / 叫人"允许出现在屏幕上——展开态与胶囊态都算
|
||||
// (胶囊态也允许,学生收起后还能再点开;普通通知则一律隐藏)
|
||||
const immediateShowing = this.state.active !== null && this.isImmediate(this.state.active);
|
||||
if (this.state.inClass && !immediateShowing) {
|
||||
this.hideImmediately();
|
||||
return;
|
||||
|
||||
@@ -36,6 +36,10 @@ interface IslandRect {
|
||||
radius?: number;
|
||||
pathWidth?: number;
|
||||
pathHeight?: number;
|
||||
/** 形状路径绕行总角度(凸、不自交时 |winding| ≈ 2π) */
|
||||
winding?: number;
|
||||
/** 绕行单调性:角的采样参数写反时会在角上“往回折”,这里会变 false */
|
||||
windingOk?: boolean;
|
||||
}
|
||||
|
||||
interface IslandShotStats {
|
||||
@@ -55,6 +59,8 @@ interface IslandShotStats {
|
||||
/** 岛矩形之外的偏红像素(窗口留白区,紧急光晕不外溢的断言依据) */
|
||||
outsideReddish?: number;
|
||||
filePath: string;
|
||||
/** 四角圆角几何检查:四个角的边界步进必须一致(抓“角缺一块 / 角画反”) */
|
||||
corners?: { steps: number[]; expectedInset: number; spread: number; limit: number };
|
||||
}
|
||||
|
||||
const islandShots: IslandShotStats[] = [];
|
||||
@@ -160,6 +166,18 @@ async function captureIsland(
|
||||
}
|
||||
|
||||
const size = cropped.getSize();
|
||||
// 四角圆角像素检查:半径取自卡片 CSS 变量 --card-r(与 SVG path 同源)
|
||||
const scaleRatio = image.getSize().width / Math.max(1, geometry?.window.width ?? image.getSize().width);
|
||||
const cardRadius = await win.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
const card = document.querySelector('.island-card');
|
||||
if (!card) return 0;
|
||||
return Number.parseFloat(getComputedStyle(card).getPropertyValue('--card-r')) || 0;
|
||||
})()`,
|
||||
)
|
||||
.catch(() => 0);
|
||||
const corners = cardRadius > 0 ? analyzeCorners(cropped, cardRadius, scaleRatio) : undefined;
|
||||
const stats: IslandShotStats = {
|
||||
name,
|
||||
expected,
|
||||
@@ -167,6 +185,7 @@ async function captureIsland(
|
||||
height: size.height,
|
||||
filePath,
|
||||
...analyzeBitmap(cropped),
|
||||
corners,
|
||||
outsideReddish,
|
||||
};
|
||||
islandShots.push(stats);
|
||||
@@ -250,6 +269,62 @@ function cropToIsland(
|
||||
return image.crop({ x, y, width, height });
|
||||
}
|
||||
|
||||
/**
|
||||
* 圆角像素级检查(专抓“圆角缺一块 / 角画反”这类几何错误)。
|
||||
*
|
||||
* 做法:从裁剪图的四个角沿对角线向内逐像素走,找到第一个“连续 3 像素属于岛体”的位置,
|
||||
* 记为该角的边界步进 `steps`。连续圆角(superellipse n=4.2)理论边界在 0.153r 处,
|
||||
* 四个角必须互相一致——这正是之前的回归点:旧实现四个角共用同一个偏移向量,
|
||||
* 左上/右下被切掉一块,两两之间步进会明显不同。该指标只看角尖端 2~7px,与卡片内文字无关。
|
||||
*/
|
||||
function analyzeCorners(
|
||||
image: NativeImage,
|
||||
radius: number,
|
||||
ratio: number,
|
||||
): { steps: number[]; expectedInset: number; spread: number; limit: number } {
|
||||
const bitmap = image.toBitmap();
|
||||
const size = image.getSize();
|
||||
const r = Math.max(3, radius * ratio);
|
||||
// “岛体存在”判定:与截图时注入的窗口背景色 #1f2733 差异明显即算岛体(纯黑填充、红色描边、
|
||||
// 白色文字都算),这样紧急态顶部的红色渐层不会把四角判定带偏。
|
||||
const bg = { r: 0x1f, g: 0x27, b: 0x33 };
|
||||
const isFill = (x: number, y: number): boolean => {
|
||||
if (x < 0 || y < 0 || x >= size.width || y >= size.height) return false;
|
||||
const index = (y * size.width + x) * 4;
|
||||
const blue = bitmap[index] ?? 255;
|
||||
const green = bitmap[index + 1] ?? 255;
|
||||
const red = bitmap[index + 2] ?? 255;
|
||||
return Math.abs(red - bg.r) > 16 || Math.abs(green - bg.g) > 16 || Math.abs(blue - bg.b) > 16;
|
||||
};
|
||||
const signs: [number, number][] = [
|
||||
[1, 1],
|
||||
[-1, 1],
|
||||
[1, -1],
|
||||
[-1, -1],
|
||||
];
|
||||
const maxSteps = Math.ceil(r) + 2;
|
||||
const steps: number[] = [];
|
||||
for (const [sx, sy] of signs) {
|
||||
let found = maxSteps;
|
||||
for (let k = 0; k <= maxSteps; k += 1) {
|
||||
const x = sx > 0 ? k : size.width - 1 - k;
|
||||
const y = sy > 0 ? k : size.height - 1 - k;
|
||||
if (isFill(x, y) && isFill(x + sx, y + sy) && isFill(x + 2 * sx, y + 2 * sy)) {
|
||||
found = k;
|
||||
break;
|
||||
}
|
||||
}
|
||||
steps.push(found);
|
||||
}
|
||||
const spread = Math.max(...steps) - Math.min(...steps);
|
||||
return {
|
||||
steps,
|
||||
expectedInset: Number((0.153 * r).toFixed(2)),
|
||||
spread,
|
||||
limit: Math.round(0.3 * r) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/** 偏红像素的包围盒(诊断形状错位:与几何读数对比即可定位偏差来源) */
|
||||
function reddishBBox(image: Electron.NativeImage): { x: number; y: number; width: number; height: number } {
|
||||
const bitmap = image.toBitmap();
|
||||
@@ -451,6 +526,32 @@ async function runIslandChecks(
|
||||
const raw = getComputedStyle(card).getPropertyValue('--card-r').trim();
|
||||
const path = card.querySelector('.shape path');
|
||||
const box = path && typeof path.getBBox === 'function' ? path.getBBox() : null;
|
||||
// 路径绕行方向检查:沿路径等距采样,相邻点的方位角必须单调递增(凸、不自交)。
|
||||
// 角的采样参数若写反,路径会在角上“往回折”,这里会立刻暴露。
|
||||
let winding = 0;
|
||||
let windingOk = true;
|
||||
if (path && typeof path.getTotalLength === 'function') {
|
||||
const total = path.getTotalLength();
|
||||
const points = [];
|
||||
for (let index = 0; index < 96; index += 1) {
|
||||
const point = path.getPointAtLength((index / 96) * total);
|
||||
points.push([point.x, point.y]);
|
||||
}
|
||||
const cx = points.reduce((sum, p) => sum + p[0], 0) / points.length;
|
||||
const cy = points.reduce((sum, p) => sum + p[1], 0) / points.length;
|
||||
let previousAngle = null;
|
||||
for (const [x, y] of points) {
|
||||
const angle = Math.atan2(y - cy, x - cx);
|
||||
if (previousAngle !== null) {
|
||||
let delta = angle - previousAngle;
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI;
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI;
|
||||
if (delta < -0.05) windingOk = false;
|
||||
winding += delta;
|
||||
}
|
||||
previousAngle = angle;
|
||||
}
|
||||
}
|
||||
return {
|
||||
left: r.left,
|
||||
top: r.top,
|
||||
@@ -459,6 +560,8 @@ async function runIslandChecks(
|
||||
radius: parseFloat(raw) || 0,
|
||||
pathWidth: box ? box.width : 0,
|
||||
pathHeight: box ? box.height : 0,
|
||||
winding,
|
||||
windingOk,
|
||||
};
|
||||
})()`,
|
||||
)
|
||||
@@ -474,6 +577,8 @@ async function runIslandChecks(
|
||||
radius: rect.radius,
|
||||
pathWidth: rect.pathWidth,
|
||||
pathHeight: rect.pathHeight,
|
||||
winding: rect.winding,
|
||||
windingOk: rect.windingOk,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -527,13 +632,44 @@ async function runIslandChecks(
|
||||
);
|
||||
await captureIsland('island-3-urgent', ISLAND_URGENT_SIZE);
|
||||
|
||||
// 3.1) 上课期间请求"收起"也不应显示胶囊(上课严格不显示灵动岛)
|
||||
// 3.1) 上课期间“收起”紧急通知:只回缩为胶囊且保持可见,并且还能再次展开(回归)
|
||||
island.handleAction({ action: 'collapse' });
|
||||
await sleep(400);
|
||||
await sleep(450);
|
||||
const collapsedUrgent = island.getState();
|
||||
const collapsedGeometry = await readIslandGeometry();
|
||||
record(
|
||||
'上课期间收起不会回缩成胶囊(严格不显示)',
|
||||
island.getState().mode === 'hidden' && !(islandWindow?.isVisible() ?? true),
|
||||
`mode=${island.getState().mode} visible=${islandWindow?.isVisible() ?? true}`,
|
||||
'上课期间紧急通知收起后回缩为胶囊且保持可见',
|
||||
collapsedUrgent.mode === 'pill' &&
|
||||
(islandWindow?.isVisible() ?? false) &&
|
||||
Math.abs((collapsedGeometry?.island.width ?? 0) - ISLAND_SIZES.pill.width) <= 2,
|
||||
`mode=${collapsedUrgent.mode} visible=${islandWindow?.isVisible() ?? '-'} ` +
|
||||
`岛=${collapsedGeometry?.island.width?.toFixed(0) ?? '-'}x${collapsedGeometry?.island.height?.toFixed(0) ?? '-'}`,
|
||||
);
|
||||
island.handleAction({ action: 'expand' });
|
||||
await sleep(600);
|
||||
const reExpanded = island.getState();
|
||||
const reExpandedGeometry = await readIslandGeometry();
|
||||
record(
|
||||
'上课期间紧急通知回收后可再次展开(回归)',
|
||||
reExpanded.mode === 'expanded' &&
|
||||
reExpanded.active?.id === 'smoke-urgent-in-class' &&
|
||||
Math.abs((reExpandedGeometry?.island.width ?? 0) - ISLAND_URGENT_SIZE.width) <= 2,
|
||||
`mode=${reExpanded.mode} active=${reExpanded.active?.id ?? '-'} ` +
|
||||
`岛=${reExpandedGeometry?.island.width?.toFixed(0) ?? '-'}x${reExpandedGeometry?.island.height?.toFixed(0) ?? '-'}` +
|
||||
`(期望 ${ISLAND_URGENT_SIZE.width})`,
|
||||
);
|
||||
island.pushNotification(makeNotification('smoke-normal-in-class-2', 'NORMAL', '上课中的普通通知'), {
|
||||
inClass: true,
|
||||
});
|
||||
await sleep(350);
|
||||
island.handleAction({ action: 'collapse' });
|
||||
await sleep(300);
|
||||
const afterNormalPush = island.getState();
|
||||
record(
|
||||
'上课期间普通通知只进队列(不顶掉正在展示的紧急消息)',
|
||||
afterNormalPush.active?.id === 'smoke-urgent-in-class' &&
|
||||
afterNormalPush.queued.some((item) => item.id === 'smoke-normal-in-class-2'),
|
||||
`active=${afterNormalPush.active?.id ?? '-'} queued=[${afterNormalPush.queued.map((item) => item.id).join(',')}]`,
|
||||
);
|
||||
|
||||
// 4) 非上课时段普通通知 → 直接显示胶囊(无入场动画);点击后展开
|
||||
@@ -639,6 +775,8 @@ async function runIslandChecks(
|
||||
radius: number;
|
||||
pathWidth: number;
|
||||
pathHeight: number;
|
||||
winding?: number;
|
||||
windingOk?: boolean;
|
||||
}[] = [];
|
||||
const shrinkSampler = setInterval(() => {
|
||||
void readIslandGeometry()
|
||||
@@ -650,6 +788,8 @@ async function runIslandChecks(
|
||||
radius: Number(geometry.island.radius ?? 0),
|
||||
pathWidth: Number(geometry.island.pathWidth ?? 0),
|
||||
pathHeight: Number(geometry.island.pathHeight ?? 0),
|
||||
winding: Number(geometry.island.winding ?? 0),
|
||||
windingOk: geometry.island.windingOk === true,
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
@@ -665,17 +805,24 @@ async function runIslandChecks(
|
||||
(width, index) => index === 0 || width <= (islandWidths[index - 1] ?? width) + 1,
|
||||
);
|
||||
// 画出来的形状必须与卡片框一致:SVG 路径 bbox ≈ 卡片宽高(±2px)。
|
||||
// 这能一次性抓住"viewBox/preserveAspectRatio 错位导致形状被缩放或残留角"这类问题。
|
||||
// 这能一次性抓住“viewBox/preserveAspectRatio 错位导致形状被缩放或残留角”这类问题。
|
||||
const pathFits = shrinkSamples.every(
|
||||
(item) =>
|
||||
Math.abs(item.pathWidth - Number(item.island.split('x')[0])) <= 2 &&
|
||||
Math.abs(item.pathHeight - Number(item.island.split('x')[1])) <= 2,
|
||||
);
|
||||
// 圆角绕行方向:连续圆角路径必须凸且不自交(|绕行| ≈ 2π);角的采样写反会在形变中被抓到
|
||||
const windingSamples = shrinkSamples.filter((item) => item.windingOk !== undefined);
|
||||
const windingOk =
|
||||
windingSamples.length > 0 &&
|
||||
windingSamples.every((item) => item.windingOk === true) &&
|
||||
windingSamples.every((item) => Math.abs(Math.abs(item.winding ?? 0) - Math.PI * 2) < 0.6);
|
||||
record(
|
||||
'开合过程中窗口全程恒定、岛在窗口内连续形变(照搬 WinIsland 架构)',
|
||||
shrinkSamples.length >= 3 && windowSignatures.length === 1 && islandMonotonic && pathFits,
|
||||
shrinkSamples.length >= 3 && windowSignatures.length === 1 && islandMonotonic && pathFits && windingOk,
|
||||
`采样=${shrinkSamples.length} 窗口签名=[${windowSignatures.join(' ')}](要求只有 1 种) ` +
|
||||
`岛尺寸序列=[${islandSizes.slice(0, 8).join('→')}${islandSizes.length > 8 ? '…' : ''}] 单调收缩=${islandMonotonic} 形状与卡片框一致=${pathFits}`,
|
||||
`岛尺寸序列=[${islandSizes.slice(0, 8).join('→')}${islandSizes.length > 8 ? '…' : ''}] 单调收缩=${islandMonotonic} ` +
|
||||
`形状与卡片框一致=${pathFits} 圆角绕行正确=${windingOk}(|绕行|=${(windingSamples[0]?.winding ?? 0).toFixed(2)})`,
|
||||
);
|
||||
|
||||
// 4.5) 点击屏幕其他位置(窗口失焦)→ 自动回缩为胶囊
|
||||
@@ -731,13 +878,13 @@ async function runIslandChecks(
|
||||
);
|
||||
await captureIsland('island-7-homework', ISLAND_SIZES.expanded);
|
||||
|
||||
// 4.7) "叫人":上课时段也立即展开,展示"请 XXX 同学找 XXX 老师"
|
||||
// 4.7) "紧急叫人":URGENT + kind=call → 上课时段也立即展开
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
island.setClassState({ inClass: true, currentPeriodEnd: '08:45', week: 1 });
|
||||
await sleep(400);
|
||||
island.pushNotification(
|
||||
{
|
||||
...makeNotification('smoke-call', 'HIGH', '请 王小明 同学找 张老师'),
|
||||
...makeNotification('smoke-call', 'URGENT', '请 王小明 同学找 张老师'),
|
||||
kind: 'call',
|
||||
subtitle: '请尽快前往,收到后点「收到」',
|
||||
teacherName: '张老师',
|
||||
@@ -762,7 +909,7 @@ async function runIslandChecks(
|
||||
}))()`,
|
||||
);
|
||||
record(
|
||||
'叫人消息上岛(上课时段也立即展开,无需点击)',
|
||||
'紧急叫人消息上岛(上课时段也立即展开,无需点击)',
|
||||
callState.mode === 'expanded' &&
|
||||
callState.active?.kind === 'call' &&
|
||||
callState.reason === 'call' &&
|
||||
@@ -773,7 +920,40 @@ async function runIslandChecks(
|
||||
`徽标=${callDom?.badge} 标题="${callDom?.title}" 按钮=${callDom?.solid}`,
|
||||
);
|
||||
await captureIsland('island-8-call', ISLAND_CALL_SIZE);
|
||||
|
||||
// 4.8) "普通叫人":kind=call 但 priority=HIGH(服务端 urgent 缺省)→ 不打断课堂,
|
||||
// 只进队列;与"紧急叫人"合并前的旧行为相反,这里守住回归。
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(300);
|
||||
island.pushNotification(
|
||||
{
|
||||
...makeNotification('smoke-call-normal', 'HIGH', '请 王小明 同学找 张老师'),
|
||||
kind: 'call',
|
||||
subtitle: '请尽快前往,收到后点「收到」',
|
||||
},
|
||||
{ inClass: true },
|
||||
);
|
||||
await sleep(500);
|
||||
const normalCallState = island.getState();
|
||||
record(
|
||||
'普通叫人只进队列(上课时段不打断、下课再弹)',
|
||||
normalCallState.mode === 'hidden' &&
|
||||
normalCallState.active?.id !== 'smoke-call-normal' &&
|
||||
normalCallState.queued.some((item) => item.id === 'smoke-call-normal'),
|
||||
`mode=${normalCallState.mode} active=${normalCallState.active?.id ?? '-'} ` +
|
||||
`queued=[${normalCallState.queued.map((item) => item.id).join(',')}]`,
|
||||
);
|
||||
// 下课:队列中的普通叫人自动弹出(与普通通知同一路径)
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(600);
|
||||
const afterClassCall = island.getState();
|
||||
record(
|
||||
'下课后普通叫人自动弹出详情(叫人文案不丢)',
|
||||
afterClassCall.mode === 'expanded' && afterClassCall.active?.id === 'smoke-call-normal',
|
||||
`mode=${afterClassCall.mode} active=${afterClassCall.active?.id ?? '-'} kind=${afterClassCall.active?.kind}`,
|
||||
);
|
||||
island.handleAction({ action: 'dismiss' });
|
||||
await sleep(400);
|
||||
island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 });
|
||||
await sleep(400);
|
||||
|
||||
@@ -826,6 +1006,28 @@ async function runIslandChecks(
|
||||
`紧急态岛内偏红=${urgentReddish} 岛外留白偏红=${outsideReddish}(要求 0)`,
|
||||
);
|
||||
|
||||
// 7b) 圆角几何:从四角沿对角线向内找边界,四个角的步进必须一致(旧实现四角共用同一个
|
||||
// 偏移向量,左上/右下被切掉一块,两两步进差异明显)。只看角尖端 2~7px,与文字无关。
|
||||
const cornerShots = shotDetails.filter((shot) => shot.corners);
|
||||
const badCorners = cornerShots.filter((shot) => {
|
||||
const corner = shot.corners;
|
||||
if (!corner) return false;
|
||||
return corner.spread > 2 || corner.steps.some((step) => step < 1 || step > corner.limit);
|
||||
});
|
||||
const cornerReport = cornerShots
|
||||
.map((shot) => {
|
||||
const corner = shot.corners;
|
||||
if (!corner) return shot.name;
|
||||
const flag = corner.spread > 2 ? ' ←离散超限' : '';
|
||||
return `${shot.name} 步进=[${corner.steps.join(',')}] 理论=${corner.expectedInset} 离散=${corner.spread}${flag}`;
|
||||
})
|
||||
.join(' | ');
|
||||
record(
|
||||
'圆角四角一致(无“缺一块 / 角画反”:四角边界步进一致且落在连续圆角理论值内)',
|
||||
cornerShots.length >= 4 && badCorners.length === 0,
|
||||
cornerReport,
|
||||
);
|
||||
|
||||
// 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局
|
||||
const appearanceBefore = island.getAppearance();
|
||||
island.setAppearance({ height: 56, width: 300, radius: 26, fontSize: 14, opacity: 0.92, speed: 1.5 });
|
||||
@@ -1192,6 +1394,13 @@ async function runIslandChecks(
|
||||
);
|
||||
// 10.7b) 固定大包围盒窗口的鼠标穿透(照搬 WinIsland set_cursor_hittest):
|
||||
// 默认整块窗口穿透,指针进入岛体才接收鼠标 —— 否则会吞掉桌面点击。
|
||||
// 指针进入岛体前先保证岛真的画出来了(隐藏态曾让这条断言偶发失败)
|
||||
await drainIsland();
|
||||
island.pushNotification(makeNotification('smoke-interactive', 'NORMAL', '鼠标穿透校验'), {
|
||||
inClass: false,
|
||||
});
|
||||
await waitForIslandDom('.island-card.pill .pill-title');
|
||||
await sleep(200);
|
||||
await islandWindow?.webContents
|
||||
.executeJavaScript(
|
||||
`(() => {
|
||||
|
||||
@@ -110,7 +110,11 @@ export function pushHomeworkToIsland(homework: HomeworkDto): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** "叫人"上岛:无论是否上课都立即展开,展示"请 XXX 同学找 XXX 老师" */
|
||||
/**
|
||||
* "叫人"上岛:紧急叫人(priority=URGENT)无视上课时段立即展开;
|
||||
* 普通叫人按普通通知处理(课间先显示胶囊、点击展开,上课时段只排队)。
|
||||
* 两种都带"叫人"类型(徽标/「收到」按钮/更大的展开卡)。
|
||||
*/
|
||||
export function pushCallToIsland(call: NotificationDto): void {
|
||||
window.desktop?.islandPush({
|
||||
notification: {
|
||||
|
||||
@@ -567,6 +567,27 @@ async function main() {
|
||||
callByTeacher.status === 201 && callTitle.includes('请') && callTitle.includes('找'),
|
||||
`status=${callByTeacher.status} title="${callTitle}"`,
|
||||
);
|
||||
record(
|
||||
'普通叫人(默认)不是紧急级别:priority=HIGH,学生端按普通通知排队',
|
||||
callByTeacher.payload?.data?.priority === 'HIGH',
|
||||
`priority=${callByTeacher.payload?.data?.priority ?? '-'}(期望 HIGH)`,
|
||||
);
|
||||
|
||||
const urgentCall = await api('/calls', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: {
|
||||
classId,
|
||||
studentId: studentUser.id,
|
||||
quickPhrase: '请立刻到办公室',
|
||||
urgent: true,
|
||||
},
|
||||
});
|
||||
record(
|
||||
'紧急叫人(urgent=true → priority=URGENT,学生端无视上课时段立即展开)',
|
||||
urgentCall.status === 201 && urgentCall.payload?.data?.priority === 'URGENT',
|
||||
`status=${urgentCall.status} priority=${urgentCall.payload?.data?.priority ?? '-'}`,
|
||||
);
|
||||
|
||||
const callWithMessage = await api('/calls', {
|
||||
method: 'POST',
|
||||
@@ -591,9 +612,9 @@ async function main() {
|
||||
body: { classId, studentId: studentUser.id, quickPhrase: '请马上来一趟' },
|
||||
});
|
||||
record(
|
||||
'上课时段允许叫人(不受紧急通知 409 限制)',
|
||||
'上课时段允许叫人(普通叫人同样不受紧急通知 409 限制)',
|
||||
callDuringClass.status === 201,
|
||||
`status=${callDuringClass.status}`,
|
||||
`status=${callDuringClass.status} priority=${callDuringClass.payload?.data?.priority ?? '-'}`,
|
||||
);
|
||||
|
||||
const studentCalls = await api('/calls', {
|
||||
@@ -614,11 +635,11 @@ async function main() {
|
||||
);
|
||||
|
||||
// 清理:删除本次叫人产生的通知
|
||||
for (const created of [callByTeacher, callWithMessage, callDuringClass]) {
|
||||
for (const created of [callByTeacher, urgentCall, callWithMessage, callDuringClass]) {
|
||||
const id = created.payload?.data?.id;
|
||||
if (id) await api(`/notifications/${id}`, { method: 'DELETE', token: teacherToken });
|
||||
}
|
||||
record('清理叫人测试数据', true, '已删除 3 条叫人通知');
|
||||
record('清理叫人测试数据', true, '已删除 4 条叫人通知');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6.4 班级角色权限矩阵
|
||||
|
||||
@@ -13,6 +13,8 @@ export const createCallSchema = z
|
||||
quickPhrase: z.string().trim().max(64).optional(),
|
||||
/** 自定义消息(可选;与 quickPhrase 至少填一个) */
|
||||
message: z.string().trim().max(200).optional(),
|
||||
/** 紧急叫人:落库 URGENT,学生端无视上课时段立即展开;默认普通(只进队列) */
|
||||
urgent: z.boolean().optional(),
|
||||
})
|
||||
.refine((data) => Boolean(data.studentId), { message: '请选择学生', path: ['studentId'] })
|
||||
.refine((data) => Boolean(data.message || data.quickPhrase), {
|
||||
|
||||
@@ -15,7 +15,11 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
|
||||
*
|
||||
* 设计:复用通知表落库(学生端通知中心可见、已读状态天然可用、离线也能看到历史),
|
||||
* 同时额外广播 `call:new` 到该学生的 user 房间 —— 客户端据此把消息标记为
|
||||
* "叫人"类型,无论是否在上课都立即展开显示。
|
||||
* "叫人"类型(徽标/按钮/尺寸),**是否立即展开则由 priority 决定**:
|
||||
* - 紧急叫人(urgent=true → URGENT):无视上课时段立即展开;
|
||||
* - 普通叫人(默认 → HIGH):按普通通知处理,上课时段只进队列、下课后弹出,
|
||||
* 课间则先显示胶囊、点击后展开。
|
||||
* 两种情况都不会被"上课时段发布紧急通知"的 409 拦截(老师确实需要学生过来)。
|
||||
*/
|
||||
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
|
||||
await assertCanPublishContent(user, input.classId);
|
||||
@@ -42,8 +46,9 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
||||
classId: input.classId,
|
||||
title: buildCallTitle(studentName, teacherName),
|
||||
content: message,
|
||||
// 叫人属于需要立即响应的消息,用 HIGH 级别(不会触发上课时段 409 拦截)
|
||||
priority: 'HIGH',
|
||||
// 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH
|
||||
// (不会触发上课时段 409 拦截,但学生端按普通通知排队处理)
|
||||
priority: input.urgent ? 'URGENT' : 'HIGH',
|
||||
createdBy: user.sub,
|
||||
},
|
||||
include: { creator: creatorSelect, reads: true },
|
||||
@@ -58,6 +63,6 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
||||
// 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁)
|
||||
emitToUser(created.classId, SOCKET_EVENTS.callNew, dto);
|
||||
|
||||
logger.info(`叫人:${teacherName} → ${studentName}(${message})`);
|
||||
logger.info(`${input.urgent ? '紧急叫人' : '叫人'}:${teacherName} → ${studentName}(${message})`);
|
||||
return dto;
|
||||
}
|
||||
@@ -293,6 +293,12 @@ export interface CreateNotificationRequest {
|
||||
export interface CreateCallRequest {
|
||||
classId: string;
|
||||
studentId: string;
|
||||
/**
|
||||
* 是否"紧急叫人":
|
||||
* - true:落库为 URGENT,学生端灵动岛无视上课时段立即展开(与紧急通知同等待遇);
|
||||
* - 省略/false:普通叫人(默认),按普通通知处理 —— 上课时段只进队列、不打断课堂,下课后弹出。
|
||||
*/
|
||||
urgent?: boolean;
|
||||
/** 快捷短语(与 message 至少填一个) */
|
||||
quickPhrase?: string;
|
||||
/** 自定义消息(优先于快捷短语) */
|
||||
|
||||
@@ -65,12 +65,15 @@ const callStudentId = ref('');
|
||||
const callStudents = ref<StudentDto[]>([]);
|
||||
const callQuickPhrase = ref<string>(CALL_QUICK_PHRASES[0] ?? '');
|
||||
const callMessage = ref('');
|
||||
/** 紧急叫人:无视上课时段立即展开;默认普通(只进队列,课后弹出) */
|
||||
const callUrgent = ref(false);
|
||||
|
||||
async function openCall(): Promise<void> {
|
||||
callClassId.value = filter.classId || classes.value[0]?.id || '';
|
||||
callStudentId.value = '';
|
||||
callMessage.value = '';
|
||||
callQuickPhrase.value = CALL_QUICK_PHRASES[0] ?? '';
|
||||
callUrgent.value = false;
|
||||
callStudents.value = [];
|
||||
callVisible.value = true;
|
||||
if (callClassId.value) await loadCallStudents();
|
||||
@@ -103,10 +106,11 @@ async function submitCall(): Promise<void> {
|
||||
const created = await callApi.create({
|
||||
classId: callClassId.value,
|
||||
studentId: callStudentId.value,
|
||||
urgent: callUrgent.value,
|
||||
...(callMessage.value.trim() ? { message: callMessage.value.trim() } : {}),
|
||||
...(callQuickPhrase.value.trim() ? { quickPhrase: callQuickPhrase.value.trim() } : {}),
|
||||
});
|
||||
ElMessage.success(`已通知:${created.title}`);
|
||||
ElMessage.success(`已${callUrgent.value ? '紧急' : ''}通知:${created.title}`);
|
||||
callVisible.value = false;
|
||||
} finally {
|
||||
callSending.value = false;
|
||||
@@ -382,11 +386,21 @@ onUnmounted(() => {
|
||||
placeholder="可选;填写后优先于快捷短语"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="级别">
|
||||
<el-radio-group v-model="callUrgent">
|
||||
<el-radio-button :value="false">普通(不打断课堂)</el-radio-button>
|
||||
<el-radio-button :value="true">紧急(立即展开)</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert
|
||||
type="warning"
|
||||
:type="callUrgent ? 'error' : 'warning'"
|
||||
:closable="false"
|
||||
title="学生端桌面会立即浮出「请 XXX 同学找 XXX 老师」,上课时段也照常弹出"
|
||||
:title="
|
||||
callUrgent
|
||||
? '紧急叫人:学生端桌面无视上课时段立即展开,需学生点「收到」'
|
||||
: '普通叫人:学生端课间先显示胶囊、点击展开;上课时段只排队,下课后弹出'
|
||||
"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="callVisible = false">取消</el-button>
|
||||
|
||||
@@ -123,12 +123,13 @@ onMounted(async () => {
|
||||
const callVisible = ref(false);
|
||||
const callSending = ref(false);
|
||||
const callTarget = ref<StudentDto | null>(null);
|
||||
const callForm = reactive({ quickPhrase: '', message: '' });
|
||||
const callForm = reactive({ quickPhrase: '', message: '', urgent: false });
|
||||
|
||||
function openCall(row: StudentDto): void {
|
||||
callTarget.value = row;
|
||||
callForm.quickPhrase = CALL_QUICK_PHRASES[0] ?? '';
|
||||
callForm.message = '';
|
||||
callForm.urgent = false;
|
||||
callVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -153,10 +154,11 @@ async function submitCall(): Promise<void> {
|
||||
const created = await callApi.create({
|
||||
classId: target.classId,
|
||||
studentId: target.id,
|
||||
urgent: callForm.urgent,
|
||||
...(callForm.message.trim() ? { message: callForm.message.trim() } : {}),
|
||||
...(callForm.quickPhrase.trim() ? { quickPhrase: callForm.quickPhrase.trim() } : {}),
|
||||
});
|
||||
ElMessage.success(`已通知 ${target.name}:${created.title}`);
|
||||
ElMessage.success(`已${callForm.urgent ? '紧急' : ''}通知 ${target.name}:${created.title}`);
|
||||
callVisible.value = false;
|
||||
} finally {
|
||||
callSending.value = false;
|
||||
@@ -235,8 +237,11 @@ async function submitCall(): Promise<void> {
|
||||
class="call-alert"
|
||||
>
|
||||
<template #default>
|
||||
发送后学生端桌面会立即浮出「请 {{ callTarget.name }} 同学找 XXX 老师」,
|
||||
无需学生刷新;上课时段也会立刻显示。
|
||||
发送后学生端桌面会浮出「请 {{ callTarget.name }} 同学找 XXX 老师」;
|
||||
<b>紧急</b>
|
||||
叫人无视上课时段立即展开,
|
||||
<b>普通</b>
|
||||
叫人课间先显示胶囊、上课时段只排队。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
@@ -256,6 +261,14 @@ async function submitCall(): Promise<void> {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="call-section">
|
||||
<div class="call-label">级别(默认普通:不打断课堂)</div>
|
||||
<el-radio-group v-model="callForm.urgent">
|
||||
<el-radio-button :value="false">普通</el-radio-button>
|
||||
<el-radio-button :value="true">紧急</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="call-section">
|
||||
<div class="call-label">自定义消息(可选,优先于快捷短语)</div>
|
||||
<el-input
|
||||
|
||||
@@ -394,7 +394,29 @@ async function main() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const darkTag = dialog.querySelector('.call-phrase.el-tag--dark');
|
||||
const picked = Boolean(darkTag) && darkTag.textContent.trim() === expected;
|
||||
return { ok: true, phrases, hasTextarea, target, picked, path: location.pathname };
|
||||
// 紧急 / 普通两级:默认普通(不打断课堂),切到紧急必须联动提示文案
|
||||
const radioNodes = Array.from(dialog.querySelectorAll('.el-radio-button'));
|
||||
const levels = radioNodes.map((node) => node.textContent.trim());
|
||||
const alertBefore = dialog.querySelector('.el-alert__title')?.textContent?.trim() ?? '';
|
||||
const urgentNode = radioNodes.find((node) => node.textContent.includes('紧急'));
|
||||
if (urgentNode) urgentNode.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
const alertAfter = dialog.querySelector('.el-alert__title')?.textContent?.trim() ?? '';
|
||||
const urgentChecked = radioNodes.some(
|
||||
(node) => node.textContent.includes('紧急') && node.classList.contains('is-active'),
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
phrases,
|
||||
hasTextarea,
|
||||
target,
|
||||
picked,
|
||||
path: location.pathname,
|
||||
levels,
|
||||
alertBefore,
|
||||
alertAfter,
|
||||
urgentChecked,
|
||||
};
|
||||
})()`);
|
||||
record(
|
||||
'叫人入口(快捷短语 + 自定义消息)',
|
||||
@@ -405,6 +427,17 @@ async function main() {
|
||||
`path=${callDialog?.path} 对象="${callDialog?.target}" 短语数=${callDialog?.phrases?.length ?? 0} 自定义输入=${callDialog?.hasTextarea} 可选中=${callDialog?.picked}` +
|
||||
`${callDialog?.reason ? ` 原因=${callDialog.reason}` : ''}`,
|
||||
);
|
||||
record(
|
||||
'叫人分紧急/普通两级(默认普通,"紧急"联动提示文案)',
|
||||
(callDialog?.levels?.length ?? 0) === 2 &&
|
||||
(callDialog?.levels ?? []).some((level) => level.includes('普通')) &&
|
||||
(callDialog?.levels ?? []).some((level) => level.includes('紧急')) &&
|
||||
(callDialog?.alertBefore ?? '').includes('普通') &&
|
||||
(callDialog?.alertAfter ?? '').includes('紧急') &&
|
||||
callDialog?.urgentChecked === true,
|
||||
`级别=[${(callDialog?.levels ?? []).join(' / ')}] 默认提示="${callDialog?.alertBefore}" ` +
|
||||
`切紧急后="${callDialog?.alertAfter}" 紧急选中=${callDialog?.urgentChecked}`,
|
||||
);
|
||||
await win.webContents.executeJavaScript(`(() => {
|
||||
const cancel = Array.from(document.querySelectorAll('.el-dialog__footer button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '取消',
|
||||
|
||||
Reference in new issue
Block a user