From 05848570d1b5420a842d8409e0ea38b31c913d0f Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sat, 17 Oct 2026 15:12:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(island):=20=E9=87=8D=E5=86=99=E5=B8=B8?= =?UTF-8?q?=E9=A9=BB=E5=B2=9B=E8=87=AA=E9=80=82=E5=BA=94=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E4=B8=8E=20ClassIsland=20=E5=8A=A8=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/dev/20261017-implementation.md | 28 +- docs/reference.md | 6 +- .../desktop-client/src/island/IslandApp.vue | 17 +- .../src/island/LayoutIslandApp.vue | 29 +- .../src/island/ResidentIsland.vue | 359 ++++++++++++++++++ .../src/island/custom-island.css | 2 +- .../src/island/resident-island.css | 304 +++++++++++++++ packages/desktop-client/src/main/island.ts | 108 +++++- .../desktop-client/src/main/smoke-resident.ts | 182 ++++++++- packages/desktop-client/src/preload/island.ts | 6 + 10 files changed, 1017 insertions(+), 24 deletions(-) create mode 100644 packages/desktop-client/src/island/ResidentIsland.vue create mode 100644 packages/desktop-client/src/island/resident-island.css diff --git a/docs/dev/20261017-implementation.md b/docs/dev/20261017-implementation.md index cf7bc10..c9f1102 100644 --- a/docs/dev/20261017-implementation.md +++ b/docs/dev/20261017-implementation.md @@ -68,7 +68,7 @@ 本机 `islandLayout.residentEnabled` 默认关闭,仅在 custom 模式生效。开启后,现有方案 rows 作为收起内容,新增 expandedRows 保存没有通知时点击展开的内容;两套内容在同一编辑器中切换编辑,随方案复制、导入导出和本机配置一起保存。旧配置自动补齐展开布局,不改变原来的常驻选择。 -按本次产品要求,常驻模式中普通、紧急和叫人消息都先提示右侧铃铛及去重后的数量,点击常驻内容、铃铛或按 Enter/空格才展开原通知列表;上课时允许主动查看。没有消息时展开自定义内容,收起、处理完成及通知撤回后保留常驻岛。经典模式与关闭常驻的自定义模式继续使用原提醒规则。 +按本次产品要求,常驻模式中普通、紧急和叫人消息都先提示右侧铃铛及去重后的数量,点击常驻内容、铃铛或按 Enter/空格才展开通知列表;上课时允许主动查看。没有消息时展开自定义内容,收起、处理完成及通知撤回后保留常驻岛。经典模式与关闭常驻的自定义模式继续使用原提醒规则。 铃铛固定占位,避免收消息时内容横移。普通组件默认继承行的垂直居中对齐;原先无条件 align-self:stretch 会使不同字号文字从顶部排版,现只在父容器有宽度且明确配置时应用覆盖对齐。时钟采用等宽数字,分隔符保持固定线高,展开页标题与收起按钮在同一基准线上。设置页重新获得焦点时读回本机配置,避免编辑器内改常驻选项后主设置页显示旧值。 @@ -84,6 +84,32 @@ 最终构建视觉验收 118/118,随后后台重启检查 43/43、单实例锁和配置句柄释放通过;全仓类型、lint、构建与产物凭据门禁、格式及文档严格检查通过。最终截图为 `.cache/resident-island-ci-final-shots/`,验收日志为 `.cache/resident-island-ci-visual-final.log`。本次不修改业务数据库、不打包或推送发布。 +## 常驻岛界面与动画重写 + +按用户反馈重新读取上游 `ClassicTheme/Styles.axaml`、`Controls/MainWindowLine.cs` 与 `Controls/MainWindowLine.axaml`。 +上游背景宽度过渡为 550ms、`cubic-bezier(0,1,.5,1.1)`,内容使用 200ms 的 SineEaseIn 淡入;行高 40、左右留白 12。 +本产品需要可交互的通知列表和独立空闲展开页,沿用这些度量、背景曲线与内容淡入,展开页高度也使用同一背景曲线。 + +旧问题由三个因素叠加:内嵌提醒仍播放 WinIsland 胶囊弹簧,外层 ResizeObserver 把动画中间帧当成自然尺寸,渲染端与主进程各延迟 120ms 调整窗口;外壳 `overflow:auto` 在两端不同步时出现滚动条。 +新 `ResidentIsland.vue` 独立渲染常驻内容、空闲页和通知列表,保留既有的消息去重、排序、读回执及按钮动作。 +经典渲染器保留挂载和心跳,但在常驻模式停止弹簧循环、兜底定时器和命中上报,不再内嵌到常驻岛。 + +常驻窗口使用受工作区限制的固定画布,开合不调用逐帧 setBounds,也不再隐藏 320ms 后重新显示。 +两页内容按各自未变形的 offsetWidth/offsetHeight 测量,保持自己的排版和缩放;只让一个岛壳过渡尺寸。 +内容自然尺寸、配置缩放、字体加载和窗口 resize 决定目标尺寸。长文字先限制宽度并省略,时间、分隔线和文字成组对齐;多行布局整体适配可用宽高。 +消息正文限制可见行数,按高度限制列表数量,剩余内容由应用内入口承接;底部动作不被正文挤出,运行界面没有滚动容器。 + +原生窗口的 show/hide 通过专用白名单桥同步到渲染器;不可见时直接应用目标几何,避免后台 CSS 动画冻结在中间帧。 +命中矩形仅在动画期间每 60ms 限流上报,结束时补报;静止时没有动画采样循环。 +开合期间暂停指针淡化,避免鼠标留在旧展开区域时因过期命中矩形误触发 5% 透明度,造成收起闪烁。 +Windows 触摸模式使用 Electron setShape:动画开始放开绘制区域,完成后只保留岛体及 8px 投影留白的原生区域,透明画布不再长期吞掉周围桌面输入。进入编辑、关闭常驻或关闭触摸模式时清除区域。 + +新增回归覆盖开合窗口矩形恒定、动画帧间隔、所有运行节点无滚动容器、360×260 窄画布与 2.5 倍缩放、底部右侧停靠、长正文及多通知底部动作、触摸输入区域收敛。测试保持隔离配置与本地通知,不连接真实班级。 + +本轮视觉验收 123/123,通过连续快速开合与停靠命中检查;1.8 秒取得 109 个动画采样,帧间隔 P95 为 16.8ms,原生窗口矩形只有 1 个。 +最终补充收起期间错误淡化的回归后,后台检查 49/49;桌面类型检查、全仓 lint、构建及产物凭据门禁、格式和文档严格构建通过。 +视觉记录与截图为 `.cache/resident-rewrite-final-visual.json`、`.cache/resident-rewrite-final-shots/`,最终构建后台记录为 `.cache/resident-rewrite-delivery-quick.json`。 + ## 十项自检 1. 第三周周三第二节:ClassPlan 的规则与 ClassPlanEntry 的 timeLayoutItemId → `resolveActiveClassPlan` → `loadTermContext/resolveCurrentWeek` → `projectPlan` → `/schedules/active-week` → `refreshIslandSchedule` 的整周 IndexedDB 缓存 → `recomputeClassState` 发送当天课程 → `ComponentNode.vue` 课程表组件。 diff --git a/docs/reference.md b/docs/reference.md index 944e837..a7ebd82 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -1584,12 +1584,16 @@ makensis 临时目录、ClassIsland 路径与日志编码等)。这里只列 天气组件本轮不做:没有可离线使用的数据源,验收不依赖外网。 自定义组件栏位于灵动岛内部,提醒内容也在同一个岛体内展开,不再嵌套另一张带底色的岛卡片。 可在「设置 → 灵动岛」选择常驻岛:收起内容与无通知展开内容分别编辑,并随方案一起导入导出。 -常驻模式的新消息(含紧急消息)先亮铃铛和数量,点击岛体或铃铛查看原通知列表;上课时也可主动查看。 +常驻模式的新消息(含紧急消息)先亮铃铛和数量,点击岛体或铃铛查看通知列表;上课时也可主动查看。 没有待处理消息时点击岛体,展示自定义的空闲展开页;处理完消息后回到常驻内容。关闭常驻后使用原提醒规则。 铃铛在右侧预留固定空间,收到消息时内容不横移;默认组件按垂直中心对齐,时间使用等宽数字。 常驻外观重新按 ClassIsland 源码对齐:单行基准高 40 px、左右留白 12 px、四档字号 14/16/18/20、时钟使用强调字号 18 px;默认缩放 1×、圆角 8 px、背景不透明度 0.5、行间距 5 px。 窗口设置沿用「指针移入淡化」与「反转指针移入淡化」;普通背景淡化至 5% 并穿透点击,移出恢复。铃铛/展开按钮、编辑模式、已经展开的内容及触摸操作短暂宽限期保持可交互。 界面缩放范围 0.75–2.5×,圆角 0–20 px,背景不透明度 0–1;整体外观可一键恢复 ClassIsland 默认值。 +常驻岛独立使用 ClassIsland 经典主题的 550ms 背景尺寸曲线与 200ms 内容淡入;旧胶囊弹簧在常驻模式停止运行。 +开合只改变岛壳,原生窗口保持固定画布,避免窗口重排、闪烁和两端尺寸防抖互相追赶。隐藏窗口直接落到目标尺寸。 +布局按内容自然尺寸、屏幕可用空间和缩放设置自动适配;长文本限制宽度并省略,同一行的时间、分隔线与文字保持成组对齐,左右及底部停靠保持对应锚点。 +通知列表和空闲展开页均不出现滚动条;较多消息按可用高度展示,完整长正文及剩余消息可通过「查看完整内容」进入应用查看,底部操作始终保留。 编辑器左侧按用途添加组件、中央预览岛内排列、右侧只显示当前对象的设置;方案操作收在顶部菜单。 主设置页选择经典或自定义灵动岛,自定义模式的组件尺寸、配色在编辑器中调整,主设置页保留位置与提醒选项。 迁移保留旧 Schedule 作为审计来源;新门面只写新模型。重复占用同一时间点的旧行无法同时成为一节课, diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index 86b9ad2..7f8d034 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -1,6 +1,6 @@ diff --git a/packages/desktop-client/src/island/ResidentIsland.vue b/packages/desktop-client/src/island/ResidentIsland.vue new file mode 100644 index 0000000..2bfce87 --- /dev/null +++ b/packages/desktop-client/src/island/ResidentIsland.vue @@ -0,0 +1,359 @@ + + + + diff --git a/packages/desktop-client/src/island/custom-island.css b/packages/desktop-client/src/island/custom-island.css index 607253b..25d87f7 100644 --- a/packages/desktop-client/src/island/custom-island.css +++ b/packages/desktop-client/src/island/custom-island.css @@ -6,7 +6,7 @@ .custom-card { box-sizing: border-box; padding: 12px; - overflow: auto; + overflow: hidden; background: var(--custom-color); border-radius: var(--custom-radius); box-shadow: diff --git a/packages/desktop-client/src/island/resident-island.css b/packages/desktop-client/src/island/resident-island.css new file mode 100644 index 0000000..4b41b3c --- /dev/null +++ b/packages/desktop-client/src/island/resident-island.css @@ -0,0 +1,304 @@ +/* ClassIsland ClassicTheme: 40px 行高、12px 内边距、550ms 背景、200ms 内容淡入。 */ +.resident-window { + position: fixed; + inset: 10px; + overflow: visible; + pointer-events: none; +} +.resident-window .resident-card { + position: absolute; + left: 50%; + top: 0; + transform: translateX(-50%); + padding: 0; + min-width: 0; + min-height: 0; + max-width: none; + max-height: none; + overflow: hidden; + overflow: clip; + isolation: isolate; + pointer-events: auto; + background: var(--resident-background); + border-radius: var(--resident-radius); + font-size: 16px; + line-height: 1.4; + box-shadow: 0 2px 3px #0000001c; + transition: + width var(--resident-duration) cubic-bezier(0, 1, 0.5, 1.1), + height var(--resident-duration) cubic-bezier(0, 1, 0.5, 1.1), + border-radius var(--resident-fade-duration) ease; +} +.resident-window[data-anchor='left'] .resident-card { + left: 0; + transform: none; +} +.resident-window[data-anchor='right'] .resident-card { + left: auto; + right: 0; + transform: none; +} +.resident-window[data-bottom='true'] .resident-card { + top: auto; + bottom: 0; +} +.resident-card .resident-page { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + width: max-content; + opacity: 0; + pointer-events: none; + visibility: hidden; + transition: + opacity var(--resident-fade-duration) cubic-bezier(0.47, 0, 0.745, 0.715), + visibility 0s var(--resident-fade-duration); +} +.resident-card .resident-page.page-active { + opacity: 1; + pointer-events: auto; + visibility: visible; + transition-delay: 0s; +} +.resident-card .resident-source { + width: max-content; + transform: scale(var(--resident-scale)); + transform-origin: center top; +} +.resident-window[data-anchor='left'] .resident-page { + left: 0; + transform: none; +} +.resident-window[data-anchor='left'] .resident-source { + transform-origin: left top; +} +.resident-window[data-anchor='right'] .resident-page { + left: auto; + right: 0; + transform: none; +} +.resident-window[data-anchor='right'] .resident-source { + transform-origin: right top; +} +.resident-card .resident-compact { + display: flex; + align-items: center; + gap: 6px; + padding: 0 12px; + min-height: 40px; +} +.resident-window .resident-card.resident-separated { + background: transparent; + box-shadow: none; +} +.resident-separated .resident-compact { + padding: 0; +} +.resident-separated .resident-compact .layout-rows { + align-items: center; +} +.resident-separated .resident-compact .layout-row { + padding: 0 12px; + background: var(--resident-background); + border-radius: var(--resident-radius); +} +.resident-separated .resident-compact .resident-bell { + background: var(--resident-background); +} +.resident-card .layout-rows { + display: flex; + flex-direction: column; + min-width: 0; +} +.resident-card .row-components { + display: flex; + align-items: center; + gap: 0; + min-height: 40px; +} +.resident-card .resident-compact .component-node:not(.container) { + min-height: 40px; + max-height: 40px; + display: flex; + align-items: center; + padding: 0 calc(var(--row-gap, 12px) / 2); +} +.resident-card .resident-compact .component-node.container { + padding: 0; +} +.resident-card .resident-compact .component-node.text { + max-width: min(320px, max(80px, calc(var(--resident-row-width) - 112px))); +} +.resident-card .resident-compact .text > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.resident-card .resident-compact .schedule { + flex-direction: row; + gap: 12px; +} +.resident-card .resident-compact .schedule > div + div { + margin-top: 0; +} +.resident-card .separator > span { + height: 20px; +} +.resident-card .resident-bell { + align-self: center; + color: inherit; +} +.resident-card .resident-bell.bell-empty { + color: #ffffff80; +} +.resident-card .resident-bell span { + animation: resident-indicator-in 250ms cubic-bezier(0.25, 1, 0.5, 1); +} +.resident-card .resident-details { + min-width: 160px; + padding: 0 12px 12px; +} +.resident-card .resident-panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + height: 40px; + padding: 0 6px; + margin: 0; + border: 0; + color: #ffffff94; + font-size: 12px; +} +.resident-card .resident-panel-heading .icon-btn { + min-height: 28px; + width: 28px; + height: 28px; + padding: 4px; +} +.resident-card .icon-btn svg { + width: 18px; + height: 18px; + display: block; +} +.resident-card .resident-expanded-rows { + border-top: 1px solid #ffffff1a; + padding-top: 8px; +} +.resident-card .resident-expanded-rows .row-components { + gap: var(--row-gap, 12px); +} +.resident-card #custom-alert-host { + width: 100%; + max-width: 100%; +} +.resident-card .resident-messages { + display: flex; + flex-direction: column; + gap: 8px; +} +.resident-card .list-row { + display: block; + height: auto; + min-height: 0; + padding: 12px; + border: 1px solid #ffffff18; + border-radius: 6px; + background: #ffffff08; +} +.resident-card .resident-message-title { + display: flex; + align-items: center; + gap: 12px; +} +.resident-card .resident-message-title strong { + flex: 1; + min-width: 0; + font-size: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.resident-card .resident-message-title > span { + flex: none; + color: #ffffff80; + font-size: 11px; +} +.resident-card .list-row[data-priority='URGENT'] .resident-message-title > span { + color: #ff9c94; +} +.resident-card .list-row p { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + margin: 6px 0; + font-size: 14px; + line-height: 1.5; + overflow-wrap: anywhere; + white-space: pre-line; +} +.resident-card .list-row:only-of-type p { + -webkit-line-clamp: 5; +} +.resident-card .list-row small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: #ffffff80; +} +.resident-card .resident-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding-top: 4px; +} +.resident-card .resident-actions button:first-child { + margin-right: auto; +} +.resident-card .resident-actions button, +.resident-card .resident-more { + border: 0; + border-radius: 6px; + padding: 8px 10px; + background: #ffffff0d; + color: inherit; + font: inherit; + font-size: 12px; + cursor: pointer; + white-space: nowrap; +} +.resident-card .resident-actions .solid-btn { + background: #ffffffdd; + color: #202124; +} +.resident-card button:focus-visible { + outline: 2px solid #adc4ff; + outline-offset: -2px; +} +@media (hover: hover) and (pointer: fine) { + .resident-card button:hover { + filter: brightness(1.18); + } +} +@media (prefers-reduced-motion: reduce) { + .resident-window .resident-card, + .resident-card .resident-page { + transition-duration: 0ms; + } + .resident-card .resident-bell span { + animation: none; + } +} +@keyframes resident-indicator-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index 7134d76..ccadaf1 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -208,7 +208,50 @@ class IslandController { private residentWasEnabled = false; private residentFaded = false; private residentTouchUntil = 0; + private residentMotionUntil = 0; private residentControl: { x: number; y: number; width: number; height: number } | null = null; + private residentShapeTimer: ReturnType | null = null; + private residentShaped = false; + /** 触摸屏的透明画布不应吞掉桌面:动画时放开绘制区,停稳后只保留岛体及投影区域。 */ + private syncResidentShape(transition = false): void { + if (!this.win || this.win.isDestroyed() || process.platform !== 'win32') return; + if (this.residentShapeTimer) clearTimeout(this.residentShapeTimer); + this.residentShapeTimer = null; + if (!this.residentEnabled || !this.touchMode || this.editingLayout) { + if (this.residentShaped) this.win.setShape([]); + this.residentShaped = false; + return; + } + this.residentShaped = true; + const apply = () => { + this.residentShapeTimer = null; + const r = this.hitRect; + if ( + !r || + !this.win || + this.win.isDestroyed() || + !this.residentEnabled || + !this.touchMode || + this.editingLayout + ) + return; + this.win.setShape([ + { + x: Math.max(0, Math.floor(r.x - 8)), + y: Math.max(0, Math.floor(r.y - 8)), + width: Math.ceil(r.width + 16), + height: Math.ceil(r.height + 16), + }, + ]); + }; + if (transition) { + this.win.setShape([]); + this.residentShapeTimer = setTimeout( + apply, + this.appearance.animations ? 750 / this.appearance.speed : 50, + ); + } else apply(); + } getEditingLayout(): boolean { return this.editingLayout; } @@ -227,9 +270,10 @@ class IslandController { this.emit(); } this.win?.webContents.send('island:layout', getConfig().islandLayout); + this.syncResidentShape(true); this.syncWindow(); } - /** 常驻岛的离散收起:先隐藏,等两端内容尺寸防抖完成后以常态尺寸重新显示。 */ + /** 仅用于首次切换布局、启动与退出编辑;日常常驻岛开合保持窗口可见。 */ private settleCustomLayout(): void { if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer); this.settlingLayout = true; @@ -246,6 +290,7 @@ class IslandController { this.layoutSettleTimer = null; this.settlingLayout = false; this.editingLayout = enabled; + this.syncResidentShape(); if (enabled) saveConfig({ islandLayout: { ...getConfig().islandLayout, layout: 'custom' } }); this.win?.webContents.send('island:layout', getConfig().islandLayout); this.win?.webContents.send('island:editing', enabled); @@ -277,13 +322,34 @@ class IslandController { } } reportCustomSize(value: { width: number; height: number }): void { - if (!this.customLayout || !Number.isFinite(value?.width) || !Number.isFinite(value?.height)) return; + if ( + !this.customLayout || + !Number.isFinite(value?.width) || + !Number.isFinite(value?.height) || + value.width <= 0 || + value.height <= 0 + ) + return; this.customDesiredSize = { ...value }; if (this.editingLayout) return; + if (this.residentEnabled) { + if (this.customTimer) clearTimeout(this.customTimer); + this.customTimer = null; + // 常驻岛只报告目标几何;窗口画布固定,禁止逐帧 setBounds 和两端尺寸防抖互相追赶。 + if ( + this.customSize.width !== Math.ceil(value.width) || + this.customSize.height !== Math.ceil(value.height) + ) + this.residentMotionUntil = + Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0); + this.customSize = { width: Math.ceil(value.width), height: Math.ceil(value.height) }; + this.syncResidentShape(true); + return; + } if (this.customTimer) clearTimeout(this.customTimer); this.customTimer = setTimeout(() => { this.customTimer = null; - if (!this.win || !this.customLayout || this.editingLayout) return; + if (!this.win || !this.customLayout || this.editingLayout || this.residentEnabled) return; const area = screen.getDisplayNearestPoint(this.win.getBounds()).workArea; const current = this.win.getBounds(); const width = Math.max(40, Math.min(Math.ceil(value.width), area.width - 2 * ISLAND_SHADOW_PAD)); @@ -425,6 +491,12 @@ class IslandController { // 默认整块窗口不接收鼠标(避免大面积透明窗口吞掉桌面点击), // 只有指针进入"岛"的可见区域时,渲染进程才通过 island:set-interactive 打开命中。 win.setIgnoreMouseEvents(true, { forward: true }); + const notifyVisibility = (visible: boolean) => { + if (this.win === win && this.ready && !this.recovering && !win.webContents.isDestroyed()) + win.webContents.send('island:visibility', visible); + }; + win.on('show', () => notifyVisibility(true)); + win.on('hide', () => notifyVisibility(false)); // 光标命中兜底轮询:即使 forward 的 mousemove 丢失,也能按光标位置自行校正命中 this.startHitPoll(); win.on('closed', () => { @@ -1047,6 +1119,8 @@ class IslandController { } destroy(): void { + if (this.residentShapeTimer) clearTimeout(this.residentShapeTimer); + this.residentShapeTimer = null; this.clearTimers(); if (this.layoutSettleTimer) clearTimeout(this.layoutSettleTimer); this.layoutSettleTimer = null; @@ -1231,9 +1305,14 @@ class IslandController { /** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */ private setState(patch: Partial, options: { skipWindow?: boolean } = {}): void { - if (this.residentEnabled && this.state.mode === 'expanded' && patch.mode && patch.mode !== 'expanded') - this.settleCustomLayout(); + if ( + this.residentEnabled && + patch.mode && + (patch.mode === 'expanded') !== (this.state.mode === 'expanded') + ) + this.residentMotionUntil = Date.now() + (this.appearance.animations ? 600 / this.appearance.speed : 0); this.state = { ...this.state, ...patch, updatedAt: Date.now() }; + if (this.residentEnabled) this.syncResidentShape(true); // 离开展开态就把"展开更多"复位:下次再点开时默认回到"只显示前三条" if (this.state.mode !== 'expanded') this.state.listExpanded = false; // 列表形态比普通展开卡高:窗口要**先**长高,卡片才开始形变,否则底部会被窗口裁掉 @@ -1281,7 +1360,8 @@ class IslandController { !(process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_MODE !== 'visual') ) this.win.showInactive(); - this.setInteractive(true, '自定义组件栏'); + if (this.residentEnabled) this.syncHitFromCursor(); + else this.setInteractive(true, '自定义组件栏'); return; } @@ -1344,6 +1424,14 @@ class IslandController { * - 触摸模式:贴合当前形态(岛体 + 2×阴影留白),否则大包围盒的透明区会吞掉桌面的触摸。 */ private windowBox(): { width: number; height: number } { + if (this.residentEnabled && this.win) { + const area = this.targetDisplay().workArea; + // 与 ClassIsland 一样,让背景/内容在固定画布内过渡。预留一帧绘制边界,避免过冲裁切。 + return { + width: Math.min(960, area.width), + height: Math.min(640, Math.max(60, area.height - this.appearance.marginY)), + }; + } if (this.customLayout) return { width: this.customSize.width + 2 * ISLAND_SHADOW_PAD, @@ -1612,7 +1700,7 @@ class IslandController { const next = passthrough || this.residentFaded ? false - : this.touchMode || this.editingLayout || this.customLayout + : this.touchMode || this.editingLayout || (this.customLayout && !this.residentEnabled) ? true : interactive; if (this.interactive === next) return; @@ -1676,6 +1764,7 @@ class IslandController { settings.pointerFadeEnabled && !this.editingLayout && Date.now() >= this.residentTouchUntil && + Date.now() >= this.residentMotionUntil && this.state.mode !== 'expanded' && this.isInsideHitRect(point) !== settings.pointerFadeReversed && !overControl; @@ -1699,6 +1788,7 @@ class IslandController { setTouchMode(enabled: boolean): void { if (this.touchMode === enabled) return; this.touchMode = enabled; + this.syncResidentShape(); logger.info( `灵动岛:触摸模式=${enabled}(窗口${enabled ? '贴合岛体并始终接收输入' : '回到固定包围盒穿透'})`, ); @@ -2141,6 +2231,10 @@ export function registerIslandIpc(): void { }); ipcMain.handle('island:edit-layout', () => island.editLayout()); ipcMain.handle('island:get-editing', () => island.getEditingLayout()); + ipcMain.handle( + 'island:get-visible', + (event) => event.sender === island.getWindow()?.webContents && (island.getWindow()?.isVisible() ?? false), + ); ipcMain.on('island:resident-control', (event, value) => { if (event.sender === island.getWindow()?.webContents) island.setResidentControl(value); }); diff --git a/packages/desktop-client/src/main/smoke-resident.ts b/packages/desktop-client/src/main/smoke-resident.ts index 331cab5..eb42c35 100644 --- a/packages/desktop-client/src/main/smoke-resident.ts +++ b/packages/desktop-client/src/main/smoke-resident.ts @@ -15,15 +15,33 @@ export async function verifyResidentSmoke( ) { const win = island.getWindow()!; const originalTouchMode = island.getTouchMode(); + const originalAppearance = island.getAppearance(); + island.setAppearance({ animations: true, speed: 1 }); island.setTouchMode(false); island.setHitTestCursor({ x: 0, y: 0 }); const pause = (ms = 600) => new Promise((resolve) => setTimeout(resolve, ms)); async function shot(name: string) { - if (!process.env.ISLAND_SHOTS_DIR) return; + if (!process.env.ISLAND_SHOTS_DIR || process.env.ELECTRON_SMOKE_MODE !== 'visual') return; fs.mkdirSync(process.env.ISLAND_SHOTS_DIR, { recursive: true }); fs.writeFileSync( path.join(process.env.ISLAND_SHOTS_DIR, `${name}.png`), - (await win.webContents.capturePage()).toPNG(), + ( + await win.webContents.capturePage( + island.getEditingLayout() + ? undefined + : (() => { + const r = island.getHitRect(); + return r + ? { + x: Math.max(0, Math.floor(r.x - 8)), + y: Math.max(0, Math.floor(r.y - 8)), + width: Math.ceil(r.width + 16), + height: Math.ceil(r.height + 16), + } + : undefined; + })(), + ) + ).toPNG(), ); } const fixture = defaultIslandLayouts(); @@ -140,7 +158,7 @@ export async function verifyResidentSmoke( island.getState().mode === 'expanded' && !island.getState().active && (await win.webContents.executeJavaScript( - `(() => {const card=document.querySelector('.custom-card');return Boolean(card?.querySelector('.resident-panel-heading')&&card.textContent.includes('今日安排')&&!card.textContent.includes('常驻概览'));})()`, + `(() => {const page=document.querySelector('.resident-detail-page.page-active');return Boolean(page?.querySelector('.resident-panel-heading')&&page.textContent.includes('今日安排')&&document.querySelector('.resident-compact-page[inert]'));})()`, )) ); }); @@ -188,20 +206,20 @@ export async function verifyResidentSmoke( )) ); }); - await check('点击铃铛展开原通知列表,上课时也可主动查看', async () => { + await check('点击铃铛展开通知列表,上课时也可主动查看', async () => { await win.webContents.executeJavaScript(`document.querySelector('.resident-bell')?.click()`); await pause(1800); return ( island.getState().mode === 'expanded' && (await win.webContents.executeJavaScript( - `(() => {const host=document.querySelector('#custom-alert-host');return Boolean(host&&host.getBoundingClientRect().height>0&&host.querySelectorAll('.list-row').length===2&&host.textContent.includes('常驻通知一')&&host.textContent.includes('常驻通知二')&&!document.querySelector('.resident-panel-heading'));})()`, + `(() => {const host=document.querySelector('#custom-alert-host');return Boolean(host&&host.getBoundingClientRect().height>0&&host.querySelectorAll('.list-row').length===2&&host.textContent.includes('常驻通知一')&&host.textContent.includes('常驻通知二')&&!host.querySelector('.island-root'));})()`, )) ); }); await shot('resident-notifications'); await check('收起通知保留铃铛,键盘可重新展开', async () => { await win.webContents.executeJavaScript( - `document.querySelector('#custom-alert-host .icon-btn')?.click()`, + `document.querySelector('.resident-panel-heading .icon-btn')?.click()`, ); await pause(800); const collapsed = island.getState().mode === 'pill'; @@ -211,7 +229,7 @@ export async function verifyResidentSmoke( await pause(); return collapsed && island.getState().mode === 'expanded'; }); - await check('处理完通知清除铃铛,恢复常驻内容与窗口留白', async () => { + await check('处理完通知清除铃铛,恢复40px常驻内容且命中不占整窗', async () => { await win.webContents.executeJavaScript( `document.querySelector('#custom-alert-host .solid-btn')?.click()`, ); @@ -225,8 +243,9 @@ export async function verifyResidentSmoke( island.getState().queued.length === 0 && dimensions.empty === true && dimensions.content && - Math.abs(box.width - dimensions.width - 2 * ISLAND_SHADOW_PAD) <= 2 && - Math.abs(box.height - dimensions.height - 2 * ISLAND_SHADOW_PAD) <= 2 + dimensions.width + 2 * ISLAND_SHADOW_PAD <= box.width && + Math.abs(dimensions.height - 40) <= 1 && + Math.abs((island.getHitRect()?.height ?? 0) - dimensions.height) <= 2 ); }); await check('空闲展开时收到消息返回铃铛提示', async () => { @@ -239,6 +258,150 @@ export async function verifyResidentSmoke( await pause(); return collapsed; }); + await check('常驻岛开合复用固定画布,动画中没有滚动容器或旧胶囊形变', async () => { + const bounds = new Set(); + const timer = setInterval(() => bounds.add(JSON.stringify(win.getBounds())), 20); + let result; + try { + result = await win.webContents.executeJavaScript(`new Promise(resolve => { + const frames=[], gaps=[]; let last=performance.now(), count=0; + const sample=()=>{ + const card=document.querySelector('.resident-card'), r=card?.getBoundingClientRect(); + if(!r){frames.push(false);return;} + const nodes=[card,...card.querySelectorAll('*')]; + frames.push(r.left>=0&&r.top>=0&&r.right<=innerWidth+1&&r.bottom<=innerHeight+1&&nodes.every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)&&!['auto','scroll'].includes(getComputedStyle(n).overflowX))); + }; + const animate=time=>{gaps.push(time-last);last=time;count++;sample();if(time-start<1800)requestAnimationFrame(animate);}; + const start=performance.now();requestAnimationFrame(animate); + window.island.sendAction('expand');setTimeout(()=>window.island.sendAction('collapse'),700); + setTimeout(()=>{sample(); const card=document.querySelector('.resident-card');resolve({valid:frames.every(Boolean),count,gaps:gaps.filter(g=>g>0).sort((a,b)=>a-b),height:card.getBoundingClientRect().height,legacy:document.querySelector('.island-root')?.getBoundingClientRect().height===0});},1900); + })`); + } finally { + clearInterval(timer); + } + if (process.env.ISLAND_SHOTS_DIR) + fs.writeFileSync( + path.join( + process.env.ISLAND_SHOTS_DIR, + `resident-animation-metrics-${process.env.ELECTRON_SMOKE_MODE}.json`, + ), + JSON.stringify({ ...result, windowRects: bounds.size }, null, 2), + ); + const visual = process.env.ELECTRON_SMOKE_MODE === 'visual'; + return Boolean( + result?.valid && + result.legacy && + Math.abs(result.height - 40) <= 1 && + bounds.size === 1 && + (!visual || (result.count >= 20 && result.gaps[Math.floor(result.gaps.length * 0.95)] < 100)), + ); + }); + await check('常驻岛连续快速开合后恢复40px且没有遗留过渡', async () => { + const box = win.getBounds(); + const stable = await win.webContents.executeJavaScript(`new Promise(resolve=>{ + for(let i=0;i<8;i++)setTimeout(()=>window.island.sendAction(i%2?'collapse':'expand'),i*70); + setTimeout(()=>{const r=document.querySelector('.resident-card').getBoundingClientRect();resolve({width:r.width,height:r.height,active:document.querySelector('.resident-compact-page').classList.contains('page-active')});},1300); + })`); + return Boolean( + stable.active && + Math.abs(stable.height - 40) <= 1 && + Math.abs(stable.width - compactWidth) <= 2 && + JSON.stringify(box) === JSON.stringify(win.getBounds()), + ); + }); + await check('收起时鼠标留在旧展开区域不会错误触发5%淡化闪烁', async () => { + island.handleAction({ action: 'expand' }); + await pause(800); + const hit = island.getHitRect()!, + box = win.getBounds(), + opacity = win.getOpacity(); + island.setHitTestCursor({ x: box.x + hit.x + hit.width / 2, y: box.y + hit.y + hit.height - 12 }); + island.handleAction({ action: 'collapse' }); + const immediate = win.getOpacity(); + await pause(120); + const after = win.getOpacity(); + island.setHitTestCursor({ x: 0, y: 0 }); + await pause(700); + return Math.abs(immediate - opacity) < 0.01 && Math.abs(after - opacity) < 0.01; + }); + await check('长文本多行与2.5倍字号自动适配,变窄窗口和底部右侧停靠均不溢出', async () => { + const appearance = island.getAppearance(); + const dense = JSON.parse(JSON.stringify(fixture)); + dense.schemes[0].scale = 2.5; + dense.schemes[0].separated = true; + dense.schemes[0].rows[0].nodes[2].settings.text = '自适应常驻内容'.repeat(18); + dense.schemes[0].rows.push({ ...dense.schemes[0].rows[0], id: 'resident-dense-row' }); + saveConfig({ islandLayout: dense }); + island.syncCustomLayout(); + island.setAppearance({ position: 'bottom-right' }); + await pause(800); + // 模拟更小的工作区画布,检查响应的是 resize,而不是上一次固定像素尺寸。 + const box = win.getBounds(); + win.setBounds({ ...box, width: 360, height: 260 }); + await pause(800); + const geometry = await win.webContents.executeJavaScript(`(() => { + const card=document.querySelector('.resident-card'), page=card.querySelector('.page-active'), source=page.querySelector('.resident-source'); + const r=card.getBoundingClientRect(), c=source.getBoundingClientRect(); + return {r:r.toJSON(), c:c.toJSON(), width:innerWidth,height:innerHeight, fits:r.width<=340&&r.height<=240&&r.right<=innerWidth&&r.bottom<=innerHeight&&c.left>=r.left-1&&c.right<=r.right+1&&c.bottom<=r.bottom+1&&getComputedStyle(card).overflowY!=='auto'&&card.classList.contains('resident-separated')}; + })()`); + const hit = island.getHitRect(); + const hitMatches = hit && Math.abs(hit.x - geometry.r.x) <= 2 && Math.abs(hit.y - geometry.r.y) <= 2; + await shot('resident-adaptive-narrow'); + saveConfig({ islandLayout: fixture }); + island.syncCustomLayout(); + island.setAppearance(appearance); + await pause(800); + if (!geometry.fits || !hitMatches) throw new Error(JSON.stringify({ ...geometry, hit })); + return true; + }); + await check('常驻消息较多及超长正文不产生滚动条,底部操作始终可见', async () => { + for (let i = 0; i < 8; i++) + island.pushNotification({ + ...message('resident-overflow-' + i, '较多通知 ' + i, 'NORMAL'), + content: '超长正文'.repeat(200), + }); + island.handleAction({ action: 'expand' }); + await pause(800); + island.handleAction({ action: 'expand-list' }); + await pause(800); + const geometry = await win.webContents.executeJavaScript(`(() => { + const card=document.querySelector('.resident-card'), footer=card.querySelector('.resident-actions'), r=card.getBoundingClientRect(), f=footer?.getBoundingClientRect(); + return {r:r.toJSON(),f:f?.toJSON(), fits:Boolean(f&&f.bottom<=r.bottom+1&&f.left>=r.left&&f.right<=r.right+1&&card.querySelector('.resident-more')&&[card,...card.querySelectorAll('*')].every(n=>!['auto','scroll'].includes(getComputedStyle(n).overflowY)))}; + })()`); + await shot('resident-many-notifications'); + island.handleAction({ action: 'dismiss-all' }); + await pause(800); + if (!geometry.fits) throw new Error(JSON.stringify(geometry)); + return true; + }); + await check('触摸常驻岛停稳后只保留岛体输入区域,开合不缩放原生窗口', async () => { + if (process.platform !== 'win32') return true; + const original = win.setShape.bind(win); + let shape: Electron.Rectangle[] = []; + win.setShape = (rects) => { + shape = rects; + original(rects); + }; + try { + island.setTouchMode(true); + const box = win.getBounds(); + island.handleAction({ action: 'expand' }); + await pause(1000); + island.handleAction({ action: 'collapse' }); + await pause(1000); + const hit = island.getHitRect(); + return Boolean( + hit && + shape.length === 1 && + shape[0]!.width <= hit.width + 18 && + shape[0]!.height <= hit.height + 18 && + JSON.stringify(box) === JSON.stringify(win.getBounds()), + ); + } finally { + win.setShape = original; + island.setTouchMode(false); + } + }); await check('常驻和无通知展开内容可独立编辑并持久化', async () => { island.editLayout(); await pause(); @@ -282,4 +445,5 @@ export async function verifyResidentSmoke( }); island.setHitTestCursor(null); island.setTouchMode(originalTouchMode); + island.setAppearance(originalAppearance); } diff --git a/packages/desktop-client/src/preload/island.ts b/packages/desktop-client/src/preload/island.ts index 3f612e9..d017389 100644 --- a/packages/desktop-client/src/preload/island.ts +++ b/packages/desktop-client/src/preload/island.ts @@ -14,6 +14,8 @@ export interface IslandBridge { saveLayout(value: ContractIslandLayoutSettings): Promise; onLayout(handler: (value: ContractIslandLayoutSettings) => void): void; getEditing(): Promise; + getVisible(): Promise; + onVisibility(handler: (visible: boolean) => void): void; setEditing(value: boolean): Promise; onEditing(handler: (value: boolean) => void): void; reportSize(value: { width: number; height: number }): void; @@ -60,6 +62,10 @@ const bridge: IslandBridge = { ipcRenderer.on('island:layout', (_event, value) => handler(value)); }, getEditing: () => ipcRenderer.invoke('island:get-editing'), + getVisible: () => ipcRenderer.invoke('island:get-visible'), + onVisibility: (handler) => { + ipcRenderer.on('island:visibility', (_event, value) => handler(value)); + }, setEditing: (value) => ipcRenderer.invoke('island:set-editing', value), onEditing: (handler) => { ipcRenderer.on('island:editing', (_event, value) => handler(value));