Files
ClassHelper/packages/desktop-client/src/main/tray.ts
T
LoveChengke 0dcab6f369 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` 配置目录按进程号隔离,避免上次异常退出的目录占用导致启动失败。
2026-09-12 15:19:13 +08:00

77 lines
2.3 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import { Menu, Tray, app, nativeImage } from 'electron';
import { logger } from './logger.js';
/**
* 系统托盘(需求 5):关闭主窗口不退出程序,而是隐藏到托盘后台运行;
* 托盘图标可恢复窗口,并提供"退出"入口(退出前统一清理资源)。
*/
let tray: Tray | null = null;
export interface TrayHandlers {
onShow(): void;
onHide(): void;
onQuit(): void;
}
/** 托盘图标:优先用打包进 dist/renderer 的 tray.png,其次资源目录 */
function resolveTrayIcon(): string | null {
const candidates = [
path.join(__dirname, '../renderer/tray.png'),
path.join(process.resourcesPath ?? '', 'tray.png'),
];
for (const candidate of candidates) {
if (candidate && fs.existsSync(candidate)) return candidate;
}
return null;
}
export function createTray(handlers: TrayHandlers): Tray | null {
if (tray && !tray.isDestroyed()) return tray;
const iconPath = resolveTrayIcon();
const image = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
tray = new Tray(image);
tray.setToolTip('班级小助手(后台运行中)');
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => handlers.onShow() },
{ label: '隐藏到托盘', click: () => handlers.onHide() },
{ type: 'separator' },
{ label: '退出班级小助手', click: () => handlers.onQuit() },
]),
);
// 左键单击/双击托盘图标:恢复窗口(用户习惯)
tray.on('click', () => handlers.onShow());
tray.on('double-click', () => handlers.onShow());
logger.info(iconPath ? `系统托盘已就绪:${iconPath}` : '系统托盘已就绪(未找到图标文件,使用空图标)');
return tray;
}
export function destroyTray(): void {
if (!tray) return;
try {
tray.destroy();
} catch {
/* 忽略销毁异常 */
}
tray = null;
logger.info('系统托盘已销毁');
}
export function isTrayReady(): boolean {
return Boolean(tray && !tray.isDestroyed());
}
/** 供"关于/设置"展示(也可用于冒烟断言) */
export function getTrayState(): { ready: boolean; hasIcon: boolean; appPath: string } {
return {
ready: isTrayReady(),
hasIcon: Boolean(resolveTrayIcon()),
appPath: app.getPath('exe'),
};
}