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:
1 parent
30e4c36809
commit
0dcab6f369
15 files changed
+923
-56
No files matched your search
@@ -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} 项通过`);
|
||||
|
||||
Reference in new issue
Block a user