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

+166 -3
View File
@@ -5,6 +5,7 @@ import type { BrowserWindow, NativeImage } from 'electron';
import type { IslandNotification } from '@classhelper/shared';
import { getDiagnostics } from './ipc.js';
import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
interface SmokeResult {
name: string;
@@ -175,7 +176,11 @@ function makeNotification(
* 5. 点击卡片空白处 / 右上角收起按钮 → 回缩为胶囊
* 6. 上课时间段内点击胶囊不会展开(上课不显示灵动岛)
*/
async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promise<void> {
async function runIslandChecks(
mainWin: BrowserWindow,
record: Recorder,
results: SmokeResult[],
): Promise<void> {
record('灵动岛窗口已创建(置顶/透明/不占任务栏)', island.isReady(), `ready=${island.isReady()}`);
if (!island.isReady()) return;
@@ -449,7 +454,13 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
},
{ inClass: true },
);
await sleep(900);
// 采样状态轨迹,便于定位"出现了又被隐藏"这类时序问题
const callTrace: string[] = [];
for (let index = 0; index < 9; index += 1) {
await sleep(100);
const snapshot = island.getState();
callTrace.push(`${snapshot.mode}/${snapshot.reason ?? '-'}`);
}
const callState = island.getState();
const callDom = await islandWindow?.webContents.executeJavaScript(
`(() => ({
@@ -525,6 +536,126 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
`紧急态偏红=${urgentReddish} 最外圈偏红=${edgeReddish}(要求 0)`,
);
// 8) 个性化外观:高度/宽度/圆角/字号/透明度/动画速度实时生效,且不破坏布局
const appearanceBefore = island.getAppearance();
island.setAppearance({ height: 56, width: 300, radius: 26, fontSize: 14, opacity: 0.92, speed: 1.5 });
island.handleAction({ action: 'dismiss' });
await sleep(300);
island.pushNotification(makeNotification('smoke-appearance', 'NORMAL', '外观设置校验'), {
inClass: false,
});
await sleep(600);
const pillBounds = islandWindow?.getBounds();
const cssVars = await islandWindow?.webContents.executeJavaScript(
`(() => {
const style = getComputedStyle(document.documentElement);
const card = document.querySelector('.island-card');
return {
h: style.getPropertyValue('--island-h').trim(),
w: style.getPropertyValue('--island-w').trim(),
radius: style.getPropertyValue('--island-radius').trim(),
font: style.getPropertyValue('--island-font').trim(),
accent: style.getPropertyValue('--island-accent').trim(),
cardHeight: card ? Math.round(card.getBoundingClientRect().height) : 0,
};
})()`,
);
record(
'个性化外观生效(高度/宽度/圆角/字号 → 窗口与卡片同步)',
pillBounds?.width === 300 &&
pillBounds?.height === 56 &&
cssVars?.h === '56px' &&
cssVars?.w === '300px' &&
cssVars?.radius === '26px' &&
cssVars?.font === '14px',
`窗口=${pillBounds?.width ?? '-'}x${pillBounds?.height ?? '-'} CSS变量 h=${cssVars?.h} w=${cssVars?.w} ` +
`圆角=${cssVars?.radius} 字号=${cssVars?.font} 卡片高=${cssVars?.cardHeight}`,
);
// 展开后内容不溢出(高度调大也不能出现内容超出卡片)
island.handleAction({ action: 'expand' });
await sleep(700);
const overflowCheck = await islandWindow?.webContents.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card.expanded');
if (!card) return null;
return {
overflow: card.scrollHeight - card.clientHeight,
width: Math.round(card.getBoundingClientRect().width),
height: Math.round(card.getBoundingClientRect().height),
};
})()`,
);
record(
'调整尺寸后内容不溢出',
Boolean(overflowCheck) && overflowCheck.overflow <= 1,
`卡片=${overflowCheck?.width ?? '-'}x${overflowCheck?.height ?? '-'} 溢出=${overflowCheck?.overflow ?? '-'}px`,
);
// 9) 抖动修复(需求 4):只量"收回"过程
// - 窗口宽度必须单调收缩(由主进程 rAF 循环驱动,不回跳)
// - 卡片布局尺寸用 offsetWidth/offsetTop(不含 CSS transform),只允许在"详情/胶囊"两种固定值间切换
island.setAppearance(appearanceBefore);
await sleep(300);
island.handleAction({ action: 'expand' });
await sleep(700);
const jitterSamples: { win: number; cardWidth: number; cardTop: number }[] = [];
const jitterSampler = setInterval(() => {
const winWidth = islandWindow?.getBounds().width ?? 0;
void islandWindow?.webContents
.executeJavaScript(
`(() => {
const card = document.querySelector('.island-card');
return card ? { width: card.offsetWidth, top: card.offsetTop } : null;
})()`,
)
.then((value: { width: number; top: number } | null) => {
if (value) jitterSamples.push({ win: winWidth, cardWidth: value.width, cardTop: value.top });
})
.catch(() => undefined);
}, 30);
island.handleAction({ action: 'collapse' });
await sleep(800);
clearInterval(jitterSampler);
const winSequence = jitterSamples.map((item) => item.win);
const monotonicShrink = winSequence.every(
(width, index) => index === 0 || width <= (winSequence[index - 1] ?? width) + 1,
);
const cardTops = jitterSamples.map((item) => item.cardTop);
const topSpread = cardTops.length > 0 ? Math.max(...cardTops) - Math.min(...cardTops) : 99;
const cardWidths = [...new Set(jitterSamples.map((item) => item.cardWidth))];
const expectedWidths = [ISLAND_SIZES.expanded.width - 8, ISLAND_SIZES.pill.width - 8];
const widthOk = cardWidths.every((width) => expectedWidths.includes(width));
record(
'收回过程不抖动(窗口宽度单调收缩 + 卡片布局尺寸不拉伸)',
jitterSamples.length >= 4 && monotonicShrink && topSpread <= 1 && widthOk && cardWidths.length <= 2,
`采样=${jitterSamples.length} 窗口序列=[${winSequence.join(',')}] 单调收缩=${monotonicShrink} ` +
`卡片上边缘波动=${topSpread}px 卡片宽度集合=[${cardWidths.join(',')}](期望 ${expectedWidths.join('/')})`,
);
// 10) 连续快速开合:最终状态与尺寸必须稳定
for (let index = 0; index < 5; index += 1) {
island.handleAction({ action: 'expand' });
await sleep(45);
island.handleAction({ action: 'collapse' });
await sleep(45);
}
await sleep(600);
const rapidState = island.getState();
const rapidBounds = islandWindow?.getBounds();
const currentAppearance = island.getAppearance();
record(
'连续快速展开/收起后状态与尺寸稳定',
rapidState.mode === 'pill' &&
rapidBounds?.width === currentAppearance.width &&
rapidBounds?.height === currentAppearance.height,
`mode=${rapidState.mode} 窗口=${rapidBounds?.width ?? '-'}x${rapidBounds?.height ?? '-'}` +
`(期望 ${currentAppearance.width}x${currentAppearance.height})`,
);
island.setAppearance(appearanceBefore);
await sleep(300);
// 收尾:隐藏灵动岛,避免影响后续用例
island.handleAction({ action: 'dismiss' });
await sleep(300);
@@ -626,6 +757,35 @@ async function runIslandRealtimeCheck(
* 结果同时输出到 stdout,并在设置 ELECTRON_SMOKE_RESULT 时写入 JSON 文件 ——
* 打包后的 Windows GUI 进程没有 stdout,用结果文件才能在 CI/脚本里验证安装包。
*/
/**
* 托盘与退出清理校验(需求 5)。
* 这两个用例会隐藏主窗口并销毁托盘/灵动岛,因此必须放在所有依赖它们的用例之后。
*/
async function runTrayAndShutdownChecks(mainWin: BrowserWindow, record: Recorder): Promise<void> {
// 11) 关闭到托盘:窗口关闭后进程存活、托盘仍在;清理后资源归零
const trayBefore = getTrayState();
mainWin.close();
await sleep(500);
const afterClose = { visible: mainWin.isVisible(), destroyed: mainWin.isDestroyed(), alive: app.isReady() };
record(
'关闭窗口隐藏到托盘(进程继续后台运行)',
trayBefore.ready === true &&
afterClose.visible === false &&
afterClose.destroyed === false &&
afterClose.alive === true,
`托盘就绪=${trayBefore.ready} 有图标=${trayBefore.hasIcon} 关闭后 visible=${afterClose.visible} destroyed=${afterClose.destroyed} 进程存活=${afterClose.alive}`,
);
destroyTray();
island.destroy();
await sleep(200);
record(
'退出前资源清理(托盘/灵动岛已释放)',
isTrayReady() === false && island.isReady() === false,
`托盘=${isTrayReady()} 灵动岛=${island.isReady()}`,
);
}
export async function runSmokeTest(win: BrowserWindow): Promise<void> {
const results: SmokeResult[] = [];
const record = (name: string, ok: boolean, detail = ''): void => {
@@ -744,7 +904,7 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? ''));
// ---------------------------------------------------------------- 灵动岛
await runIslandChecks(record, results);
await runIslandChecks(win, record, results);
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
@@ -797,6 +957,9 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
record('冒烟收尾:清理登录态', Boolean(cleanup?.ok), String(cleanup?.detail ?? ''));
}
// 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛
await runTrayAndShutdownChecks(win, record);
const failed = results.filter((item) => !item.ok);
const passed = results.length - failed.length;
console.log(`\n[SMOKE] ${passed}/${results.length} 项通过`);