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
@@ -1,7 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app, safeStorage } from 'electron';
|
||||
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
|
||||
import { DEFAULT_ISLAND_APPEARANCE, DEFAULT_SERVER_URL, type IslandAppearance } from '@classhelper/shared';
|
||||
import type { DesktopStoredConfig } from '../types/desktop.js';
|
||||
|
||||
interface PersistedConfig {
|
||||
@@ -10,6 +10,8 @@ interface PersistedConfig {
|
||||
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
|
||||
token: string | null;
|
||||
tokenEncrypted: boolean;
|
||||
/** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
||||
island?: Partial<IslandAppearance>;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PersistedConfig = {
|
||||
@@ -17,8 +19,35 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
||||
username: '',
|
||||
token: null,
|
||||
tokenEncrypted: false,
|
||||
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
||||
};
|
||||
|
||||
/** 外观字段的合法区间校验(越界值直接夹紧,避免用户配置破坏灵动岛布局) */
|
||||
function normalizeIslandAppearance(input?: Partial<IslandAppearance>): IslandAppearance {
|
||||
const clamp = (value: unknown, min: number, max: number, fallback: number): number => {
|
||||
const numeric = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(numeric * 100) / 100));
|
||||
};
|
||||
const positions: IslandAppearance['position'][] = ['top-center', 'top-left', 'top-right', 'bottom-center'];
|
||||
return {
|
||||
height: clamp(input?.height, 36, 72, DEFAULT_ISLAND_APPEARANCE.height),
|
||||
width: clamp(input?.width, 220, 420, DEFAULT_ISLAND_APPEARANCE.width),
|
||||
radius: clamp(input?.radius, 8, 32, DEFAULT_ISLAND_APPEARANCE.radius),
|
||||
opacity: clamp(input?.opacity, 0.4, 1, DEFAULT_ISLAND_APPEARANCE.opacity),
|
||||
accent:
|
||||
typeof input?.accent === 'string' && /^#[0-9a-fA-F]{6}$/.test(input.accent)
|
||||
? input.accent
|
||||
: DEFAULT_ISLAND_APPEARANCE.accent,
|
||||
fontSize: clamp(input?.fontSize, 11, 18, DEFAULT_ISLAND_APPEARANCE.fontSize),
|
||||
animations: input?.animations !== false,
|
||||
speed: clamp(input?.speed, 0.5, 2, DEFAULT_ISLAND_APPEARANCE.speed),
|
||||
position: positions.includes(input?.position as IslandAppearance['position'])
|
||||
? (input?.position as IslandAppearance['position'])
|
||||
: DEFAULT_ISLAND_APPEARANCE.position,
|
||||
alwaysOnTop: input?.alwaysOnTop !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function configFilePath(): string {
|
||||
return path.join(app.getPath('userData'), 'config.json');
|
||||
}
|
||||
@@ -57,6 +86,7 @@ function readPersisted(): PersistedConfig {
|
||||
username: typeof parsed.username === 'string' ? parsed.username : '',
|
||||
token: typeof parsed.token === 'string' ? parsed.token : null,
|
||||
tokenEncrypted: parsed.tokenEncrypted === true,
|
||||
island: normalizeIslandAppearance(parsed.island),
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
@@ -76,6 +106,7 @@ export function getConfig(): DesktopStoredConfig {
|
||||
serverUrl: persisted.serverUrl,
|
||||
username: persisted.username,
|
||||
token: decryptToken(persisted),
|
||||
island: normalizeIslandAppearance(persisted.island),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +120,9 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
||||
if (typeof patch.username === 'string') {
|
||||
persisted.username = patch.username;
|
||||
}
|
||||
if (patch.island !== undefined) {
|
||||
persisted.island = normalizeIslandAppearance({ ...persisted.island, ...patch.island });
|
||||
}
|
||||
if (patch.token !== undefined) {
|
||||
const encrypted = encryptToken(patch.token);
|
||||
persisted.token = encrypted.token;
|
||||
|
||||
Reference in new issue
Block a user