diff --git a/README.md b/README.md index 93d098c..14b85c7 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 离线缓存 | ✅ 已完成(冒烟验证 59/59) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 62/62) | | 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 # 后端端到端验收脚本(135 项) + │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(136 项) │ └── 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 + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:135 项 +pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:136 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项 -pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/紧急与普通叫人、真实链路、课表时间轴、开合不震动、连续圆角四角一致、个性化 10 项参数与托盘/退出无残留):59 项 +pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/再次展开、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):62 项 ``` -后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 135 项, +后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 136 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 -(**班级账号登录** + 四类数据 + Socket.IO),实测 59/59 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 53/53)。 +(**班级账号登录** + 四类数据 + Socket.IO),实测 62/62 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 56/56)。 ## 默认账号与种子数据 @@ -208,36 +208,54 @@ 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` | 后端端到端验收(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 | +| 命令 | 说明 | +| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `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` | 后端端到端验收(136 项,含角色权限矩阵、导入与班级账号,需服务端已启动) | +| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(62 项,含灵动岛圆角/收起常驻与再次展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) | +| `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 管理端(含手机小屏适配) 布局沿用 **1Panel 风格**:固定深色侧边栏(`#1f2d3d` + 蓝色圆角选中态)+ 白色顶栏(当前页标题/连接状态/用户菜单)+ 浅灰底 + 白色圆角卡片内容区。手机小屏下侧边栏收进抽屉,由顶栏汉堡按钮唤出。 +### 圆角设计(统一设计令牌) + +全站圆角与柔和投影集中定义在 `packages/web-admin/src/styles/index.css` 的 `:root` 令牌里, +组件层不写死数值;Element Plus 也通过对齐它的圆角变量(`--el-border-radius-*`)一并改造: + +| 令牌 | 值 | 用在哪 | +| ---------------------- | -------- | ------------------------------------------------------------ | +| `--ch-radius-xl: 22px` | 22px | 弹窗、抽屉、侧栏外缘 | +| `--ch-radius-lg: 18px` | 18px | 卡片(`el-card`)、统计卡、消息框 | +| `--ch-radius-md: 14px` | 14px | 下拉/浮层/气泡、`el-alert`、描述列表、上传区 | +| `--ch-radius-sm: 10px` | 10px | 按钮、输入框、下拉框、日期选择、分页、侧栏菜单项、单选按钮组 | +| `--ch-radius-pill` | 999px | 标签 `el-tag`、开关、进度条、圆形按钮 | +| `--ch-shadow-sm/md` | 两级投影 | 卡片默认 / 悬停与弹窗 | + +配套观感调整:卡片去掉 1px 灰边框(改用轻投影 + 悬停微浮起)、顶栏底部圆角、深色侧栏右侧圆角、 +表头底色统一为 `#f7f9fc`、弹窗头部/底部留白加大。全部通过 CSS 变量与少量类选择器覆盖,未改组件源码。 +`pnpm verify:web` 会在真实浏览器里点击各页面并断言这些视觉不破坏交互(含手机端截图)。 + | 断点 | 布局行为 | | ---------------- | --------------------------------------------------------------------------------------------- | | `> 992px` 桌面 | 常驻 210px 深色侧边栏 + 顶栏显示连接状态与"已接收 N 条实时事件" | @@ -332,23 +350,25 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 > 渲染层做命中测试后只在指针进入岛体时打开命中——**大窗口不会吞掉桌面点击**(有专项断言)。 > 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。 -| 场景 | 灵动岛行为 | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 | -| 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 | -| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) | -| **点击屏幕任意位置** | 同样回缩为胶囊:展开时窗口临时可聚焦,点到别处即失焦收起(WinUI 同款"点击外部关闭") | -| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(此前只从灵动岛队列移除、列表仍显示未读) | -| **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见"截止时间:…" | -| **紧急叫人**(老师点名·紧急) | **上课时段也立即展开**:琥珀金卡片、`叫人` 徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到"(`priority=URGENT`) | -| **普通叫人**(默认级别) | 课间先显示 `叫人` 胶囊、点击展开;**上课时段只进队列**(不打断课堂),下课后自动弹出详情(`priority=HIGH`) | -| 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 —— 收回时不会出现被拉伸的"方框",图标也不会上下跳 | -| 点击右上角收起按钮 | 同上:回缩为胶囊 | -| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情(14 秒)→ 收起为胶囊(25 秒) | -| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | -| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 | -| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" | -| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) | +| 场景 | 灵动岛行为 | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看` | +| 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了` | +| **收起后胶囊常驻** | 自动收起 / 手动收起只把卡片回缩为胶囊,**有未处理通知时胶囊不会消失** —— 随时都能再次点开(彻底消失只在"知道了 / 标为已读"、队列清空或进入上课时段) | +| **收起态点击命中** | 收起瞬间窗口会保持可交互,且渲染进程在每次状态变化后都按**缓存的指针位置**重算命中并被固化为回归用例("收起态点击可再次展开(指针未移动也算命中)") | +| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) | +| **点击屏幕任意位置** | 同样回缩为胶囊:展开时窗口临时可聚焦,点到别处即失焦收起(WinUI 同款"点击外部关闭") | +| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(此前只从灵动岛队列移除、列表仍显示未读) | +| **新作业发布** | 也上岛:胶囊显示"新作业"(青蓝描边 + 书本图标),展开可见作业要求(**截止时间功能已下线**) | +| **紧急叫人**(老师点名·紧急) | **上课时段也立即展开**:琥珀金卡片、`叫人` 徽标、"请 XXX 同学找 XXX 老师"、按钮为"收到"(`priority=URGENT`) | +| **普通叫人**(默认级别) | 课间先显示 `叫人` 胶囊、点击展开;**上课时段只进队列**(不打断课堂),下课后自动弹出详情(`priority=HIGH`) | +| 卡片形变 | 卡片是**固定尺寸、顶部居中锚定**的,窗口只负责露出/裁切透明区域 —— 收回时不会出现被拉伸的"方框",图标也不会上下跳 | +| 点击右上角收起按钮 | 同上:回缩为胶囊 | +| 上课时间段收到普通通知 | **完全不显示**(窗口直接隐藏,不打扰课堂)并进入待发队列;下课后自动弹出详情 → 收起为胶囊 | +| 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带内部红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | +| 上课时间段内的任何点击 | 一律不显示(不会展开、也不会回缩出胶囊),只有紧急通知能出现在屏幕上 | +| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" | +| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) | - 动画:窗口尺寸用逐帧缓动实现"形变",**单调不过冲**——展开 300ms、收回 220ms,统一 `easeOutCubic` (早期版本用 `easeOutBack` 弹簧缓动 + 卡片 `cubic-bezier(0.34, 1.56, 0.64, 1)` 缩放入场, @@ -529,6 +549,8 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS | **开合不震动:收回** | ✅ 逐帧采样 56 帧:岛中心/上边缘波动 **0.00px**;窗口全程冻结;岛 `386x230→…→268x44` 单调收缩无回跳 | | **WinIsland 架构:窗口恒定 + 形状正确** | ✅ 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种);SVG 形状 bbox 与卡片框一致(±2px);**路径绕行 \|winding\| ≈ 2π**(凸、不自交,可抓出"角上往回折") | | **岛外鼠标穿透** | ✅ 指针在岛内 `interactive=true`、在岛外 `interactive=false`(固定大包围盒窗口不吞桌面点击) | +| **收起后胶囊常驻(可再次点开)** | ✅ 收起后等满 `TIMEOUTS.pill`(15s)超时:`mode=pill 可见=true`(有未处理通知就不消失);显式把命中置为穿透后,一次状态变化即恢复 `interactive=true`,点击后 `mode=expanded active=smoke-collapse-reopen` | +| **空闲细缝也可点开** | ✅ `mode=hidden 可见=true interactive=true 细缝=6x22`(细缝虽窄,指针在其上即可命中) | | **紧急叫人(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** | @@ -543,7 +565,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS | **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 | 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=` 写出 JSON 结果, -已实测 `packaged: true`、59/59 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +已实测 `packaged: true`、62/62 通过、退出码 0)。灵动岛各状态的窗口截图会写到 `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 @@ -885,7 +907,8 @@ pnpm db:migrate && pnpm db:seed | 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | | 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 | | 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` | -| 作业发布也上岛("新作业"胶囊 + 截止时间) | ✅ | `kind=homework` + 展开显示"截止时间:…" | +| 作业发布也上岛("新作业"胶囊) | ✅ | `kind=homework` + 展开显示作业要求;**截止时间功能已下线**(接口不再返回 `dueAt`,e2e 断言 `dueAt === undefined`) | +| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:「收起后胶囊常驻(超时不再消失)」「收起态点击可再次展开(指针未移动也算命中)」「空闲细缝态保持可交互」 | | 叫人(老师点名,分紧急/普通两级) | ✅ | `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 节 | @@ -907,14 +930,14 @@ pnpm db:migrate && pnpm db:seed 当前实测: -| 验证 | 结果 | -| ---------------------------------------------------------------------------------------------------------- | ----------------------------------- | -| `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 未实测 ⚠️ | +| 验证 | 结果 | +| ------------------------------------------------------------------------------------------------------------------ | ----------------------------------- | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | +| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻与再次展开 + 个性化全参数 + 托盘与退出无残留) | **62/62** ✅ | +| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | +| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | +| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | ## 常见问题(本机环境已知坑) diff --git a/docs/screenshots/web-mobile/mobile-1-drawer.png b/docs/screenshots/web-mobile/mobile-1-drawer.png index dc50bf3..445e026 100644 Binary files a/docs/screenshots/web-mobile/mobile-1-drawer.png and b/docs/screenshots/web-mobile/mobile-1-drawer.png differ diff --git a/docs/screenshots/web-mobile/mobile-2-notifications.png b/docs/screenshots/web-mobile/mobile-2-notifications.png index 9e5d055..b8852e6 100644 Binary files a/docs/screenshots/web-mobile/mobile-2-notifications.png and b/docs/screenshots/web-mobile/mobile-2-notifications.png differ diff --git a/docs/screenshots/web-mobile/mobile-3-dialog.png b/docs/screenshots/web-mobile/mobile-3-dialog.png index 9e58a74..e606bc0 100644 Binary files a/docs/screenshots/web-mobile/mobile-3-dialog.png and b/docs/screenshots/web-mobile/mobile-3-dialog.png differ diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index 5fb2028..48beacd 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -186,12 +186,15 @@ function syncTargets(snap = false): void { springs.radius.snap(radius); morph.value = { width: size.width, height: size.height, radius }; progress.value = mode.value === 'expanded' ? 1 : 0; + refreshInteractive(); return; } springs.width.setTarget(size.width); springs.height.setTarget(size.height); springs.radius.setTarget(radius); startFrameLoop(); + // 目标几何变了:立刻按新尺寸重算一次命中(形变过程中每帧也会重算) + refreshInteractive(); } function startFrameLoop(): void { @@ -218,6 +221,8 @@ function startFrameLoop(): void { span < 1 ? (mode.value === 'expanded' ? 1 : 0) : Math.abs(morph.value.height - pill) / span; if (moving || !springs.width.settled || !springs.height.settled || !springs.radius.settled) { + // 形变过程中岛的可点区域在变:每帧按缓存指针位置重算命中(值不变时不发 IPC) + refreshInteractive(); rafHandle = requestAnimationFrame(frame); return; } @@ -228,6 +233,7 @@ function startFrameLoop(): void { springs.radius.snap(targetRadius(size)); morph.value = { width: size.width, height: size.height, radius: springs.radius.value }; progress.value = mode.value === 'expanded' ? 1 : 0; + refreshInteractive(); rafHandle = null; }; rafHandle = requestAnimationFrame(frame); @@ -293,7 +299,11 @@ const islandClass = computed(() => ({ * 指针在岛体上(或可交互控件上)时才让窗口接收鼠标,否则穿透,保证不吞桌面点击。 */ let lastInteractive: boolean | null = null; +/** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */ +let lastPointer: { x: number; y: number } | null = null; + function updateInteractive(clientX: number, clientY: number): void { + lastPointer = { x: clientX, y: clientY }; const node = document.querySelector('.island-card') as HTMLElement | null; let inside = false; if (node) { @@ -310,6 +320,29 @@ function updateInteractive(clientX: number, clientY: number): void { bridge?.setInteractive?.(inside); } +/** + * 状态 / 外观变化后主动重算一次命中测试。 + * + * 为什么必须主动算:窗口默认 `setIgnoreMouseEvents(true, {forward:true})`, + * 只有在收到 mousemove 时才会打开命中。岛"收起 / 展开 / 换形态"时几何变了, + * 但指针可能一动没动(没有新的 mousemove)—— 此时窗口会一直保持"穿透", + * 表现就是**收起后怎么点都打不开**(用户反馈)。所以每次状态变化都用缓存的指针位置重算一次。 + */ +function refreshInteractive(): void { + const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver; + if (fullyHidden) { + if (lastInteractive !== false) { + lastInteractive = false; + bridge?.setInteractive?.(false); + } + return; + } + if (!lastPointer) return; + const pointer = lastPointer; + lastInteractive = null; // 强制按新几何再算一次 + updateInteractive(pointer.x, pointer.y); +} + function applyAppearance(next: IslandAppearance | null | undefined): void { if (!next) return; appearance.value = next; diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 0dbaa31..13b9ff2 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -371,10 +371,22 @@ class IslandController { handleAction(payload: IslandActionPayload): void { switch (payload.action) { case 'expand': { - const active = this.state.active; // 上课时段:普通通知(含普通叫人)一律不显示;但紧急通知 / 紧急叫人本来就是必须 // 立刻看到的,收起(回缩为胶囊)之后必须能再次点开——否则学生会误以为消息消失了。 - if (this.state.inClass && !(active && this.isImmediate(active))) break; + const showing = this.state.active; + if (this.state.inClass && !(showing && this.isImmediate(showing))) break; + // 收起态(胶囊 / 空闲细缝)再次点击必须能打开: + // active 为空但队列里还有未看通知时,先把它取出来当"当前通知"再展开, + // 否则会出现"岛明明在屏幕上,点了没反应"(部分情况下的收起态无法再次打开)。 + if (!this.state.active) { + const pending = this.state.queued[this.state.queued.length - 1]; + if (pending) { + this.state.queued = this.state.queued.filter((item) => item.id !== pending.id); + this.state.active = pending; + logger.info(`灵动岛:收起态点击,重新打开队列中的通知 ${pending.id}`); + } + } + const active = this.state.active; if (active) { this.setState({ mode: 'expanded' }); this.scheduleCollapse( @@ -515,14 +527,19 @@ class IslandController { if (this.state.mode === 'expanded') this.setState({ mode: 'pill' }); return; } - if (this.state.mode === 'expanded') { - const hasMore = this.state.queued.length > 1; - if (hasMore) { - this.setState({ mode: 'pill' }); - this.scheduleCollapse(TIMEOUTS.afterClassPill); - } else { - this.hide(); - } + // 关键行为:只要还有"没处理完"的通知(当前展示的或队列里的),收起后**保持胶囊**, + // 不再直接消失 —— 否则用户看到岛收起了却点不到("收起状态无法再次打开")。 + // 彻底隐藏只发生在:用户点了"知道了 / 标为已读"、队列清空、或进入上课时段。 + const pending = this.state.active !== null || this.state.queued.length > 0; + if (this.state.mode === 'expanded' && this.state.queued.length > 1) { + // 还有多条未看:先回缩为"还有 N 条"胶囊 + this.setState({ mode: 'pill' }); + if (pending) return; + this.hide(); + return; + } + if (pending) { + if (this.state.mode !== 'pill') this.setState({ mode: 'pill' }); return; } this.hide(); @@ -959,4 +976,9 @@ export function registerIslandIpc(): void { }); } -export { SIZES as ISLAND_SIZES, URGENT_SIZE as ISLAND_URGENT_SIZE, CALL_SIZE as ISLAND_CALL_SIZE }; +export { + SIZES as ISLAND_SIZES, + URGENT_SIZE as ISLAND_URGENT_SIZE, + CALL_SIZE as ISLAND_CALL_SIZE, + TIMEOUTS as ISLAND_TIMEOUTS, +}; diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 332a330..39c5fa1 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -5,7 +5,14 @@ import type { BrowserWindow, NativeImage } from 'electron'; import type { IslandAppearance, IslandNotification } from '@classhelper/shared'; import { getConfig, saveConfig } from './config.js'; import { getDiagnostics } from './ipc.js'; -import { getSliverSize, island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js'; +import { + getSliverSize, + island, + ISLAND_CALL_SIZE, + ISLAND_SIZES, + ISLAND_TIMEOUTS, + ISLAND_URGENT_SIZE, +} from './island.js'; import { destroyTray, getTrayState, isTrayReady } from './tray.js'; interface SmokeResult { @@ -839,14 +846,14 @@ async function runIslandChecks( `展开时 focusable=${focusable} 失焦后 mode=${afterBlur.mode} focusable=${islandWindow?.isFocusable() ?? '-'}`, ); - // 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示截止时间 + // 4.6) 新作业上岛(kind=homework):胶囊 + 展开显示作业要求 + //(截止时间功能已下线:这里同时守住"作业卡上不再出现截止时间") island.handleAction({ action: 'dismiss' }); await sleep(400); island.pushNotification( { ...makeNotification('smoke-homework', 'NORMAL', '第 3 章课后练习'), kind: 'homework', - subtitle: '截止时间:2026-09-15 22:00', teacherName: '张老师', }, { inClass: false }, @@ -868,13 +875,13 @@ async function runIslandChecks( ); island.handleAction({ action: 'expand' }); await sleep(700); - const homeworkHint = await islandWindow?.webContents.executeJavaScript( - `document.querySelector('.island-card.expanded .call-hint')?.textContent?.trim() ?? ''`, + const homeworkCardText = await islandWindow?.webContents.executeJavaScript( + `document.querySelector('.island-card.expanded')?.innerText?.replace(/\\s+/g, ' ').trim() ?? ''`, ); record( - '作业详情显示截止时间', - (homeworkHint ?? '').includes('截止时间'), - `附加说明="${homeworkHint ?? '-'}"`, + '作业详情只显示作业要求(截止时间已下线)', + (homeworkCardText ?? '').includes('第 3 章课后练习') && !(homeworkCardText ?? '').includes('截止时间'), + `作业卡文案="${homeworkCardText ?? '-'}"`, ); await captureIsland('island-7-homework', ISLAND_SIZES.expanded); @@ -957,6 +964,105 @@ async function runIslandChecks( island.setClassState({ inClass: false, currentPeriodEnd: null, week: 1 }); await sleep(400); + // 4.9) 收起态回归(用户反馈"收起后部分情况无法再次打开"): + // a) 胶囊不再因超时消失(有未处理通知时常驻); + // b) 收起后胶囊仍然可命中(窗口保持可交互)→ 点击可再次展开; + // c) 空闲细缝态同样是"可见即可点"(细缝 6x22 也能命中)。 + // 注:此处不使用后面的 drainIsland(它在本函数更靠下的位置定义),就地排空队列。 + const drainForCollapseCheck = async (): Promise => { + for (let index = 0; index < 20; index += 1) { + const current = island.getState(); + if (!current.active && current.queued.length === 0) return; + island.handleAction({ action: 'dismiss' }); + await sleep(120); + } + }; + /** 合成"指针移到卡片上":命中测试由渲染进程按指针位置决定(真实场景由系统转发 mousemove) */ + const hoverCard = async (): Promise => + Boolean( + await islandWindow?.webContents + .executeJavaScript( + `(() => { + const card = document.querySelector('.island-card'); + if (!card) return false; + const r = card.getBoundingClientRect(); + window.dispatchEvent( + new MouseEvent('mousemove', { + clientX: r.left + r.width / 2, + clientY: r.top + r.height / 2, + bubbles: true, + }), + ); + return true; + })()`, + ) + .catch(() => false), + ); + await drainForCollapseCheck(); + await sleep(300); + island.pushNotification(makeNotification('smoke-collapse-reopen', 'NORMAL', '收起态回归校验'), { + inClass: false, + }); + await sleep(500); + const collapseBefore = island.getState(); + await hoverCard(); + await sleep(200); + await sleep(ISLAND_TIMEOUTS.pill + 700); + const lingered = island.getState(); + const lingeredVisible = islandWindow?.isVisible() ?? false; + record( + '收起后胶囊常驻(超时不再消失,随时可再次点开)', + collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible, + `收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`, + ); + // 命中:把窗口显式置为"穿透"(等价于收起瞬间窗口被重置),再做一次状态变化 —— + // 主进程会按当前形态重新下发命中,渲染进程也会按缓存指针重算,两者都必须把命中恢复。 + island.setInteractive(false); + await sleep(60); + const forcedThrough = island.getInteractive(); + island.handleAction({ action: 'collapse' }); + await sleep(300); + const interactiveWhilePill = island.getInteractive(); + await islandWindow?.webContents + .executeJavaScript( + `(() => { + document.querySelector('.island-card')?.click(); + return true; + })()`, + ) + .catch(() => undefined); + await sleep(750); + const reopened = island.getState(); + record( + '收起态点击可再次展开(胶囊命中恢复 + 点击后展开)', + forcedThrough === false && + interactiveWhilePill === true && + reopened.mode === 'expanded' && + reopened.active?.id === 'smoke-collapse-reopen', + `置穿透后=${forcedThrough} 状态变化后=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` + + `active=${reopened.active?.id ?? '-'}`, + ); + + const sliverForInteractive = getSliverSize(); + island.setAppearance({ ...island.getAppearance(), idleSliver: true }); + await sleep(200); + await drainForCollapseCheck(); + await sleep(500); + const sliverState = island.getState(); + await hoverCard(); + await sleep(250); + const sliverInteractive = island.getInteractive(); + const sliverVisible = islandWindow?.isVisible() ?? false; + record( + '空闲细缝态保持可交互(细缝很窄,指针停在岛上仍能点开)', + sliverState.mode === 'hidden' && sliverVisible && sliverInteractive === true, + `mode=${sliverState.mode} 可见=${sliverVisible} interactive=${sliverInteractive} ` + + `细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`, + ); + island.setAppearance({ ...island.getAppearance(), idleSliver: false }); + await drainForCollapseCheck(); + await sleep(300); + // 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与 // 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。 const shotDetails = islandShots.map((shot) => { diff --git a/packages/desktop-client/src/renderer/island/bridge.ts b/packages/desktop-client/src/renderer/island/bridge.ts index 468b920..05938ef 100644 --- a/packages/desktop-client/src/renderer/island/bridge.ts +++ b/packages/desktop-client/src/renderer/island/bridge.ts @@ -1,6 +1,5 @@ import { SOCKET_EVENTS, - formatDate, resolveClassStatus, type ClassPeriod, type HomeworkDto, @@ -91,9 +90,8 @@ export function pushNotificationToIsland(notification: NotificationDto): void { }); } -/** 新作业上岛(homework:new):胶囊提示"新作业",展开看截止时间 */ +/** 新作业上岛(homework:new):胶囊提示"新作业",点击展开看作业要求 */ export function pushHomeworkToIsland(homework: HomeworkDto): void { - const due = homework.dueAt ? formatDate(homework.dueAt, true) : '未设置截止时间'; window.desktop?.islandPush({ notification: { id: `homework-${homework.id}`, @@ -104,7 +102,6 @@ export function pushHomeworkToIsland(homework: HomeworkDto): void { courseName: homework.course?.name ?? null, teacherName: homework.creator?.name ?? null, kind: 'homework', - subtitle: `截止时间:${due}`, }, context: getIslandClassContext(), }); diff --git a/packages/desktop-client/src/renderer/stores/realtime.ts b/packages/desktop-client/src/renderer/stores/realtime.ts index 63b5141..ece563b 100644 --- a/packages/desktop-client/src/renderer/stores/realtime.ts +++ b/packages/desktop-client/src/renderer/stores/realtime.ts @@ -79,7 +79,7 @@ export const useRealtimeStore = defineStore('realtime', () => { instance.on(SOCKET_EVENTS.homeworkNew, (payload: HomeworkDto) => { mark(SOCKET_EVENTS.homeworkNew); ElMessage.success(`新作业:${payload.title}`); - // 作业发布也上岛:胶囊提示"新作业",点击展开看截止时间与要求 + // 作业发布也上岛:胶囊提示"新作业",点击展开看作业要求 pushHomeworkToIsland(payload); dispatch(SOCKET_EVENTS.homeworkNew, payload); }); diff --git a/packages/desktop-client/src/renderer/views/HomeworkView.vue b/packages/desktop-client/src/renderer/views/HomeworkView.vue index 3ec3e65..9b1a94a 100644 --- a/packages/desktop-client/src/renderer/views/HomeworkView.vue +++ b/packages/desktop-client/src/renderer/views/HomeworkView.vue @@ -1,7 +1,7 @@