diff --git a/README.md b/README.md index 7e0aa8c..71920eb 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 离线缓存 | ✅ 已完成(冒烟验证 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=` 写出 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 未实测 ⚠️ | diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index 31b750b..11a01e5 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -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); } diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 7940f34..0f33ccb 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -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(); diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index eeb85ef..cfb2c54 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -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 => { + 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 => { + 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;