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
@@ -4,6 +4,7 @@ import { registerIpcHandlers } from './ipc.js';
|
||||
import { island, registerIslandIpc } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
import { runSmokeTest } from './smoke.js';
|
||||
import { createTray, destroyTray, isTrayReady } from './tray.js';
|
||||
|
||||
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
|
||||
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
|
||||
@@ -19,6 +20,9 @@ if (smokeProfile) app.setPath('userData', smokeProfile);
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
/** 是否正在退出:用于区分「关闭窗口 = 隐藏到托盘」与「真正退出」 */
|
||||
let isQuitting = false;
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
@@ -106,6 +110,25 @@ if (!gotLock) {
|
||||
logger.error('灵动岛初始化失败(不影响主功能)', error);
|
||||
}
|
||||
|
||||
// 关闭主窗口 → 隐藏到系统托盘(需求 5),只有托盘"退出"才真正结束进程
|
||||
// 注意:必须在冒烟验证之前挂载,否则 close() 会真的销毁窗口
|
||||
mainWindow?.on('close', (event) => {
|
||||
if (isQuitting) return;
|
||||
event.preventDefault();
|
||||
mainWindow?.hide();
|
||||
logger.info('主窗口已隐藏到系统托盘(后台继续运行,左键托盘图标可恢复)');
|
||||
});
|
||||
|
||||
try {
|
||||
createTray({
|
||||
onShow: () => showMainWindow(),
|
||||
onHide: () => mainWindow?.hide(),
|
||||
onQuit: () => quitApp(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
|
||||
if (isSmokeTest) {
|
||||
try {
|
||||
await runSmokeTest(mainWindow);
|
||||
@@ -116,13 +139,63 @@ if (!gotLock) {
|
||||
}
|
||||
});
|
||||
|
||||
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */
|
||||
function showMainWindow(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
mainWindow = createWindow();
|
||||
void loadRenderer(mainWindow);
|
||||
}
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出前的统一资源清理(需求 5:托盘 / 定时器 / 网络连接 / 窗口都不留残留)。
|
||||
* 幂等:托盘退出、before-quit、will-quit 都可能触发。
|
||||
*/
|
||||
function shutdownResources(): string {
|
||||
const done: string[] = [];
|
||||
if (isTrayReady()) {
|
||||
destroyTray();
|
||||
done.push('托盘已销毁');
|
||||
}
|
||||
if (island.isReady()) {
|
||||
island.destroy();
|
||||
done.push('灵动岛已销毁');
|
||||
}
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
// 渲染进程随之结束:其中的 Socket.IO 连接、定时器、IndexedDB 句柄一并释放
|
||||
win.destroy();
|
||||
done.push('窗口已销毁');
|
||||
}
|
||||
}
|
||||
return done.length > 0 ? done.join('、') : '无需要清理的资源';
|
||||
}
|
||||
|
||||
/** 真正退出:先清理资源,再 app.quit,并用 app.exit 兜底(确保不留进程) */
|
||||
function quitApp(): void {
|
||||
if (isQuitting) return;
|
||||
isQuitting = true;
|
||||
logger.info(`正在退出:${shutdownResources()}`);
|
||||
app.quit();
|
||||
setTimeout(() => app.exit(0), 1500);
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
island.destroy();
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
// 有托盘常驻:窗口全关也不退出,继续后台接收通知
|
||||
logger.info('所有窗口已关闭,程序继续在系统托盘后台运行');
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
island.destroy();
|
||||
isQuitting = true;
|
||||
shutdownResources();
|
||||
});
|
||||
|
||||
app.on('will-quit', () => {
|
||||
const summary = shutdownResources();
|
||||
logger.info(`应用退出清理:${summary}`);
|
||||
});
|
||||
|
||||
app.on('activate', async () => {
|
||||
|
||||
Reference in new issue
Block a user