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:
1 parent
97a89de944
commit
9c9ac69ac6
4 files changed
+295
-81
No files matched your search
@@ -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>
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user