diff --git a/README.md b/README.md index e3feb21..525ddd1 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 离线缓存 | ✅ 已完成(冒烟验证 62/62) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 65/65) | | 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 + 上课时段拦截 + 紧急/普通叫人 + 导入 + 班级账号:136 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、紧急通知 + 成绩/时间配置导入弹窗):20 项 -pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/再次展开、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):62 项 +pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、个性化 10 项参数与托盘/退出无残留):65 项 ``` 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权**等 136 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 -(**班级账号登录** + 四类数据 + Socket.IO),实测 62/62 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 56/56)。 +(**班级账号登录** + 四类数据 + Socket.IO),实测 65/65 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 59/59)。 ## 默认账号与种子数据 @@ -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` | 后端端到端验收(136 项,含角色权限矩阵、导入与班级账号,需服务端已启动) | -| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) | -| `pnpm verify:desktop` | EXE 客户端冒烟验证(62 项,含灵动岛圆角/收起常驻与再次展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) | -| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | -| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | -| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | -| `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | -| `pnpm db:migrate` | 创建并应用迁移 | -| `pnpm db:seed` | 写入种子数据 | -| `pnpm db:reset` | 重置数据库并重新执行 seed | -| `pnpm db:studio` | 打开 Prisma Studio | -| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | +| 命令 | 说明 | +| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | +| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | +| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | +| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | +| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | +| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | +| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | +| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | +| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | +| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | +| `pnpm verify:e2e` | 后端端到端验收(136 项,含角色权限矩阵、导入与班级账号,需服务端已启动) | +| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) | +| `pnpm verify:desktop` | EXE 客户端冒烟验证(65 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、个性化全参数) | +| `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 管理端(含手机小屏适配) @@ -348,6 +348,13 @@ pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/再次 > (dt 以帧为单位;换向只保留 35% 动量、单帧位移不超过剩余距离 20%),并额外加**零过冲钳制**(越过目标立即吸附)。 > 鼠标命中照搬 `set_cursor_hittest`:窗口默认 `setIgnoreMouseEvents(true, { forward: true })` 整块穿透, > 渲染层做命中测试后只在指针进入岛体时打开命中——**大窗口不会吞掉桌面点击**(有专项断言)。 +> +> **双保险**:Windows 下 `forward: true` 的 mousemove 转发并不总是可靠,"展开 → 收起"后一旦转发丢失, +> 窗口会永远停在穿透状态(用户反馈的"点开再收起就再也点不开")。因此渲染进程会把**岛体矩形**上报主进程 +> (`island:set-hit-rect`),主进程每 120ms 用 `screen.getCursorScreenPoint()` 与矩形比对校正命中—— +> 命中不再依赖"必须先收到一次 mousemove"。冒烟通过 `setHitTestCursor()` 注入光标位置做确定性断言 +> (细缝上可点 / 移开即穿透 / 展开收起再展开)。 +> > 由于窗口固定,Windows 那条"透明窗口最小高度约 36px"的限制不再作用于岛:空闲细缝可以真正做到 6px 宽。 | 场景 | 灵动岛行为 | @@ -550,7 +557,9 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS | **WinIsland 架构:窗口恒定 + 形状正确** | ✅ 逐帧窗口签名恒为 `476x282@482,-2`(只有 1 种);SVG 形状 bbox 与卡片框一致(±2px);**路径绕行 \|winding\| ≈ 2π**(凸、不自交,可抓出"角上往回折") | | **岛外鼠标穿透** | ✅ 指针在岛内 `interactive=true`、在岛外 `interactive=false`(固定大包围盒窗口不吞桌面点击) | | **收起后胶囊常驻(可再次点开)** | ✅ 收起后等满 `TIMEOUTS.pill`(15s)超时:`mode=pill 可见=true`(有未处理通知就不消失);显式把命中置为穿透后,一次状态变化即恢复 `interactive=true`,点击后 `mode=expanded active=smoke-collapse-reopen` | -| **空闲细缝也可点开** | ✅ `mode=hidden 可见=true interactive=true 细缝=6x22`(细缝虽窄,指针在其上即可命中) | +| **空闲细缝也可点开** | ✅ 光标注入到细缝上 → `interactive=true`;移开后 `interactive=false`(`mode=hidden 可见=true 细缝=6x22`) | +| **展开 → 收起 → 再次展开(回归)** | ✅ 真实点击往返:`expanded(命中框 424x230)→ pill(命中框 268x44)→ expanded`(此前第二步之后会卡在穿透状态,点了没反应) | +| **命中兜底随形态更新** | ✅ 岛体矩形随形态上报主进程(展开 424x230 → 收起 268x44);彻底隐藏时撤销命中框(`mode=hidden 命中框=无`),保证看不见的窗口不吞桌面点击 | | **紧急叫人(URGENT + 叫人)** | ✅ 上课中也立即展开:`mode=expanded kind=call reason=call 徽标=叫人 按钮=收到` | | **普通叫人(HIGH + 叫人)** | ✅ 上课只进队列:`mode=hidden active≠smoke-call-normal queued=[…smoke-call-normal]`;下课后自动弹出 `active=smoke-call-normal kind=call` | | **WinIsland 排版:字号即时生效** | ✅ 13px → 19px:胶囊标题 `11.96→17.48`、胶囊副题 `9.62→14.06`、标题 `14.04→20.52`、正文 `12.35→18.05`、按钮 `9.88→14.44`,**倍率全部 1.462 = 19/13** | @@ -565,7 +574,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS | **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 | 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=` 写出 JSON 结果, -已实测 `packaged: true`、62/62 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +已实测 `packaged: true`、65/65 通过、退出码 0)。灵动岛各状态的窗口截图会写到 `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 @@ -932,14 +941,14 @@ pnpm db:migrate && pnpm db:seed 当前实测: -| 验证 | 结果 | -| ------------------------------------------------------------------------------------------------------------------ | ----------------------------------- | -| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | -| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻与再次展开 + 个性化全参数 + 托盘与退出无残留) | **62/62** ✅ | -| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | -| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | -| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | +| 验证 | 结果 | +| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **133/133** ✅ | +| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **19/19** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 个性化全参数 + 托盘与退出无残留) | **65/65** ✅ | +| `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 48beacd..4898f8f 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -301,6 +301,19 @@ const islandClass = computed(() => ({ let lastInteractive: boolean | null = null; /** 最近一次已知的指针位置:岛收起/变大后用它重算命中,避免"鼠标没动就点不到" */ let lastPointer: { x: number; y: number } | null = null; +/** 最近一次上报给主进程的岛体矩形(去重,避免形变期间刷屏 IPC) */ +let lastHitRectKey: string | null = null; + +/** 把岛体矩形上报给主进程:主进程按光标位置兜底校正命中(不依赖 mousemove 转发) */ +function reportHitRect(rect: DOMRect | null): void { + const payload = rect ? { x: rect.left, y: rect.top, width: rect.width, height: rect.height } : null; + const key = payload + ? `${Math.round(payload.x)},${Math.round(payload.y)},${Math.round(payload.width)},${Math.round(payload.height)}` + : 'none'; + if (key === lastHitRectKey) return; + lastHitRectKey = key; + bridge?.setHitRect?.(payload); +} function updateInteractive(clientX: number, clientY: number): void { lastPointer = { x: clientX, y: clientY }; @@ -327,9 +340,14 @@ function updateInteractive(clientX: number, clientY: number): void { * 只有在收到 mousemove 时才会打开命中。岛"收起 / 展开 / 换形态"时几何变了, * 但指针可能一动没动(没有新的 mousemove)—— 此时窗口会一直保持"穿透", * 表现就是**收起后怎么点都打不开**(用户反馈)。所以每次状态变化都用缓存的指针位置重算一次。 + * + * 另外这里会把当前岛体矩形上报主进程(`setHitRect`):即使 Windows 下 mousemove 转发丢失, + * 主进程也能按光标位置把命中校正回来 —— 这是"点开→收起→再也点不开"的兜底修复。 */ function refreshInteractive(): void { + const node = document.querySelector('.island-card') as HTMLElement | null; const fullyHidden = state.value?.mode === 'hidden' && !appearance.value.idleSliver; + reportHitRect(node && !fullyHidden ? node.getBoundingClientRect() : null); if (fullyHidden) { if (lastInteractive !== false) { lastInteractive = false; diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 13b9ff2..5b7f9fd 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -125,6 +125,12 @@ class IslandController { private backgroundMaterial: string = 'none'; /** 当前是否接收鼠标(固定大包围盒窗口默认穿透,由渲染进程按命中动态打开) */ private interactive = false; + /** 渲染进程上报的岛体矩形(窗口内 CSS px):主进程据此做光标命中兜底轮询 */ + private hitRect: { x: number; y: number; width: number; height: number } | null = null; + /** 光标命中兜底轮询定时器(见 syncHitFromCursor 注释) */ + private hitTimer: ReturnType | null = null; + /** 诊断/冒烟用:覆盖光标位置(屏幕坐标),null 表示使用真实光标 */ + private cursorOverride: { x: number; y: number } | null = null; private state: IslandState = { mode: 'hidden', @@ -195,6 +201,8 @@ class IslandController { // 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击), // 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。 win.setIgnoreMouseEvents(true, { forward: true }); + // 光标命中兜底轮询:即使 forward 的 mousemove 丢失,也能按光标位置自行校正命中 + this.startHitPoll(); win.on('closed', () => { this.win = null; this.ready = false; @@ -473,6 +481,7 @@ class IslandController { destroy(): void { this.clearTimers(); + this.stopHitPoll(); this.cancelBoundsAnimation(); if (this.win && !this.win.isDestroyed()) this.win.destroy(); this.win = null; @@ -811,6 +820,11 @@ class IslandController { * 鼠标命中开关(照搬 WinIsland 的 `set_cursor_hittest`): * 窗口是固定大包围盒,若不控制命中就会把整片桌面的点击都吃掉。 * 渲染进程做命中测试(指针是否在"岛"的可见区域/可交互控件内),再回调这里切换。 + * + * 另外主进程还有一路**兜底**:`syncHitFromCursor()` 每 120ms 直接读光标坐标与渲染进程 + * 上报的岛体矩形比对。原因是 Windows 下 `forward: true` 的 mousemove 转发并不总是可靠, + * 一旦"展开 → 收起"后转发丢失,窗口会永远停在穿透状态 —— 用户看到的就是 + * "点开了再收起,就再也点不开了"。 */ setInteractive(interactive: boolean): void { if (this.interactive === interactive) return; @@ -821,6 +835,66 @@ class IslandController { this.win.setIgnoreMouseEvents(!interactive, { forward: true }); } + /** 渲染进程上报的岛体矩形(窗口内 CSS px,坐标系与窗口 DIP 一致) */ + setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void { + this.hitRect = rect && rect.width > 0 && rect.height > 0 ? { ...rect } : null; + // 立即校正一次,避免最长 120ms 的"点了没反应"窗口 + this.syncHitFromCursor(); + } + + /** 当前岛体矩形(供冒烟验证) */ + getHitRect(): { x: number; y: number; width: number; height: number } | null { + return this.hitRect ? { ...this.hitRect } : null; + } + + /** + * 诊断 / 冒烟专用:覆盖"光标位置"(屏幕坐标),让命中兜底轮询按指定坐标校正。 + * + * 真实场景用 `screen.getCursorScreenPoint()`;冒烟里不能去挪用户的物理鼠标, + * 因此提供一个显式注入点,把"光标在岛上 / 不在岛上"两种情况都变成可复现的断言。 + */ + setHitTestCursor(point: { x: number; y: number } | null): void { + this.cursorOverride = point ? { ...point } : null; + this.syncHitFromCursor(); + } + + /** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */ + private syncHitFromCursor(): void { + if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) { + this.setInteractive(false); + return; + } + const rect = this.hitRect; + if (!rect) { + this.setInteractive(false); + return; + } + const bounds = this.win.getBounds(); + const point = this.cursorOverride ?? screen.getCursorScreenPoint(); + const localX = point.x - bounds.x; + const localY = point.y - bounds.y; + const margin = 2; + const inside = + localX >= rect.x - margin && + localX <= rect.x + rect.width + margin && + localY >= rect.y - margin && + localY <= rect.y + rect.height + margin; + this.setInteractive(inside); + } + + private startHitPoll(): void { + if (this.hitTimer) return; + this.hitTimer = setInterval(() => this.syncHitFromCursor(), 120); + // 定时器不应拖住进程退出 + this.hitTimer.unref?.(); + } + + private stopHitPoll(): void { + if (!this.hitTimer) return; + clearInterval(this.hitTimer); + this.hitTimer = null; + } + /** 当前是否接收鼠标(供冒烟验证) */ getInteractive(): boolean { return this.interactive; @@ -970,6 +1044,14 @@ export function registerIslandIpc(): void { island.setInteractive(interactive === true); }); + // 渲染进程上报岛体矩形 → 主进程按光标位置做命中兜底(不依赖 mousemove 转发) + ipcMain.on( + 'island:set-hit-rect', + (_event, rect: { x: number; y: number; width: number; height: number } | null) => { + island.setHitRect(rect ?? null); + }, + ); + ipcMain.on('island:action', (_event, payload: IslandActionPayload) => { if (!payload?.action) return; island.handleAction(payload); diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 39c5fa1..e2dfc0f 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -977,6 +977,24 @@ async function runIslandChecks( await sleep(120); } }; + /** + * 岛体矩形上的相对点(fx/fy ∈ [0,1])→ 屏幕坐标。 + * 主进程的命中兜底轮询读的是屏幕坐标,冒烟里用注入点代替"挪动用户鼠标"。 + */ + const islandCardScreenPoint = (fx: number, fy: number): { x: number; y: number } => { + const bounds = island.getWindowBounds(); + const rect = island.getHitRect(); + if (!rect) { + return { + x: bounds.x + Math.round(bounds.width / 2), + y: bounds.y + Math.round(bounds.height / 2), + }; + } + return { + x: Math.round(bounds.x + rect.x + rect.width * fx), + y: Math.round(bounds.y + rect.y + rect.height * fy), + }; + }; /** 合成"指针移到卡片上":命中测试由渲染进程按指针位置决定(真实场景由系统转发 mousemove) */ const hoverCard = async (): Promise => Boolean( @@ -1015,13 +1033,14 @@ async function runIslandChecks( collapseBefore.mode === 'pill' && lingered.mode === 'pill' && lingeredVisible, `收起前=${collapseBefore.mode} 超时后=${lingered.mode} 可见=${lingeredVisible}`, ); - // 命中:把窗口显式置为"穿透"(等价于收起瞬间窗口被重置),再做一次状态变化 —— - // 主进程会按当前形态重新下发命中,渲染进程也会按缓存指针重算,两者都必须把命中恢复。 + // 命中兜底:把窗口显式置为"穿透",再把光标位置注入到胶囊中心 —— + // 主进程每 120ms 按光标校正一次命中,必须自己恢复(这正是"点开再收起后点不开"的兜底修复)。 island.setInteractive(false); await sleep(60); const forcedThrough = island.getInteractive(); - island.handleAction({ action: 'collapse' }); - await sleep(300); + const pillPoint = islandCardScreenPoint(0.5, 0.5); + island.setHitTestCursor(pillPoint); + await sleep(260); const interactiveWhilePill = island.getInteractive(); await islandWindow?.webContents .executeJavaScript( @@ -1039,7 +1058,7 @@ async function runIslandChecks( interactiveWhilePill === true && reopened.mode === 'expanded' && reopened.active?.id === 'smoke-collapse-reopen', - `置穿透后=${forcedThrough} 状态变化后=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` + + `置穿透后=${forcedThrough} 光标在胶囊上时=${interactiveWhilePill} 点击后 mode=${reopened.mode} ` + `active=${reopened.active?.id ?? '-'}`, ); @@ -1049,20 +1068,100 @@ async function runIslandChecks( await drainForCollapseCheck(); await sleep(500); const sliverState = island.getState(); - await hoverCard(); - await sleep(250); + island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5)); + await sleep(260); const sliverInteractive = island.getInteractive(); const sliverVisible = islandWindow?.isVisible() ?? false; + // 光标移开细缝(屏幕左上角)→ 命中必须撤销,避免细缝窗口吞掉桌面点击 + island.setHitTestCursor({ x: 1, y: 1 }); + await sleep(260); + const sliverInteractiveOutside = island.getInteractive(); record( - '空闲细缝态保持可交互(细缝很窄,指针停在岛上仍能点开)', - sliverState.mode === 'hidden' && sliverVisible && sliverInteractive === true, - `mode=${sliverState.mode} 可见=${sliverVisible} interactive=${sliverInteractive} ` + - `细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`, + '空闲细缝态命中跟随光标(细缝上可点、移开即穿透)', + sliverState.mode === 'hidden' && + sliverVisible && + sliverInteractive === true && + sliverInteractiveOutside === false, + `mode=${sliverState.mode} 可见=${sliverVisible} 光标在细缝上=${sliverInteractive} ` + + `光标移开=${sliverInteractiveOutside} 细缝=${sliverForInteractive.width}x${sliverForInteractive.height}`, ); + island.setHitTestCursor(null); island.setAppearance({ ...island.getAppearance(), idleSliver: false }); await drainForCollapseCheck(); await sleep(300); + // 4.10) 用户复现路径:"点开灵动岛再收起就无法再次打开了" + // 之前是一条"展开 → 收起 → 再也点不开"的死路(窗口停在穿透状态)。 + // 这里用真实 DOM 点击走完 展开 → 收起 → 再次展开,并断言每个形态都有命中框上报。 + const clickIsland = async (selector = '.island-card'): Promise => + Boolean( + await islandWindow?.webContents + .executeJavaScript( + `(() => { + const node = document.querySelector(${JSON.stringify(selector)}); + if (!node) return false; + node.click(); + return true; + })()`, + ) + .catch(() => false), + ); + const rectText = (rect: { width: number; height: number } | null): string => + rect ? `${Math.round(rect.width)}x${Math.round(rect.height)}` : '无'; + await drainForCollapseCheck(); + await sleep(300); + island.pushNotification(makeNotification('smoke-reopen-roundtrip', 'NORMAL', '展开收起再展开'), { + inClass: false, + }); + await sleep(500); + const clickedFirst = await clickIsland(); + await sleep(700); + const afterFirstOpen = island.getState(); + const hitRectExpanded = island.getHitRect(); + const clickedCollapse = await clickIsland('.island-card.expanded .body'); + await sleep(700); + const afterCollapse = island.getState(); + const hitRectPill = island.getHitRect(); + const clickedSecond = await clickIsland(); + await sleep(750); + const afterSecondOpen = island.getState(); + record( + '展开 → 收起 → 再次展开(用户复现路径)', + clickedFirst && + clickedCollapse && + clickedSecond && + afterFirstOpen.mode === 'expanded' && + afterCollapse.mode === 'pill' && + afterSecondOpen.mode === 'expanded' && + afterSecondOpen.active?.id === 'smoke-reopen-roundtrip' && + hitRectExpanded !== null && + hitRectPill !== null, + `${afterFirstOpen.mode}(命中框 ${rectText(hitRectExpanded)}) → ${afterCollapse.mode}` + + `(命中框 ${rectText(hitRectPill)}) → ${afterSecondOpen.mode}` + + ` active=${afterSecondOpen.active?.id ?? '-'}`, + ); + // 主进程的命中兜底轮询:岛体矩形必须随形态更新(指针交给系统,这里只断言"依据"是对的) + await island.handleAction({ action: 'collapse' }); + await sleep(500); + const hitRectCollapsed = island.getHitRect(); + record( + '命中兜底:岛体矩形随形态更新(供主进程按光标校正)', + hitRectCollapsed !== null && + Math.abs(hitRectCollapsed.width - ISLAND_SIZES.pill.width) <= 2 && + hitRectExpanded !== null && + hitRectExpanded.width > hitRectCollapsed.width, + `展开 ${rectText(hitRectExpanded)} → 收起 ${rectText(hitRectCollapsed)}(期望 ${ISLAND_SIZES.pill.width})`, + ); + // 彻底隐藏(无细缝)时必须撤销命中框,避免"看不见的窗口吞桌面点击" + await drainForCollapseCheck(); + await sleep(600); + const hitRectHidden = island.getHitRect(); + record( + '彻底隐藏时撤销命中框(不吞桌面点击)', + island.getState().mode === 'hidden' && hitRectHidden === null, + `mode=${island.getState().mode} 命中框=${rectText(hitRectHidden)}`, + ); + // 5) 截图留档的像素级断言:每张图必须有实际绘制内容、颜色丰富,且宽高比与 // 状态机配置的窗口尺寸一致(DPI 无关),紧急形态还必须出现红色描边/内部光晕像素。 const shotDetails = islandShots.map((shot) => { @@ -1524,6 +1623,9 @@ async function runIslandChecks( ) .catch(() => undefined); await sleep(180); + // 兜底轮询读的是主进程光标坐标:注入到岛体中心,等价于"指针停在岛内" + island.setHitTestCursor(islandCardScreenPoint(0.5, 0.5)); + await sleep(260); const interactiveInside = island.getInteractive(); await islandWindow?.webContents .executeJavaScript( @@ -1533,8 +1635,11 @@ async function runIslandChecks( })()`, ) .catch(() => undefined); - await sleep(180); + // 光标的权威判定在主进程(兜底轮询):这里把光标注入到屏幕左上角,等价于"指针离开岛体" + island.setHitTestCursor({ x: 1, y: 1 }); + await sleep(260); const interactiveOutside = island.getInteractive(); + island.setHitTestCursor(null); record( '岛外鼠标穿透(固定大窗口不吞桌面点击)', interactiveInside === true && interactiveOutside === false, diff --git a/packages/desktop-client/src/preload/island.ts b/packages/desktop-client/src/preload/island.ts index f35e9d4..7879089 100644 --- a/packages/desktop-client/src/preload/island.ts +++ b/packages/desktop-client/src/preload/island.ts @@ -14,6 +14,8 @@ export interface IslandBridge { getAppearance(): Promise; /** 命中测试结果:指针是否在岛体上(决定窗口是否接收鼠标) */ setInteractive(interactive: boolean): void; + /** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */ + setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void; } const bridge: IslandBridge = { @@ -31,6 +33,9 @@ const bridge: IslandBridge = { setInteractive: (interactive) => { ipcRenderer.send('island:set-interactive', interactive); }, + setHitRect: (rect) => { + ipcRenderer.send('island:set-hit-rect', rect); + }, }; contextBridge.exposeInMainWorld('island', bridge); diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index 8b30131..88ec075 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -75,6 +75,8 @@ export interface IslandRendererBridge { getState(): Promise; /** 命中测试结果:指针是否在岛体上(决定固定大窗口是否接收鼠标) */ setInteractive(interactive: boolean): void; + /** 上报岛体矩形(窗口内 CSS px):主进程据此按光标位置兜底校正命中 */ + setHitRect(rect: { x: number; y: number; width: number; height: number } | null): void; } declare global {