fix(island): 开合不再震动——去掉弹簧过冲 + 锚点整数化

问题(用户反馈"灵动岛开合的时候不要震动"):
之前虽然有「收回不抖动」断言,但它只看窗口宽度单调收缩,漏掉了两处真实回弹。
逐帧采样卡片真实屏幕矩形后复现:
- 展开时窗口冲到 411x329(目标 404x316)→ 主进程用了 easeOutBack 弹簧缓动,尺寸超过目标再弹回
- 卡片被放大到 400.6x311.6(目标 396x310)→ 卡片入场用 cubic-bezier(0.34,1.56,0.64,1) 缩放,先超过 1 再回落
- 卡片中心左右波动 1.00px、上边缘波动 2.88px → 逐帧 Math.round 在 0.5 边界跳变 + transform 位移

修复:
1. 缓动单调化: 删除 easeOutBack,展开/收回统一 easeOutCubic(窗口与卡片都不再过冲)
2. 锚点整数化且与尺寸解耦: 新增 resolveAnchor(),停靠位置决定不动的那条边/中心,动画前算一次
3. 卡片宽度取偶数 + boundsFor 统一推导窗口矩形(消除逐帧 0.5px 抖动)
4. 卡片入场去掉 scale/translateY,只做 180ms 淡入,形变完全交给窗口尺寸
5. 顺带修掉新探针暴露的真实缺陷: 隐藏后再弹出胶囊时透明度被硬编码为 1,忽略了个性化设置

验证(verify:desktop 49/49,修复前后对比):
- 展开: 卡片中心波动 0.00px / 上边缘 0.00px,最大窗口 404x316(=目标)、最大卡片 396x308,单调=true
- 收回: 0.00px / 0.00px,最小窗口 268,宽度序列单调无回跳
- 透明度 0.62 生效;四个停靠位置、置顶、动画开关/速度、持久化全部通过
- verify:e2e 133/133、verify:web 19/19 不受影响
文档: README 形变动画章节更新为「单调不过冲 + 整数锚点」,并记录修复前后实测数据
This commit is contained in:
LoveChengke committed 2026-09-12 16:41:01 +08:00
1 parent 97a89de944
commit 9c9ac69ac6
4 files changed
+295 -81

No files matched your search

+52 -37
View File
@@ -20,7 +20,7 @@
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 47/47) | | 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 49/49) |
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
@@ -173,13 +173,13 @@ pnpm verify:desktop
# 另开一个终端先启动后端:pnpm dev:server # 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:133 项 pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:133 项
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 成绩/时间配置导入弹窗):19 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 成绩/时间配置导入弹窗):19 项
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):47 项 pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、开合不震动、个性化 10 项参数与托盘/退出无残留):49 项
``` ```
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 133 项, 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 133 项,
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
(**班级账号登录** + 四类数据 + Socket.IO),实测 47/47 通过;退出后再启动一次校验单实例锁与文件锁已释放。 (**班级账号登录** + 四类数据 + Socket.IO),实测 49/49 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 43/43)。
## 默认账号与种子数据 ## 默认账号与种子数据
@@ -208,30 +208,30 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
## 常用命令 ## 常用命令
| 命令 | 说明 | | 命令 | 说明 |
| ------------------------------------------- | --------------------------------------------------------------------------------------- | | ------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | | `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | | `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | | `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | | `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | | `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | | `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | | **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | | `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
| `pnpm verify:e2e` | 后端端到端验收(133 项,含角色权限矩阵、导入与班级账号,需服务端已启动) | | `pnpm verify:e2e` | 后端端到端验收(133 项,含角色权限矩阵、导入与班级账号,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,19 项,含权限入口隐藏与导入弹窗) | | `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,19 项,含权限入口隐藏与导入弹窗) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(47 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴、个性化全参数) | | `pnpm verify:desktop` | EXE 客户端冒烟验证(49 项,含灵动岛开合不震动/已读/作业/叫人、课表时间轴、个性化全参数) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | | `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | | `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) |
| `pnpm db:migrate` | 创建并应用迁移 | | `pnpm db:migrate` | 创建并应用迁移 |
| `pnpm db:seed` | 写入种子数据 | | `pnpm db:seed` | 写入种子数据 |
| `pnpm db:reset` | 重置数据库并重新执行 seed | | `pnpm db:reset` | 重置数据库并重新执行 seed |
| `pnpm db:studio` | 打开 Prisma Studio | | `pnpm db:studio` | 打开 Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | | `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider |
## Web 管理端(含手机小屏适配) ## Web 管理端(含手机小屏适配)
@@ -320,7 +320,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
| 场景 | 灵动岛行为 | | 场景 | 灵动岛行为 |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- | | -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 | | 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 |
| 点击胶囊 | 展开为**详情卡**(弹簧形变 + 内容弹入):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 | | 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 |
| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) | | 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
| **点击屏幕任意位置** | 同样回缩为胶囊:展开时窗口临时可聚焦,点到别处即失焦收起(WinUI 同款"点击外部关闭") | | **点击屏幕任意位置** | 同样回缩为胶囊:展开时窗口临时可聚焦,点到别处即失焦收起(WinUI 同款"点击外部关闭") |
| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(此前只从灵动岛队列移除、列表仍显示未读) | | **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(此前只从灵动岛队列移除、列表仍显示未读) |
@@ -334,9 +334,10 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" | | 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" |
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) | | 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) |
- 动画:窗口尺寸用逐帧缓动实现"形变"(胶囊 → 详情/紧急为 340ms `easeOutBack` **弹簧回弹**, - 动画:窗口尺寸用逐帧缓动实现"形变",**单调不过冲**——展开 300ms、收回 220ms,统一 `easeOutCubic`
收起为 260ms `easeOutCubic`),胶囊出现**不做动画**直接显示; (早期版本用 `easeOutBack` 弹簧缓动 + 卡片 `cubic-bezier(0.34, 1.56, 0.64, 1)` 缩放入场,
卡片内部用 `cubic-bezier(0.34, 1.56, 0.64, 1)` 弹簧缩放入场,未读点用 `dot-pulse`。 实测窗口会冲到 411×329(目标 404×316)、卡片放大到 400.6px 再回落,观感就是"开合时震一下",现已移除);
卡片只做 180ms 淡入,形变完全交给窗口尺寸,未读点用 `dot-pulse`。
尺寸与淡入使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。 尺寸与淡入使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。
- 光晕:紧急形态的红色呼吸光晕**只用 inset 阴影**画在卡片内部。 - 光晕:紧急形态的红色呼吸光晕**只用 inset 阴影**画在卡片内部。
窗口只比卡片大 2~4px,任何向外的 box-shadow/光晕都会被窗口边界裁切, 窗口只比卡片大 2~4px,任何向外的 box-shadow/光晕都会被窗口边界裁切,
@@ -410,11 +411,23 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
- 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。 - 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。
- 落盘位置:客户端 `config.json` 的 `island` 字段(旧配置没有该字段时自动补默认值,向后兼容)。 - 落盘位置:客户端 `config.json` 的 `island` 字段(旧配置没有该字段时自动补默认值,向后兼容)。
**形变动画(需求 4 抖动修复)**:窗口尺寸改为**单条帧循环**驱动(优先 `requestAnimationFrame`, **形变动画(需求 4 抖动修复 + 无震动)**:窗口尺寸由**单条帧循环**驱动(优先 `requestAnimationFrame`,
主进程没有该全局时退化为 ~60fps 定时器),新动画会取消上一条并作废令牌,结束时对齐最终尺寸; 主进程没有该全局时退化为 ~60fps 定时器),新动画会取消上一条并作废令牌,结束时对齐最终尺寸;
卡片始终是**固定尺寸 + 顶部居中锚定**,只让窗口露出/裁切,形变过程中卡片上边缘不动; 卡片始终是**固定尺寸 + 顶部居中锚定**,只让窗口露出/裁切,形变过程中卡片上边缘不动;
展开动画结束后才取焦点。冒烟实测:收回过程窗口宽度序列 `362→330→305→288→274→270→268…` 展开动画结束后才取焦点。
单调收缩、卡片上边缘波动 **0px**、卡片宽度只取 `396/260` 两个固定值,连续 5 次快速开合后状态与尺寸稳定。
为了让"开合"完全没有振动感,几何与缓动做了三件事(每一条都有实测断言):
1. **缓动单调不过冲**:去掉 `easeOutBack` 与卡片 `cubic-bezier(…, 1.56, …)` 缩放入场,
尺寸/位置只朝目标单向变化,窗口不再"超过目标再弹回来";
2. **锚点取整数、且与窗口尺寸无关**:停靠位置决定"不动的那条边/中心",动画开始前算一次,
过程中不逐帧重算,避免 `Math.round` 在 0.5 边界上左右跳变;
3. **卡片宽度取偶数**:卡片在窗口内水平居中,偶宽保证卡片中心落在整数像素上。
冒烟实测(逐帧采样窗口矩形 + 卡片真实屏幕矩形):展开时**卡片中心波动 0.00px、上边缘波动 0.00px**,
窗口最大 404×316(等于目标,不再过冲到 411×329),卡片最大 396×308(不再放大到 400.6px 再回落);
收回时同样 0.00px / 0.00px、最小窗口 268,窗口宽度序列 `356→318→286→274→270→268…` 单调收缩无回跳;
连续 5 次快速开合后状态与尺寸稳定。
**系统托盘(需求 5)**:关闭主窗口不退出程序,而是隐藏到托盘后台继续接收通知; **系统托盘(需求 5)**:关闭主窗口不退出程序,而是隐藏到托盘后台继续接收通知;
托盘图标(左键单击/双击)恢复主窗口,右键菜单提供「显示主窗口 / 隐藏到托盘 / 退出班级小助手」。 托盘图标(左键单击/双击)恢复主窗口,右键菜单提供「显示主窗口 / 隐藏到托盘 / 退出班级小助手」。
@@ -482,7 +495,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| 上课期间点击不展开(严格不显示) | ✅ `mode=hidden visible=false` | | 上课期间点击不展开(严格不显示) | ✅ `mode=hidden visible=false` |
| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` | | 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` |
| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` | | 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` |
| **紧急通知带展开动画** | ✅ 16ms 采样 34 帧,出现 7 个中间尺寸,最终 `424x344`(窗口从胶囊尺寸弹簧展开) | | **紧急通知带展开动画** | ✅ 16ms 采样 34 帧,出现 7 个中间尺寸,最终 `424x344`(窗口从胶囊尺寸单调展开,无过冲) |
| 上课期间收起不回缩成胶囊 | ✅ `mode=hidden visible=false` | | 上课期间收起不回缩成胶囊 | ✅ `mode=hidden visible=false` |
| **普通通知直接显示胶囊(无入场动画)** | ✅ 推送前已隐藏,60ms 内窗口即为 `268x44`、`mode=pill`(无"上岛"动画) | | **普通通知直接显示胶囊(无入场动画)** | ✅ 推送前已隐藏,60ms 内窗口即为 `268x44`、`mode=pill`(无"上岛"动画) |
| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) | | 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) |
@@ -492,6 +505,8 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") | | **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") |
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ `login=高一(1)班 classSession=true` / 1 班级 / 30 节课 / Socket.IO 已连接 | | 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ `login=高一(1)班 classSession=true` / 1 班级 / 30 节课 / Socket.IO 已连接 |
| **个性化:尺寸联动不破坏布局** | ✅ 高 56 / 宽 300 / 圆角 26 / 字号 14 → 窗口 `300x56`,CSS 变量同步,展开态内容溢出 **0px** | | **个性化:尺寸联动不破坏布局** | ✅ 高 56 / 宽 300 / 圆角 26 / 字号 14 → 窗口 `300x56`,CSS 变量同步,展开态内容溢出 **0px** |
| **开合不震动:展开** | ✅ 逐帧采样 55 帧:卡片中心波动 **0.00px**、上边缘波动 **0.00px**、最大窗口 `404x316`(=目标,无过冲)、单调扩张 |
| **开合不震动:收回** | ✅ 逐帧采样 57 帧:卡片中心/上边缘波动 **0.00px**、最小窗口 `268`、宽度序列单调收缩无回跳 |
| **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` | | **个性化:透明度 / 主题色** | ✅ `setOpacity` 实际 **0.62**(设置 0.62);`--island-accent` = `#ff7043` |
| **个性化:停靠位置(4 个锚点)** | ✅ 左上 `8,8` / 右上 `1164,8` / 底部居中 `586,800` / 顶部居中 `586,8` | | **个性化:停靠位置(4 个锚点)** | ✅ 左上 `8,8` / 右上 `1164,8` / 底部居中 `586,800` / 顶部居中 `586,8` |
| **个性化:置顶开关** | ✅ 关闭后 `isAlwaysOnTop=false`,开启后 `true` | | **个性化:置顶开关** | ✅ 关闭后 `isAlwaysOnTop=false`,开启后 `true` |
@@ -501,7 +516,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 | | **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果, 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
已实测 `packaged: true`、47/47 通过、退出码 0)。灵动岛各状态的窗口截图会写到 已实测 `packaged: true`、49/49 通过、退出码 0)。灵动岛各状态的窗口截图会写到
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
@@ -860,7 +875,7 @@ pnpm db:migrate && pnpm db:seed
| -------------------------------------------------------------------------------------------------- | ----------------------------------- | | -------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回 + 个性化全参数 + 托盘与退出无残留) | **47/47** ✅ | | `pnpm verify:desktop`(客户端冒烟 + 开合不震动 + 个性化全参数 + 托盘与退出无残留) | **49/49** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
@@ -832,26 +832,27 @@ body,
opacity: 0; opacity: 0;
} }
/* 展开态:弹簧缩放入场(配合窗口尺寸缓动) */ /*
* 展开 / 收回:只做轻微淡入淡出,**不做缩放与位移**。
*
* 之前用 `cubic-bezier(0.34, 1.56, 0.64, 1)` + `scale(0.88) translateY(-6px)` 做弹簧入场,
* 缩放会先超过 1 再回落(实测卡片被放大到 400.6px 再回到 396px),观感就是"开合时震一下"。
* 现在形变完全交给主进程的窗口尺寸缓动(单调 easeOutCubic),卡片保持固定尺寸与固定锚点,
* 由窗口"揭开"卡片,全程无色块回弹、无位移抖动。
*/
.island-pop-enter-active { .island-pop-enter-active {
transition: transition: opacity 0.18s ease-out;
opacity 0.2s ease,
transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1);
} }
.island-pop-leave-active { .island-pop-leave-active {
transition: transition: opacity 0.12s ease-out;
opacity 0.14s ease,
transform 0.2s ease;
} }
.island-pop-enter-from { .island-pop-enter-from {
opacity: 0; opacity: 0;
transform: scale(0.88) translateY(-6px);
} }
.island-pop-leave-to { .island-pop-leave-to {
opacity: 0; opacity: 0;
transform: scale(0.92);
} }
</style> </style>
+116 -35
View File
@@ -485,32 +485,71 @@ class IslandController {
} }
} }
private computeBounds(size: { width: number; height: number }): { /**
x: number; * 屏幕锚点:由停靠位置决定的"不动的那条边/中心",全程使用**整数**。
y: number; *
width: number; * 关键点(修复"开合时震动"):
height: number; * - 锚点只跟屏幕工作区与停靠位置有关,与窗口尺寸无关 → 形变过程中不会逐帧漂移;
} { * - 全部取整,避免 `Math.round` 在 0.5 边界上左右跳变(那会表现为 ±0.5px 的横向抖动)。
*/
private resolveAnchor(): IslandAnchor {
const area = screen.getPrimaryDisplay().workArea; const area = screen.getPrimaryDisplay().workArea;
const margin = 8; const margin = ISLAND_MARGIN;
let x = Math.round(area.x + (area.width - size.width) / 2); const center = Math.round(area.x + area.width / 2);
let y = area.y + margin;
switch (this.appearance.position) { switch (this.appearance.position) {
case 'top-left': case 'top-left':
x = area.x + margin; return { hMode: 'left', hValue: area.x + margin, vMode: 'top', vValue: area.y + margin };
break;
case 'top-right': case 'top-right':
x = area.x + area.width - size.width - margin; return {
break; hMode: 'right',
hValue: area.x + area.width - margin,
vMode: 'top',
vValue: area.y + margin,
};
case 'bottom-center': case 'bottom-center':
y = area.y + area.height - size.height - margin; return {
hMode: 'center',
hValue: center,
vMode: 'bottom',
vValue: area.y + area.height - margin,
};
default:
return { hMode: 'center', hValue: center, vMode: 'top', vValue: area.y + margin };
}
}
/**
* 由锚点 + 目标尺寸推出窗口矩形。
*
* 卡片宽度强制取**偶数**:卡片始终在窗口内水平居中,偶宽保证卡片中心落在整数像素上,
* 于是形变过程中卡片中心恒定不动(实测波动 0.00px),不会出现"左右轻微振动"。
*/
private boundsFor(size: { width: number; height: number }, anchor: IslandAnchor): Bounds {
const cardWidth = Math.max(2, evenWidth(Math.round(size.width) - CARD_PAD_X * 2));
const cardHeight = Math.max(2, Math.round(size.height) - CARD_PAD_Y * 2);
const width = cardWidth + CARD_PAD_X * 2;
const height = cardHeight + CARD_PAD_Y * 2;
let x: number;
switch (anchor.hMode) {
case 'left':
x = anchor.hValue;
break;
case 'right':
x = anchor.hValue - width;
break; break;
default: default:
x = anchor.hValue - cardWidth / 2 - CARD_PAD_X;
break; break;
} }
return { x, y, width: size.width, height: size.height }; const y = anchor.vMode === 'bottom' ? anchor.vValue - height : anchor.vValue;
return { x, y, width, height };
}
private computeBounds(size: { width: number; height: number }): Bounds {
return this.boundsFor(size, this.resolveAnchor());
} }
/** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */ /** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */
@@ -568,7 +607,8 @@ class IslandController {
// 普通通知:直接出现在屏幕上,不做"上岛"入场动画 // 普通通知:直接出现在屏幕上,不做"上岛"入场动画
if (size === SIZES.pill) { if (size === SIZES.pill) {
this.cancelFade(); this.cancelFade();
this.win.setOpacity(1); // 必须使用个性化透明度:这里以前硬编码 1,导致"隐藏后再弹出胶囊"会忽略透明度设置
this.win.setOpacity(this.appearance.opacity);
this.win.setBounds(pillBounds); this.win.setBounds(pillBounds);
this.win.showInactive(); this.win.showInactive();
return; return;
@@ -646,14 +686,17 @@ class IslandController {
* *
* 用**单一 requestAnimationFrame 循环**驱动(需求 4): * 用**单一 requestAnimationFrame 循环**驱动(需求 4):
* - 不再使用 setTimeout 链:避免掉帧、避免"每帧重新排定时器"带来的节奏抖动 * - 不再使用 setTimeout 链:避免掉帧、避免"每帧重新排定时器"带来的节奏抖动
* - 锚点固定:横向/纵向都由 computeBounds 决定,形变过程中卡片上边缘(或下边缘)不动 * - 锚点固定:锚点在动画开始前算一次(整数),过程中只让"对侧边"移动
* - 缓动**单调不过冲**:早期版本用 easeOutBack 弹簧缓动,窗口尺寸会超过目标再弹回来,
* 观感就是"开合时震一下";现在统一用 easeOutCubic,尺寸/位置只朝目标单向变化
* - 每帧只用整数像素、卡片宽度取偶数,卡片中心与不动的那条边全程恒定
* - 关闭动画(appearance.animations = false)时直接落到目标尺寸 * - 关闭动画(appearance.animations = false)时直接落到目标尺寸
* - `spring` 使用回弹缓动(轻微过冲后回落),用于"展开成卡片"的弹簧手感
*/ */
private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void { private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void {
if (!this.win || this.win.isDestroyed()) return; if (!this.win || this.win.isDestroyed()) return;
const from = this.win.getBounds(); const from = this.win.getBounds();
const to = this.computeBounds(size); const anchor = this.resolveAnchor();
const to = this.boundsFor(size, anchor);
if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return; if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return;
this.cancelBoundsAnimation(); this.cancelBoundsAnimation();
@@ -664,26 +707,39 @@ class IslandController {
return; return;
} }
const spring = options.spring === true; // 形变时长:展开稍长一点(视觉上更"从容"),收回更快;倍速来自个性化设置
const duration = (spring ? 340 : 260) / Math.max(0.25, this.appearance.speed); const duration = Math.round(
(options.spring === true ? 300 : 220) / Math.max(0.25, this.appearance.speed),
);
const fromCardWidth = Math.max(2, evenWidth(from.width - CARD_PAD_X * 2));
const fromCardHeight = Math.max(2, from.height - CARD_PAD_Y * 2);
const toCardWidth = Math.max(2, evenWidth(to.width - CARD_PAD_X * 2));
const toCardHeight = Math.max(2, to.height - CARD_PAD_Y * 2);
const startedAt = Date.now(); const startedAt = Date.now();
const frame = (): void => { const frame = (): void => {
if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return; if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return;
const progress = Math.min(1, (Date.now() - startedAt) / duration); const progress = Math.min(1, (Date.now() - startedAt) / duration);
const eased = spring ? easeOutBack(progress) : 1 - Math.pow(1 - progress, 3); // easeOutCubic // easeOutCubic:单调递减斜率,永不越过 1(因此不会有回弹)
const bounds = { const eased = 1 - Math.pow(1 - progress, 3);
x: Math.round(from.x + (to.x - from.x) * eased),
y: Math.round(from.y + (to.y - from.y) * eased), const cardWidth = Math.max(
width: Math.max(1, Math.round(from.width + (to.width - from.width) * eased)), 2,
height: Math.max(1, Math.round(from.height + (to.height - from.height) * eased)), evenWidth(Math.round(fromCardWidth + (toCardWidth - fromCardWidth) * eased)),
}; );
const cardHeight = Math.max(2, Math.round(fromCardHeight + (toCardHeight - fromCardHeight) * eased));
// 注意:boundsFor 接收的是**窗口**尺寸(内部自行扣掉卡片内边距)
const bounds = this.boundsFor(
{ width: cardWidth + CARD_PAD_X * 2, height: cardHeight + CARD_PAD_Y * 2 },
anchor,
);
this.win.setBounds(bounds); this.win.setBounds(bounds);
if (progress < 1) { if (progress < 1) {
this.boundsFrame = scheduleFrame(frame); this.boundsFrame = scheduleFrame(frame);
return; return;
} }
// 收尾对齐到最终尺寸,避免四舍五入留下 1px 误差(会让卡片边缘"抖一下") // 收尾对齐到最终尺寸,避免四舍五入留下误差(会让卡片边缘"抖一下")
this.win.setBounds(to); this.win.setBounds(to);
this.boundsFrame = null; this.boundsFrame = null;
this.applyFocusable(); this.applyFocusable();
@@ -773,12 +829,37 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
}; };
} }
/** 回弹缓动(easeOutBack):进度超过 1 形成轻微过冲,营造弹簧感 */ /**
function easeOutBack(progress: number): number { * 说明:早期版本这里有一个 easeOutBack 弹簧缓动(进度过冲到 1 以上再回落),
const c1 = 1.15; * 会让窗口尺寸"超过目标再弹回来",观感就是开合时震一下,因此已移除;
const c3 = c1 + 1; * 现在所有形变统一走单调的 easeOutCubic(见 animateBounds),不再有任何回弹。
const p = progress - 1; */
return 1 + c3 * p * p * p + c1 * p * p;
/** 卡片相对窗口的内边距(必须与渲染进程 CSS 的 4px / 3px 一致) */
const CARD_PAD_X = 4;
const CARD_PAD_Y = 3;
/** 灵动岛与屏幕工作区边缘的留白 */
const ISLAND_MARGIN = 8;
interface Bounds {
x: number;
y: number;
width: number;
height: number;
}
/** 屏幕锚点:由停靠位置决定的"不动的那条边/中心"(整数) */
interface IslandAnchor {
hMode: 'center' | 'left' | 'right';
hValue: number;
vMode: 'top' | 'bottom';
vValue: number;
}
/** 卡片宽度取偶数:保证水平居中的卡片中心落在整数像素上,形变时不左右跳变 */
function evenWidth(value: number): number {
const safe = Math.max(2, Math.round(value));
return safe - (safe % 2);
} }
export const island = new IslandController(); export const island = new IslandController();
+117
View File
@@ -654,6 +654,123 @@ async function runIslandChecks(
`(期望 ${currentAppearance.width}x${currentAppearance.height})`, `(期望 ${currentAppearance.width}x${currentAppearance.height})`,
); );
// 10.5) 「开合不震动」实测:把窗口矩形与卡片的**真实屏幕矩形**逐帧采样,
// 断言 ①卡片的水平中心与上边缘绝对不动(锚点稳定,不左右/上下跳动)
// ②卡片与窗口的尺寸单调变化且**从不过冲**(不再有"弹一下"的回弹)
const motionSamples: {
winW: number;
winH: number;
cardCx: number;
cardTop: number;
cardW: number;
cardH: number;
}[] = [];
const sampleCardRect = async (): Promise<void> => {
const bounds = islandWindow?.getBounds();
const rect = await islandWindow?.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
if (!card) return null;
const r = card.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
})()`,
)
.catch(() => null);
if (!bounds || !rect) return;
motionSamples.push({
winW: bounds.width,
winH: bounds.height,
cardCx: bounds.x + rect.left + rect.width / 2,
cardTop: bounds.y + rect.top,
cardW: rect.width,
cardH: rect.height,
});
};
const runMotionProbe = async (action: 'expand' | 'collapse'): Promise<void> => {
motionSamples.length = 0;
island.handleAction({ action });
const deadline = Date.now() + 900;
while (Date.now() < deadline) {
await sampleCardRect();
await sleep(12);
}
};
island.handleAction({ action: 'dismiss' });
await sleep(250);
island.pushNotification(makeNotification('smoke-motion', 'NORMAL', '开合不震动校验'), { inClass: false });
await sleep(350);
const expandedCard = { width: ISLAND_SIZES.expanded.width - 8, height: ISLAND_SIZES.expanded.height - 6 };
const pillCard = { width: ISLAND_SIZES.pill.width - 8, height: ISLAND_SIZES.pill.height - 6 };
await runMotionProbe('expand');
const expandSamples = [...motionSamples];
const cxSpread = expandSamples.length
? Math.max(...expandSamples.map((item) => item.cardCx)) -
Math.min(...expandSamples.map((item) => item.cardCx))
: 99;
const topSpreadExpand = expandSamples.length
? Math.max(...expandSamples.map((item) => item.cardTop)) -
Math.min(...expandSamples.map((item) => item.cardTop))
: 99;
const maxCardW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardW)) : 0;
const maxCardH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.cardH)) : 0;
const maxWinW = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winW)) : 0;
const maxWinH = expandSamples.length ? Math.max(...expandSamples.map((item) => item.winH)) : 0;
const expandMonotonic = expandSamples.every(
(item, index) => index === 0 || item.winW >= (expandSamples[index - 1]?.winW ?? 0) - 1,
);
const expandNoOvershoot =
maxCardW <= expandedCard.width + 0.5 &&
maxCardH <= expandedCard.height + 0.5 &&
maxWinW <= ISLAND_SIZES.expanded.width + 1 &&
maxWinH <= ISLAND_SIZES.expanded.height + 1;
record(
'展开过程不震动(锚点不动 + 尺寸不过冲)',
expandSamples.length >= 5 &&
cxSpread <= 0.5 &&
topSpreadExpand <= 0.5 &&
expandMonotonic &&
expandNoOvershoot,
`采样=${expandSamples.length} 卡片中心波动=${cxSpread.toFixed(2)}px 上边缘波动=${topSpreadExpand.toFixed(2)}px ` +
`最大卡片=${maxCardW.toFixed(1)}x${maxCardH.toFixed(1)}(目标 ${expandedCard.width}x${expandedCard.height})` +
` 最大窗口=${maxWinW}x${maxWinH}(目标 ${ISLAND_SIZES.expanded.width}x${ISLAND_SIZES.expanded.height}) 单调=${expandMonotonic}`,
);
await runMotionProbe('collapse');
const collapseSamples = [...motionSamples];
const cxSpreadCollapse = collapseSamples.length
? Math.max(...collapseSamples.map((item) => item.cardCx)) -
Math.min(...collapseSamples.map((item) => item.cardCx))
: 99;
const topSpreadCollapse = collapseSamples.length
? Math.max(...collapseSamples.map((item) => item.cardTop)) -
Math.min(...collapseSamples.map((item) => item.cardTop))
: 99;
const minWinW = collapseSamples.length ? Math.min(...collapseSamples.map((item) => item.winW)) : 0;
const minCardW = collapseSamples.length ? Math.min(...collapseSamples.map((item) => item.cardW)) : 0;
const collapseMonotonic = collapseSamples.every(
(item, index) =>
index === 0 || item.winW <= (collapseSamples[index - 1]?.winW ?? Number.MAX_SAFE_INTEGER) + 1,
);
const collapseNoOvershoot = minCardW >= pillCard.width - 0.5 && minWinW >= ISLAND_SIZES.pill.width - 1;
record(
'收回过程不震动(锚点不动 + 尺寸不过冲)',
collapseSamples.length >= 5 &&
cxSpreadCollapse <= 0.5 &&
topSpreadCollapse <= 0.5 &&
collapseMonotonic &&
collapseNoOvershoot,
`采样=${collapseSamples.length} 卡片中心波动=${cxSpreadCollapse.toFixed(2)}px 上边缘波动=${topSpreadCollapse.toFixed(2)}px ` +
`最小卡片宽=${minCardW.toFixed(1)}(目标 ${pillCard.width}) 最小窗口宽=${minWinW}(目标 ${ISLAND_SIZES.pill.width}) 单调=${collapseMonotonic}`,
);
// 收尾:保持"胶囊可见"状态——后续个性化用例(透明度 / 位置)需要窗口可见才会立即生效
await sleep(250);
// 11) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化 // 11) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化
// (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示) // (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示)
const workArea = screen.getPrimaryDisplay().workArea; const workArea = screen.getPrimaryDisplay().workArea;