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:
LoveChengke committed 2026-09-12 15:19:13 +08:00
1 parent 30e4c36809
commit 0dcab6f369
15 files changed
+924 -57

No files matched your search

+37 -3
View File
@@ -20,7 +20,7 @@
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 34/34) | | 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 40/40) |
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
@@ -173,7 +173,7 @@ pnpm verify:desktop
# 另开一个终端先启动后端:pnpm dev:server # 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:82 项 pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:82 项
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知):17 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知):17 项
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴):34 项 pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项
``` ```
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
@@ -383,6 +383,40 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
> 后续可继续深化:迷你悬浮课表窗口(ClassIsland 的桌面课表条)、上课/下课提醒动画与铃声。 > 后续可继续深化:迷你悬浮课表窗口(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:e2e`(开发环境与**安装后的生产实例**各跑一次) | **82/82** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + PWA) | **17/17** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + PWA) | **17/17** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **34/34** ✅ | | `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { import {
DEFAULT_ISLAND_APPEARANCE,
type IslandAppearance,
PRIORITY_LABELS, PRIORITY_LABELS,
formatDate, formatDate,
type IslandNotification, type IslandNotification,
@@ -111,12 +113,31 @@ function openApp(): void {
bridge?.sendAction('open-app'); bridge?.sendAction('open-app');
} }
/** 个性化外观:主进程下发后写入 CSS 变量,卡片尺寸/圆角/字号/主题色随之实时变化 */
const appearance = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
function applyAppearance(next: IslandAppearance | null | undefined): void {
if (!next) return;
appearance.value = next;
const style = document.documentElement.style;
style.setProperty('--island-accent', next.accent);
style.setProperty('--island-radius', `${next.radius}px`);
style.setProperty('--island-font', `${next.fontSize}px`);
style.setProperty('--island-w', `${next.width}px`);
style.setProperty('--island-h', `${next.height}px`);
style.setProperty('--island-anim', next.animations ? '1' : '0');
}
onMounted(async () => { onMounted(async () => {
bridge?.onState((next) => { bridge?.onState((next) => {
state.value = next; state.value = next;
}); });
const initial = await bridge?.getState(); const initial = await bridge?.getState();
if (initial) state.value = initial; if (initial) state.value = initial;
// 外观:先同步一次,再订阅后续改动
bridge?.onAppearance((next) => applyAppearance(next));
const initialAppearance = await bridge?.getAppearance?.();
applyAppearance(initialAppearance);
}); });
</script> </script>
@@ -260,6 +281,13 @@ body,
background: transparent; background: transparent;
overflow: hidden; overflow: hidden;
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Arial, sans-serif; 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; -webkit-user-select: none;
user-select: none; user-select: none;
} }
@@ -298,30 +326,30 @@ body,
/* 各形态固定尺寸(与主进程 ISLAND_SIZES 对齐:卡片刻意比窗口小 4~8px,四周留 2~4px 透明边) */ /* 各形态固定尺寸(与主进程 ISLAND_SIZES 对齐:卡片刻意比窗口小 4~8px,四周留 2~4px 透明边) */
.island-card.pill { .island-card.pill {
left: calc(50% - 130px); left: calc(50% - var(--island-w) / 2 + 4px);
width: 260px; width: calc(var(--island-w) - 8px);
height: 38px; height: calc(var(--island-h) - 6px);
border-radius: 20px; border-radius: var(--island-radius);
} }
.island-card.expanded { .island-card.expanded {
left: calc(50% - 198px); left: calc(50% - (var(--island-w) + 136px) / 2 + 4px);
width: 396px; width: calc(var(--island-w) + 128px);
height: 308px; height: calc(var(--island-h) + 264px);
border-radius: 24px; border-radius: var(--island-radius);
} }
.island-card.expanded.urgent { .island-card.expanded.urgent {
left: calc(50% - 208px); left: calc(50% - (var(--island-w) + 156px) / 2 + 4px);
width: 416px; width: calc(var(--island-w) + 148px);
height: 336px; height: calc(var(--island-h) + 292px);
} }
/* 叫人:卡片略大(对应主进程 CALL_SIZE 440x360) */ /* 叫人:卡片略大(对应主进程 CALL_SIZE 440x360) */
.island-card.expanded.call { .island-card.expanded.call {
left: calc(50% - 216px); left: calc(50% - (var(--island-w) + 172px) / 2 + 4px);
width: 432px; width: calc(var(--island-w) + 164px);
height: 352px; height: calc(var(--island-h) + 308px);
} }
/* 隐藏态占位:只保留结构,不占视觉空间 */ /* 隐藏态占位:只保留结构,不占视觉空间 */
@@ -457,8 +485,8 @@ body,
width: 26px; width: 26px;
height: 26px; height: 26px;
border-radius: 50%; border-radius: 50%;
color: #9fd0ff; color: var(--island-accent);
background: rgba(79, 172, 254, 0.14); background: color-mix(in srgb, var(--island-accent) 16%, transparent);
flex: 0 0 auto; flex: 0 0 auto;
} }
@@ -664,7 +692,7 @@ body,
.more { .more {
font-size: 11px; font-size: 11px;
color: #9fd0ff; color: var(--island-accent);
} }
.more.muted { .more.muted {
@@ -702,7 +730,7 @@ body,
.solid-btn { .solid-btn {
color: #06263f; color: #06263f;
background: #6cc4ff; background: var(--island-accent);
font-weight: 650; font-weight: 650;
} }
+35 -1
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { app, safeStorage } from 'electron'; 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'; import type { DesktopStoredConfig } from '../types/desktop.js';
interface PersistedConfig { interface PersistedConfig {
@@ -10,6 +10,8 @@ interface PersistedConfig {
/** 加密后的 token(base64)或明文(当系统不支持加密时) */ /** 加密后的 token(base64)或明文(当系统不支持加密时) */
token: string | null; token: string | null;
tokenEncrypted: boolean; tokenEncrypted: boolean;
/** 个性化设置:灵动岛外观(向后兼容:旧配置文件没有该字段时用默认值补齐) */
island?: Partial<IslandAppearance>;
} }
const DEFAULT_CONFIG: PersistedConfig = { const DEFAULT_CONFIG: PersistedConfig = {
@@ -17,8 +19,35 @@ const DEFAULT_CONFIG: PersistedConfig = {
username: '', username: '',
token: null, token: null,
tokenEncrypted: false, 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 { function configFilePath(): string {
return path.join(app.getPath('userData'), 'config.json'); return path.join(app.getPath('userData'), 'config.json');
} }
@@ -57,6 +86,7 @@ function readPersisted(): PersistedConfig {
username: typeof parsed.username === 'string' ? parsed.username : '', username: typeof parsed.username === 'string' ? parsed.username : '',
token: typeof parsed.token === 'string' ? parsed.token : null, token: typeof parsed.token === 'string' ? parsed.token : null,
tokenEncrypted: parsed.tokenEncrypted === true, tokenEncrypted: parsed.tokenEncrypted === true,
island: normalizeIslandAppearance(parsed.island),
}; };
} catch { } catch {
return { ...DEFAULT_CONFIG }; return { ...DEFAULT_CONFIG };
@@ -76,6 +106,7 @@ export function getConfig(): DesktopStoredConfig {
serverUrl: persisted.serverUrl, serverUrl: persisted.serverUrl,
username: persisted.username, username: persisted.username,
token: decryptToken(persisted), token: decryptToken(persisted),
island: normalizeIslandAppearance(persisted.island),
}; };
} }
@@ -89,6 +120,9 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (typeof patch.username === 'string') { if (typeof patch.username === 'string') {
persisted.username = patch.username; persisted.username = patch.username;
} }
if (patch.island !== undefined) {
persisted.island = normalizeIslandAppearance({ ...persisted.island, ...patch.island });
}
if (patch.token !== undefined) { if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token); const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token; persisted.token = encrypted.token;
+76 -3
View File
@@ -4,6 +4,7 @@ import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js'; import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
import { runSmokeTest } from './smoke.js'; import { runSmokeTest } from './smoke.js';
import { createTray, destroyTray, isTrayReady } from './tray.js';
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname // 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html) // (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 mainWindow: BrowserWindow | null = null;
/** 是否正在退出:用于区分「关闭窗口 = 隐藏到托盘」与「真正退出」 */
let isQuitting = false;
function createWindow(): BrowserWindow { function createWindow(): BrowserWindow {
const win = new BrowserWindow({ const win = new BrowserWindow({
width: 1200, width: 1200,
@@ -106,6 +110,25 @@ if (!gotLock) {
logger.error('灵动岛初始化失败(不影响主功能)', error); 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) { if (isSmokeTest) {
try { try {
await runSmokeTest(mainWindow); await runSmokeTest(mainWindow);
@@ -116,13 +139,63 @@ if (!gotLock) {
} }
}); });
app.on('window-all-closed', () => { /** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */
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(); island.destroy();
if (process.platform !== 'darwin') app.quit(); 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', () => {
// 有托盘常驻:窗口全关也不退出,继续后台接收通知
logger.info('所有窗口已关闭,程序继续在系统托盘后台运行');
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
island.destroy(); isQuitting = true;
shutdownResources();
});
app.on('will-quit', () => {
const summary = shutdownResources();
logger.info(`应用退出清理:${summary}`);
}); });
app.on('activate', async () => { app.on('activate', async () => {
+208 -22
View File
@@ -1,7 +1,14 @@
import path from 'node:path'; import path from 'node:path';
import { BrowserWindow, ipcMain, screen } from 'electron'; 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 { logger } from './logger.js';
import { getConfig } from './config.js';
/** /**
* 灵动岛(Dynamic Island)主进程控制器。 * 灵动岛(Dynamic Island)主进程控制器。
@@ -14,18 +21,52 @@ import { logger } from './logger.js';
* - 上课时间段:非紧急通知不弹出(自动隐藏),进入待发队列 * - 上课时间段:非紧急通知不弹出(自动隐藏),进入待发队列
* - 下课后:自动在桌面中上方弹出队列中的通知详情 * - 下课后:自动在桌面中上方弹出队列中的通知详情
* - 紧急通知:无论是否在上课,立刻展开显示详情,无需点击 * - 紧急通知:无论是否在上课,立刻展开显示详情,无需点击
*
* 动画实现要点(需求 4:展开/收起不能抖动):
* - 窗口尺寸用**单一 requestAnimationFrame 循环**插值,不再用 setTimeout 链(避免掉帧与回跳)
* - 锚点固定:横向按停靠位置计算、纵向固定,形变过程中卡片上边缘不动
* - 卡片本身是固定尺寸(渲染进程),只让窗口露出/裁切,内容不参与布局重排
* - 展开完成之后才取焦点(避免取焦瞬间抢走动画帧)
* - 关闭动画时直接落到目标尺寸
*/ */
const SIZES: Record<IslandMode, { width: number; height: number }> = { /** 基础尺寸:真实尺寸 = 外观设置联动计算(见 sizes()) */
hidden: { width: 268, height: 44 }, const BASE = {
pill: { width: 268, height: 44 }, pill: { width: 268, height: 44 },
expanded: { width: 404, height: 316 }, 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 老师") */ function scheduleFrame(callback: () => void): FrameHandle {
const CALL_SIZE = { width: 440, height: 360 }; 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<typeof setTimeout>);
}
const DEFAULT_SIZES: Record<IslandMode, { width: number; height: number }> = {
hidden: BASE.pill,
pill: BASE.pill,
expanded: BASE.expanded,
};
let SIZES: Record<IslandMode, { width: number; height: number }> = { ...DEFAULT_SIZES };
let URGENT_SIZE = { ...BASE.urgent };
let CALL_SIZE = { ...BASE.call };
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */ /** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
const TIMEOUTS = { const TIMEOUTS = {
@@ -67,6 +108,10 @@ class IslandController {
private boundsToken = 0; private boundsToken = 0;
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */ /** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
private fadeToken = 0; private fadeToken = 0;
/** 尺寸动画的 requestAnimationFrame 句柄(需求 4:单循环,避免抖动) */
private boundsFrame: number | null = null;
/** 外观设置(设置页可调,主进程持久化) */
private appearance: IslandAppearance = { ...DEFAULT_ISLAND_APPEARANCE };
private state: IslandState = { private state: IslandState = {
mode: 'hidden', mode: 'hidden',
@@ -94,6 +139,13 @@ class IslandController {
async init(rendererUrl: string | null): Promise<void> { async init(rendererUrl: string | null): Promise<void> {
this.rendererUrl = rendererUrl; 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; if (this.win && !this.win.isDestroyed()) return;
const win = new BrowserWindow({ 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.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
win.setIgnoreMouseEvents(false); win.setIgnoreMouseEvents(false);
win.on('closed', () => { win.on('closed', () => {
@@ -164,7 +216,8 @@ class IslandController {
this.emit(this.pendingState); this.emit(this.pendingState);
this.pendingState = null; this.pendingState = null;
} }
// 首次进入时同步一次当前状态,保证渲染进程不会停在初始默认值 // 首次进入时同步一次外观与状态,保证渲染进程不会停在初始默认值
this.emitAppearance();
this.emit(); this.emit();
logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)'); logger.info('灵动岛已就绪(置顶透明窗口,顶部居中)');
} }
@@ -349,6 +402,7 @@ class IslandController {
*/ */
hideImmediately(): void { hideImmediately(): void {
this.clearTimers(); this.clearTimers();
this.cancelBoundsAnimation();
this.fadeToken += 1; this.fadeToken += 1;
const changed = this.state.mode !== 'hidden' || this.state.reason !== null; const changed = this.state.mode !== 'hidden' || this.state.reason !== null;
if (changed) { if (changed) {
@@ -363,6 +417,7 @@ class IslandController {
destroy(): void { destroy(): void {
this.clearTimers(); this.clearTimers();
this.cancelBoundsAnimation();
if (this.win && !this.win.isDestroyed()) this.win.destroy(); if (this.win && !this.win.isDestroyed()) this.win.destroy();
this.win = null; this.win = null;
} }
@@ -437,12 +492,25 @@ class IslandController {
height: number; height: number;
} { } {
const area = screen.getPrimaryDisplay().workArea; const area = screen.getPrimaryDisplay().workArea;
return { const margin = 8;
x: Math.round(area.x + (area.width - size.width) / 2), let x = Math.round(area.x + (area.width - size.width) / 2);
y: area.y + 8, let y = area.y + margin;
width: size.width,
height: size.height, 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 { private fadeIn(): void {
if (!this.win || this.win.isDestroyed()) return; 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; const token = ++this.fadeToken;
let opacity = this.win.getOpacity(); let opacity = this.win.getOpacity();
const step = (): void => { const step = (): void => {
if (!this.win || this.win.isDestroyed() || token !== this.fadeToken) return; 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); this.win.setOpacity(opacity);
if (opacity < 1) setTimeout(step, 16); if (opacity < target) setTimeout(step, 16);
}; };
step(); step();
} }
private fadeOut(): void { private fadeOut(): void {
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) return; 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; const token = ++this.fadeToken;
let opacity = this.win.getOpacity(); let opacity = this.win.getOpacity();
const step = (): void => { const step = (): void => {
@@ -563,7 +643,12 @@ class IslandController {
/** /**
* 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果。 * 尺寸/位置缓动:Windows 下没有原生窗口动画,这里按帧插值实现"形变"效果。
* `spring` 使用回弹缓动(轻微过冲后回落),用于"展开成卡片"的弹簧手感。 *
* 用**单一 requestAnimationFrame 循环**驱动(需求 4):
* - 不再使用 setTimeout 链:避免掉帧、避免"每帧重新排定时器"带来的节奏抖动
* - 锚点固定:横向/纵向都由 computeBounds 决定,形变过程中卡片上边缘(或下边缘)不动
* - 关闭动画(appearance.animations = false)时直接落到目标尺寸
* - `spring` 使用回弹缓动(轻微过冲后回落),用于"展开成卡片"的弹簧手感
*/ */
private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void { private animateBounds(size: { width: number; height: number }, options: { spring?: boolean } = {}): void {
if (!this.win || this.win.isDestroyed()) return; if (!this.win || this.win.isDestroyed()) return;
@@ -571,12 +656,19 @@ class IslandController {
const to = this.computeBounds(size); const to = this.computeBounds(size);
if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return; if (from.width === to.width && from.height === to.height && from.x === to.x && from.y === to.y) return;
this.cancelBoundsAnimation();
const token = ++this.boundsToken; const token = ++this.boundsToken;
if (!this.appearance.animations) {
this.win.setBounds(to);
return;
}
const spring = options.spring === true; 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 startedAt = Date.now();
const step = (): void => { const frame = (): void => {
if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return; if (!this.win || this.win.isDestroyed() || token !== this.boundsToken) return;
const progress = Math.min(1, (Date.now() - startedAt) / duration); const progress = Math.min(1, (Date.now() - startedAt) / duration);
const eased = spring ? easeOutBack(progress) : 1 - Math.pow(1 - progress, 3); // easeOutCubic 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)), height: Math.max(1, Math.round(from.height + (to.height - from.height) * eased)),
}; };
this.win.setBounds(bounds); this.win.setBounds(bounds);
if (progress < 1) setTimeout(step, 12); if (progress < 1) {
}; this.boundsFrame = scheduleFrame(frame);
step(); return;
} }
// 收尾对齐到最终尺寸,避免四舍五入留下 1px 误差(会让卡片边缘"抖一下")
this.win.setBounds(to);
this.boundsFrame = null;
this.applyFocusable();
};
this.boundsFrame = scheduleFrame(frame);
}
/** 取消进行中的尺寸动画(帧循环) */
private cancelBoundsAnimation(): void {
if (this.boundsFrame !== null) {
cancelFrame(this.boundsFrame);
this.boundsFrame = null;
}
this.boundsToken += 1;
}
/**
* 应用外观设置(设置页实时生效):尺寸联动、透明度、置顶、动画开关与位置。
* 尺寸只改窗口与卡片变量,渲染进程用 CSS 变量适配,不会引发布局错乱。
*/
setAppearance(patch: Partial<IslandAppearance>): 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 形成轻微过冲,营造弹簧感 */ /** 回弹缓动(easeOutBack):进度超过 1 形成轻微过冲,营造弹簧感 */
@@ -620,6 +800,12 @@ export function registerIslandIpc(): void {
ipcMain.handle('island:get-state', () => island.getState()); ipcMain.handle('island:get-state', () => island.getState());
ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => {
if (patch) island.setAppearance(patch);
});
ipcMain.handle('island:get-appearance', () => island.getAppearance());
ipcMain.on('island:action', (_event, payload: IslandActionPayload) => { ipcMain.on('island:action', (_event, payload: IslandActionPayload) => {
if (!payload?.action) return; if (!payload?.action) return;
island.handleAction(payload); island.handleAction(payload);
+166 -3
View File
@@ -5,6 +5,7 @@ import type { BrowserWindow, NativeImage } from 'electron';
import type { IslandNotification } from '@classhelper/shared'; import type { IslandNotification } from '@classhelper/shared';
import { getDiagnostics } from './ipc.js'; import { getDiagnostics } from './ipc.js';
import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js'; import { island, ISLAND_CALL_SIZE, ISLAND_SIZES, ISLAND_URGENT_SIZE } from './island.js';
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
interface SmokeResult { interface SmokeResult {
name: string; name: string;
@@ -175,7 +176,11 @@ function makeNotification(
* 5. 点击卡片空白处 / 右上角收起按钮 → 回缩为胶囊 * 5. 点击卡片空白处 / 右上角收起按钮 → 回缩为胶囊
* 6. 上课时间段内点击胶囊不会展开(上课不显示灵动岛) * 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()}`); record('灵动岛窗口已创建(置顶/透明/不占任务栏)', island.isReady(), `ready=${island.isReady()}`);
if (!island.isReady()) return; if (!island.isReady()) return;
@@ -449,7 +454,13 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
}, },
{ inClass: true }, { 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 callState = island.getState();
const callDom = await islandWindow?.webContents.executeJavaScript( const callDom = await islandWindow?.webContents.executeJavaScript(
`(() => ({ `(() => ({
@@ -525,6 +536,126 @@ async function runIslandChecks(record: Recorder, results: SmokeResult[]): Promis
`紧急态偏红=${urgentReddish} 最外圈偏红=${edgeReddish}(要求 0)`, `紧急态偏红=${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' }); island.handleAction({ action: 'dismiss' });
await sleep(300); await sleep(300);
@@ -626,6 +757,35 @@ async function runIslandRealtimeCheck(
* 结果同时输出到 stdout,并在设置 ELECTRON_SMOKE_RESULT 时写入 JSON 文件 —— * 结果同时输出到 stdout,并在设置 ELECTRON_SMOKE_RESULT 时写入 JSON 文件 ——
* 打包后的 Windows GUI 进程没有 stdout,用结果文件才能在 CI/脚本里验证安装包。 * 打包后的 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> { export async function runSmokeTest(win: BrowserWindow): Promise<void> {
const results: SmokeResult[] = []; const results: SmokeResult[] = [];
const record = (name: string, ok: boolean, detail = ''): void => { 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 ?? '')); record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? ''));
// ---------------------------------------------------------------- 灵动岛 // ---------------------------------------------------------------- 灵动岛
await runIslandChecks(record, results); await runIslandChecks(win, record, results);
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用) // 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
if (process.env.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 ?? '')); record('冒烟收尾:清理登录态', Boolean(cleanup?.ok), String(cleanup?.detail ?? ''));
} }
// 托盘与退出清理放在最后:会隐藏主窗口并销毁托盘/灵动岛
await runTrayAndShutdownChecks(win, record);
const failed = results.filter((item) => !item.ok); const failed = results.filter((item) => !item.ok);
const passed = results.length - failed.length; const passed = results.length - failed.length;
console.log(`\n[SMOKE] ${passed}/${results.length} 项通过`); console.log(`\n[SMOKE] ${passed}/${results.length} 项通过`);
+76
View File
@@ -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'),
};
}
@@ -29,6 +29,10 @@ const bridge: DesktopBridge = {
ipcRenderer.send('island:class-state', payload); ipcRenderer.send('island:class-state', payload);
}, },
islandGetState: () => ipcRenderer.invoke('island:get-state'), 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 => { onIslandMarkRead: (handler: (id: string) => void): void => {
ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id)); ipcRenderer.on('island:mark-read', (_event, id: string) => handler(id));
@@ -1,14 +1,17 @@
import { contextBridge, ipcRenderer } from 'electron'; import { contextBridge, ipcRenderer } from 'electron';
import type { IslandState } from '@classhelper/shared'; import type { IslandAppearance, IslandState } from '@classhelper/shared';
/** /**
* 灵动岛窗口的预加载脚本。 * 灵动岛窗口的预加载脚本。
* 只暴露「订阅状态」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。 * 只暴露「订阅状态/外观」与「发送用户操作」两件事,渲染进程不接触任何 Node 能力。
*/ */
export interface IslandBridge { export interface IslandBridge {
onState(handler: (state: IslandState) => void): void; onState(handler: (state: IslandState) => void): void;
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void; sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
getState(): Promise<IslandState>; getState(): Promise<IslandState>;
/** 订阅个性化外观(设置页改动后实时生效) */
onAppearance(handler: (appearance: IslandAppearance) => void): void;
getAppearance(): Promise<IslandAppearance>;
} }
const bridge: IslandBridge = { const bridge: IslandBridge = {
@@ -19,6 +22,10 @@ const bridge: IslandBridge = {
ipcRenderer.send('island:action', { action, id }); ipcRenderer.send('island:action', { action, id });
}, },
getState: () => ipcRenderer.invoke('island:get-state'), 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); contextBridge.exposeInMainWorld('island', bridge);
@@ -2,7 +2,15 @@
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; 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 type { DesktopAppInfo } from '../../types/desktop.js';
import { pingHealth, setApiBaseUrl } from '../api/http.js'; import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { normalizeServerUrl } from '../config.js'; import { normalizeServerUrl } from '../config.js';
@@ -102,9 +110,68 @@ async function logout(): Promise<void> {
await router.replace('/login'); await router.replace('/login');
} }
/* ------------------------------------------------------------ 灵动岛个性化 */
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const savingIsland = ref(false);
const positionOptions = ISLAND_POSITIONS.map((value) => ({
value,
label: ISLAND_POSITION_LABELS[value],
}));
/** 读取当前生效的外观(主进程为单一事实来源) */
async function loadIslandAppearance(): Promise<void> {
if (!window.desktop?.islandGetAppearance) return;
island.value = await window.desktop.islandGetAppearance();
}
/**
* 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。
* 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。
*/
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
island.value = { ...island.value, ...patch };
window.desktop?.islandSetAppearance(island.value);
}
async function saveIslandAppearance(): Promise<void> {
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<void> {
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 () => { onMounted(async () => {
await loadAppInfo(); await loadAppInfo();
await appStore.refreshCacheStats(); await appStore.refreshCacheStats();
await loadIslandAppearance();
}); });
</script> </script>
@@ -150,6 +217,118 @@ onMounted(async () => {
</el-descriptions> </el-descriptions>
</el-card> </el-card>
<el-card shadow="never" class="mt-12">
<template #header>
<span>灵动岛 · 个性化</span>
</template>
<el-form label-width="96px" label-position="left">
<el-form-item :label="`高度 ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#6cc4ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="显示位置">
<el-select
:model-value="island.position"
style="width: 180px"
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
>
<el-option
v-for="item in positionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="动画">
<el-switch
:model-value="island.animations"
@change="
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
"
/>
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
</el-form-item>
<el-form-item label="始终置顶">
<el-switch
:model-value="island.alwaysOnTop"
@change="
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
"
/>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">
保存设置
</el-button>
<el-button @click="testIsland">预览效果</el-button>
<el-button @click="resetIslandAppearance">恢复默认</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
/>
</el-card>
<el-card shadow="never" class="mt-12"> <el-card shadow="never" class="mt-12">
<template #header><span>账号信息</span></template> <template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small"> <el-descriptions :column="1" border size="small">
+10 -1
View File
@@ -2,7 +2,7 @@
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。 * 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
* 渲染进程只依赖这些最小 API,不接触 Node.js。 * 渲染进程只依赖这些最小 API,不接触 Node.js。
*/ */
import type { IslandNotification, IslandState } from '@classhelper/shared'; import type { IslandAppearance, IslandNotification, IslandState } from '@classhelper/shared';
export interface DesktopStoredConfig { export interface DesktopStoredConfig {
/** 后端服务地址,例如 http://127.0.0.1:4000 */ /** 后端服务地址,例如 http://127.0.0.1:4000 */
@@ -11,6 +11,8 @@ export interface DesktopStoredConfig {
username: string; username: string;
/** 登录令牌(主进程用 safeStorage 加密后落盘,读回时自动解密) */ /** 登录令牌(主进程用 safeStorage 加密后落盘,读回时自动解密) */
token: string | null; token: string | null;
/** 个性化设置:灵动岛外观(高度/宽度/圆角/透明度/主题色/字号/动画/位置/置顶) */
island: IslandAppearance;
} }
export interface DesktopAppInfo { export interface DesktopAppInfo {
@@ -55,6 +57,10 @@ export interface DesktopBridge {
islandSetClassState(payload: IslandClassStatePayload): void; islandSetClassState(payload: IslandClassStatePayload): void;
/** 读取灵动岛当前状态(设置页/冒烟验证用) */ /** 读取灵动岛当前状态(设置页/冒烟验证用) */
islandGetState(): Promise<IslandState>; islandGetState(): Promise<IslandState>;
/** 应用灵动岛外观设置(实时生效) */
islandSetAppearance(appearance: Partial<IslandAppearance>): void;
/** 读取当前生效的外观设置 */
islandGetAppearance(): Promise<IslandAppearance>;
/** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */ /** 订阅"灵动岛点了标为已读"事件,用于同步通知中心 */
onIslandMarkRead(handler: (id: string) => void): void; onIslandMarkRead(handler: (id: string) => void): void;
} }
@@ -62,6 +68,9 @@ export interface DesktopBridge {
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */ /** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
export interface IslandRendererBridge { export interface IslandRendererBridge {
onState(handler: (state: IslandState) => void): void; onState(handler: (state: IslandState) => void): void;
/** 订阅外观设置(CSS 变量实时生效) */
onAppearance(handler: (appearance: IslandAppearance) => void): void;
getAppearance(): Promise<IslandAppearance>;
sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void; sendAction(action: 'expand' | 'collapse' | 'dismiss' | 'mark-read' | 'open-app', id?: string): void;
getState(): Promise<IslandState>; getState(): Promise<IslandState>;
} }
+34 -1
View File
@@ -1,4 +1,4 @@
import type { NotificationPriority, UserRole } from './types.js'; import type { NotificationPriority, UserRole } from './types.js';
/** API 前缀 */ /** API 前缀 */
export const API_PREFIX = '/api'; export const API_PREFIX = '/api';
@@ -64,6 +64,39 @@ export const WEEKDAY_LABELS: Record<number, string> = {
export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7]; 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; export const DEFAULT_WEEK_COUNT = 20;
+27
View File
@@ -377,6 +377,33 @@ export interface IslandNotification {
subtitle?: string | null; 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 { export interface IslandState {
mode: IslandMode; mode: IslandMode;
+15 -1
View File
@@ -24,4 +24,18 @@ const child = spawn(executable, [path.join(root, 'scripts', 'icons', 'render.cjs
env: resolveElectronEnv(), 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);
});