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 / 环境变量 | ✅ 已完成 |
|
||||
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
|
||||
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
|
||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 47/47) |
|
||||
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 49/49) |
|
||||
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
|
||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||
@@ -173,13 +173,13 @@ pnpm verify:desktop
|
||||
# 另开一个终端先启动后端:pnpm dev:server
|
||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:133 项
|
||||
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 成绩/时间配置导入弹窗):19 项
|
||||
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):47 项
|
||||
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、开合不震动、个性化 10 项参数与托盘/退出无残留):49 项
|
||||
```
|
||||
|
||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 133 项,
|
||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||
客户端脚本验证 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: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 客户端冒烟验证(47 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴、个性化全参数) |
|
||||
| `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` | 后端端到端验收(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 |
|
||||
|
||||
## Web 管理端(含手机小屏适配)
|
||||
|
||||
@@ -320,7 +320,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
||||
| 场景 | 灵动岛行为 |
|
||||
| -------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| 收到通知(非上课时段) | **直接出现胶囊**(无"上岛"入场动画):`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 |
|
||||
| 点击胶囊 | 展开为**详情卡**(弹簧形变 + 内容弹入):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 |
|
||||
| 点击胶囊 | 展开为**详情卡**(形变 + 淡入,无回弹):标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 |
|
||||
| 点击卡片空白处 | 回缩为**胶囊**(不直接消失,仍可再次点开) |
|
||||
| **点击屏幕任意位置** | 同样回缩为胶囊:展开时窗口临时可聚焦,点到别处即失焦收起(WinUI 同款"点击外部关闭") |
|
||||
| **点"标为已读"** | 通知中心同步标记已读、未读红点立即减少(此前只从灵动岛队列移除、列表仍显示未读) |
|
||||
@@ -334,9 +334,10 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
||||
| 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" |
|
||||
| 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) |
|
||||
|
||||
- 动画:窗口尺寸用逐帧缓动实现"形变"(胶囊 → 详情/紧急为 340ms `easeOutBack` **弹簧回弹**,
|
||||
收起为 260ms `easeOutCubic`),胶囊出现**不做动画**直接显示;
|
||||
卡片内部用 `cubic-bezier(0.34, 1.56, 0.64, 1)` 弹簧缩放入场,未读点用 `dot-pulse`。
|
||||
- 动画:窗口尺寸用逐帧缓动实现"形变",**单调不过冲**——展开 300ms、收回 220ms,统一 `easeOutCubic`
|
||||
(早期版本用 `easeOutBack` 弹簧缓动 + 卡片 `cubic-bezier(0.34, 1.56, 0.64, 1)` 缩放入场,
|
||||
实测窗口会冲到 411×329(目标 404×316)、卡片放大到 400.6px 再回落,观感就是"开合时震一下",现已移除);
|
||||
卡片只做 180ms 淡入,形变完全交给窗口尺寸,未读点用 `dot-pulse`。
|
||||
尺寸与淡入使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。
|
||||
- 光晕:紧急形态的红色呼吸光晕**只用 inset 阴影**画在卡片内部。
|
||||
窗口只比卡片大 2~4px,任何向外的 box-shadow/光晕都会被窗口边界裁切,
|
||||
@@ -410,11 +411,23 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
||||
- 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。
|
||||
- 落盘位置:客户端 `config.json` 的 `island` 字段(旧配置没有该字段时自动补默认值,向后兼容)。
|
||||
|
||||
**形变动画(需求 4 抖动修复)**:窗口尺寸改为**单条帧循环**驱动(优先 `requestAnimationFrame`,
|
||||
**形变动画(需求 4 抖动修复 + 无震动)**:窗口尺寸由**单条帧循环**驱动(优先 `requestAnimationFrame`,
|
||||
主进程没有该全局时退化为 ~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)**:关闭主窗口不退出程序,而是隐藏到托盘后台继续接收通知;
|
||||
托盘图标(左键单击/双击)恢复主窗口,右键菜单提供「显示主窗口 / 隐藏到托盘 / 退出班级小助手」。
|
||||
@@ -482,7 +495,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
||||
| 上课期间点击不展开(严格不显示) | ✅ `mode=hidden visible=false` |
|
||||
| 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` |
|
||||
| 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` |
|
||||
| **紧急通知带展开动画** | ✅ 16ms 采样 34 帧,出现 7 个中间尺寸,最终 `424x344`(窗口从胶囊尺寸弹簧展开) |
|
||||
| **紧急通知带展开动画** | ✅ 16ms 采样 34 帧,出现 7 个中间尺寸,最终 `424x344`(窗口从胶囊尺寸单调展开,无过冲) |
|
||||
| 上课期间收起不回缩成胶囊 | ✅ `mode=hidden visible=false` |
|
||||
| **普通通知直接显示胶囊(无入场动画)** | ✅ 推送前已隐藏,60ms 内窗口即为 `268x44`、`mode=pill`(无"上岛"动画) |
|
||||
| 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) |
|
||||
@@ -492,6 +505,8 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
||||
| **紧急红晕不外溢(窗口边缘无红光)** | ✅ 截图最外圈(2px)偏红像素 = **0**(守住"卡片周围不出现光晕硬边") |
|
||||
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ `login=高一(1)班 classSession=true` / 1 班级 / 30 节课 / Socket.IO 已连接 |
|
||||
| **个性化:尺寸联动不破坏布局** | ✅ 高 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` |
|
||||
| **个性化:停靠位置(4 个锚点)** | ✅ 左上 `8,8` / 右上 `1164,8` / 底部居中 `586,800` / 顶部居中 `586,8` |
|
||||
| **个性化:置顶开关** | ✅ 关闭后 `isAlwaysOnTop=false`,开启后 `true` |
|
||||
@@ -501,7 +516,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
||||
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
|
||||
|
||||
该验证对**开发产物与打包后的 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/`),
|
||||
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
|
||||
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
|
||||
@@ -860,7 +875,7 @@ pnpm db:migrate && pnpm db:seed
|
||||
| -------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
||||
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ |
|
||||
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ |
|
||||
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回 + 个性化全参数 + 托盘与退出无残留) | **47/47** ✅ |
|
||||
| `pnpm verify:desktop`(客户端冒烟 + 开合不震动 + 个性化全参数 + 托盘与退出无残留) | **49/49** ✅ |
|
||||
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
|
||||
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
|
||||
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
|
||||
|
||||
@@ -832,26 +832,27 @@ body,
|
||||
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 {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
transition: opacity 0.18s ease-out;
|
||||
}
|
||||
|
||||
.island-pop-leave-active {
|
||||
transition:
|
||||
opacity 0.14s ease,
|
||||
transform 0.2s ease;
|
||||
transition: opacity 0.12s ease-out;
|
||||
}
|
||||
|
||||
.island-pop-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.88) translateY(-6px);
|
||||
}
|
||||
|
||||
.island-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
</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 margin = 8;
|
||||
let x = Math.round(area.x + (area.width - size.width) / 2);
|
||||
let y = area.y + margin;
|
||||
const margin = ISLAND_MARGIN;
|
||||
const center = Math.round(area.x + area.width / 2);
|
||||
|
||||
switch (this.appearance.position) {
|
||||
case 'top-left':
|
||||
x = area.x + margin;
|
||||
break;
|
||||
return { hMode: 'left', hValue: area.x + margin, vMode: 'top', vValue: area.y + margin };
|
||||
case 'top-right':
|
||||
x = area.x + area.width - size.width - margin;
|
||||
break;
|
||||
return {
|
||||
hMode: 'right',
|
||||
hValue: area.x + area.width - margin,
|
||||
vMode: 'top',
|
||||
vValue: area.y + margin,
|
||||
};
|
||||
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;
|
||||
default:
|
||||
x = anchor.hValue - cardWidth / 2 - CARD_PAD_X;
|
||||
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) {
|
||||
this.cancelFade();
|
||||
this.win.setOpacity(1);
|
||||
// 必须使用个性化透明度:这里以前硬编码 1,导致"隐藏后再弹出胶囊"会忽略透明度设置
|
||||
this.win.setOpacity(this.appearance.opacity);
|
||||
this.win.setBounds(pillBounds);
|
||||
this.win.showInactive();
|
||||
return;
|
||||
@@ -646,14 +686,17 @@ class IslandController {
|
||||
*
|
||||
* 用**单一 requestAnimationFrame 循环**驱动(需求 4):
|
||||
* - 不再使用 setTimeout 链:避免掉帧、避免"每帧重新排定时器"带来的节奏抖动
|
||||
* - 锚点固定:横向/纵向都由 computeBounds 决定,形变过程中卡片上边缘(或下边缘)不动
|
||||
* - 锚点固定:锚点在动画开始前算一次(整数),过程中只让"对侧边"移动
|
||||
* - 缓动**单调不过冲**:早期版本用 easeOutBack 弹簧缓动,窗口尺寸会超过目标再弹回来,
|
||||
* 观感就是"开合时震一下";现在统一用 easeOutCubic,尺寸/位置只朝目标单向变化
|
||||
* - 每帧只用整数像素、卡片宽度取偶数,卡片中心与不动的那条边全程恒定
|
||||
* - 关闭动画(appearance.animations = false)时直接落到目标尺寸
|
||||
* - `spring` 使用回弹缓动(轻微过冲后回落),用于"展开成卡片"的弹簧手感
|
||||
*/
|
||||
private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void {
|
||||
if (!this.win || this.win.isDestroyed()) return;
|
||||
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;
|
||||
|
||||
this.cancelBoundsAnimation();
|
||||
@@ -664,26 +707,39 @@ class IslandController {
|
||||
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 frame = (): void => {
|
||||
if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return;
|
||||
const progress = Math.min(1, (Date.now() - startedAt) / duration);
|
||||
const eased = spring ? easeOutBack(progress) : 1 - Math.pow(1 - progress, 3); // easeOutCubic
|
||||
const bounds = {
|
||||
x: Math.round(from.x + (to.x - from.x) * eased),
|
||||
y: Math.round(from.y + (to.y - from.y) * eased),
|
||||
width: Math.max(1, Math.round(from.width + (to.width - from.width) * eased)),
|
||||
height: Math.max(1, Math.round(from.height + (to.height - from.height) * eased)),
|
||||
};
|
||||
// easeOutCubic:单调递减斜率,永不越过 1(因此不会有回弹)
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
const cardWidth = Math.max(
|
||||
2,
|
||||
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);
|
||||
if (progress < 1) {
|
||||
this.boundsFrame = scheduleFrame(frame);
|
||||
return;
|
||||
}
|
||||
// 收尾对齐到最终尺寸,避免四舍五入留下 1px 误差(会让卡片边缘"抖一下")
|
||||
// 收尾对齐到最终尺寸,避免四舍五入留下误差(会让卡片边缘"抖一下")
|
||||
this.win.setBounds(to);
|
||||
this.boundsFrame = null;
|
||||
this.applyFocusable();
|
||||
@@ -773,12 +829,37 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
||||
};
|
||||
}
|
||||
|
||||
/** 回弹缓动(easeOutBack):进度超过 1 形成轻微过冲,营造弹簧感 */
|
||||
function easeOutBack(progress: number): number {
|
||||
const c1 = 1.15;
|
||||
const c3 = c1 + 1;
|
||||
const p = progress - 1;
|
||||
return 1 + c3 * p * p * p + c1 * p * p;
|
||||
/**
|
||||
* 说明:早期版本这里有一个 easeOutBack 弹簧缓动(进度过冲到 1 以上再回落),
|
||||
* 会让窗口尺寸"超过目标再弹回来",观感就是开合时震一下,因此已移除;
|
||||
* 现在所有形变统一走单调的 easeOutCubic(见 animateBounds),不再有任何回弹。
|
||||
*/
|
||||
|
||||
/** 卡片相对窗口的内边距(必须与渲染进程 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();
|
||||
|
||||
@@ -654,6 +654,123 @@ async function runIslandChecks(
|
||||
`(期望 ${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) 个性化设置的其余参数:透明度 / 主题色 / 动画开关 / 速度 / 位置 / 置顶 / 持久化
|
||||
// (需求 2 的完整清单,逐项断言真实窗口属性,而不是只看设置页显示)
|
||||
const workArea = screen.getPrimaryDisplay().workArea;
|
||||
|
||||
Reference in new issue
Block a user