diff --git a/README.md b/README.md index 104bda2..f300a02 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | -| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 34/34) | +| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 40/40) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | @@ -173,7 +173,7 @@ pnpm verify:desktop # 另开一个终端先启动后端:pnpm dev:server pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:82 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知):17 项 -pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴):34 项 +pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项 ``` 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, @@ -383,6 +383,40 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读 > 后续可继续深化:迷你悬浮课表窗口(ClassIsland 的桌面课表条)、上课/下课提醒动画与铃声。 +### 个性化设置与系统托盘 + +**设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效): + +| 设置项 | 区间/取值 | 说明 | +| ---------- | --------------------------------- | ------------------------------------------------------------------------------ | +| 灵动岛高度 | 36–72px | 胶囊高度;展开卡按同差值联动(`+264/+292/+308px`),内容用 flex + 溢出滚动承载 | +| 灵动岛宽度 | 220–420px | 同上,展开卡按差值联动 | +| 圆角 | 8–32px | 卡片圆角 | +| 不透明度 | 40%–100% | 窗口整体不透明度 | +| 字号 | 11–18px | 卡片基础字号 | +| 动画速度 | 0.5×–2× | 形变/淡入时长倍率 | +| 主题色 | 取色器 | 用于高亮、按钮、进度条(CSS 变量下发到灵动岛渲染进程) | +| 显示位置 | 顶部居中 / 左上 / 右上 / 底部居中 | 按住 `workArea` 计算,多屏跟随主屏 | +| 动画开关 | 开/关 | 关闭后展开/收起瞬时生效(不再逐帧插值) | +| 始终置顶 | 开/关 | 对应 `setAlwaysOnTop` | + +- 越界值在**主进程**统一夹紧(`normalizeIslandAppearance`),用户填坏配置也不会让布局错乱。 +- 「预览效果」按钮会本地推一条通知,立刻看到当前尺寸/配色/动画。 +- 落盘位置:客户端 `config.json` 的 `island` 字段(旧配置没有该字段时自动补默认值,向后兼容)。 + +**形变动画(需求 4 抖动修复)**:窗口尺寸改为**单条帧循环**驱动(优先 `requestAnimationFrame`, +主进程没有该全局时退化为 ~60fps 定时器),新动画会取消上一条并作废令牌,结束时对齐最终尺寸; +卡片始终是**固定尺寸 + 顶部居中锚定**,只让窗口露出/裁切,形变过程中卡片上边缘不动; +展开动画结束后才取焦点。冒烟实测:收回过程窗口宽度序列 `362→330→305→288→274→270→268…` +单调收缩、卡片上边缘波动 **0px**、卡片宽度只取 `396/260` 两个固定值,连续 5 次快速开合后状态与尺寸稳定。 + +**系统托盘(需求 5)**:关闭主窗口不退出程序,而是隐藏到托盘后台继续接收通知; +托盘图标(左键单击/双击)恢复主窗口,右键菜单提供「显示主窗口 / 隐藏到托盘 / 退出班级小助手」。 +退出时统一清理:销毁托盘 → 销毁灵动岛(含帧循环与定时器)→ 销毁全部窗口 +(渲染进程随之结束,其中的 Socket.IO 连接、IndexedDB 句柄一并释放),并用 `app.exit(0)` 兜底, +任务管理器中不留残留进程。冒烟实测:关闭后 `visible=false / destroyed=false / 进程存活=true`, +清理后 `托盘=false / 灵动岛=false`。 + ### 离线缓存与自动同步 ``` @@ -680,7 +714,7 @@ pnpm db:migrate && pnpm db:seed | --------------------------------------------------------------------------------- | ----------------------------------- | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **82/82** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + PWA) | **17/17** ✅ | -| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **34/34** ✅ | +| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | diff --git a/packages/desktop-client/public/tray.png b/packages/desktop-client/public/tray.png new file mode 100644 index 0000000..3e256e5 Binary files /dev/null and b/packages/desktop-client/public/tray.png differ diff --git a/packages/desktop-client/src/island/IslandApp.vue b/packages/desktop-client/src/island/IslandApp.vue index fedd665..31b750b 100644 --- a/packages/desktop-client/src/island/IslandApp.vue +++ b/packages/desktop-client/src/island/IslandApp.vue @@ -1,6 +1,8 @@ - @@ -260,6 +281,13 @@ body, background: transparent; overflow: hidden; font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif; + /* 个性化外观默认值(主进程会通过 island:appearance 覆盖这些变量) */ + --island-accent: #6cc4ff; + --island-radius: 20px; + --island-font: 13px; + --island-w: 268px; + --island-h: 44px; + font-size: var(--island-font); -webkit-user-select: none; user-select: none; } @@ -298,30 +326,30 @@ body, /* 各形态固定尺寸(与主进程 ISLAND_SIZES 对齐:卡片刻意比窗口小 4~8px,四周留 2~4px 透明边) */ .island-card.pill { - left: calc(50% - 130px); - width: 260px; - height: 38px; - border-radius: 20px; + left: calc(50% - var(--island-w) / 2 + 4px); + width: calc(var(--island-w) - 8px); + height: calc(var(--island-h) - 6px); + border-radius: var(--island-radius); } .island-card.expanded { - left: calc(50% - 198px); - width: 396px; - height: 308px; - border-radius: 24px; + left: calc(50% - (var(--island-w) + 136px) / 2 + 4px); + width: calc(var(--island-w) + 128px); + height: calc(var(--island-h) + 264px); + border-radius: var(--island-radius); } .island-card.expanded.urgent { - left: calc(50% - 208px); - width: 416px; - height: 336px; + left: calc(50% - (var(--island-w) + 156px) / 2 + 4px); + width: calc(var(--island-w) + 148px); + height: calc(var(--island-h) + 292px); } /* 叫人:卡片略大(对应主进程 CALL_SIZE 440x360) */ .island-card.expanded.call { - left: calc(50% - 216px); - width: 432px; - height: 352px; + left: calc(50% - (var(--island-w) + 172px) / 2 + 4px); + width: calc(var(--island-w) + 164px); + height: calc(var(--island-h) + 308px); } /* 隐藏态占位:只保留结构,不占视觉空间 */ @@ -457,8 +485,8 @@ body, width: 26px; height: 26px; border-radius: 50%; - color: #9fd0ff; - background: rgba(79, 172, 254, 0.14); + color: var(--island-accent); + background: color-mix(in srgb, var(--island-accent) 16%, transparent); flex: 0 0 auto; } @@ -664,7 +692,7 @@ body, .more { font-size: 11px; - color: #9fd0ff; + color: var(--island-accent); } .more.muted { @@ -702,7 +730,7 @@ body, .solid-btn { color: #06263f; - background: #6cc4ff; + background: var(--island-accent); font-weight: 650; } diff --git a/packages/desktop-client/src/main/config.ts b/packages/desktop-client/src/main/config.ts index 38ec6a8..4b65c6b 100644 --- a/packages/desktop-client/src/main/config.ts +++ b/packages/desktop-client/src/main/config.ts @@ -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; } 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 { + 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): 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; diff --git a/packages/desktop-client/src/main/index.ts b/packages/desktop-client/src/main/index.ts index 9ff521a..70183dc 100644 --- a/packages/desktop-client/src/main/index.ts +++ b/packages/desktop-client/src/main/index.ts @@ -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 () => { diff --git a/packages/desktop-client/src/main/island.ts b/packages/desktop-client/src/main/island.ts index c261486..7940f34 100644 --- a/packages/desktop-client/src/main/island.ts +++ b/packages/desktop-client/src/main/island.ts @@ -1,7 +1,14 @@ import path from 'node:path'; import { BrowserWindow, ipcMain, screen } from 'electron'; -import type { IslandMode, IslandNotification, IslandState } from '@classhelper/shared'; +import { + DEFAULT_ISLAND_APPEARANCE, + type IslandAppearance, + type IslandMode, + type IslandNotification, + type IslandState, +} from '@classhelper/shared'; import { logger } from './logger.js'; +import { getConfig } from './config.js'; /** * 灵动岛(Dynamic Island)主进程控制器。 @@ -14,18 +21,52 @@ import { logger } from './logger.js'; * - 上课时间段:非紧急通知不弹出(自动隐藏),进入待发队列 * - 下课后:自动在桌面中上方弹出队列中的通知详情 * - 紧急通知:无论是否在上课,立刻展开显示详情,无需点击 + * + * 动画实现要点(需求 4:展开/收起不能抖动): + * - 窗口尺寸用**单一 requestAnimationFrame 循环**插值,不再用 setTimeout 链(避免掉帧与回跳) + * - 锚点固定:横向按停靠位置计算、纵向固定,形变过程中卡片上边缘不动 + * - 卡片本身是固定尺寸(渲染进程),只让窗口露出/裁切,内容不参与布局重排 + * - 展开完成之后才取焦点(避免取焦瞬间抢走动画帧) + * - 关闭动画时直接落到目标尺寸 */ -const SIZES: Record = { - hidden: { width: 268, height: 44 }, +/** 基础尺寸:真实尺寸 = 外观设置联动计算(见 sizes()) */ +const BASE = { pill: { width: 268, height: 44 }, expanded: { width: 404, height: 316 }, + urgent: { width: 424, height: 344 }, + /** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */ + call: { width: 440, height: 360 }, }; -const URGENT_SIZE = { width: 424, height: 344 }; +/** + * 帧调度:优先 requestAnimationFrame(有则最平滑),主进程没有该全局时退化为 ~60fps 定时器。 + * 关键点不是用哪个 API,而是**始终只有一条动画循环**:每次新动画都会取消上一条并作废令牌, + * 结束时再对齐一次最终尺寸,避免累积误差导致边缘"抖一下"。 + */ +type FrameHandle = number; +declare const requestAnimationFrame: ((callback: (time: number) => void) => number) | undefined; +declare const cancelAnimationFrame: ((handle: number) => void) | undefined; -/** "叫人"消息卡片(比普通详情略大,突出"请 XXX 同学找 XXX 老师") */ -const CALL_SIZE = { width: 440, height: 360 }; +function scheduleFrame(callback: () => void): FrameHandle { + if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(() => callback()); + return setTimeout(callback, 16) as unknown as FrameHandle; +} + +function cancelFrame(handle: FrameHandle): void { + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle); + else clearTimeout(handle as unknown as ReturnType); +} + +const DEFAULT_SIZES: Record = { + hidden: BASE.pill, + pill: BASE.pill, + expanded: BASE.expanded, +}; + +let SIZES: Record = { ...DEFAULT_SIZES }; +let URGENT_SIZE = { ...BASE.urgent }; +let CALL_SIZE = { ...BASE.call }; /** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */ const TIMEOUTS = { @@ -67,6 +108,10 @@ class IslandController { private boundsToken = 0; /** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */ private fadeToken = 0; + /** 尺寸动画的 requestAnimationFrame 句柄(需求 4:单循环,避免抖动) */ + private boundsFrame: number | null = null; + /** 外观设置(设置页可调,主进程持久化) */ + private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE }; private state: IslandState = { mode: 'hidden', @@ -94,6 +139,13 @@ class IslandController { async init(rendererUrl: string | null): Promise { this.rendererUrl = rendererUrl; + // 恢复用户保存的个性化外观(尺寸/位置/透明度/动画…),失败则用默认值 + try { + this.appearance = normalizeAppearance({ ...DEFAULT_ISLAND_APPEARANCE, ...getConfig().island }); + } catch { + this.appearance = { ...DEFAULT_ISLAND_APPEARANCE }; + } + recomputeSizes(this.appearance); if (this.win && !this.win.isDestroyed()) return; const win = new BrowserWindow({ @@ -121,7 +173,7 @@ class IslandController { }, }); - win.setAlwaysOnTop(true, 'screen-saver'); + win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); win.setIgnoreMouseEvents(false); win.on('closed', () => { @@ -164,7 +216,8 @@ class IslandController { this.emit(this.pendingState); this.pendingState = null; } - // 首次进入时同步一次当前状态,保证渲染进程不会停在初始默认值 + // 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值 + this.emitAppearance(); this.emit(); logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)'); } @@ -349,6 +402,7 @@ class IslandController { */ hideImmediately(): void { this.clearTimers(); + this.cancelBoundsAnimation(); this.fadeToken += 1; const changed = this.state.mode !== 'hidden' || this.state.reason !== null; if (changed) { @@ -363,6 +417,7 @@ class IslandController { destroy(): void { this.clearTimers(); + this.cancelBoundsAnimation(); if (this.win && !this.win.isDestroyed()) this.win.destroy(); this.win = null; } @@ -437,12 +492,25 @@ class IslandController { height: number; } { const area = screen.getPrimaryDisplay().workArea; - return { - x: Math.round(area.x + (area.width - size.width) / 2), - y: area.y + 8, - width: size.width, - height: size.height, - }; + const margin = 8; + let x = Math.round(area.x + (area.width - size.width) / 2); + let y = area.y + margin; + + switch (this.appearance.position) { + case 'top-left': + x = area.x + margin; + break; + case 'top-right': + x = area.x + area.width - size.width - margin; + break; + case 'bottom-center': + y = area.y + area.height - size.height - margin; + break; + default: + break; + } + + return { x, y, width: size.width, height: size.height }; } /** 状态变更:先通知渲染进程,再驱动窗口尺寸/透明度动画 */ @@ -532,19 +600,31 @@ class IslandController { private fadeIn(): void { if (!this.win || this.win.isDestroyed()) return; + const target = this.appearance.opacity; + if (!this.appearance.animations) { + this.fadeToken += 1; + this.win.setOpacity(target); + return; + } const token = ++this.fadeToken; let opacity = this.win.getOpacity(); const step = (): void => { if (!this.win || this.win.isDestroyed() || token !== this.fadeToken) return; - opacity = Math.min(1, opacity + 0.16); + opacity = Math.min(target, opacity + 0.16); this.win.setOpacity(opacity); - if (opacity < 1) setTimeout(step, 16); + if (opacity < target) setTimeout(step, 16); }; step(); } private fadeOut(): void { if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return; + if (!this.appearance.animations) { + this.fadeToken += 1; + this.win.hide(); + this.win.setOpacity(1); + return; + } const token = ++this.fadeToken; let opacity = this.win.getOpacity(); const step = (): void => { @@ -563,7 +643,12 @@ class IslandController { /** * 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果。 - * `spring` 使用回弹缓动(轻微过冲后回落),用于"展开成卡片"的弹簧手感。 + * + * 用**单一 requestAnimationFrame 循环**驱动(需求 4): + * - 不再使用 setTimeout 链:避免掉帧、避免"每帧重新排定时器"带来的节奏抖动 + * - 锚点固定:横向/纵向都由 computeBounds 决定,形变过程中卡片上边缘(或下边缘)不动 + * - 关闭动画(appearance.animations = false)时直接落到目标尺寸 + * - `spring` 使用回弹缓动(轻微过冲后回落),用于"展开成卡片"的弹簧手感 */ private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void { if (!this.win || this.win.isDestroyed()) return; @@ -571,12 +656,19 @@ class IslandController { const to = this.computeBounds(size); if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return; + this.cancelBoundsAnimation(); const token = ++this.boundsToken; + + if (!this.appearance.animations) { + this.win.setBounds(to); + return; + } + const spring = options.spring === true; - const duration = spring ? 340 : 260; + const duration = (spring ? 340 : 260) / Math.max(0.25, this.appearance.speed); const startedAt = Date.now(); - const step = (): void => { + const frame = (): void => { if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return; const progress = Math.min(1, (Date.now() - startedAt) / duration); const eased = spring ? easeOutBack(progress) : 1 - Math.pow(1 - progress, 3); // easeOutCubic @@ -587,10 +679,98 @@ class IslandController { height: Math.max(1, Math.round(from.height + (to.height - from.height) * eased)), }; this.win.setBounds(bounds); - if (progress < 1) setTimeout(step, 12); + if (progress < 1) { + this.boundsFrame = scheduleFrame(frame); + return; + } + // 收尾对齐到最终尺寸,避免四舍五入留下 1px 误差(会让卡片边缘"抖一下") + this.win.setBounds(to); + this.boundsFrame = null; + this.applyFocusable(); }; - step(); + this.boundsFrame = scheduleFrame(frame); } + + /** 取消进行中的尺寸动画(帧循环) */ + private cancelBoundsAnimation(): void { + if (this.boundsFrame !== null) { + cancelFrame(this.boundsFrame); + this.boundsFrame = null; + } + this.boundsToken += 1; + } + + /** + * 应用外观设置(设置页实时生效):尺寸联动、透明度、置顶、动画开关与位置。 + * 尺寸只改窗口与卡片变量,渲染进程用 CSS 变量适配,不会引发布局错乱。 + */ + setAppearance(patch: Partial): void { + this.appearance = normalizeAppearance({ ...this.appearance, ...patch }); + recomputeSizes(this.appearance); + + if (this.win && !this.win.isDestroyed()) { + this.win.setAlwaysOnTop(this.appearance.alwaysOnTop, 'screen-saver'); + if (this.win.isVisible()) { + // 显示中:立即应用透明度(隐藏时由 fadeIn 负责) + this.win.setOpacity(this.appearance.opacity); + } + } + this.emitAppearance(); + // 外观变化后按新尺寸重新同步(隐藏态不显示,不做额外动作) + if (this.state.mode !== 'hidden') this.syncWindow(); + logger.info( + `灵动岛外观已更新:${this.appearance.width}x${this.appearance.height} 圆角=${this.appearance.radius} ` + + `透明度=${this.appearance.opacity} 位置=${this.appearance.position} 动画=${this.appearance.animations} ` + + `速度=${this.appearance.speed}`, + ); + } + + getAppearance(): IslandAppearance { + return { ...this.appearance }; + } + + /** 把外观同步给灵动岛渲染进程(CSS 变量) */ + private emitAppearance(): void { + if (!this.win || this.win.isDestroyed() || !this.ready) return; + this.win.webContents.send('island:appearance', this.getAppearance()); + } +} + +/** 按外观设置换算各形态尺寸(胶囊尺寸可调,展开卡按比例联动,保证内容不溢出) */ +export function recomputeSizes(appearance: IslandAppearance): void { + const widthDelta = appearance.width - BASE.pill.width; + const heightDelta = appearance.height - BASE.pill.height; + const grow = (size: { width: number; height: number }): { width: number; height: number } => ({ + width: Math.max(appearance.width, size.width + widthDelta), + height: Math.max(appearance.height, size.height + heightDelta), + }); + SIZES = { + hidden: { width: appearance.width, height: appearance.height }, + pill: { width: appearance.width, height: appearance.height }, + expanded: grow(BASE.expanded), + }; + URGENT_SIZE = grow(BASE.urgent); + CALL_SIZE = grow(BASE.call); +} + +/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */ +export function normalizeAppearance(input: IslandAppearance): IslandAppearance { + const clamp = (value: number, min: number, max: number): number => + Math.min(max, Math.max(min, Math.round(value * 100) / 100)); + return { + height: clamp(input.height, 36, 72), + width: clamp(input.width, 220, 420), + radius: clamp(input.radius, 8, 32), + opacity: clamp(input.opacity, 0.4, 1), + accent: /^#[0-9a-fA-F]{6}$/.test(input.accent) ? input.accent : DEFAULT_ISLAND_APPEARANCE.accent, + fontSize: clamp(input.fontSize, 11, 18), + animations: input.animations !== false, + speed: clamp(input.speed, 0.5, 2), + position: ['top-center', 'top-left', 'top-right', 'bottom-center'].includes(input.position) + ? input.position + : 'top-center', + alwaysOnTop: input.alwaysOnTop !== false, + }; } /** 回弹缓动(easeOutBack):进度超过 1 形成轻微过冲,营造弹簧感 */ @@ -620,6 +800,12 @@ export function registerIslandIpc(): void { ipcMain.handle('island:get-state', () => island.getState()); + ipcMain.on('island:set-appearance', (_event, patch: Partial) => { + if (patch) island.setAppearance(patch); + }); + + ipcMain.handle('island:get-appearance', () => island.getAppearance()); + ipcMain.on('island:action', (_event, payload: IslandActionPayload) => { if (!payload?.action) return; island.handleAction(payload); diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index e66fe92..fea451f 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -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 { +async function runIslandChecks( + mainWin: BrowserWindow, + record: Recorder, + results: SmokeResult[], +): Promise { 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 { + // 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 { const results: SmokeResult[] = []; const record = (name: string, ok: boolean, detail = ''): void => { @@ -744,7 +904,7 @@ export async function runSmokeTest(win: BrowserWindow): Promise { 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 { 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} 项通过`); diff --git a/packages/desktop-client/src/main/tray.ts b/packages/desktop-client/src/main/tray.ts new file mode 100644 index 0000000..f51b9ea --- /dev/null +++ b/packages/desktop-client/src/main/tray.ts @@ -0,0 +1,76 @@ +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'), + }; +} diff --git a/packages/desktop-client/src/preload/index.ts b/packages/desktop-client/src/preload/index.ts index bc252b6..17f0f08 100644 --- a/packages/desktop-client/src/preload/index.ts +++ b/packages/desktop-client/src/preload/index.ts @@ -29,6 +29,10 @@ const bridge: DesktopBridge = { ipcRenderer.send('island:class-state', payload); }, islandGetState: () => ipcRenderer.invoke('island:get-state'), + islandSetAppearance: (appearance): void => { + ipcRenderer.send('island:set-appearance', appearance); + }, + islandGetAppearance: () => ipcRenderer.invoke('island:get-appearance'), /** 灵动岛点了"标为已读":主进程转交渲染进程同步通知中心 */ onIslandMarkRead: (handler: (id: string) => void): void => { ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id)); diff --git a/packages/desktop-client/src/preload/island.ts b/packages/desktop-client/src/preload/island.ts index 92f7604..8cfc9d5 100644 --- a/packages/desktop-client/src/preload/island.ts +++ b/packages/desktop-client/src/preload/island.ts @@ -1,14 +1,17 @@ import { contextBridge, ipcRenderer } from 'electron'; -import type { IslandState } from '@classhelper/shared'; +import type { IslandAppearance, IslandState } from '@classhelper/shared'; /** * 灵动岛窗口的预加载脚本。 - * 只暴露「订阅状态」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。 + * 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。 */ export interface IslandBridge { onState(handler: (state: IslandState) => void): void; sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void; getState(): Promise; + /** 订阅个性化外观(设置页改动后实时生效) */ + onAppearance(handler: (appearance: IslandAppearance) => void): void; + getAppearance(): Promise; } const bridge: IslandBridge = { @@ -19,6 +22,10 @@ const bridge: IslandBridge = { ipcRenderer.send('island:action', { action, id }); }, getState: () => ipcRenderer.invoke('island:get-state'), + onAppearance: (handler) => { + ipcRenderer.on('island:appearance', (_event, appearance: IslandAppearance) => handler(appearance)); + }, + getAppearance: () => ipcRenderer.invoke('island:get-appearance'), }; contextBridge.exposeInMainWorld('island', bridge); diff --git a/packages/desktop-client/src/renderer/views/SettingsView.vue b/packages/desktop-client/src/renderer/views/SettingsView.vue index d76468c..b5c1d86 100644 --- a/packages/desktop-client/src/renderer/views/SettingsView.vue +++ b/packages/desktop-client/src/renderer/views/SettingsView.vue @@ -2,7 +2,15 @@ import { computed, onMounted, ref } from 'vue'; import { useRouter } from 'vue-router'; import { ElMessage, ElMessageBox } from 'element-plus'; -import { formatDate } from '@classhelper/shared'; +import { + DEFAULT_ISLAND_APPEARANCE, + ISLAND_APPEARANCE_RANGES as RANGES, + ISLAND_POSITION_LABELS, + ISLAND_POSITIONS, + formatDate, + type IslandAppearance, + type IslandPosition, +} from '@classhelper/shared'; import type { DesktopAppInfo } from '../../types/desktop.js'; import { pingHealth, setApiBaseUrl } from '../api/http.js'; import { normalizeServerUrl } from '../config.js'; @@ -102,9 +110,68 @@ async function logout(): Promise { await router.replace('/login'); } +/* ------------------------------------------------------------ 灵动岛个性化 */ + +const island = ref({ ...DEFAULT_ISLAND_APPEARANCE }); +const savingIsland = ref(false); + +const positionOptions = ISLAND_POSITIONS.map((value) => ({ + value, + label: ISLAND_POSITION_LABELS[value], +})); + +/** 读取当前生效的外观(主进程为单一事实来源) */ +async function loadIslandAppearance(): Promise { + if (!window.desktop?.islandGetAppearance) return; + island.value = await window.desktop.islandGetAppearance(); +} + +/** + * 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。 + * 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。 + */ +function applyIslandAppearance(patch: Partial = {}): void { + island.value = { ...island.value, ...patch }; + window.desktop?.islandSetAppearance(island.value); +} + +async function saveIslandAppearance(): Promise { + if (!window.desktop?.saveConfig) return; + savingIsland.value = true; + try { + await window.desktop.saveConfig({ island: island.value }); + ElMessage.success('个性化设置已保存'); + } finally { + savingIsland.value = false; + } +} + +async function resetIslandAppearance(): Promise { + await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' }); + applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE }); + await saveIslandAppearance(); +} + +/** 本地推一条测试通知,立刻确认外观效果 */ +function testIsland(): void { + window.desktop?.islandPush?.({ + notification: { + id: `appearance-test-${Date.now()}`, + title: '灵动岛外观预览', + content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。', + priority: 'NORMAL', + createdAt: new Date().toISOString(), + courseName: null, + teacherName: '本地预览', + }, + context: { inClass: false, currentPeriodEnd: null }, + }); +} + onMounted(async () => { await loadAppInfo(); await appStore.refreshCacheStats(); + await loadIslandAppearance(); }); @@ -150,6 +217,118 @@ onMounted(async () => { + + + + + + + + + + + + + + + + + + + + + + + + 用于高亮、按钮与进度条 + + + + + + + + + 关闭后展开/收起为瞬时生效 + + + + + +
+ + 保存设置 + + 预览效果 + 恢复默认 +
+ +
diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts index 4c65044..ecb62c1 100644 --- a/packages/desktop-client/src/types/desktop.d.ts +++ b/packages/desktop-client/src/types/desktop.d.ts @@ -2,7 +2,7 @@ * 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。 * 渲染进程只依赖这些最小 API,不接触 Node.js。 */ -import type { IslandNotification, IslandState } from '@classhelper/shared'; +import type { IslandAppearance, IslandNotification, IslandState } from '@classhelper/shared'; export interface DesktopStoredConfig { /** 后端服务地址,例如 http://127.0.0.1:4000 */ @@ -11,6 +11,8 @@ export interface DesktopStoredConfig { username: string; /** 登录令牌(主进程用 safeStorage 加密后落盘,读回时自动解密) */ token: string | null; + /** 个性化设置:灵动岛外观(高度/宽度/圆角/透明度/主题色/字号/动画/位置/置顶) */ + island: IslandAppearance; } export interface DesktopAppInfo { @@ -55,6 +57,10 @@ export interface DesktopBridge { islandSetClassState(payload: IslandClassStatePayload): void; /** 读取灵动岛当前状态(设置页/冒烟验证用) */ islandGetState(): Promise; + /** 应用灵动岛外观设置(实时生效) */ + islandSetAppearance(appearance: Partial): void; + /** 读取当前生效的外观设置 */ + islandGetAppearance(): Promise; /** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */ onIslandMarkRead(handler: (id: string) => void): void; } @@ -62,6 +68,9 @@ export interface DesktopBridge { /** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */ export interface IslandRendererBridge { onState(handler: (state: IslandState) => void): void; + /** 订阅外观设置(CSS 变量实时生效) */ + onAppearance(handler: (appearance: IslandAppearance) => void): void; + getAppearance(): Promise; sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void; getState(): Promise; } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index ef8f0a5..50c0278 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,4 +1,4 @@ -import type { NotificationPriority, UserRole } from './types.js'; +import type { NotificationPriority, UserRole } from './types.js'; /** API 前缀 */ export const API_PREFIX = '/api'; @@ -64,6 +64,39 @@ export const WEEKDAY_LABELS: Record = { export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7]; +/** 灵动岛默认外观(设置页个性化项的初始值,也是区间校验的单一来源) */ +export const DEFAULT_ISLAND_APPEARANCE = { + height: 44, + width: 268, + radius: 20, + opacity: 1, + accent: '#6cc4ff', + fontSize: 13, + animations: true, + speed: 1, + position: 'top-center', + alwaysOnTop: true, +} as const; + +/** 个性化设置项的合法区间(前端滑块与后端校验共用,避免越界导致布局错乱) */ +export const ISLAND_APPEARANCE_RANGES = { + height: { min: 36, max: 72 }, + width: { min: 220, max: 420 }, + radius: { min: 8, max: 32 }, + opacity: { min: 0.4, max: 1 }, + fontSize: { min: 11, max: 18 }, + speed: { min: 0.5, max: 2 }, +} as const; + +/** 停靠位置可选值 */ +export const ISLAND_POSITIONS = ['top-center', 'top-left', 'top-right', 'bottom-center'] as const; + +export const ISLAND_POSITION_LABELS: Record<(typeof ISLAND_POSITIONS)[number], string> = { + 'top-center': '顶部居中', + 'top-left': '左上角', + 'top-right': '右上角', + 'bottom-center': '底部居中', +}; /** 默认学期周次上限 */ export const DEFAULT_WEEK_COUNT = 20; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 555ed10..545a546 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -377,6 +377,33 @@ export interface IslandNotification { subtitle?: string | null; } +/** 灵动岛外观设置(设置页可调,主进程持久化并按此驱动窗口与渲染进程) */ +export interface IslandAppearance { + /** 胶囊高度(px,36~72):展开卡高度按比例联动,保证布局不错乱 */ + height: number; + /** 胶囊宽度(px,220~420) */ + width: number; + /** 卡片圆角(px,8~32) */ + radius: number; + /** 整体不透明度(0.4~1) */ + opacity: number; + /** 主题色(强调色,用于高亮与按钮) */ + accent: string; + /** 基础字号(px,11~18) */ + fontSize: number; + /** 是否启用动画(关闭后展开/收起为瞬时) */ + animations: boolean; + /** 动画速度倍率(0.5 慢 ~ 2 快) */ + speed: number; + /** 显示位置 */ + position: IslandPosition; + /** 是否始终置顶 */ + alwaysOnTop: boolean; +} + +/** 灵动岛停靠位置 */ +export type IslandPosition = 'top-center' | 'top-left' | 'top-right' | 'bottom-center'; + /** 灵动岛当前状态(主进程持有,渲染进程与冒烟测试读取) */ export interface IslandState { mode: IslandMode; diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs index 967ea8f..9ba101d 100644 --- a/scripts/generate-icons.mjs +++ b/scripts/generate-icons.mjs @@ -24,4 +24,18 @@ const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs env: resolveElectronEnv(), }); -child.on('exit', (code) => process.exit(code ?? 1)); +child.on('exit', (code) => { + if (code === 0) { + // 托盘图标需要一张 PNG 随 renderer 一起打包(Vite 会把 public/ 复制到 dist/renderer) + try { + const source = path.join(root, 'packages', 'desktop-client', 'build', 'icon.png'); + const targetDir = path.join(root, 'packages', 'desktop-client', 'public'); + fs.mkdirSync(targetDir, { recursive: true }); + fs.copyFileSync(source, path.join(targetDir, 'tray.png')); + console.log('[icons] 托盘图标已同步:packages/desktop-client/public/tray.png'); + } catch (error) { + console.warn('[icons] 同步托盘图标失败(不影响构建)', error); + } + } + process.exit(code ?? 1); +});