feat(client): 灵动岛个性化设置(需求 2)、形变抖动修复(需求 4)、关闭到系统托盘与退出清理(需求 5)

## 需求 2:设置页个性化(实时生效 + 持久化)

- 新增 `IslandAppearance`(shared 类型)+ `DEFAULT_ISLAND_APPEARANCE` / `ISLAND_APPEARANCE_RANGES` /
  `ISLAND_POSITIONS`:高度 36–72、宽度 220–420、圆角 8–32、不透明度 0.4–1、字号 11–18、
  动画速度 0.5–2、主题色、显示位置(顶部居中/左上/右上/底部居中)、动画开关、始终置顶。
- 客户端 `config.json` 增加 `island` 字段(`normalizeIslandAppearance` 夹紧越界值;
  旧配置无该字段时自动补默认值,向后兼容);`SettingsView` 新增「灵动岛 · 个性化」面板:
  滑块/取色器/开关/位置选择 + 「保存设置 / 预览效果 / 恢复默认」,拖动即实时预览。
- 主进程 `island.setAppearance()`:窗口尺寸按外观联动(展开卡按差值同步放大,内容用 flex + 溢出滚动承载)、
  透明度、置顶、动画开关与速度、停靠位置实时生效,并把外观通过 `island:appearance` 下发给灵动岛渲染进程;
  渲染进程用 CSS 变量(`--island-w/-h/-radius/-font/-accent`)驱动卡片尺寸、圆角、字号与强调色。

## 需求 4:形变抖动修复

- 窗口尺寸动画改为**单条帧循环**(优先 `requestAnimationFrame`,主进程无该全局时退化为 ~60fps 定时器);
  新动画取消上一条并作废令牌;结束对齐最终尺寸(消除 1px 累积误差);**展开动画结束后才取焦点**。
- 卡片保持固定尺寸 + 顶部居中锚定(此前已修),只让窗口露出/裁切,形变时内容不参与重排。
- 冒烟新增断言:收回过程窗口宽度**单调收缩**(实测 362→330→305→288→274→270→268…)、
  卡片上边缘波动 **0px**、卡片布局宽度只取 `396/260` 两个固定值;连续 5 次快速开合后状态与尺寸稳定。

## 需求 5:关闭到系统托盘 + 退出清理

- 新增 `main/tray.ts`:托盘图标(`public/tray.png`,`pnpm icons` 会自动同步)、右键菜单
  「显示主窗口 / 隐藏到托盘 / 退出班级小助手」、左键单击/双击恢复窗口。
- `main/index.ts`:`close` → `preventDefault + hide`(关闭窗口不退出,后台继续接收通知,日志留痕);
  `window-all-closed` 不再退出;`quitApp()` 与 `before-quit`/`will-quit` 统一走
  `shutdownResources()`:销毁托盘 → 销毁灵动岛(帧循环/定时器)→ 销毁全部窗口
  (渲染进程随之释放 Socket.IO 连接与 IndexedDB 句柄)→ `app.quit()` + `app.exit(0)` 兜底。
- 冒烟新增断言:关闭后 `visible=false / destroyed=false / 进程存活=true`;清理后 `托盘=false / 灵动岛=false`。

## 验证

- `pnpm verify:desktop` **40/40** ✅(新增 6 项:个性化外观生效、调整尺寸后内容不溢出、收回不抖动、
  快速开合稳定、关闭到托盘、退出前资源清理)
- `pnpm verify:e2e` **82/82** ✅、`pnpm verify:web` **17/17** ✅(回归无破坏)
- `pnpm lint` / `format:check` / `typecheck` 全绿
- 顺带修复:托盘/关闭处理器此前挂在冒烟验证之后,导致冒烟模式关闭窗口会真的销毁窗口
  (现已调整为在冒烟前挂载);`smoke.mjs` 配置目录按进程号隔离,避免上次异常退出的目录占用导致启动失败。
This commit is contained in:
LoveChengke committed 2026-09-12 15:19:13 +08:00
1 parent 30e4c36809
commit 0dcab6f369
15 files changed
+923 -56

No files matched your search

@@ -1,6 +1,8 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import {
DEFAULT_ISLAND_APPEARANCE,
type IslandAppearance,
PRIORITY_LABELS,
formatDate,
type IslandNotification,
@@ -111,12 +113,31 @@ function openApp(): void {
bridge?.sendAction('open-app');
}
/** 个性化外观:主进程下发后写入 CSS 变量,卡片尺寸/圆角/字号/主题色随之实时变化 */
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
function applyAppearance(next: IslandAppearance | null | undefined): void {
if (!next) return;
appearance.value = next;
const style = document.documentElement.style;
style.setProperty('--island-accent', next.accent);
style.setProperty('--island-radius', `${next.radius}px`);
style.setProperty('--island-font', `${next.fontSize}px`);
style.setProperty('--island-w', `${next.width}px`);
style.setProperty('--island-h', `${next.height}px`);
style.setProperty('--island-anim', next.animations ? '1' : '0');
}
onMounted(async () => {
bridge?.onState((next) => {
state.value = next;
});
const initial = await bridge?.getState();
if (initial) state.value = initial;
// 外观:先同步一次,再订阅后续改动
bridge?.onAppearance((next) => applyAppearance(next));
const initialAppearance = await bridge?.getAppearance?.();
applyAppearance(initialAppearance);
});
</script>
@@ -260,6 +281,13 @@ body,
background: transparent;
overflow: hidden;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif;
/* 个性化外观默认值(主进程会通过 island:appearance 覆盖这些变量) */
--island-accent: #6cc4ff;
--island-radius: 20px;
--island-font: 13px;
--island-w: 268px;
--island-h: 44px;
font-size: var(--island-font);
-webkit-user-select: none;
user-select: none;
}
@@ -298,30 +326,30 @@ body,
/* 各形态固定尺寸(与主进程 ISLAND_SIZES 对齐:卡片刻意比窗口小 4~8px,四周留 2~4px 透明边) */
.island-card.pill {
left: calc(50% - 130px);
width: 260px;
height: 38px;
border-radius: 20px;
left: calc(50% - var(--island-w) / 2 + 4px);
width: calc(var(--island-w) - 8px);
height: calc(var(--island-h) - 6px);
border-radius: var(--island-radius);
}
.island-card.expanded {
left: calc(50% - 198px);
width: 396px;
height: 308px;
border-radius: 24px;
left: calc(50% - (var(--island-w) + 136px) / 2 + 4px);
width: calc(var(--island-w) + 128px);
height: calc(var(--island-h) + 264px);
border-radius: var(--island-radius);
}
.island-card.expanded.urgent {
left: calc(50% - 208px);
width: 416px;
height: 336px;
left: calc(50% - (var(--island-w) + 156px) / 2 + 4px);
width: calc(var(--island-w) + 148px);
height: calc(var(--island-h) + 292px);
}
/* 叫人:卡片略大(对应主进程 CALL_SIZE 440x360) */
.island-card.expanded.call {
left: calc(50% - 216px);
width: 432px;
height: 352px;
left: calc(50% - (var(--island-w) + 172px) / 2 + 4px);
width: calc(var(--island-w) + 164px);
height: calc(var(--island-h) + 308px);
}
/* 隐藏态占位:只保留结构,不占视觉空间 */
@@ -457,8 +485,8 @@ body,
width: 26px;
height: 26px;
border-radius: 50%;
color: #9fd0ff;
background: rgba(79, 172, 254, 0.14);
color: var(--island-accent);
background: color-mix(in srgb, var(--island-accent) 16%, transparent);
flex: 0 0 auto;
}
@@ -664,7 +692,7 @@ body,
.more {
font-size: 11px;
color: #9fd0ff;
color: var(--island-accent);
}
.more.muted {
@@ -702,7 +730,7 @@ body,
.solid-btn {
color: #06263f;
background: #6cc4ff;
background: var(--island-accent);
font-weight: 650;
}