Files

411 lines
50 KiB
Markdown
Raw Permalink Normal View History

## 2. 技术栈与工程结构
### 2.1 技术选型总则(必须逐条遵守)
1. 你必须用 **TypeScript + Electron + Vue 3** 实现本应用,工程形态必须是 **pnpm monorepo**,不得做成单包项目、不得用 Webpack、不得用 Electron Forge、不得用 `electron-vite` 之外的脚手架替代本文规定的手写构建管线。
2. 你必须**完整沿用 ClassHelper 桌面客户端的工程约定**:主进程/preload 用 esbuild 打包为 CommonJS,渲染进程用 Vite 多入口打包,打包用 electron-builder;共享类型、常量、纯函数放 `packages/shared`;共享 Vue 控件与样式令牌放 `packages/ui`;Electron 应用全部代码放 `packages/desktop-client`。依据:`repos/ClassHelper-master/pnpm-workspace.yaml:1-2`、`repos/ClassHelper-master/packages/desktop-client/package.json:8-18`。
3. 你**不得**把 ClassHelper 的服务端(Express/Prisma/Socket.IO)引入主流程。ClassIsland 是本地单机应用,一切业务数据必须落在本机;仅当实现「可选管理端联动」时才允许把 `packages/server` 作为可裁剪子包,且主流程不得依赖网络。依据:`repos/ClassHelper-master/packages/server/package.json:19-40` 的依赖清单,以及总纲的 P2 定义。
4. 你必须把「版本」收敛为**单一来源**:根 `package.json` 的 `version` 是唯一手写值,所有子包版本由脚本同步;日常功能提交不得涨版本。依据:`repos/ClassHelper-master/AGENTS.md`「根 package.json 是版本单一来源」与 `repos/ClassHelper-master/docs/dev/release.md:8-10`。
5. 你必须保持「渲染层零 Node 能力」:渲染进程不得 `require('node:fs')`、不得访问 `process` 环境变量之外的 Node API,所有文件、系统、更新、托盘能力一律经主进程 IPC。依据:`repos/ClassHelper-master/AGENTS.md`「preload 仅暴露 contextBridge 白名单,不暴露 ipcRenderer」。
### 2.2 依赖清单(版本必须按此锁定)
以下版本是硬约束,`pnpm-lock.yaml` 必须锁定到这些 semver 区间;不得因为"想用新 API"而升级大版本。
| 用途 | 包名 | 版本 | 证据 |
|---|---|---|---|
| 包管理器 | pnpm | `11.8.0`(`packageManager` 字段) | `repos/ClassHelper-master/package.json:6` |
| Node 运行时 | Node.js | `>=20.19.0` | `repos/ClassHelper-master/package.json:7-9` |
| 桌面壳 | `electron` | `^44.3.0` | `repos/ClassHelper-master/packages/desktop-client/package.json:32` |
| 打包 | `electron-builder` | `^26.15.3` | 同上 `:34` |
| UI 框架 | `vue` | `^3.5.42` | 同上 `:26` |
| 路由 | `vue-router` | `^5.3.1` | 同上 `:27` |
| 状态 | `pinia` | `^4.0.3` | 同上 `:24` |
| 组件库(仅设置/管理界面) | `element-plus` | `^2.14.5` | 同上 `:23` |
| 图标 | `@element-plus/icons-vue` | `^2.3.2` | 同上 `:21` |
| 构建 | `vite` | `^8.3.0` | 同上 `:37` |
| Vue 插件 | `@vitejs/plugin-vue` | `^6.0.8` | 同上 `:32` |
| 主进程打包 | `esbuild` | `^0.28.2` | 同上 `:35` |
| 语言 | `typescript` | `~5.9.3` | 根 `package.json:74` |
| 类型检查 | `vue-tsc` | `^3.3.11` | `desktop-client/package.json:38` |
| Node 类型 | `@types/node` | `^22.20.2` | 同上 `:31` |
允许额外引入的第三方库(必须写进依赖清单并锁版本):`xlsx`(Excel 导入导出;**P1 必须实现**,用 xlsx 库重建 ClassIsland 的 ReoGrid/WPF 版导入导出,ClassHelper 服务端同款 `0.18.5`,见 `packages/server/package.json:38`)、`adm-zip` 或 `yauzl`(`.cipx`/主题包解压)、`yaml`(读取插件 `manifest.yml`,见 `packages/server/package.json:40`)、`electron-log` 可选但**不强制**(本文要求自研 `logger.ts`,理由见 2.8)。禁止引入:`electron-updater`(本文的更新模型是自研的哈希校验 + 独立安装助手,见 2.11 与 12 部分)、`better-sqlite3` 等原生模块(`electron-builder.yml:37-38` 明确 `npmRebuild: false`、无原生模块)、任何需要在线服务的 SDK。
`pnpm-workspace.yaml` 必须显式声明允许执行构建脚本的依赖,否则 pnpm 11 会因 `ERR_PNPM_IGNORED_BUILDS` 直接失败:
```yaml
packages:
- 'packages/*'
allowBuilds:
esbuild: true
electron: true
vue-demi: true
electron-winstaller: false
verifyDepsBeforeRun: false
```
依据:`repos/ClassHelper-master/pnpm-workspace.yaml:1-26`(含注释解释 electron-winstaller 为何必须显式为 false)。
### 2.3 仓库目录结构(必须逐项落地)
你必须在仓库根建立如下结构;标注「新增」的是本应用特有模块,其余照抄 ClassHelper 的组织方式。
```text
classisland-ts/
├─ package.json # 版本单一来源、pnpm@11.8.0、engines>=20.19
├─ pnpm-workspace.yaml
├─ pnpm-lock.yaml
├─ tsconfig.base.json # target ES2023、strict、verbatimModuleSyntax
├─ .prettierrc.json # LF、单引号、100 列
├─ scripts/
│ ├─ version.mjs # 版本唯一修改入口(--bump patch|minor|major)
│ ├─ check-icons.mjs # 图标资源完整性门禁
│ └─ lib/electron-env.mjs # 跨平台解析 electron 可执行文件
├─ packages/
│ ├─ shared/ # 三端共享:类型/常量/schema/纯函数
│ │ └─ src/
│ │ ├─ index.ts # 统一导出(照抄 packages/shared/src/index.ts)
│ │ ├─ constants.ts # 尺寸/默认值/枚举/映射表
│ │ ├─ types.ts # DTO 与持久化类型
│ │ ├─ motion.ts # OK 动效令牌(纯常量+纯函数)
│ │ ├─ island-layout.ts # 岛行/组件方案 schema + normalize(新增扩展)
│ │ ├─ schedule.ts # 时间表/课表/周次纯函数(新增)
│ │ ├─ time.ts # 本地日期/日键/时间补偿(新增)
│ │ ├─ notifications.ts # 提醒 DTO/优先级(新增)
│ │ ├─ automation.ts # 自动化 DTO(新增)
│ │ ├─ plugins.ts # 插件清单/权限/扩展点类型(新增)
│ │ └─ ipc.ts # IPC 通道名与 payload 类型(新增,主进程与 preload 共用)
│ ├─ ui/ # 共享 Vue 控件与样式令牌
│ │ └─ src/
│ │ ├─ index.ts # registerSharedUi(app)
│ │ ├─ tokens.css # 基础色/圆角/间距(照抄)
│ │ ├─ theme.css # 语义色 + html.dark(照抄后扩展)
│ │ └─ *.vue # DatePicker/PageHeader/FullScreenGuide 等
│ └─ desktop-client/ # Electron 应用
│ ├─ package.json # main: dist/main/index.js
│ ├─ electron-builder.yml
│ ├─ index.html # 应用窗口(设置/编辑/OOBE)
│ ├─ island.html # 岛渲染入口(必须独立)
│ ├─ vite.config.mts # 双入口、base './'、outDir dist/renderer、5174
│ ├─ tsconfig.json # 渲染层(DOM lib)
│ ├─ tsconfig.node.json # 主进程/preload(node lib)
│ ├─ scripts/
│ │ ├─ dev.mjs # vite 就绪后起 electron(VITE_DEV_SERVER_URL)
│ │ ├─ build-main.mjs # esbuild -> dist/main + dist/preload
│ │ ├─ build-update-helper.mjs # 编译独立更新助手(新增,见 2.11)
│ │ ├─ dist-win.mjs # electron-builder 包装 + 产物归档
│ │ └─ check-bundle-secrets.mjs # 打包不得含凭据门禁
│ └─ src/
│ ├─ main/
│ │ ├─ index.ts # 启动编排(照抄 ClassHelper 顺序)
│ │ ├─ config.ts # config.json 读写/归一化/合并写
│ │ ├─ channels.ts # IPC 通道名叶子模块(零 import)
│ │ ├─ ipc.ts # ipcMain.handle 注册表
│ │ ├─ island.ts # 岛窗口控制器(ClassIsland 主界面宿主)
│ │ ├─ main-window.ts # 设置/编辑窗口管理(新增)
│ │ ├─ tray.ts # 系统托盘
│ │ ├─ logger.ts # main.log + 1MB 轮转
│ │ ├─ crash-guard.ts # 崩溃自愈
│ │ ├─ auto-update.ts # 下载/校验/空闲安装
│ │ ├─ update-installer.ts # 安装助手就绪/提交/回滚
│ │ ├─ storage.ts # 数据文件读写/备份/迁移(新增,见 13 部分)
│ │ ├─ scheduler.ts # 时间引擎:上课/下课/准备/放学(新增)
│ │ ├─ automation.ts # 自动化运行时(新增)
│ │ ├─ notifications.ts # 提醒队列/宿主(新增)
│ │ ├─ plugins.ts # 插件宿主(新增,见 12 部分)
│ │ └─ platform/ # 开机自启、单实例、DPI、全屏检测
│ ├─ preload/
│ │ ├─ index.ts # contextBridge 暴露 window.desktop
│ │ └─ island.ts # contextBridge 暴露 window.island
│ ├─ island/ # 岛渲染进程(手写 CSS,零 Element Plus)
│ │ ├─ main.ts # 注入动效令牌后 mount
│ │ ├─ LayoutIslandApp.vue # 选择经典/自定义渲染
│ │ ├─ spring.ts # SpringValue 帧积分
│ │ ├─ squircle.ts # 连续圆角路径
│ │ ├─ classic-island.css # 经典岛样式
│ │ └─ components/ # 岛内组件(日期/时钟/课程/进度/提醒)
│ ├─ renderer/ # 应用窗口渲染进程
│ │ ├─ main.ts # 挂载顺序见 2.9
│ │ ├─ router/index.ts # createWebHashHistory
│ │ ├─ stores/ # pinia:ui/notifications/onboarding/profile/settings
│ │ ├─ views/ # 设置中心、档案编辑、OOBE、关于…
│ │ ├─ composables/motion.ts # applyMotionTokens/useReducedMotion/useCountUp…
│ │ ├─ api/ # 本地数据访问(经 preload IPC,不含网络)
│ │ └─ styles/index.css # 语义色令牌 + 全局样式
│ └─ types/desktop.d.ts # 主进程↔渲染进程桥接契约
```
依据:`repos/ClassHelper-master/packages/desktop-client/package.json:7-18`(入口与脚本)、`vite.config.mts`(入口与产物)、`src/main/*`、`src/preload/*`、`src/island/*`、`src/renderer/*` 的既有布局。
### 2.4 进程模型(必须五类进程分离)
1. **主进程(`src/main/index.ts`)**:唯一持有窗口、托盘、配置、定时器、文件系统、自动更新、插件宿主的进程。它必须承载 ClassIsland 的「Core 服务层」职责:时间引擎、课表状态机、提醒宿主、自动化、存储与备份、插件注册表。
2. **应用窗口渲染进程(`index.html`)**:设置中心、档案编辑器、OOBE、关于页、日志、调试、恢复。按需创建;关闭窗口必须只隐藏不退出(ClassIsland 主窗口关闭即隐藏的逻辑改成「设置窗口关闭=销毁,托盘常驻」)。
3. **岛渲染进程(`island.html`)**:常驻内存,只画岛。必须独立 `BrowserWindow`,独立 preload(`dist/preload/island.js`),独立 CSS,**不得**加载 Element Plus。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:480-512`(窗口参数与 preload)、`src/island/main.ts:1-30`(零 UI 库)。
4. **插件/自动化隔离进程(`utilityProcess`,Electron 44 内置)**:插件代码、用户自动化脚本、外部命令执行必须跑在这个进程里,崩溃不得拖垮主进程;与主进程之间用 `MessagePort` 传纯数据。ClassIsland 用 `PluginLoadContext` 做程序集隔离(`repos/ClassIsland/ClassIsland/Services/PluginService.cs:186-235`),在 Electron 下等价物就是 utilityProcess + 动态 `import()`。
5. **独立更新助手(`update-helper.exe`)**:Windows 下由构建脚本生成的小型可执行程序,负责"旧进程退出后替换文件、失败回滚、写 `install-result.json`"。不得由主进程自己替换正在运行的 exe。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/auto-update.ts:516-564`(就绪→commit→退出)与 `scripts/build-main.mjs:12`(构建时产出助手)。
### 2.5 构建管线(命令与产物必须一致)
**主进程与 preload**:用 esbuild 打成 CJS,`platform: 'node'`、`target: 'node20'`、`external: ['electron']`、`define process.env.NODE_ENV`;产物固定为:
- `dist/main/index.js`
- `dist/preload/index.js`
- `dist/preload/island.js`
- `dist/main/update-helper.exe`(构建时由 `build-update-helper.mjs` 生成)
依据:`repos/ClassHelper-master/packages/desktop-client/scripts/build-main.mjs:14-46`。
**渲染进程**:用 Vite 双入口构建:`index.html`(应用窗口)与 `island.html`(岛);`base: './'`(Electron 以 `file://` 加载,必须相对路径)、`outDir: 'dist/renderer'`、`emptyOutDir: true`、`sourcemap: false`、`chunkSizeWarningLimit: 2000`;dev 服务器固定 `127.0.0.1:5174`、`strictPort: true`。依据:`repos/ClassHelper-master/packages/desktop-client/vite.config.mts:13-39`。
**开发模式**:`scripts/dev.mjs` 必须先探测 5174 端口可用,再以 `VITE_DEV_SERVER_URL=http://127.0.0.1:5174` 启动 Electron;主进程读到该变量时 `loadURL` 并打开 DevTools,读不到时 `loadFile('../renderer/index.html')`;岛的开发地址是 `${devUrl}/island.html`。依据:`src/main/index.ts:18`、`:137-145`、`:221`;`scripts/dev.mjs:21-60`。
**打包**:`electron-builder.yml` 必须包含:
| 项 | 值 | 证据 |
|---|---|---|
| appId | `com.classisland.desktop`(从 ClassHelper 改名) | `electron-builder.yml:1` |
| productName | `ClassIsland` | `:2` |
| 语言包 | 仅 `zh-CN`(删除其余 54 个 locales) | `:14-15` |
| files | `dist/**/*`、`package.json`、排除 `node_modules`、排除 `*.map` | `:30-34` |
| asar | `true` | `:36` |
| npmRebuild | `false`(无原生模块) | `:37-38` |
| compression | `normal` | `:39` |
| win target | `nsis` x64 + `portable` x64 | `:42-47` |
| artifactName | `${productName}-${version}-${arch}.${ext}` | `:52` |
| nsis | `oneClick:false`、`perMachine:false`、`allowToChangeInstallationDirectory:true`、桌面+开始菜单快捷方式 | `:54-61` |
| portable | `${productName}-${version}-${arch}-portable.${ext}` | `:63-64` |
**必须建立的门禁脚本**(在 CI/发布前按序执行,任何一步失败即中止):
1. `pnpm build:shared`(先建共享包,再建下游——依据 ClassHelper 根脚本 `package.json:11-17`)。
2. `pnpm typecheck`(`vue-tsc --noEmit` + `tsc --noEmit -p tsconfig.node.json`,依据 `desktop-client/package.json:16`)。
3. `pnpm lint`(ESLint + 自定义 authz/资源检查)。
4. `pnpm check:icons`(图标资源完整性)。
5. `pnpm build:desktop`(= check-icons + build:main + vite build + check:secrets,依据 `desktop-client/package.json:10-13`)。
### 2.6 应用启动时序(必须按此顺序,不得提前读配置)
1. 解析命令行参数(`--hidden` 静默启动、`--safe` 安全模式、`--diagnostic` 诊断模式、`--recovery` 恢复模式、`--importV1/--importV2` 导入)。
2. 若存在 `ELECTRON_SMOKE_PROFILE`(仅自动化验收使用),`app.setPath('userData', profile)`;正常启动不得设置。
3. `app.requestSingleInstanceLock()`;拿不到锁立刻 `app.quit()`,拿到锁后监听 `second-instance` 并聚焦/唤起已有窗口。依据:`src/main/index.ts:176-182`。
4. `app.whenReady()` 之后依次:`logger.setVerbose(config.developerMode)` → `registerIpcHandlers()` → `registerIslandIpc()` → 应用开机自启登录项 → 创建设置窗口(隐藏态可配)→ `installCrashGuard()` → 加载渲染页 → 注册退出回调 → `island.init()` → 创建托盘。依据:`src/main/index.ts:184-262`。
5. 退出时统一资源清理:停止定时器 → `flushConfigSync()`(配置同步落盘)→ 销毁托盘 → 销毁岛 → 销毁全部窗口 → `app.quit()`,并用 `setTimeout(() => app.exit(0), 1500)` 兜底。依据:`src/main/index.ts:291-325`。
**关键纪律**:配置必须在 `app.setPath('userData')` 之后**懒加载**,不得在模块顶层读盘(否则读到错误 profile 的 config.json)。依据:`src/main/config.ts:261-272` 的注释与实现。
### 2.7 窗口参数(像素级基线)
**岛窗口(ClassIsland 主界面)**必须满足:
- `width/height` 使用"固定包围盒 + 窗内形变",**以第 4 部分 4.3 为唯一实现口径**:`winW = 目标屏幕宽(DIP)`,`winH = contentMaxHeight + 2 × safePadding`(`safePadding ≥ 20`,其中包含阴影/光晕余量,参考 ClassHelper 的 `ISLAND_SHADOW_PAD = 10` 双侧口径);窗口在开合、行增删与提醒展开过程中尺寸不变,形变全部发生在渲染层画布内,只有显示器/DPI 变化、行数超出上限、编辑模式进出、偏移/缩放/停靠设置变化时才 `setBounds` 且节流 ≥100ms。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:72-112,480-512`(固定包围盒思想)与第 4 部分 4.3(ClassIsland 全屏宽窗口适配)。
- 窗口标志:`frame:false`、`transparent:true`、`resizable:false`、`movable:false`、`minimizable:false`、`maximizable:false`、`fullscreenable:false`、`skipTaskbar:true`、`hasShadow:false`、`focusable:false`、`alwaysOnTop:true`、`type:'toolbar'`。依据:`src/main/island.ts:480-504`;其中 `focusable` 必须为 `false`(ClassIsland 主窗口不抢焦点,见 04 §4.2)。
- `webPreferences`:`preload` 指向 `dist/preload/island.js`、`contextIsolation:true`、`nodeIntegration:false`、`sandbox:true`、`backgroundThrottling:false`。依据:`:505-511`。
- 创建后必须:`setAlwaysOnTop(appearance.alwaysOnTop,'screen-saver')`、`setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true})`、`setWindowOpenHandler(()=>({action:'deny'}))`、`will-navigate` 白名单校验、默认 `setIgnoreMouseEvents(true,{forward:true})`、**禁止** `setBackgroundMaterial()`。依据:`:514-527`。
- 定位:按 ClassIsland 的 6 个停靠位(`WindowDockingLocation`:0 左上 / 1 中上 / 2 右上 / 3 左下 / 4 中下 / 5 右下)+ `WindowDockingOffsetX/Y`(默认 0)+ `WindowDockingMonitorIndex`(默认 0);ClassHelper 的 `top-center` 等 6 个枚举与 `marginX/marginY=8` 只作参考,**不得**替代 ClassIsland 的停靠与偏移语义。依据:`repos/ClassIsland/ClassIsland/Models/Settings.cs:1810-1833`。
- **坐标口径(强制)**:所有窗口 bounds、停靠偏移、边距、组件尺寸与间距必须使用 **DIP(CSS px)**;`BrowserWindow` 的 `x/y/width/height`、`screen.getPrimaryDisplay().workArea` 全部按 DIP 传入与读取。**禁止**在业务布局代码里用 `devicePixelRatio` 做坐标换算;只有"截图/像素级视觉验收"时才用 `devicePixelRatio` 把 DIP 换回物理像素。依据:【推断】(与 04 部分统一口径,避免窗口定位与组件布局出现两套坐标系)。
- **非 Windows 平台的命中降级(必须实现)**:`BrowserWindow.setShape()` 仅在 Windows 生效;macOS/Linux 必须降级为"整窗默认 `setIgnoreMouseEvents(true, { forward: true })`,岛可见区域之外不接收鼠标,指针进入岛体时由渲染层上报 `set-interactive` 打开命中",不得为平台差异改变岛的视觉尺寸或停靠位置。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:278-316`(`setShape` 只在 `process.platform === 'win32'` 分支执行)。
**设置窗口**必须采用 **ClassIsland 的尺寸(第 10 部分为唯一口径)**:`width 1240`、`height 550`、`minWidth 560`、`frame:false`(Windows 11 21H2+ 可用 Mica,否则退回纯色背景)、`center:true`、`minHeight 550`,并补上 `preload: dist/preload/index.js`、`contextIsolation:true`、`nodeIntegration:false`、`sandbox:true`、`backgroundThrottling:false`。依据:`repos/ClassIsland/ClassIsland/Views/SettingsWindowNew.axaml`(第 10 部分 10.1)与 `src/main/index.ts:63-88`(窗口骨架参考)。
### 2.8 主进程核心模块(ClassIsland 服务层在 Electron 下的等价物)
| ClassIsland 概念 | ClassHelper/Electron 实现位置 | 必须实现的职责 |
|---|---|---|
| `AppBase` / `App` 启动编排 | `src/main/index.ts` | 单实例、窗口、托盘、退出清理、崩溃自愈 |
| `SettingsService`(`Settings.json` → `config.json`) | `src/main/config.ts` | `config.json` 为运行期唯一设置源;`Settings.json` 只做 ClassIsland 格式只读导入/导出兼容层(不双向双写);默认值合并、字段归一化、120ms 合并写、退出同步落盘、字段白名单 |
| `ProfileService`(档案) | `src/main/storage.ts` | 档案/课表/时间表读写、临时层清理(对应 `ProfileService.CleanExpiredTempClassPlan`,`repos/ClassIsland/ClassIsland/Services/ProfileService.cs:295-328`) |
| `ComponentsService` | `src/main/storage.ts` + `src/island/*` | 组件方案 JSON 读写、迁移、注册表驱动渲染 |
| `LessonsService`(时间引擎) | `src/main/scheduler.ts` | 每 200ms 推进一次状态机、计算下一时间点/剩余时间、触发上/下课事件 |
| `NotificationHostService` | `src/main/notifications.ts` | 队列、优先级、播放会话、强提醒遮罩 |
| `AutomationService` | `src/main/automation.ts` | 触发器/行动/规则集、信号与 URI |
| `PluginService` | `src/main/plugins.ts` | 插件发现/加载/启用/卸载/依赖排序 |
| `AppLogger` | `src/main/logger.ts` | 控制台 + `app.getPath('logs')/main.log`,单文件 1MB 轮转、保留 `main.log.1` |
| `IpcService` | `src/main/ipc.ts` + `channels.ts` | 通道注册、事件下发、数据校验 |
日志必须照抄 ClassHelper 的轮转与容错口径:`LOG_MAX_BYTES = 1024*1024`、`main.log → main.log.1`、写入失败静默忽略、`app.getPath('logs')` 在 ready 前调用会抛所以路径必须懒解析。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/logger.ts:21-67`。**不得**用 `console.log` 作为唯一排障手段:打包后的 GUI 没有可见控制台。
### 2.9 渲染层挂载顺序(必须逐行对齐)
应用窗口入口 `src/renderer/main.ts` 必须按以下顺序执行,**顺序错一个都会出现白闪或首帧无动画**:
1. `createApp(App)`;
2. 注册用到的 Element Plus 图标(只注册用到的,不全量 293 个);
3. `app.use(createPinia())`;
4. `app.use(router)`;
5. `app.use(ElementPlus, { locale: zhCn })`;
6. `registerSharedUi(app)`(来自 `@classhelper/ui`);
7. 冒烟钩子(若 `window.desktop?.smokeTest`,用 `await import()` 动态加载,禁止静态 import 进主 chunk);
8. `applyMotionTokens()`(把动效令牌写进 `:root`,必须在 `mount` 之前);
9. `await router.isReady()`;
10. `app.mount('#app')`。
依据:`repos/ClassHelper-master/packages/desktop-client/src/renderer/main.ts:17-57`;动效令牌注入理由见 `docs/dev/motion.md:53-59`。
路由必须用 `createWebHashHistory()`,因为生产环境是 `file://`,history 模式会 404。依据:`src/renderer/router/index.ts:5-8,110-113`。路由守卫必须做两件事:未初始化时进 OOBE;开发者页仅当 `developerMode` 为 true 时可进并做运行时兜底(读取 pinia 状态,不每次跨 contextBridge)。依据:`:115-133`。
### 2.10 岛渲染进程的技术约定(必须与 ClassIsland 视觉对齐)
1. **岛内不得引入 Element Plus、不得引入任何图标字体**;只用 Vue + 手写 CSS + 内联 SVG。依据:`src/island/main.ts:6-10`。
2. `island.html` 必须带与主窗口一致的 CSP,且 `html, body { margin:0; padding:0; background:transparent }`。依据:`repos/ClassHelper-master/packages/desktop-client/island.html:6-21`。
3. 岛必须**再注入一次**动效令牌(两个渲染进程不共享 `:root`),漏掉会让 `transition` 简写整条失效。依据:`src/island/main.ts:13-24`、`docs/dev/motion.md:58-59`。
4. 动画时长与曲线**以第 14 部分为准**(ClassIsland 的 Storyboard/Transition 规格);弹簧动画(`SpringValue`:`stiffness=0.10`、`damping=0.68`、`momentumRetention=0.35`、`maxDistanceRatio=0.2`、`maxDt=6`、`snapThreshold=0.05px`、帧单位 `1000/60`、零过冲钳制)只用于第 14 部分明确标注为「spring/跟手」的交互(如拖拽预览、内容宽度跟随),**不得**用它替代第 14 部分给出确定时长的规格动画。依据:`repos/ClassHelper-master/packages/desktop-client/src/island/spring.ts:26-30,41-47,85-114` 与第 14 部分。
5. 圆角实现:**默认必须使用 CSS `border-radius`(等同 Avalonia `CornerRadius`,半径取 `Settings.RadiusX`,默认 8)**,以保证与 ClassIsland 的直角圆弧逐像素一致;ClassHelper 的连续圆角(squircle:超椭圆指数 `4.2`、每角 `14` 段、半径 `min(r,w/2,h/2)`)只能由主题包显式启用(P2),不得作为默认,否则 8px 角的曲率会与 ClassIsland 原版不同。依据:`repos/ClassIsland/ClassIsland/MainWindow.axaml:282` 与 `XamlThemes/FluentTheme/Styles.axaml:48`;对照实现见 `src/island/squircle.ts:21-24,44-81`。
6. 岛组件的几何基线必须集中到 `packages/shared/src/island-layout.ts` 的 `CLASSISLAND_ISLAND_METRICS`:`rowHeight 40`、`horizontalPadding 12`、`radius 8`、`opacity 0.5`、`scale 1`(0.75–2.5)、`secondaryFontSize 14`、`bodyFontSize 16`、`emphasizedFontSize 18`、`largeFontSize 20`、`rowSpacing 5`、`fadedOpacity 0.05`。依据:`repos/ClassHelper-master/packages/shared/src/island-layout.ts:19-36`。
7. 岛形态尺寸增量必须集中常量:`ISLAND_SIZE_DELTA = { expanded:{w:156,h:186}, urgent:{w:172,h:202}, call:{w:188,h:218} }`、`ISLAND_SHADOW_PAD=10`、`ISLAND_SLIVER_WIDTH=6`。依据:`packages/shared/src/constants.ts:336-351`。
8. **参考值(不是本应用默认值)**:ClassHelper 既有「经典岛」外观默认值 `width 268`、`height 44`、`radius 20`、`fontSize 13`、`opacity 1`、`accent #0a84ff`、`style 'black'`、`position 'top-center'`、`marginX/marginY 8`、`idleSliver false`、`followCursorDisplay false`(范围:高度 36–72、宽 220–420、圆角 8–32、透明度 0.4–1、字号 11–20、速度 0.5–2、marginX 0–200、marginY 0–160,依据 `constants.ts:265-302`)。本应用(ClassIsland 复刻)的默认值**必须**按第 3、4、10 部分:行高 40、圆角 8、字号 14/16/18/20、透明度 0.5、停靠位 `WindowDockingLocation=1`、偏移 0,**不得**套用 ClassHelper 经典岛的 268×44 胶囊默认值。
9. 岛内 `:hover` 规则必须包在 `@media (hover: hover) and (pointer: fine)` 内,否则希沃触摸屏会出现"点过之后永远高亮"的幽灵 hover。依据:`docs/dev/motion.md:80-81`。
10. 阴影常驻、只随形态改参数,不得在形态切换时增删 `filter`(会新建合成表面导致闪帧)。依据:`docs/dev/winisland-design-tokens.md:25-26`。
### 2.11 自动更新与打包(工程结构的一部分)
- 更新目录:`<userData>/updates`;下载先写 `.part` 再校验后 `rename`;`policy.json` 记录服务端下发的更新策略。依据:`src/main/auto-update.ts:59-133`。
- 下载超时 10 分钟;文件名必须匹配 `^[A-Za-z0-9._-]+$`(挡住路径穿越);下载完必须校验 SHA-256,服务端未给哈希则**拒绝安装**。依据:`:124-128,320-391`。
- 安装时机三档:`immediate` 立即;`after-school` 不早于 `updateNotBefore`;`idle` 需主窗口隐藏满 5 分钟(`CLIENT_UPDATE_IDLE_MS = 5*60*1000`)。依据:`packages/shared/src/constants.ts:677`、`auto-update.ts:474-506`。
- 安装必须交给独立助手:先生成/校验助手 → 助手就绪 → 主进程退出 → 助手替换文件 → 写 `install-result.json`(失败时主进程下次启动读回并展示 `failed` 状态)。依据:`auto-update.ts:516-573`。
- **安装目录策略:不做 side-by-side 多版本并存**。必须使用固定安装目录(NSIS 装到固定路径);更新助手在替换前把当前安装目录的关键文件(`ClassIsland.exe`、`resources/app.asar` 等)复制到 `updates/rollback/<版本>/`,替换失败或新版启动失败时用该副本回滚并重新拉起旧版。ClassIsland 的 `app-<版本>` + `.current` 多版本方案(`repos/ClassIsland/ClassIsland.Launcher/Program.cs:30-57`、`UpdateService.cs:841-855`)只作为"可回滚"语义参考,**不照搬目录结构**。**【推断】**
- **来源安全**:更新下载默认只接受 `https://`;镜像/自定义源必须由用户显式开启并提示"非官方来源风险",默认不启用。清单必须校验 SHA-256;签名可选(Ed25519,带 `keyId`,支持公钥轮换),详见 12.6。
- 打包产物必须做"凭据门禁":`dist/renderer` 内不得出现任何账号/口令字面量(`scripts/check-bundle-secrets.mjs`),因为 asar 可直接解包。依据:`repos/ClassHelper-master/packages/desktop-client/package.json:13`、`src/preload/index.ts:43-54`。
### 2.12 ClassIsland → ClassHelper 技术映射决策表(必须按此实现)
| ClassIsland(C#/Avalonia) | 本实现(Electron/Vue) | 关键差异与对策 |
|---|---|---|
| Avalonia `Window` + `TransparencyLevelHint=AcrylicBlur` | `BrowserWindow{transparent:true, frame:false}` + CSS 半透明背景 | Electron 下**禁止** `setBackgroundMaterial()`,也**禁止** `backdrop-filter`(透明窗口下 Chromium 采样不可靠,会闪/发灰);毛玻璃效果用**预混合半透明纯色**实现(ClassHelper 的 glass 同样是半透明色、不采样桌面模糊,见 `docs/dev/winisland-design-tokens.md:23`);编辑模式按第 4 部分用全屏半透明纯色遮罩 |
| XAML `Style`/`ResourceDictionary` 主题 | `packages/ui/src/theme.css` + `html.dark` + CSS 变量 | 主题包改为"CSS 变量清单 + 资源目录 + 预览图",见 9 部分 |
| `Storyboard`/`Transition`(如 `BackgroundWidth 300ms cubic-bezier(0.65,0,0.35,1)`) | CSS `transition`/`@keyframes` + `motion.ts` 令牌 | 令牌数值单一来源放 `packages/shared/src/motion.ts`;不得在组件里手写曲线字面量 |
| `DispatcherTimer` 时间引擎 | 主进程 `setInterval` + `setTimeout` 精确对齐 + 系统休眠唤醒修正 | Node 定时器会漂移:必须以"当前时间与下一时间点差值"重排,不得累加计数 |
| `Mutex("Global\\ClassIsland.Lock")` 单实例 | `app.requestSingleInstanceLock()` | 二次启动必须唤起已有窗口(`second-instance`),不得静默退出 |
| `dotnetCampus.Ipc` 命名管道(`ClassIsland.IPC.v2.Server`) | `ipcMain/ipcRenderer` + contextBridge;**可选**本地只读 HTTP API | 不恢复命名管道。外部只读集成如启用,必须:只监听 `127.0.0.1`、端口随机分配、默认关闭、开启后需一次性 token 鉴权、只提供读接口(当前课程/课表/状态),**不得提供任何写操作**。详见 12.4 |
| 插件 `.cipx`(zip + `manifest.yml` + `.dll`) | 同构 zip(`.cipx`)+ `manifest.yml` + ESM 入口 `.mjs` | 插件必须在 utilityProcess 内 `import()`,不得污染主进程;清单统一 camelCase、兼容读取 PascalCase 旧清单;见 12 部分 |
| `Settings.json`(`CommonDirectories.AppRootFolderPath`) | `<userData>/config.json` 为主 + `Settings.json` 导入/导出兼容层 | 运行期只读写 `config.json`;`Settings.json` 只做 ClassIsland 格式的**只读导入**与导出,**不双向双写**;`IsIslandSeperated` 等历史拼写必须保留(见 13 部分) |
| `Profiles/<name>.json` | `<userData>/data/profiles/<name>.json` | 保留 PascalCase 字段名以兼容导入导出 ClassIsland 档案 |
| `Backups/Auto_*.zip` | `<userData>/backups/` | 自动备份 7 天一次、最多 16 份,包内含 `Config/` + `Profiles/` + `Settings.json`(若存在;见 13 部分) |
| 独立 Launcher + 多版本目录 | electron-builder NSIS + 固定安装目录 + 独立更新助手 + 回滚副本 | 不做 side-by-side 多版本;见 2.11 |
| `PackageRoot` 环境变量 | `app.getPath('userData')` + `process.resourcesPath` | 所有路径必须用 Electron API 解析,禁止硬编码 `C:\` |
| `Color`(Avalonia)序列化为 `#AARRGGBB` | 字符串 `#RRGGBB`/`#AARRGGBB` | 解析必须容忍 6 位与 8 位、容忍大小写,写出统一 8 位(AlphaComponentPosition.Trailing) |
### 2.13 第三方库等价映射(.NET/Avalonia → npm,必须按此实现)
ClassIsland 依赖的一批 .NET 库在本技术栈下必须换成下表等价物;不得因为"找不到同名库"就自研底层协议。
| ClassIsland 依赖 | 本实现替代 | 使用约束 |
|---|---|---|
| `System.Text.Json` + `ConfigureFileHelper` | Node `JSON.parse/stringify` + 自研 `storage.ts` | 必须保留"写入前浅拷贝快照、写入中不阻塞 UI、退出前同步落盘"语义(见 2.16) |
| `YamlDotNet`(插件 `manifest.yml`) | `yaml`(npm,服务端同款) | 解析必须 `ignoreUnknownKeys`:未知字段不得导致插件加载失败(对齐 `repos/ClassIsland/ClassIsland/Services/PluginService.cs:68-72` 的 `IgnoreUnmatchedProperties`) |
| `NAudio`/系统音频播放 | 渲染层 `HTMLAudioElement`,主进程只下发"播放/停止/音量" | 音效文件必须走 `file://` 本地资源;音量 0–1,默认 1.0(`Settings.NotificationSoundVolume`) |
| `System.Speech`/`EdgeTtsSharp` TTS | Windows 用 `System.Speech` 等价方案:优先系统 TTS(Windows `SAPI` 通过 PowerShell 启动会污染进程,改为可插拔 TTS 服务,默认关闭),可选在线 TTS 必须由用户显式开启 | 离线时不得阻塞提醒播放,TTS 失败只记日志 |
| `RestSharp`/`HttpClient` | Node 内置 `fetch`(Electron 44/Node 20+) | 必须显式 `AbortSignal.timeout(...)`、`redirect:'error'`(对齐 `auto-update.ts:193-199`) |
| `Downloader`(分片并行下载) | 自研流式下载 + 4 并发分片 | 必须支持 `.part` 临时文件、断点续传可选、进度节流 250ms(对齐 `auto-update.ts:216-219`) |
| `Octokit`(GitHub API) | 不用:更新源改为自建静态 JSON 清单 + 固定 URL 模板 | 若保留 GitHub 通道,必须只读、无 token、失败回退到主通道 |
| `SharpZipLib`(部署包解压) | `adm-zip` 或 Node `zlib` + 自研 zip 读取 | 解压前必须校验路径不越出目标目录(对齐 `UpdateService.cs:733-743` 的 `IsSafePath`) |
| `CommunityToolkit.Mvvm`(`ObservableObject`/`RelayCommand`) | Vue `ref/reactive/computed` + `watch` | 不得把 Vue 响应式代理跨 contextBridge 传递(见 2.15 数据纯净化) |
| `DynamicData`(集合变换) | `computed` + 普通数组操作 | 大数据量列表必须避免在 `computed` 里做 O(n²) 操作 |
| `MoonSharp`(Lua 教程脚本) | 禁用脚本引擎;教程改为声明式 JSON 步骤 | 不得为了教程引入任意代码执行能力(安全边界见 2.17) |
| `Sentry`(崩溃上报) | 默认关闭;开启时只上报匿名崩溃栈,不含课表与个人信息 | 与 ClassIsland 的 `IsReportingEnabled` 默认值保持一致(默认 true,但必须可在设置中关闭) |
| `dotnetCampus.Ipc` | Electron IPC(见 12 部分) | 通道名必须与 `channels.ts` 单一来源 |
| `HotAvalonia`(热重载) | Vite HMR | 仅开发期启用;生产包不得包含 HMR 客户端 |
### 2.14 共享包(`packages/shared`)的工程约定
1. `packages/shared` 必须是**纯 TypeScript**:`type: module`、`main: ./dist/index.js`、`types: ./dist/index.d.ts`、构建用 `tsc -p tsconfig.json`,产物只含 `dist`。依据:`repos/ClassHelper-master/packages/shared/package.json:4-18`。
2. `tsconfig` 的 `lib` 不得包含 `DOM`:共享包会被主进程(Node)与渲染进程(浏览器)同时消费,任何 `document`/`window`/`localStorage` 引用都会让主进程侧编译失败。依据:`repos/ClassHelper-master/packages/shared/src/motion.ts:10-16` 的显式约束与 `docs/dev/motion.md:30-37`。
3. 入口必须单一:`packages/shared/src/index.ts` 只做 `export *`/`export type *` 转发;新增模块必须同步加一行导出。依据:`packages/shared/src/index.ts:1-16`。
4. 命名约定:类型用 `PascalCase`;常量全大写下划线;函数用 `camelCase`;持久化 JSON 字段必须与 ClassIsland 原名一致(如 `TimeLayouts`、`ClassPlans`、`WeekCountDivTotal`),以便直接导入导出 ClassIsland 档案;IPC 内部 DTO 用 `camelCase` 并在 `types.ts` 注明与持久化字段的映射。**【推断】**(ClassIsland 的 JSON 为 PascalCase,见 `repos/ClassIsland/examples/example.json:4-19`。)
5. 全部"归一化"函数必须放在 shared:例如 `normalizeIslandLayouts(input)` 负责把任意 IPC/导入数据夹紧到合法区间(最多 12 行、200 节点、6 层、颜色必须 `#rrggbb`、未知 kind 丢弃、重复 id 重命名)。依据:`packages/shared/src/island-layout.ts:256-403`。
6. `motion.ts` 必须保持"纯常量 + 纯函数":只导出缓动曲线、弹簧参数、时长令牌、`motionCssVars()`;DOM 注入放各端 `composables/motion.ts`。依据:`packages/shared/src/motion.ts:136-241`。
### 2.15 共享 UI 包(`packages/ui`)与样式令牌工程
1. `packages/ui` 必须以源码形式被消费:`exports` 直接指向 `./src/index.ts`、`./src/theme.css`、`./src/tokens.css`,不产出 dist;`peerDependencies` 只允许 `vue` 与 `element-plus`。依据:`repos/ClassHelper-master/packages/ui/package.json`。
2. `tokens.css` 只放"基础令牌"(品牌色、圆角、间距、阴影),且必须提供 `html.dark` 覆盖;不得在 `html.dark` 里改圆角与动效令牌(几何必须两套主题一致,否则切换时布局跳动)。依据:`packages/ui/src/tokens.css:1-21` 与 `docs/dev/motion.md:123-130`。
3. 基础令牌必须至少包含:`--ch-ui-primary:#5d6bfa`、`--ch-ui-bg:#f7f8fa`、`--ch-ui-surface:#fff`、`--ch-ui-surface-alt:#f3f5f9`、`--ch-ui-border:#e6e9ef`、`--ch-ui-text:#242936`、`--ch-ui-muted:#737c8e`、`--ch-ui-radius:8px`、`--ch-ui-gap:16px`、`--ch-ui-shadow:0 8px 30px rgb(30 40 70 / 8%)`;深色覆盖 `bg #14161a`、`surface #1d2027`、`surface-alt #252934`、`border #343946`、`text #e9edf5`、`muted #a2abbd`、`shadow 0 8px 30px rgb(0 0 0 / 25%)`。依据:`packages/ui/src/tokens.css:1-21`。
4. 语义色令牌必须与总纲第 3 部分一致:`--ch-surface`、`--ch-surface-alt`、`--ch-surface-sunken`、`--ch-surface-hover`、`--ch-text`、`--ch-text-secondary`、`--ch-text-muted`、`--ch-text-placeholder`。组件里一律引用变量,禁止写死 `#fff`/`rgba(0,0,0,...)`(固定深色区域除外:登录页、启动过渡页、岛本身、全屏作业看板、accent 渐变上的白字)。依据:`docs/dev/motion.md:107-138`。
5. 改令牌时必须"只改值",禁止对样式表跑批量替换:把 `#303133` 全量替换成 `var(--ch-text)` 会把令牌定义本身改成自引用,导致整条自定义属性失效。依据:`docs/dev/motion.md:128-130`。
6. Element Plus 的全站过渡必须改写 `--el-transition-*` 变量(300/200ms → 200/140ms,缓动换成 `EASE_OUT`),不得改组件源码。依据:`docs/dev/motion.md:65-66`。
### 2.16 主进程并发与 I/O 纪律(直接决定"卡不卡")
1. **主进程是 Electron 的消息泵**:任何同步文件 I/O 都会同时卡住窗口动画。必须遵守:
- 配置写入必须"内存缓存 + 异步合并写 + 退出同步兜底":写盘合并窗口 `CONFIG_WRITE_DEBOUNCE_MS = 120`,写入串行化(`writing` 标志),写入期间的改动进 `pendingWrite` 再补写一次;`flushConfigSync()` 在 `before-quit` 调用。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/config.ts:235-346`。
- 读取配置必须只读一次内存缓存,不得每次 `getConfig()` 都 `readFileSync + JSON.parse`(ClassHelper 明确记录过这条导致侧栏展开卡顿)。依据:`config.ts:235-250` 的注释。
2. **禁止在主进程做 CPU 重活**:Excel 解析、主题包解压、插件加载、备份打包必须放 `utilityProcess` 或 `node:worker_threads`;主进程只负责调度与 IPC 转发。
3. **定时器纪律**:时间引擎的 tick 周期固定 200ms(ClassIsland 主计时器同量级【推断】),每次 tick 只用"当前时间 - 时间点边界"做判断,禁止累加 count 推断时间;系统休眠唤醒后必须立即重算一次(监听 `powerMonitor` 的 `resume`)。
4. **写入磁盘必须原子**:所有 JSON 先写 `<file>.tmp`,`fsync` 后 `rename` 覆盖;备份进程写 `<Auto_*.zip>.tmp` 再改名。依据:`repos/ClassIsland/ClassIsland/Shared/Helpers/ConfigureFileHelper.cs:182-188`(`FileStream.Flush(true)` 后关闭)。
5. **数据目录结构固定**:`<userData>/` 下只允许出现 `config.json`、`data/`、`logs/`、`updates/`、`backups/`、`themes/`、`plugins/`、`cache/` 八个顶层条目;不得把临时文件写到安装目录(安装目录可能只读)。
6. **退出清理必须幂等**:托盘退出、`before-quit`、`will-quit` 都可能触发同一次清理,清理函数必须可重入(ClassHelper 用 `isQuitting` 标志 + 各资源自带 `isDestroyed()` 判断,见 `src/main/index.ts:287-341`)。
### 2.17 安全基线(不得为了"方便"削弱任何一条)
1. 渲染进程必须启用 `contextIsolation: true`、`nodeIntegration: false`、`sandbox: true`、`webSecurity: true`;两个窗口都要。依据:`src/main/index.ts:79-87`、`src/main/island.ts:505-511`。
2. preload **只**通过 `contextBridge.exposeInMainWorld` 暴露显式白名单方法,绝不暴露 `ipcRenderer` 本体;不得暴露 `require`、`process`、`Buffer`。依据:`src/preload/index.ts:17-101`、`src/preload/island.ts:10-123`。
3. 通道名必须集中在**零依赖叶子模块** `src/main/channels.ts`:preload 在 `sandbox: true` 下不能 `require('node:fs')` 等内建模块,一旦 import 了主进程模块,esbuild 会把整条依赖链打进 preload 导致"加载即失败、`window.desktop === undefined`"。依据:`src/main/channels.ts:1-28` 的注释与实现。
4. 跨桥接只能传**纯数据**:传 Vue `ref`/`reactive`(Proxy)会在进入 contextBridge 之前抛 `An object could not be cloned.`。所有调用点必须展开成字面量,且 preload 返回的订阅函数必须可取消(`onXxx` 返回取消函数或由主进程移除监听)。依据:`src/types/desktop.d.ts:144-152`、`src/preload/index.ts:32-36`。
5. 导航必须白名单精确比对:开发态比对 `origin`,生产态比对"去 hash/search 后的精确 `file://` URL",不得用 `startsWith`。窗口必须 `setWindowOpenHandler(() => ({action:'deny'}))`,外链一律 `shell.openExternal`。依据:`src/main/index.ts:42-61,90-100`、`src/main/island.ts:32-50,516-522`。
6. 两个 HTML 入口都必须带 CSP:`default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'`。依据:`repos/ClassHelper-master/packages/desktop-client/index.html:20-23`、`island.html:6-10`。
7. 敏感数据(登录令牌、管理凭据)必须用 `safeStorage.encryptString` 加密后 base64 落盘;`safeStorage` 不可用时必须降级明文并写 warn 日志(不得静默)。依据:`src/main/config.ts:169-196`。
8. 更新域名/下载 URL 必须来自配置白名单,渲染进程不得指定任意 URL 下载或执行;安装包路径必须校验"位于 `updates` 目录内且 realpath 一致"。依据:`auto-update.ts:477-490`。
9. 插件代码必须在隔离进程运行、只能访问 manifest 声明的权限(文件读写范围、网络域名、能否弹提醒、能否执行外部程序);未声明即拒绝。见 12 部分。
10. 发布前必须跑 `check-bundle-secrets`:`dist/renderer` 与 `dist/main` 中不得出现账号/口令/私钥字面量。依据:`repos/ClassHelper-master/packages/desktop-client/package.json:10-13`。
### 2.18 环境变量、命令行参数与调试开关
| 名称 | 作用 | 约束 |
|---|---|---|
| `ELECTRON_SMOKE_PROFILE` | 把 `userData` 指到隔离目录 | 仅自动化验收使用;正常启动必须不设置 |
| `ELECTRON_SMOKE_TEST=1` | 冒烟模式标记 | 必须只影响"不弹窗、不写登录项、不自动重启"等行为,不得影响业务逻辑 |
| `VITE_DEV_SERVER_URL` | 开发态渲染地址 | 生产构建不得定义;主进程读到它才 `loadURL` |
| `--hidden` | 开机自启静默启动 | 因 Electron 44 在 Windows 上 `setLoginItemSettings` 没有 `openAsHidden`,必须自己带参数(依据 `src/main/index.ts:22-27,147-173`) |
| `--safe` | 安全模式 | 禁用全部第三方插件与主题、跳过自动化、禁用更新安装,仅加载内置能力 |
| `--recovery` | 恢复模式 | 允许从备份还原配置/档案,禁用写操作直到用户确认 |
| `--diagnostic` | 诊断模式 | 日志提到 debug、结束时在桌面生成诊断包(配置、日志、版本、插件列表,**不含**个人信息) |
| `--uri <classisland://...>` | 单实例 URI 导航 | 第二次启动时把 URI 转发给已运行实例后退出(参考 `repos/ClassIsland/ClassIsland/Program.cs:43-68,141-159`) |
| `--importV1/--importV2/<path>` | 导入旧版配置 | 导入前必须备份;失败可回滚 |
环境变量与参数必须在 `app.whenReady()` 之前解析完毕,之后不得再读 `process.argv` 做行为分支(避免窗口创建后又改变模式导致状态不一致)。
### 2.19 性能预算(超预算必须优化,不得降级功能)
| 指标 | 预算 | 测量方法 |
|---|---|---|
| 冷启动到岛首帧 | ≤ 2.5s(SSD 教室机) | 主进程打点:`app.ready` → 岛渲染进程 `did-finish-load` → 首帧 `requestAnimationFrame` 回传 |
| 常驻内存(主进程 + 岛渲染 + 设置窗口) | ≤ 350MB(对齐 ClassHelper 文档 250–350MB,见 `docs/get-started/requirements.md`) | 任务管理器观察 30 分钟 |
| 空闲 CPU | ≤ 1%(岛常驻,1 秒一次时钟 tick) | 任务管理器观察 10 分钟 |
| 岛动画帧率 | 60fps(掉帧率 <1%) | DevTools Performance 录制形变过程 |
| 渲染进程包体 | 应用窗口 ≤ 3.5MB(gzip 前)、岛 ≤ 800KB | `dist/renderer/assets` 体积 |
| 设置页首屏 | ≤ 400ms(路由切换) | Performance 面板 |
| 配置写入 | 单次 ≤ 5ms,且不阻塞主线程 > 1 帧 | 主进程日志打点 |
任何一项超标都必须优化实现(例如把计算移到 `computed` 缓存、拆 chunk、减少重渲染),**不得**通过删除动画、降低帧率、去掉 blur 等方式"达标"。
### 2.20 本地化与文案工程
1. 默认语言 zh-CN;文案必须集中在 `packages/shared/src/i18n/`(或 `locales/zh-CN.json`),组件内禁止硬编码中文字符串作为唯一来源(允许以 key 调用 `t('...')`)。
2. 日期/星期/时间格式必须走 `Intl.DateTimeFormat`(`zh-CN`),星期文本必须为"周一…周日";`Settings.ShowDate` 默认 true(`repos/ClassIsland/ClassIsland/Models/Settings.cs:44`)。
3. 数字与百分比必须用 `Intl.NumberFormat`;倒计时显示格式为 `mm:ss`,超过 1 小时显示 `hh:mm:ss`。
4. 文案长度必须按"最长的中文条目"预留宽度,禁止用英文长度估算导致换行溢出。
5. 所有错误提示必须给出"发生了什么 + 用户可以做什么",不得只抛出异常类名;配置加载失败必须落到"使用默认配置并提示可恢复备份"。
### 2.21 验收标准(第 2 部分)
实现者必须逐条自查,全部通过才算完成本节:
1. `pnpm install` 后可成功执行 `pnpm -r typecheck` 与 `pnpm build:desktop`,产物包含 `dist/main/index.js`、`dist/preload/index.js`、`dist/preload/island.js`、`dist/renderer/index.html`、`dist/renderer/island.html`。
2. 依赖版本与 2.2 表逐项一致(用 `pnpm list -r --depth 0` 核对),Node 版本 `>=20.19.0`,`pnpm --version` 输出 `11.8.0`。
3. 仓库为 pnpm workspace,`packages/*` 至少含 `shared`、`ui`、`desktop-client` 三个包,根 `package.json` 是唯一手写版本号。
4. 主进程启动时序与 2.6 一致;`config.json` 在 `app.setPath('userData')` 之后才被首次读取(代码审查可验证)。
5. 岛窗口参数与 2.7 列表逐项一致(逐行对照 `new BrowserWindow` 选项),且代码中不出现 `setBackgroundMaterial`。
6. 岛渲染进程产物中不含 Element Plus 与图标字体(检查 `dist/renderer/assets` 体积与 import 图)。
7. `applyMotionTokens()` 在应用窗口与岛入口都被调用,且都在 `app.mount()` 之前。
8. 打包产物通过 `electron-builder --win nsis portable` 生成 setup 与 portable 两个 exe,语言包仅含 `zh-CN.pak`,安装包内不含任何凭据字符串。
9. 单实例:第二次启动不产生第二套窗口/托盘,而是唤起已有实例。
10. 主进程日志写入 `app.getPath('logs')/main.log`,超过 1MB 轮转为 `main.log.1`,且退出前配置已同步落盘(`flushConfigSync` 被调用)。
**补充验收项(工程结构层面)**:
11. 仓库内不得出现硬编码绝对路径(`C:\`、`D:\`、`/home/`),所有路径必须经 `app.getPath`、`path.join`、`process.resourcesPath` 解析;代码审查用 `grep -rn "C:\\\\" packages/` 应为零命中。
12. 图标资源必须"源文件 + 派生文件"分离:设计导出的 `build/classisland.ico`/`.png` 为唯一源,其余尺寸由 `pnpm icons` 派生;`electron-builder.yml` 必须显式指定 `icon: build/classisland.ico`,不得依赖 electron-builder 的默认约定。依据:`repos/ClassHelper-master/packages/desktop-client/electron-builder.yml:48-52`。
13. 托盘必须存在且行为固定:左键/双击显示主窗口;右键菜单至少含「打开设置」「编辑岛」「隐藏/显示岛」「重启应用」「退出」;托盘图标缺失时必须用空图标兜底并记 warn,不得崩溃。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/tray.ts:31-54`。
14. 开机自启必须"配置值"与"系统登录项"双向一致:设置页读的是 `app.getLoginItemSettings().openAtLogin`,不是配置文件里的值;写入失败时配置仍保存并提示"下次启动重试"。依据:`src/main/ipc.ts:66-99`。
15. 所有代码必须通过 Prettier 检查(LF 换行、单引号),且不得为了过检查对历史文件做批量格式化;提交前 `pnpm format:check` 必须通过。