feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)

功能级准入改为「权限键」,`requireRole` 退成默认模板。

权限组
- 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission
- 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺;
  CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板
- 新增「权限组」页:组增删改、成员分配、生效权限预览
- 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖

客户端设备(ClassHelper 桌面端)
- 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留)
- 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒
- 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、
  60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次
- 新增「客户端设备」页:版本一览、一键推送更新、下发远控

客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制)
- POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size
- 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装

大模型助手:只解析、不落库
- POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人),
  真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用
- 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信)
- API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出
- provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥)

系统与运维
- 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥)
- 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable,
  **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项
- 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌

客户端
- 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次),
  未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例)
- 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推

发布内容批量
- POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果
- 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课
  (自动建课会把创建者写成任课老师,等于越权改任教关系)
- 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators

BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。
未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。
服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 /
AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-11 11:49:32 +08:00
1 parent e51879667a
commit 817af02357
131 files changed
+12995 -538

No files matched your search

+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
@@ -14,8 +14,7 @@
"start": "electron .",
"smoke": "node scripts/smoke.mjs",
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
"dist:win": "npm run build && node scripts/dist-win.mjs",
"dist:dir": "npm run build && node scripts/dist-win.mjs --dir"
"dist:win": "npm run build && node scripts/dist-win.mjs"
},
"dependencies": {
"@classhelper/shared": "workspace:*",
+50 -13
View File
@@ -10,12 +10,15 @@
*
* 用法:
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录
*
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
* 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
* 免安装/ electron-builder 的 win-unpacked 内容
* 构建中间/ builder-*.yml、*.nsis.7z
*
* 产物里**没有免安装目录**:electron-builder 的 `win-unpacked/` 只是生成两个 exe 的中间物
* (每版 320MB 上下),打完就删 —— 便携版 `-portable.exe` 已是自解压单文件,交付不需要解包目录。
* 因此也没有 `--dir`:那等于只产出中间物、拿不到任何交付物。需要临时跑一下解包目录时直接调
* electron-builder,别往 releases/ 里塞。
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
*/
@@ -27,9 +30,7 @@ import { resolveNodeRuntime } from '../../../scripts/lib/node-runtime.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(root, '..', '..');
const args = process.argv.slice(2);
const dirOnly = args.includes('--dir');
const passthrough = args.filter((item) => item !== '--dir');
const passthrough = process.argv.slice(2);
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
@@ -74,7 +75,7 @@ const destDir = explicitOutput
: path.join(repoRoot, 'releases', 'client', clientVersion);
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
const cliArgs = [cli, '--win', ...overrides, ...passthrough];
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
// 必须用真正的 node 执行 electron-builder CLI:
@@ -98,7 +99,17 @@ for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
}
}
if (cleaned) console.log('[dist] 已清理上一轮的免安装目录 / 安装包 / 构建中间');
if (cleaned) console.log('[dist] 已清理上一轮的中间物与安装包');
// 打包**之前**就要把输出目录的完整性标签压回 Low(继承),不留到收尾 ——
// 实测过一次:上一轮留下的 win-unpacked 是收尾时用 icacls 标成 Medium 的,
// 而 7za 自身带 Low 标签,pack 时读不动这些 Medium 目录,报
// "cannot open file ... Access is denied",打包直接失败。
try {
execFileSync('icacls', [destDir, '/setintegritylevel', '(OI)(CI)Low'], { stdio: 'pipe', windowsHide: true });
} catch {
/* 宿主目录树本就没有 Low 标签体系时会被系统拒绝,保持继承即可 */
}
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
@@ -138,9 +149,9 @@ function normalizeIntegrityLabel(dir) {
/**
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
* win-unpacked/ → 免安装/
* *.exe / *.blockmap → 安装包/
* *.yml / *.nsis.7z → 构建中间/
* win-unpacked/ → **直接删掉**(见文件头:它只是生成两个 exe 的中间物)
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
*/
@@ -152,11 +163,12 @@ function tidyArtifacts(dir) {
fs.renameSync(from, path.join(to, path.basename(from)));
};
const unpacked = path.join(dir, 'win-unpacked');
const portableDir = path.join(dir, '免安装');
if (fs.existsSync(unpacked)) {
fs.rmSync(portableDir, { recursive: true, force: true });
fs.renameSync(unpacked, portableDir);
// 必须留在最后删:electron-builder 的 portable 目标就是拿它自己解包出来的 app 做自解压包,
// 提前删会让 `-portable.exe` 这一步找不到源文件。
const throwaway = [];
for (const name of ['win-unpacked', '免安装']) {
const from = path.join(dir, name);
if (fs.existsSync(from)) throwaway.push(from);
}
for (const name of fs.readdirSync(dir)) {
@@ -165,6 +177,31 @@ function tidyArtifacts(dir) {
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
}
for (const from of throwaway) {
// 体积必须在删之前量:删完再 walk 只会得到 0
const mb = Math.round(dirSizeMb(from));
try {
fs.rmSync(from, { recursive: true, force: true });
console.log(`[dist] 已删除中间物 ${path.basename(from)}/(约 ${mb} MB)`);
} catch (error) {
console.warn(`[dist] 中间物 ${path.basename(from)}/ 删除失败(不影响交付物):${error.message}`);
}
}
}
/** 交付目录里没有现成的体积统计函数,这里只在打日志时用一次 */
function dirSizeMb(target) {
let total = 0;
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else total += fs.statSync(full).size;
}
};
if (fs.existsSync(target)) walk(target);
return total / 1024 / 1024;
}
child.on('exit', (code) => {
@@ -67,6 +67,15 @@ const isCall = computed(() => kind.value === 'call');
const isHomework = computed(() => kind.value === 'homework');
const queueCount = computed(() => state.value.queued.length);
/**
* 是不是"点了知道了 / 标为已读"之后的**收回动画**阶段(`reason === 'closing'`)。
*
* 这一阶段主进程已经把状态清空(`active` 为 null、`queued` 为空),形态却切回了胶囊:
* 卡片正沿既有的形变路径收回,**胶囊里不该显示任何文字** —— 否则会闪出一句
* "新消息 · 点击查看"(那是"有新消息"的语义,与"正在消失"正好相反)。
*/
const isClosing = computed(() => state.value.reason === 'closing');
/**
* 这一批**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」排序。
* 与主进程 `pendingNotifications()` 同一口径(同一份 `PRIORITY_RANK`)。
@@ -460,6 +469,8 @@ const islandClass = computed(() => ({
call: isCall.value,
homework: isHomework.value,
'after-class': showAfterClassHint.value,
// 收回动画:胶囊里不显示内容(见 isClosing 与 .island-card.closing 的说明)
closing: isClosing.value,
}));
/**
@@ -1048,6 +1059,18 @@ body {
justify-content: flex-end;
}
/**
* 收回动画(`reason === 'closing'`):胶囊收回过程中**不显示任何胶囊内容**。
*
* 这时状态已经在同一帧清空(没有可展示的消息),若照常渲染胶囊层,卡片刚收回就会闪出
* 一句"新消息 · 点击查看" —— 语义正好相反。这里保留 DOM(宽度固定为 `--island-w`,
* 内容盒不会重排,符合"开合时胶囊内容不许重排"的约定)只把内容隐藏,
* 卡片形状照样沿既有弹簧收回,随后由主进程淡出窗口。
*/
.island-card.closing .pill-inner {
opacity: 0;
}
.pill-icon {
display: inline-flex;
align-items: center;
@@ -0,0 +1,304 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { BrowserWindow, app } from 'electron';
import { CLIENT_UPDATE_IDLE_MS, type ClientUpdateInfoDto } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
/**
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
*
* 设计要点(都是"教室机器"这个使用场景逼出来的):
* 1. **下载随时进行、安装只在完全空闲时**:教室机的带宽与注意力都有限,上课期间重启应用是灾难,
* 所以安装的三个必要条件缺一不可 —— 不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)、
* 本进程还没装过;
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
* 3. **安装脚本脱离本进程**:`start /wait` 等安装器跑完后重新拉起应用,
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
*/
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
/** 下载文件名的合法性(服务端给的 fileName 会拼进本地路径,必须挡住路径穿越) */
const SAFE_FILE_NAME = /^[A-Za-z0-9._-]+$/;
/** 待安装更新的目录:`<userData>/updates`(放在 userData 下,随配置一起被卸载/重置清掉) */
export function pendingUpdateDir(): string {
return path.join(app.getPath('userData'), 'updates');
}
/** 主窗口"已隐藏多久"的起点(null = 窗口当前可见或还没建) */
let mainWindowHiddenAt: number | null = null;
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
let installing = false;
/**
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
*
* 为什么用注册而不是直接 `import { quitApp } from './index.js'`:那会形成
* index.ts ⇄ auto-update.ts 的循环导入,ESM 下 `quitApp` 的绑定在求值顺序变化时会变成未初始化引用。
*/
let quitHandler: (() => void) | null = null;
/** 注册"安装前退出本进程"的回调(index.ts 启动时调用一次) */
export function setQuitHandler(handler: () => void): void {
quitHandler = handler;
}
/** 正在下载的版本(同一版本不并发下载) */
const downloading = new Set<string>();
/** 这次运行的主窗口(安装判定要看它可见性;窗口重建后重新挂一遍) */
let hookedWindow: BrowserWindow | null = null;
/**
* 挂主窗口的显示/隐藏监听(index.ts 在 `createWindow()` 之后调用)。
*
* 为什么要"记时间戳"而不是每次现算:`BrowserWindow` 没有"隐藏了多久"这种查询,
* 而「窗口隐藏满 5 分钟」是本功能的核心判据,只能在 hide/show 事件上自己打点。
*/
export function watchMainWindow(win: BrowserWindow): void {
if (hookedWindow === win) return;
hookedWindow = win;
win.on('hide', () => {
if (mainWindowHiddenAt === null) mainWindowHiddenAt = Date.now();
// 刚隐藏正是最合适的安装时机(人已经走了),立即判一次
void maybeInstallPendingUpdate();
});
win.on('show', () => {
mainWindowHiddenAt = null;
});
win.on('closed', () => {
mainWindowHiddenAt = null;
if (hookedWindow === win) hookedWindow = null;
});
if (win.isVisible()) mainWindowHiddenAt = null;
}
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
function mainWindowIdleLongEnough(): boolean {
if (mainWindowHiddenAt === null) return false;
return Date.now() - mainWindowHiddenAt >= CLIENT_UPDATE_IDLE_MS;
}
/** 主窗口是否可见(空闲判定的一部分:可见 ⇒ 有人在用,绝不重启) */
function mainWindowVisible(): boolean {
if (hookedWindow && !hookedWindow.isDestroyed()) return hookedWindow.isVisible();
return BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible());
}
/** 服务端是否要求升级 / 本机是否确实更旧 */
export function shouldConsider(version: string): boolean {
return getConfig().autoUpdate !== false && !!version.trim();
}
/**
* 处理一次服务端下发的更新信息:该下就下、下好了就写进待安装配置。
*
* - `autoUpdate === false`:用户/管理员关掉了自动更新,直接返回(设置页会显示服务端的目标版本,由人工处理);
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
*/
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
const version = targetVersion.trim() || info.version.trim();
if (!shouldConsider(version)) {
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
return;
}
if (downloading.has(version)) {
logger.debug(`更新 ${version} 正在下载中,跳过重复请求`);
return;
}
const config = getConfig();
if (config.pendingUpdateVersion === version) {
const existing = config.pendingUpdateFile;
if (existing && fs.existsSync(existing)) {
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
await maybeInstallPendingUpdate();
return;
}
logger.warn(`更新 ${version} 的安装包已不在磁盘上(${existing || '未记录路径'}),重新下载`);
}
if (!SAFE_FILE_NAME.test(info.fileName)) {
logger.error(`拒绝下载更新:服务端给的文件名不合法(${info.fileName})`);
return;
}
const dir = pendingUpdateDir();
const target = path.join(dir, info.fileName);
const temp = `${target}.part`;
const token = config.token?.trim() ?? '';
if (!token) {
logger.warn(`暂不下载更新 ${version}:尚未登录(没有可用的下载令牌)`);
return;
}
downloading.add(version);
const startedAt = Date.now();
try {
fs.mkdirSync(dir, { recursive: true });
// 先删掉上次中断留下的临时文件
await fsp.rm(temp, { force: true });
const url = `${config.serverUrl}${info.url}`;
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
const response = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`下载失败:HTTP ${response.status}`);
}
if (!response.body) {
throw new Error('下载失败:响应没有内容');
}
// 流式写盘:教室机的安装包几十 MB,不能整个读进内存
const handle = await fsp.open(temp, 'w');
try {
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
await handle.write(chunk);
}
} finally {
await handle.close();
}
const digest = await sha256File(temp);
const expected = info.sha256.trim().toLowerCase();
if (!expected) {
// 服务端没给哈希就没法校验:宁可不装,也不留一个来源不明的安装包在磁盘上
await fsp.rm(temp, { force: true });
logger.error(`更新 ${version} 的安装包没有提供 sha256,已删除(不做信任放行)`);
return;
}
if (digest !== expected) {
await fsp.rm(temp, { force: true });
logger.error(
`更新 ${version} 的安装包校验失败(sha256 不符),已删除:期望 ${expected},实际 ${digest}`,
);
return;
}
await fsp.rm(target, { force: true });
await fsp.rename(temp, target);
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
logger.info(
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
(Date.now() - startedAt) / 1000,
)} 秒),将在空闲时自动安装`,
);
await maybeInstallPendingUpdate();
} catch (error) {
await fsp.rm(temp, { force: true }).catch(() => undefined);
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
downloading.delete(version);
}
}
/** 文件的 sha256(十六进制小写) */
async function sha256File(file: string): Promise<string> {
const hash = crypto.createHash('sha256');
const handle = await fsp.open(file, 'r');
try {
const buffer = Buffer.alloc(1024 * 256);
for (;;) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
if (bytesRead <= 0) break;
hash.update(buffer.subarray(0, bytesRead));
}
} finally {
await handle.close();
}
return hash.digest('hex');
}
/** 人类可读的字节数(日志与界面提示用) */
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '大小未知';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/**
* 如果条件全部满足就装更新。
*
* 条件(缺一不可):非冒烟模式 / 配置里有待安装版本 / 文件还在 / 不在上课 /
* 主窗口已隐藏满 5 分钟 / 本进程没装过。**每次心跳后与主窗口 hide 后都会调用**,
* 所以"老师下课关掉窗口 → 5 分钟后自动装上"就是靠这里触发的。
*/
export async function maybeInstallPendingUpdate(): Promise<void> {
if (installing) return;
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
const config = getConfig();
const version = config.pendingUpdateVersion?.trim() ?? '';
const installer = config.pendingUpdateFile?.trim() ?? '';
if (!version || !installer) return;
if (!fs.existsSync(installer)) {
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
if (island.getState().inClass) {
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
return;
}
if (mainWindowVisible()) {
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
return;
}
if (!mainWindowIdleLongEnough()) {
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
logger.debug(
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
`(需满 ${Math.round(CLIENT_UPDATE_IDLE_MS / 1000)} 秒),暂不安装`,
);
return;
}
if (process.platform !== 'win32') {
// 本项目客户端只发 Windows 版;其它平台(开发机上跑 Linux/macOS)只记日志
logger.warn(
`更新 ${version} 已就绪,但当前平台(${process.platform})不支持静默安装,请手动运行:${installer}`,
);
return;
}
installing = true;
logger.info(`开始静默安装更新 ${version}:${installer}`);
try {
const exe = app.getPath('exe');
const command = `start "" /wait "${installer}" /S && start "" "${exe}"`;
const child = spawn('cmd.exe', ['/c', command], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
// 安装脚本会等安装器结束后重新拉起应用;本进程随即退出(复用既有退出路径,保证配置已落盘)
if (quitHandler) {
quitHandler();
} else {
logger.warn('没有注册退出回调,改为自行退出(配置会在退出前同步落盘)');
app.quit();
}
} catch (error) {
installing = false;
logger.error(
`启动更新安装失败(将在下次空闲时重试):${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** 仅供冒烟/自检使用:当前是否处于"窗口已隐藏满 5 分钟"状态 */
export function isMainWindowIdleLongEnough(): boolean {
return mainWindowIdleLongEnough();
}
@@ -0,0 +1,33 @@
/**
* IPC 通道名集中管理(主进程与 preload 共用同一份字符串)。
*
* **为什么单独一个文件、而且这里一个 import 都不能有**:preload 需要这些字符串,
* 但 preload 在 `sandbox: true` 下运行 —— 沙箱化 preload **不能 require `node:fs` /
* `node:path` / `node:crypto` 这类内建模块**。而 esbuild 会把 preload 的**运行时 import**
* 整条依赖链打进同一个产物:preload 一旦 `import { IPC_CHANNELS } from './ipc.js'`,
* 就会把 `ipc.js` → `client-agent.js` / `config.js` / `logger.js` / `auto-update.js`
* 一起拉进来(它们都要 `node:fs`),于是 preload 在沙箱里**加载即失败**,
* 表现是渲染进程里 `window.desktop === undefined`(冒烟断言「preload contextBridge 注入」直接红,
* 后续所有 `window.desktop.*` 调用抛 `Cannot read properties of undefined`)。
*
* 所以这个文件是**叶子模块**:只有字符串常量,没有任何 import、没有任何副作用。
* 新增通道时加在这里,`main/ipc.ts` 与 `preload/index.ts` 各自 import 它。
*/
export const IPC_CHANNELS = {
getConfig: 'classhelper:config:get',
saveConfig: 'classhelper:config:save',
clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external',
updateCheck: 'classhelper:update:check',
updateIgnore: 'classhelper:update:ignore',
getAutoLaunch: 'classhelper:app:get-autolaunch',
setAutoLaunch: 'classhelper:app:set-autolaunch',
openLogDir: 'classhelper:app:open-log-dir',
openDevTools: 'classhelper:app:open-devtools',
clientStatus: 'classhelper:client:status',
clientBeatNow: 'classhelper:client:beat-now',
} as const;
/** 主进程 → 渲染进程的单向推送:有可用更新(同理,供 preload 与主进程共用) */
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
@@ -0,0 +1,414 @@
import { spawn } from 'node:child_process';
import os from 'node:os';
import { BrowserWindow, app, dialog } from 'electron';
import {
CLIENT_COMMAND_LABELS,
CLIENT_HEARTBEAT_INTERVAL_MS,
isNewerVersion,
type ClientCommandDto,
type ClientCommandKind,
type ClientHeartbeatRequest,
type ClientHeartbeatResponse,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { maybeInstallPendingUpdate, considerUpdate } from './auto-update.js';
import { getConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
/**
* 桌面客户端的服务端联动代理(心跳上报)。
*
* **为什么是"客户端定时拉"而不是服务端推**:教室机的连接经常断(代理、休眠、重启),
* 而「这台机器是什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上 —— 所以统一用
* 「服务端记状态 + 客户端定时上报」的范式(与 ClassIsland 插件的上报同一套路),不引入新的长连接握手。
* 每次上报的响应里同时带回「服务端指定的目标版本 + 待执行命令」。
*
* 三条边界:
* 1. **没有 token 就不上报**:未登录时静默跳过(登录后下一次自然就上报了),
* 不能拿空令牌去打服务端,否则日志里会刷满 401;
* 2. **401/403 退避到 5 分钟**:令牌可能已失效(班级密码被改 / 令牌过期),
* 继续 30 秒一次只是白刷服务端与日志;
* 3. **冒烟模式默认不上报**:每次冒烟都在服务端库里造一条设备记录没有意义,
* 只有显式 `ELECTRON_SMOKE_CLIENT_AGENT=1` 时才照常跑(给需要验证这条链路的用例用)。
*/
/** 启动后第一次上报的延迟:等窗口、登录流程与灵动岛都就绪,也不跟首屏抢带宽 */
const FIRST_BEAT_DELAY_MS = 3_000;
/** 令牌失效(401/403)后的退避间隔:5 分钟一次,直到用户重新登录 */
const UNAUTHORIZED_BACKOFF_MS = 5 * 60_000;
/** 单次上报的超时:教室机网络慢,但也不能挂着不放 */
const HEARTBEAT_TIMEOUT_MS = 10_000;
/**
* 远控命令的「本机二次确认」等待时长:60 秒内没人点确认就按取消处理。
*
* 为什么必须有这个超时:管理员在办公室下发关机,而教室里可能已经没人了 ——
* 一个永远挂着等确认的对话框会让这条命令一直"待执行",既没执行也看不出失败。
*/
const COMMAND_CONFIRM_TIMEOUT_MS = 60_000;
/**
* 「在线」判定窗口:与 shared 的 `CLIENT_ONLINE_WINDOW_MS`(90 秒)同口径 ——
* 连续丢两次心跳(30 秒 × 3)才算离线。这里不复用那个常量是因为它属于**服务端**的判定口径,
* 客户端展示的是"我这边的上报还新不新",单独留一个本地常量更容易看出两边的关系。
*/
const ONLINE_WINDOW_MS = 90_000;
/** 对外暴露的联动状态(设置页「服务端联动状态」与冒烟断言用) */
export interface ClientAgentStatus {
/** 设备标识尾号(服务端设备列表里显示的那串用来对账,完整 UUID 不上界面) */
deviceKeyTail: string;
/** 最近一次上报成功的时刻(ISO 字符串;从未上报过为 null) */
lastHeartbeatAt: string | null;
/** 最近一次失败的原因(空串 = 最近一次是成功的) */
lastError: string;
/** 已下载待安装的版本(空串 = 无) */
pendingUpdateVersion: string;
/** 服务端最近一次要求升到的版本(空串 = 没要求) */
updateTargetVersion: string;
/** 是否接受服务端统一下发的自动更新(配置值,供设置页回显) */
autoUpdate: boolean;
/** 最近 90 秒内上报成功过(与服务端的在线口径一致) */
online: boolean;
}
interface AgentState {
running: boolean;
/** 当前心跳定时器(退避期间与正常节奏共用它) */
timer: ReturnType<typeof setTimeout> | null;
/** 正在上报:避免手动「立即上报」与定时器撞在一起 */
beating: boolean;
lastHeartbeatAt: string | null;
lastError: string;
updateTargetVersion: string;
}
const state: AgentState = {
running: false,
timer: null,
beating: false,
lastHeartbeatAt: null,
lastError: '',
updateTargetVersion: '',
};
/** 设备标识尾号:取后 8 位(足够对账,又不至于把完整 UUID 贴到界面上) */
export function deviceKeyTail(deviceKey: string): string {
const trimmed = deviceKey.trim();
if (!trimmed) return '';
return trimmed.length > 8 ? trimmed.slice(-8) : trimmed;
}
/** 冒烟模式且没有显式打开联动开关时,一律不上报(避免每次冒烟都在库里造设备记录) */
function isAgentSuppressed(): boolean {
return process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_CLIENT_AGENT !== '1';
}
/**
* 上报里的"这台机器现在在干什么"。
*
* 取值就三种(服务端设备列表直接展示这段文字):
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(此时绝不安装更新);
* - `使用中` —— 主窗口可见,人正在用;
* - `待机` —— 窗口隐藏(最小化到托盘),机器空闲。
*/
function currentStateNote(): string {
if (island.getState().inClass) return '上课中';
const win = BrowserWindow.getAllWindows().find((item) => !item.isDestroyed());
return win?.isVisible() ? '使用中' : '待机';
}
/** 已下载待安装的版本;没有则 null(服务端把它记进设备的 `pendingVersion`) */
function pendingVersionOrNull(): string | null {
const pending = getConfig().pendingUpdateVersion.trim();
return pending ? pending : null;
}
/** 清掉待执行的心跳定时器(幂等) */
function clearTimer(): void {
if (state.timer !== null) {
clearTimeout(state.timer);
state.timer = null;
}
}
/** 排下一次上报;`delayMs` 不传时用正常节奏 */
function scheduleNext(delayMs: number = CLIENT_HEARTBEAT_INTERVAL_MS): void {
if (!state.running) return;
clearTimer();
state.timer = setTimeout(() => {
state.timer = null;
void beat();
}, delayMs);
}
/**
* 一次上报(含响应处理)。
*
* 失败一律**不抛**:网络抖动是教室机的常态,只记一行日志并按原节奏重试;
* 只有 401/403 才退避(见文件头说明)。
*/
async function beat(): Promise<void> {
if (state.beating) return;
if (isAgentSuppressed()) {
// 冒烟模式:照原节奏空转(不碰服务端,也不在库里造设备记录)
state.lastError = '';
scheduleNext();
return;
}
const config = getConfig();
const token = config.token?.trim() ?? '';
if (!token) {
// 未登录:不打服务端、不记错误,等下次心跳(登录后自然会带上令牌)
logger.debug('客户端上报:尚未登录(没有令牌),本次跳过');
scheduleNext();
return;
}
state.beating = true;
let backoff = false;
try {
const body: ClientHeartbeatRequest = {
deviceKey: config.deviceKey,
name: os.hostname(),
clientVersion: app.getVersion(),
platform: `${process.platform}-${process.arch}`,
pendingVersion: pendingVersionOrNull(),
stateNote: currentStateNote(),
};
const response = await fetch(`${config.serverUrl}/api/clients/heartbeat`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
});
if (response.status === 401 || response.status === 403) {
// 令牌失效:退避到 5 分钟一次,直到用户重新登录
backoff = true;
state.lastError = `上报被拒绝(HTTP ${response.status}),可能登录已过期,请重新登录`;
logger.warn(`客户端上报:服务端返回 HTTP ${response.status},退避为 5 分钟一次`);
return;
}
if (!response.ok) {
state.lastError = `服务端返回 HTTP ${response.status}`;
logger.warn(`客户端上报失败:HTTP ${response.status}(${HEARTBEAT_INTERVAL_TEXT})`);
return;
}
const payload = (await response.json()) as ClientHeartbeatResponse;
state.lastHeartbeatAt = new Date().toISOString();
state.lastError = '';
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
logger.debug(
`客户端上报成功(设备尾号 ${deviceKeyTail(config.deviceKey)},状态 ${body.stateNote ?? '-'}` +
`${state.updateTargetVersion ? `,服务端要求升到 ${state.updateTargetVersion}` : ''})`,
);
void handleCommand(payload.command);
await handleUpdate(payload.update, payload.updateTargetVersion);
} catch (error) {
// 网络错误 / 超时 / JSON 解析失败:按原节奏重试
state.lastError = error instanceof Error ? error.message : String(error);
logger.warn(`客户端上报失败(将于 ${HEARTBEAT_INTERVAL_TEXT} 后重试):${state.lastError}`);
} finally {
state.beating = false;
// 每次心跳(无论成败)都顺手看一下有没有下载好的更新该装了 —— 上课/窗口可见时会自动跳过
void maybeInstallPendingUpdate();
scheduleNext(backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS);
}
}
const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 1000)} 秒`;
/**
* 服务端下发的远控命令(关机 / 重启 / 注销 / 锁屏)。
*
* 两道护栏缺一不可(用户要求「必须二次确认」):
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
* 2. **同一条命令只弹一次**:它随每次心跳重复下发,用内存集合去重,避免 30 秒弹一个框。
*
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
* 下一次心跳只重试回执、不重复弹窗。
*/
const inFlightCommands = new Set<string>();
const unackedResults = new Map<string, { ok: boolean; result: string }>();
async function handleCommand(command: ClientCommandDto | null): Promise<void> {
if (!command) return;
const label = CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
const expired =
command.expiresAt !== null && Number.isFinite(Date.parse(command.expiresAt))
? Date.parse(command.expiresAt) < Date.now()
: false;
if (expired) {
logger.warn(`收到已过期的远控命令(${label}),已忽略:${command.payload || '无备注'}`);
return;
}
// 处理过但回执没送达:只重试回执
const pending = unackedResults.get(command.id);
if (pending) {
await ackCommand(command.id, pending.ok, pending.result);
return;
}
// 正在等本机确认:不要再弹第二个框
if (inFlightCommands.has(command.id)) return;
inFlightCommands.add(command.id);
try {
const confirmed = await confirmCommand(label, command.payload);
if (!confirmed) {
const result = '本机用户取消(或 60 秒未确认)';
logger.warn(`远控命令「${label}」未执行:${result}`);
await ackCommand(command.id, false, result);
return;
}
const outcome = await executeCommand(command.kind);
logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`);
await ackCommand(command.id, outcome.ok, outcome.result);
} finally {
inFlightCommands.delete(command.id);
}
}
/** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */
async function confirmCommand(label: string, note: string): Promise<boolean> {
const answered = dialog
.showMessageBox({
type: 'warning',
title: '来自管理员的远程指令',
message: `管理员要求本机「${label}」`,
detail: `${note ? `备注:${note}\n` : ''}确认后本机将立即${label};60 秒内未确认视为取消。`,
buttons: ['取消', '确认执行'],
defaultId: 0,
cancelId: 0,
noLink: true,
})
.then((result) => result.response === 1)
.catch(() => false);
return Promise.race([
answered,
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), COMMAND_CONFIRM_TIMEOUT_MS)),
]);
}
/** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */
async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; result: string }> {
if (process.platform !== 'win32') return { ok: false, result: '仅支持 Windows' };
const commands: Record<ClientCommandKind, { file: string; args: string[] }> = {
shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '0'] },
restart: { file: 'shutdown.exe', args: ['/r', '/t', '0'] },
logoff: { file: 'shutdown.exe', args: ['/l'] },
lock: { file: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
};
const plan = commands[kind];
try {
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
return { ok: true, result: '已下达系统命令' };
} catch (error) {
return { ok: false, result: error instanceof Error ? error.message : String(error) };
}
}
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
const config = getConfig();
if (!config.token) return;
try {
const response = await fetch(`${config.serverUrl}/api/clients/commands/${commandId}/ack`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${config.token}` },
body: JSON.stringify({ deviceKey: config.deviceKey, ok, result }),
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
});
if (!response.ok) {
logger.warn(`远控命令回执失败(HTTP ${response.status}),将在下次心跳重试`);
unackedResults.set(commandId, { ok, result });
return;
}
unackedResults.delete(commandId);
logger.info(`远控命令回执已送达(${ok ? '成功' : '失败'}):${result}`);
} catch (error) {
logger.warn(
`远控命令回执失败(${error instanceof Error ? error.message : String(error)}),将在下次心跳重试`,
);
unackedResults.set(commandId, { ok, result });
}
}
/** 响应里的更新信息:服务端指定了目标版本、且该版本确实比本机新,才交给 auto-update */
async function handleUpdate(info: ClientUpdateInfoDto | null, targetVersion: string | null): Promise<void> {
const target = targetVersion?.trim() ?? '';
if (!target) return;
if (!info) {
// 服务端指定了目标版本但还没有这份安装包:只记一行,客户端只能提示手动更新
logger.warn(`服务端要求升到 ${target},但服务端还没有这个版本的安装包(无法自动下载)`);
return;
}
if (!isNewerVersion(app.getVersion(), target)) return;
await considerUpdate(info, target);
}
/** 启动心跳(重复调用无副作用) */
export function startClientAgent(): void {
if (state.running) return;
state.running = true;
logger.info(
isAgentSuppressed()
? '客户端上报代理:冒烟模式下不上报(需要时设 ELECTRON_SMOKE_CLIENT_AGENT=1)'
: `客户端上报代理已启动:${FIRST_BEAT_DELAY_MS / 1000} 秒后首次上报,之后每 ${HEARTBEAT_INTERVAL_TEXT} 一次`,
);
scheduleNext(FIRST_BEAT_DELAY_MS);
}
/** 停止心跳(退出清理调用;幂等) */
export function stopClientAgent(): void {
if (!state.running) return;
state.running = false;
clearTimer();
logger.info('客户端上报代理已停止');
}
/**
* 手动触发一次上报并返回最新状态(设置页「立即上报」按钮)。
* 返回的是**报告之后**的状态,界面直接照它刷新即可。
*/
export async function beatNow(): Promise<ClientAgentStatus> {
if (!state.running) startClientAgent();
if (isAgentSuppressed()) {
logger.info('客户端上报:冒烟模式下跳过手动上报');
return getClientAgentStatus();
}
clearTimer();
await beat();
return getClientAgentStatus();
}
/** 当前联动状态(纯数据,可直接跨 contextBridge 传给渲染进程) */
export function getClientAgentStatus(): ClientAgentStatus {
const config = getConfig();
const lastAt = state.lastHeartbeatAt ? Date.parse(state.lastHeartbeatAt) : Number.NaN;
return {
deviceKeyTail: deviceKeyTail(config.deviceKey),
lastHeartbeatAt: state.lastHeartbeatAt,
lastError: state.lastError,
pendingUpdateVersion: config.pendingUpdateVersion ?? '',
updateTargetVersion: state.updateTargetVersion,
autoUpdate: config.autoUpdate !== false,
online: Number.isFinite(lastAt) && Date.now() - lastAt < ONLINE_WINDOW_MS,
};
}
+118 -2
View File
@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
@@ -44,6 +45,20 @@ interface PersistedConfig {
sidebarCollapsed?: boolean;
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
ignoredUpdateVersion?: string;
/** 本机设备标识(向后兼容:旧配置没有该字段时首次读取生成并落盘,之后永不变) */
deviceKey?: string;
/** 是否开机自启(向后兼容:旧配置没有该字段视为关闭 —— 由用户在设置页自己打开) */
autoLaunch?: boolean;
/** 是否接受服务端统一下发的自动更新(向后兼容:旧配置没有该字段视为开启) */
autoUpdate?: boolean;
/** 开发者模式:日志调到 debug 级 + 设置里多出「开发者」页(向后兼容:旧配置没有该字段视为关闭) */
developerMode?: boolean;
/** 关怀模式:整体放大正文与控件字号,供视力不佳的用户使用(向后兼容:旧配置没有该字段视为关闭) */
careMode?: boolean;
/** 已下载待安装的更新版本(空串 = 无;跨重启保留) */
pendingUpdateVersion?: string;
/** 已下载待安装的安装包绝对路径(空串 = 无;跨重启保留) */
pendingUpdateFile?: string;
}
const DEFAULT_CONFIG: PersistedConfig = {
@@ -59,6 +74,13 @@ const DEFAULT_CONFIG: PersistedConfig = {
theme: 'light',
sidebarCollapsed: false,
ignoredUpdateVersion: '',
deviceKey: '',
autoLaunch: false,
autoUpdate: true,
developerMode: false,
careMode: false,
pendingUpdateVersion: '',
pendingUpdateFile: '',
};
/** 主题白名单:非法值一律回落浅色 */
@@ -203,6 +225,15 @@ function readPersistedFromDisk(): PersistedConfig {
sidebarCollapsed: parsed.sidebarCollapsed === true,
ignoredUpdateVersion:
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
deviceKey: typeof parsed.deviceKey === 'string' ? parsed.deviceKey : '',
autoLaunch: parsed.autoLaunch === true,
// 默认开启:老配置文件里没有这个字段时按"接受服务端统一下发的更新"处理
autoUpdate: parsed.autoUpdate !== false,
developerMode: parsed.developerMode === true,
careMode: parsed.careMode === true,
pendingUpdateVersion:
typeof parsed.pendingUpdateVersion === 'string' ? parsed.pendingUpdateVersion : '',
pendingUpdateFile: typeof parsed.pendingUpdateFile === 'string' ? parsed.pendingUpdateFile : '',
};
} catch {
return { ...DEFAULT_CONFIG };
@@ -356,9 +387,52 @@ function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
theme: normalizeTheme(persisted.theme),
sidebarCollapsed: persisted.sidebarCollapsed === true,
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
deviceKey: ensureDeviceKey(persisted),
autoLaunch: persisted.autoLaunch === true,
autoUpdate: persisted.autoUpdate !== false,
developerMode: persisted.developerMode === true,
careMode: persisted.careMode === true,
pendingUpdateVersion: persisted.pendingUpdateVersion ?? '',
pendingUpdateFile: persisted.pendingUpdateFile ?? '',
};
}
/**
* `crypto.randomUUID()`:Node 18+ / Chromium 里都有,但**不能假设它一定存在**
* (打包后的运行时或某些受限环境里 `crypto.randomUUID` 为 undefined,直接调用会抛)。
* 兜底用时间戳 + 随机字节拼一个同形状的 UUID v4。
*/
function generateDeviceKey(): string {
try {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
} catch {
// 落到下面的兜底
}
const hex = crypto.randomBytes(16).toString('hex');
return (
`${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(12, 15)}` +
`-a${hex.slice(15, 18)}-${hex.slice(18, 30)}`
);
}
/**
* 设备标识:**首次读取时生成并落盘,之后永不变**(它是这台机器在服务端的身份,
* 服务端按 `deviceKey` upsert 设备记录,换了它就等于换了一台机器)。
*
* 为什么放在这里生成:`getConfig()` 是所有读取路径的唯一出口(主进程各处 + 渲染进程 IPC),
* 且此时必然已经过了 `app.setPath('userData', …)`(见 `loadCache()` 的懒初始化说明),
* 写的就是当前 profile 的 config.json。落盘走既有的 120ms 合并窗口,退出时 `flushConfigSync()` 兜底。
*/
function ensureDeviceKey(persisted: PersistedConfig): string {
const existing = typeof persisted.deviceKey === 'string' ? persisted.deviceKey.trim() : '';
if (existing) return existing;
const created = generateDeviceKey();
persisted.deviceKey = created;
scheduleWrite(persisted);
logger.info(`已为本机生成设备标识(服务端据此识别这台电脑):${created}`);
return created;
}
/** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig {
return toDesktopConfig(loadCache());
@@ -407,6 +481,30 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (typeof patch.ignoredUpdateVersion === 'string') {
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
}
if (patch.autoLaunch !== undefined) {
persisted.autoLaunch = patch.autoLaunch === true;
}
if (patch.autoUpdate !== undefined) {
persisted.autoUpdate = patch.autoUpdate === true;
}
if (patch.developerMode !== undefined) {
persisted.developerMode = patch.developerMode === true;
}
if (patch.careMode !== undefined) {
persisted.careMode = patch.careMode === true;
}
// 待安装的更新只由主进程的 auto-update 模块写入;渲染进程传进来也照收(退出登录/重置走 clearConfig)
if (typeof patch.pendingUpdateVersion === 'string') {
persisted.pendingUpdateVersion = patch.pendingUpdateVersion;
}
if (typeof patch.pendingUpdateFile === 'string') {
persisted.pendingUpdateFile = patch.pendingUpdateFile;
}
// deviceKey **不接受外部改写**:它是这台机器在服务端的身份,改了服务端就会当成一台新机器
// (旧记录永远停在上一次心跳、新记录从零开始)。要重置身份只能手工删掉 config.json 里的该字段。
if (typeof patch.deviceKey === 'string' && patch.deviceKey && patch.deviceKey !== persisted.deviceKey) {
logger.warn('忽略对设备标识(deviceKey)的改写:它是这台机器在服务端的身份,不允许变更');
}
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
@@ -419,9 +517,27 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
return toDesktopConfig(state);
}
/** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */
/**
* 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟。
*
* **保留"这台机器"的身份与本地开关**:`deviceKey`(服务端按它识别设备,换了就等于换一台机器)、
* 开机自启 / 自动更新 / 开发者模式 / 关怀模式,以及已下载待安装的更新记录 —— 退出登录不该在服务端
* 多出一台陌生设备,也不该把用户刚打开的开关悄悄关回默认值。
*/
export function clearConfig(): DesktopStoredConfig {
const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null };
const current = cache?.config ?? DEFAULT_CONFIG;
const kept: PersistedConfig = {
...DEFAULT_CONFIG,
deviceKey: current.deviceKey ?? '',
autoLaunch: current.autoLaunch === true,
autoUpdate: current.autoUpdate !== false,
developerMode: current.developerMode === true,
careMode: current.careMode === true,
pendingUpdateVersion: current.pendingUpdateVersion ?? '',
pendingUpdateFile: current.pendingUpdateFile ?? '',
};
ensureDeviceKey(kept);
const state: ConfigCache = { config: kept, token: null };
cache = state;
scheduleWrite(state.config, true);
return toDesktopConfig(state);
@@ -0,0 +1,187 @@
import { app, type BrowserWindow, type RenderProcessGoneDetails } from 'electron';
import { logger } from './logger.js';
/**
* 崩溃自愈(主窗口)。
*
* 教室机的运行环境很不友好:显卡驱动老旧、长期不重启、内存被其它教学软件挤占 ——
* 渲染进程偶发崩溃与无响应是常态。原先这种情况表现为"窗口还在、一片空白、点哪都没反应",
* 只能人工去重启,因此这里补三条自愈路径:
*
* 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**;
* 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消;
* 3. `uncaughtException` / `unhandledRejection` —— 主进程的未捕获错误,
* 记完整堆栈,并且**只有在本机确认空闲时**才重启自己。
*
* 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志):
* **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。
* 另外**冒烟模式下绝不 `app.relaunch()`**:验证实例重启自己会让父进程拿到一个莫名其妙的子进程。
*/
/** 限流:窗口内最多几次 */
const RECOVER_MAX_TIMES = 3;
/** 限流窗口:10 分钟 */
const RECOVER_WINDOW_MS = 10 * 60_000;
/** 界面无响应多久算卡死(Electron 的 `unresponsive` 事件不会再给第二次机会) */
const UNRESPONSIVE_GRACE_MS = 10_000;
export interface CrashGuardOptions {
/** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */
recoverMainWindow: () => void;
/** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */
isIdle: () => boolean;
/** 退出应用(复用既有的退出路径,保证配置落盘) */
quitApp: () => void;
}
const windowRecoverAt: number[] = [];
const restartAt: number[] = [];
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
/** 正在重建:重建过程中窗口又会发 `closed` / `destroyed`,不能再触发一次 */
let recovering = false;
/** 当前主窗口(重建后会换成新实例) */
let mainWindow: BrowserWindow | null = null;
/** install 时拿到的回调集合(事件回调里复用) */
let options: CrashGuardOptions | null = null;
/**
* 安装崩溃自愈(在 `app.whenReady()` 里、主窗口建好之后调用一次)。
*/
export function installCrashGuard(guard: CrashGuardOptions): void {
options = guard;
app.on('render-process-gone', (_event, contents, details: RenderProcessGoneDetails) => {
// 灵动岛窗口有自己的 supervisor(island.ts),这里只管主窗口
if (!isMainWindowContents(contents.id)) return;
logger.error(
`主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`,
);
recoverWindow(`渲染进程退出(${details.reason})`);
});
app.on('child-process-gone', (_event, details) => {
logger.error(
`子进程异常退出:type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`,
);
});
process.on('uncaughtException', (error) => {
logger.error('主进程未捕获异常:', error);
restartIfIdle('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
logger.error('主进程未处理的 Promise 拒绝:', reason);
restartIfIdle('unhandledRejection');
});
}
/**
* 记录当前主窗口并挂上无响应监听。
* `createWindow()` 之后调用;窗口重建后要重新调用一次(新实例、新监听)。
*/
export function watchMainWindow(win: BrowserWindow): void {
mainWindow = win;
win.on('unresponsive', () => {
logger.error('主窗口界面无响应(10 秒内未恢复将重建窗口)');
armUnresponsiveTimer();
});
win.on('responsive', () => {
if (unresponsiveTimer !== null) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
logger.info('主窗口界面已恢复响应,取消重建');
}
});
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
if (unresponsiveTimer !== null) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
}
});
}
function armUnresponsiveTimer(): void {
if (unresponsiveTimer !== null) clearTimeout(unresponsiveTimer);
unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null;
if (!mainWindow || mainWindow.isDestroyed()) return;
recoverWindow('界面无响应超过 10 秒');
}, UNRESPONSIVE_GRACE_MS);
}
function isMainWindowContents(id: number): boolean {
// 主窗口引用还没建立(启动早期)时按"就是它"处理,避免漏掉重建
if (!mainWindow || mainWindow.isDestroyed()) return true;
return mainWindow.webContents.id === id;
}
/** 静默重建主窗口(限流) */
function recoverWindow(reason: string): void {
const recover = options?.recoverMainWindow;
if (recovering) return;
if (!recover) {
logger.error(`主窗口需要重建(${reason}),但没有可用的重建回调`);
return;
}
const now = Date.now();
while (windowRecoverAt.length > 0 && now - windowRecoverAt[0] > RECOVER_WINDOW_MS) {
windowRecoverAt.shift();
}
if (windowRecoverAt.length >= RECOVER_MAX_TIMES) {
logger.error(
`主窗口在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已重建 ${RECOVER_MAX_TIMES} 次,停止自动重建(${reason})` +
':请检查显卡驱动或教学软件冲突后手动重启应用',
);
return;
}
windowRecoverAt.push(now);
recovering = true;
try {
const win = mainWindow;
if (win && !win.isDestroyed()) {
// 先隐藏再销毁:跟灵动岛的 supervisor 一样,避免留住一帧残影/白屏
win.hide();
}
logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`);
recover();
} catch (error) {
logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
recovering = false;
}
}
/** 主进程崩溃后按需重启自己(限流 + 空闲判定 + 冒烟豁免) */
function restartIfIdle(reason: string): void {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
// 冒烟模式只记日志:验证实例重启自己会污染验证结果
logger.warn(`冒烟模式:不因 ${reason} 重启应用(仅记录日志)`);
return;
}
let idle = false;
try {
idle = options?.isIdle() === true;
} catch (error) {
logger.error(`判断本机是否空闲时出错:${error instanceof Error ? error.message : String(error)}`);
}
if (!idle) {
logger.warn(`本机正在使用中(${reason}),不自动重启应用,以免打断课堂`);
return;
}
const now = Date.now();
while (restartAt.length > 0 && now - restartAt[0] > RECOVER_WINDOW_MS) {
restartAt.shift();
}
if (restartAt.length >= RECOVER_MAX_TIMES) {
logger.error(
`应用在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已自动重启 ${RECOVER_MAX_TIMES} 次,停止自动重启(${reason})`,
);
return;
}
restartAt.push(now);
logger.warn(`本机空闲,因 ${reason} 自动重启应用(本窗口期第 ${restartAt.length} 次)`);
app.relaunch();
options?.quitApp();
}
+103 -18
View File
@@ -1,7 +1,10 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron';
import { setQuitHandler, watchMainWindow as watchAutoUpdateWindow } from './auto-update.js';
import { startClientAgent, stopClientAgent } from './client-agent.js';
import { flushConfigSync, getConfig } from './config.js';
import { installCrashGuard, watchMainWindow as watchCrashGuardWindow } from './crash-guard.js';
import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js';
@@ -16,6 +19,13 @@ const currentDir = __dirname;
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
/**
* 这次启动是否需要"直接进托盘、不弹窗"——开机自启专用(见 `applyAutoLaunch()`)。
* 判定依据是我们自己在登录项里注册的命令行参数 `--hidden`(Electron 44 的
* `setLoginItemSettings` 在 Windows 上**没有** `openAsHidden` 这个开关,只能自己带参数)。
*/
const startHidden = !isSmokeTest && process.argv.includes('--hidden');
// 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁,
// 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。
const smokeProfile = process.env.ELECTRON_SMOKE_PROFILE;
@@ -50,7 +60,7 @@ function isAllowedNavigation(url: string): boolean {
}
}
function createWindow(): BrowserWindow {
function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
const theme = getConfig().theme;
const win = new BrowserWindow({
@@ -61,8 +71,8 @@ function createWindow(): BrowserWindow {
minHeight: 540,
center: true,
title: 'ClassHelper',
// 冒烟验证时不弹窗,避免打扰使用者
show: !isSmokeTest,
// 冒烟验证时不弹窗,避免打扰使用者;开机自启(--hidden)时直接进托盘
show: !isSmokeTest && options.hidden !== true,
autoHideMenuBar: true,
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: {
@@ -87,12 +97,34 @@ function createWindow(): BrowserWindow {
});
win.on('closed', () => {
mainWindow = null;
if (mainWindow === win) mainWindow = null;
});
// 崩溃自愈与「空闲安装更新」都要盯着主窗口:窗口重建后换的是新实例,因此每次建窗都重挂一次
watchCrashGuardWindow(win);
watchAutoUpdateWindow(win);
return win;
}
/**
* 取当前主窗口,必要时重建。
*
* 与 `createWindow()` 的区别:这里会把"窗口已经被崩溃自愈销毁 / 还没建"的情况一并处理掉
* (托盘、二次启动、`activate` 都通过它拿窗口),因此调用方拿到的要么是活着的窗口、要么是 null。
*/
function ensureMainWindow(options: { hidden?: boolean } = {}): BrowserWindow | null {
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
try {
mainWindow = createWindow(options);
void loadRenderer(mainWindow);
} catch (error) {
logger.error(`创建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
mainWindow = null;
}
return mainWindow;
}
async function loadRenderer(win: BrowserWindow): Promise<void> {
if (devServerUrl) {
await win.loadURL(devServerUrl);
@@ -103,24 +135,74 @@ async function loadRenderer(win: BrowserWindow): Promise<void> {
await win.loadFile(path.join(currentDir, '../renderer/index.html'));
}
/**
* 按配置里的 `autoLaunch` 应用「开机自启」。
*
* 两个实测过的坑:
* 1. Electron 44 的 `setLoginItemSettings` 在 **Windows 上没有 `openAsHidden`**
* (那是旧的 macOS 选项),所以"登录后不弹主窗口"只能自己带参数 —— 启动时注册 `--hidden`,
* `createWindow()` 看到它就把窗口建成隐藏态(见 `startHidden`);
* 2. **冒烟验证绝不写登录项**:那会改开发机真实的注册表启动项,属于"验证污染环境"。
*/
function applyAutoLaunch(): void {
if (isSmokeTest) return;
const enabled = getConfig().autoLaunch === true;
try {
app.setLoginItemSettings({
openAtLogin: enabled,
path: process.execPath,
args: enabled ? ['--hidden'] : [],
});
logger.info(enabled ? '已开启开机自启(登录 Windows 后静默启动,不弹主窗口)' : '开机自启未开启');
} catch (error) {
logger.warn(
`设置开机自启失败(不影响主功能,可在设置页重试):${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
// 单实例:重复启动时聚焦已有窗口
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
showMainWindow();
});
app.whenReady().then(async () => {
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
logger.setVerbose(getConfig().developerMode === true);
registerIpcHandlers();
registerIslandIpc();
mainWindow = createWindow();
// 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
applyAutoLaunch();
mainWindow = createWindow({ hidden: startHidden });
await loadRenderer(mainWindow);
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
installCrashGuard({
recoverMainWindow: () => {
const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
if (alive) {
alive.hide();
alive.destroy();
}
mainWindow = ensureMainWindow();
},
isIdle: () =>
island.getState().inClass === false &&
mainWindow?.isVisible() !== true &&
!BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible()),
quitApp: () => quitApp(),
});
// 空闲安装更新:先把手动退出的回调交给 auto-update(它不能再 import 本文件,否则循环导入)
setQuitHandler(() => quitApp());
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
try {
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
@@ -167,6 +249,9 @@ if (!gotLock) {
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
startClientAgent();
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
@@ -177,15 +262,13 @@ if (!gotLock) {
}
});
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动 / 灵动岛"打开应用") */
function showMainWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
mainWindow = createWindow();
void loadRenderer(mainWindow);
}
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
const win = ensureMainWindow();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
/**
@@ -194,6 +277,8 @@ if (!gotLock) {
*/
function shutdownResources(): string {
const done: string[] = [];
// 上报代理先停:否则退出过程中它还会再排一次心跳(幂等,重复调用无副作用)
stopClientAgent();
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
@@ -243,8 +328,8 @@ if (!gotLock) {
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
await loadRenderer(mainWindow);
const win = ensureMainWindow();
if (win) await loadRenderer(win);
}
});
}
+85 -11
View File
@@ -1,19 +1,15 @@
import { app, ipcMain, shell } from 'electron';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared';
import { IPC_CHANNELS } from './channels.js';
import { beatNow, getClientAgentStatus } from './client-agent.js';
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
import { logger } from './logger.js';
import { checkForUpdates, ignoreUpdateVersion } from './update.js';
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
export const IPC_CHANNELS = {
getConfig: 'classhelper:config:get',
saveConfig: 'classhelper:config:save',
clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external',
updateCheck: 'classhelper:update:check',
updateIgnore: 'classhelper:update:ignore',
} as const;
// 通道名定义搬到了叶子模块 `channels.ts`(原因见那里的注释:preload 在沙箱里不能碰 node 内建模块);
// 这里原样再导出,保持既有 `import { IPC_CHANNELS } from './ipc.js'` 的调用点不变。
export { IPC_CHANNELS };
export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
@@ -58,6 +54,84 @@ export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
if (typeof version === 'string') ignoreUpdateVersion(version);
});
/** 开机自启:读的是系统登录项,不是配置文件里的值(两者可能被外部改过) */
ipcMain.handle(IPC_CHANNELS.getAutoLaunch, (): boolean => {
try {
return app.getLoginItemSettings().openAtLogin === true;
} catch {
return getConfig().autoLaunch === true;
}
});
/**
* 开机自启:写配置 + 同步系统登录项。
*
* 与 `main/index.ts` 的 `applyAutoLaunch()` 同一套参数(`--hidden` 是"登录后不弹主窗口"的实现方式,
* 因为 Electron 44 在 Windows 上没有 `openAsHidden`),返回**系统实际生效**的值。
*/
ipcMain.handle(IPC_CHANNELS.setAutoLaunch, (_event, enabled: boolean): boolean => {
const wanted = enabled === true;
saveConfig({ autoLaunch: wanted });
try {
app.setLoginItemSettings({
openAtLogin: wanted,
path: process.execPath,
args: wanted ? ['--hidden'] : [],
});
return app.getLoginItemSettings().openAtLogin === true;
} catch (error) {
logger.warn(
`写入开机自启失败(配置已保存,下次启动会重试):${
error instanceof Error ? error.message : String(error)
}`,
);
return wanted;
}
});
/** 打开日志目录(设置页「打开日志目录」;日志文件是 main.log,见 main/logger.ts) */
ipcMain.handle(IPC_CHANNELS.openLogDir, async (): Promise<boolean> => {
try {
const message = await shell.openPath(app.getPath('logs'));
if (message) {
logger.warn(`打开日志目录失败:${message}`);
return false;
}
return true;
} catch (error) {
logger.warn(`打开日志目录失败:${error instanceof Error ? error.message : String(error)}`);
return false;
}
});
/**
* 打开开发者工具(「开发者」页的按钮;detached 独立窗口)。
*
* 两处刻意的限制:
* - **冒烟模式下直接拒绝**。冒烟验证会给窗口注入合成事件、测量侧栏几何、整屏截图取样;
* 多出一个 DevTools 窗口会抢焦点、并且 detached 窗口会改变 `capturePage()` 的取样范围,
* 把既有断言打乱。返回 false 让页面提示"冒烟模式下不可用"。
* - 窗口取自 `event.sender` 而不是模块级变量:IPC 只可能由主窗口(或无关的岛窗口)发起,
* 按发送方取窗口既不需要在 ipc.ts 里持有 mainWindow 引用(避免与 main/index.ts 循环依赖),
* 也保证打开的是**发起调用的那个窗口**的 DevTools。
*/
ipcMain.handle(IPC_CHANNELS.openDevTools, (event): boolean => {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
logger.warn('冒烟验证模式下不允许打开开发者工具(会打乱焦点与截图断言)');
return false;
}
const target = BrowserWindow.fromWebContents(event.sender);
if (!target || target.isDestroyed()) return false;
target.webContents.openDevTools({ mode: 'detach' });
return true;
});
/** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
/** 立即上报一次(设置页「立即上报」按钮) */
ipcMain.handle(IPC_CHANNELS.clientBeatNow, () => beatNow());
}
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
+79 -13
View File
@@ -101,6 +101,15 @@ let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 };
*/
let BBOX_SIZE = { width: 0, height: 0 };
/**
* 「收回动画」时长(毫秒)。
*
* 用户点了「知道了 / 标为已读」之后,主进程把形态切回胶囊、让渲染进程沿**既有的形变路径**
* 把卡片收回去,到点再淡出隐藏。取值与渲染进程的形变弹簧(约 300~500ms)同量级;
* 外观设置里的"动画速度"会同比缩放它(见 `beginClose()`)。
*/
const CLOSE_ANIMATION_MS = 360;
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
const TIMEOUTS = {
pill: 15_000,
@@ -150,6 +159,11 @@ class IslandController {
private ready = false;
private pendingState: IslandState | null = null;
private collapseTimer: ReturnType<typeof setTimeout> | null = null;
/**
* 「收回动画」定时器:点知道了 / 标为已读之后,延时把窗口淡出隐藏。
* 任何让岛重新可见的路径(`activate()` 等)都会经 `clearTimers()` 取消它。
*/
private closeTimer: ReturnType<typeof setTimeout> | null = null;
/** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */
private boundsToken = 0;
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
@@ -786,6 +800,48 @@ class IslandController {
this.setState({ mode: 'hidden', reason: null });
return;
}
this.beginClose();
}
/**
* 收回动画:把形态切回胶囊,让渲染进程沿**既有的胶囊形变路径**把卡片收回去,
* `CLOSE_ANIMATION_MS` 之后再淡出隐藏。
*
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
* (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
*/
private beginClose(): void {
// 关掉动画(含"减少动态效果"):保持原行为,直接淡出
if (!this.appearance.animations) {
this.finishClose();
return;
}
/**
* **必须 `skipWindow`**:收回动画是"卡片沿既有的胶囊形变路径缩回去",
* 而窗口此刻还是展开/列表态的大包围盒 —— 若这里同步缩窗,卡片会在动画的 360ms 里
* 被窗口裁掉(冒烟用例「整批关闭过程中卡片不被窗口裁切」实测最大越界 554.9px,
* 用户看到的是一瞬间的硬裁边)。窗口要变小一律等**隐藏之后**由 `syncWindow()` 做,
* 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md §5 第 30 条)是同一条纪律。
*/
this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true });
const speed = clamp(this.appearance.speed, 0.5, 2, 1);
this.closeTimer = setTimeout(() => {
this.closeTimer = null;
this.finishClose();
}, Math.round(CLOSE_ANIMATION_MS / speed));
}
/**
* 收回动画收尾:淡出窗口并把状态落成 hidden —— 与原来的 `hide()` 完全一致,
* 所以"窗口最终会隐藏"的既有断言(含轮询写法)不受影响。
*/
private finishClose(): void {
// 动画期间又来了新消息 / 被显式唤起:这次收回归属于旧的一批,放弃收尾
if (this.state.mode !== 'pill' || this.state.reason !== 'closing') return;
if (this.state.active || this.state.queued.length > 0) return;
this.fadeOut();
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
}
@@ -908,6 +964,11 @@ class IslandController {
clearTimeout(this.collapseTimer);
this.collapseTimer = null;
}
// 收回动画定时器:岛一旦被重新唤起(新消息/显式展开/预览),必须取消这次收尾
if (this.closeTimer) {
clearTimeout(this.closeTimer);
this.closeTimer = null;
}
if (this.previewTimer) {
clearTimeout(this.previewTimer);
this.previewTimer = null;
@@ -1690,21 +1751,26 @@ export function getSliverSize(): { width: number; height: number } {
return { ...SLIVER_SIZE };
}
/**
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
*
* 注意 `Math.max(min, NaN)` 仍是 NaN、`Math.min(max, NaN)` 也是 NaN —— 也就是说"只夹紧"
* 并不能防住 NaN。而 `setAppearance()` 的入参来自 IPC(`island:set-appearance` 只判了 `if (patch)`),
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
*
* 提到模块作用域:收回动画(`beginClose()`)也要用它按 `appearance.speed` 折算时长,
* 原先它只定义在 `normalizeAppearance()` 内部,`beginClose()` 引用不到(编译报
* `TS2304: Cannot find name 'clamp'`)。
*/
const clamp = (value: number, min: number, max: number, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
};
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
/**
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
*
* 注意 `Math.max(min, NaN)` 仍是 NaN、`Math.min(max, NaN)` 也是 NaN —— 也就是说"只夹紧"
* 并不能防住 NaN。而 `setAppearance()` 的入参来自 IPC(`island:set-appearance` 只判了 `if (patch)`),
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
*/
const clamp = (value: number, min: number, max: number, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
};
const fallbackOf = DEFAULT_ISLAND_APPEARANCE;
return {
height: clamp(input.height, 36, 72, fallbackOf.height),
+106 -5
View File
@@ -1,25 +1,126 @@
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
/**
* 主进程日志(控制台输出)。
* 主进程日志:控制台输出 + 落盘。
*
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
* - 主窗口右上角的连接状态 / 灵动岛状态
* - **本文件写出的日志文件**(设置页「打开日志目录」直达)
* 对外可见,避免"出了问题却看不到日志"。
*
* 两条实现约束:
* 1. `app.getPath('logs')` **不能在 app ready 之前调用**,所以日志文件路径是**懒解析**的:
* 解析失败(或还没 ready)时只走 console,之后任意一次写入会重新尝试;
* 2. 单文件超过上限就改名成 `main.log.1`(只留一份旧的,更老的直接删除),
* 避免长期运行的教室机器把磁盘写满。
*/
/** 单文件大小上限:超过就轮转(1MB 足够放下很多次崩溃堆栈) */
const LOG_MAX_BYTES = 1024 * 1024;
/** 轮转后的旧文件名 */
const LOG_ROTATED_NAME = 'main.log.1';
/** 日志文件名(在 app ready 之后解析;null = 暂不可用) */
let filePath: string | null = null;
/** verbose(= 配置里的 developerMode)时 debug 级别才输出 */
let verbose = false;
/** 解析日志文件路径:失败只返回 null(不抛),调用方继续走 console */
function resolveLogFile(): string | null {
try {
return path.join(app.getPath('logs'), 'main.log');
} catch {
// app ready 之前调用会抛;此时不写文件即可,控制台输出不受影响
return null;
}
}
/** 文件超过上限就轮转:main.log → main.log.1(覆盖上一份旧的) */
function rotateIfNeeded(target: string): void {
try {
const stat = fs.statSync(target);
if (stat.size <= LOG_MAX_BYTES) return;
const rotated = path.join(path.dirname(target), LOG_ROTATED_NAME);
// 先删旧的再改名:只保留一份历史日志
fs.rmSync(rotated, { force: true });
fs.renameSync(target, rotated);
} catch {
// 文件还不存在(第一次写)/ 被占用:都无需处理
}
}
/** 落盘一行。任何失败都静默忽略 —— 日志不能反过来把主进程搞崩 */
function write(line: string): void {
if (!filePath) filePath = resolveLogFile();
const target = filePath;
if (!target) return;
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.appendFileSync(target, `${line}\n`, 'utf8');
rotateIfNeeded(target);
} catch {
// 忽略:磁盘满 / 权限不足等都不该影响应用运行
}
}
function timestamp(): string {
return new Date().toISOString();
}
export const logger = {
/**
* 设置日志级别(由 `main/index.ts` 按 `config.developerMode` 调用)。
* 开启后 debug 级别的日志才会同时进控制台与日志文件。
*/
setVerbose(enabled: boolean): void {
verbose = enabled === true;
},
/** 当前是否 verbose(供设置页/冒烟断言) */
isVerbose(): boolean {
return verbose;
},
debug(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] DEBUG ${message}`, ...args);
if (!verbose) return;
const line = `[${timestamp()}] DEBUG ${message}`;
console.log(line, ...args);
write(`${line}${formatArgs(args)}`);
},
info(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] INFO ${message}`, ...args);
const line = `[${timestamp()}] INFO ${message}`;
console.log(line, ...args);
write(`${line}${formatArgs(args)}`);
},
warn(message: string, ...args: unknown[]): void {
console.warn(`[${timestamp()}] WARN ${message}`, ...args);
const line = `[${timestamp()}] WARN ${message}`;
console.warn(line, ...args);
write(`${line}${formatArgs(args)}`);
},
error(message: string, ...args: unknown[]): void {
console.error(`[${timestamp()}] ERROR ${message}`, ...args);
const line = `[${timestamp()}] ERROR ${message}`;
console.error(line, ...args);
write(`${line}${formatArgs(args)}`);
},
};
/**
* 额外参数转成一行文本(落盘用)。
*
* 崩溃自愈那边传进来的常常是 Error 对象,**必须把完整堆栈写进文件**,
* 否则事后再看 `main.log` 只有一个光秃秃的 message,等于没记。
*/
function formatArgs(args: unknown[]): string {
if (args.length === 0) return '';
const parts = args.map((item) => {
if (item instanceof Error) return item.stack ?? `${item.name}: ${item.message}`;
if (typeof item === 'string') return item;
try {
return JSON.stringify(item);
} catch {
return String(item);
}
});
return ` ${parts.join(' ')}`;
}
+33
View File
@@ -2029,6 +2029,39 @@ async function runIslandChecks(
);
await sleep(120);
const duringClose = { state: island.getState() };
/**
* 收回动画(用户要求:"点知道了 / 标为已读时也要有回收动画")。
*
* 主进程收到 dismiss / dismiss-all / mark-read / mark-all-read 之后:状态**同一帧就清空**,
* 形态先切回胶囊(`reason === 'closing'`)让渲染进程把卡片收回去,`CLOSE_ANIMATION_MS`(360ms,
* 会按外观里的"动画速度"缩放)之后才淡出隐藏。这里在点击后的小窗口内轮询采样,
* 断言"确实经过了这个阶段、且动画期间窗口仍然可见"。
*/
let closingSample: { mode: string; reason: string | null; visible: boolean } | null = null;
for (let attempt = 0; attempt < 20 && !closingSample; attempt += 1) {
const probe = island.getState();
if (probe.reason === 'closing') {
closingSample = {
mode: probe.mode,
reason: probe.reason,
visible: islandWindow?.isVisible() ?? false,
};
break;
}
await sleep(25);
}
record(
'点「知道了」有收回动画(先收回胶囊再淡出,动画期间窗口可见)',
closingSample !== null &&
closingSample.mode === 'pill' &&
closingSample.visible &&
duringClose.state.active === null,
closingSample
? `采样=${closingSample.mode}/${closingSample.reason}/可见:${closingSample.visible}`
: '未捕捉到 closing 阶段(可能动画被关掉或耗时过短)',
);
for (let attempt = 0; attempt < 26; attempt += 1) {
await probeClosingClip();
await sleep(40);
+3 -2
View File
@@ -10,6 +10,7 @@ import {
type UpdateAssetDto,
type UpdateInfo,
} from '@classhelper/shared';
import { UPDATE_AVAILABLE_CHANNEL } from './channels.js';
import { getConfig, saveConfig } from './config.js';
import { logger } from './logger.js';
@@ -25,8 +26,8 @@ import { logger } from './logger.js';
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
*/
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示);定义在叶子模块 `channels.ts` */
export { UPDATE_AVAILABLE_CHANNEL };
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
const STARTUP_DELAY_MS = 5_000;
+11 -2
View File
@@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../main/ipc.js';
import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js';
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
import type { ClientAgentStatus } from '../main/client-agent.js';
import type {
DesktopAppInfo,
DesktopBridge,
@@ -66,6 +66,15 @@ const bridge: DesktopBridge = {
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
},
/* 开机自启 / 日志 / 服务端联动 */
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.getAutoLaunch),
setAutoLaunch: (enabled: boolean): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.setAutoLaunch, enabled === true),
openLogDir: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openLogDir),
openDevTools: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openDevTools),
getClientAgentStatus: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientStatus),
beatClientNow: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientBeatNow),
};
contextBridge.exposeInMainWorld('desktop', bridge);
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { ElButton, ElNotification } from 'element-plus';
import type { UpdateInfo } from '@classhelper/shared';
import {
replayMissedNotifications,
startIslandBridge,
stopIslandBridge,
subscribeIslandMarkAllRead,
@@ -72,6 +73,20 @@ function showUpdateAvailable(info: UpdateInfo): void {
});
}
/**
* 上线补弹「离线期间收到的消息」(需求 11)。
*
* **为什么订阅放在 App.vue**:与 `onUpdateAvailable` 同一条理由 —— App.vue 整个应用生命周期
* 只挂载一次,而 `ClientLayout` 会随"登出 → 登录"反复挂载。而且恢复事件本身是"可达性由不可达
* 变为可达"的那一刻,一次会话里可能发生多次,处理器必须是**幂等**的(`replayMissedNotifications`
* 推完就清掉离线起点,重复触发时第二次读到空值、直接返回 0)。
*
* 处理器是具名函数:`stores/app.ts` 的订阅表按函数引用 off,匿名箭头函数在 onUnmounted 里摘不掉。
*/
function handleServerRecovered(): void {
void replayMissedNotifications();
}
/**
* 启动流程:
* 1) 从主进程配置恢复服务器地址与登录令牌
@@ -107,6 +122,9 @@ onMounted(async () => {
subscribeIslandMarkRead();
subscribeIslandMarkAllRead();
// 离线期间收到的消息:可达性恢复的那一刻补弹到灵动岛(App.vue 常驻,所以每次恢复只跑一次)
appStore.onServerRecovered(handleServerRecovered);
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
@@ -129,6 +147,7 @@ onMounted(async () => {
});
onUnmounted(() => {
appStore.offServerRecovered(handleServerRecovered);
stopIslandBridge();
});
</script>
@@ -1048,6 +1048,15 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
//
// ⚠️ 量之前必须等页脚真的收成 0 高:宽度到 64px 与页脚收拢各有一条 260ms 过渡,
// 而 `--nav-ease`(Material 曲线)在 74% 处就越过"宽度 ≤ 80px"这个等待条件,
// 此时页脚还剩十几像素 —— 直接量 `.menu` 会读出"溢出 16px"的**假红**
// (实测出现过一次:同一份产物上一次全绿、下一次红)。这里等的是**稳态几何**,
// 与这条断言本来的意图一致(CSS 里那句"量到的是稳态")。
const railFooter = document.querySelector<HTMLElement>('.aside-footer');
await waitUntil(() => (railFooter?.offsetHeight ?? 0) <= 1, 2000);
const settingsItem = Array.from(
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
).find((element) =>
@@ -6,9 +6,9 @@ import {
type NotificationDto,
type ScheduleDto,
} from '@classhelper/shared';
import { scheduleApi } from '../api/index.js';
import { notificationApi, scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
@@ -190,6 +190,81 @@ export function pushCallToIsland(call: NotificationDto): void {
});
}
/* ------------------------------------------------------------ 补弹离线期间收到的消息(需求 11) */
/**
* 补弹条数上限。
*
* 灵动岛的多条通知列表默认只显示 `ISLAND_LIST_METRICS.defaultVisibleRows`(3)条 +
* 「展开更多(还有 N 条)」。所以一条不补、和一次补十几条,都是同样的阅读体验 ——
* 封顶 5 条 = 3 条可见 + 2 条藏在"展开更多"里,正好是打开就能读完的量;
* 补太多只会让"展开更多"变成一个没有意义的按钮。
*/
const REPLAY_MAX_COUNT = 5;
/**
* 上线后补弹「离线期间收到的消息」(需求 11)。
*
* 调用时机:`App.vue` 订阅 `useAppStore().onServerRecovered`,也就是**可达性由不可达变为可达**
* 的那一刻(断网期间的实时通道是断的,那些通知既没弹过、也没进过用户的视野)。
*
* 数据来源复用既有的通知接口(`notificationApi.list({ unreadOnly: true })`):
* 客户端本来就是离线优先的(`fetchWithCache` 会把数据落 IndexedDB),**不为此新建数据源** ——
* 补弹只需要"服务端现在有哪些未读",而通知列表本来就是这个问题的答案。
*
* 实现上有四条约定:
* 1. **没有离线起点就直接返回 0**(首次启动 / 一直在线):离线起点只由
* `stores/app.ts` 在"由可达变不可达"的那一刻写入,没有它就说明这段时间根本没断过网,
* 此时补弹等于把**历史未读**全弹一遍,那不是「离线期间收到的」。
* 2. **只补离线起点之后创建的**(`createdAt > offlineSince`),并且在服务端排序之外再排一次
* **按时间升序**投递 —— 顺序投递才能让灵动岛的队列保持时间顺序。
* 3. **取最近的 5 条**(升序列表的尾部):断网一天可能积压几十条,取最早的那 5 条等于
* 让用户先看最旧的、最新的反而被"展开更多"挡住。取最近的 5 条更符合"我刚错过了什么"。
* 4. **推完就清掉离线起点**:同一次离线只补一次,否则每次可达性抖动都会重复弹同一批。
*
* @returns 实际补弹的条数(0 = 没补)
*/
export async function replayMissedNotifications(): Promise<number> {
// 冒烟保护:`verify:desktop` / `verify:packaged` 有一条「灵动岛队列」的断言链
// (100+ 条断言量的是卡片的条数、顺序、高度与"展开更多"的分页),
// 冒烟脚本会反复断开/恢复网络,注入的合成通知与这里的补弹会互相打乱队列,
// 让那些断言随机变红。冒烟模式下**直接跳过补弹**(也不动离线起点),
// 补弹本身由人工/单元路径覆盖,不进冒烟。
if (window.desktop?.smokeTest === true) return 0;
const offlineSince = readOfflineSince();
if (!offlineSince) return 0;
const sinceMs = Date.parse(offlineSince);
if (Number.isNaN(sinceMs)) {
// 存储里是坏值(手工改过 / 旧版本写入格式不同):清掉,别让它永远挡住补弹
clearOfflineSince();
return 0;
}
let unread: NotificationDto[];
try {
unread = await notificationApi.list({ unreadOnly: true });
} catch {
// 刚恢复就又断了(或服务端还没真正就绪):保留离线起点,等下一次恢复再补
return 0;
}
const missed = unread
.filter((item) => {
const at = Date.parse(item.createdAt);
return !Number.isNaN(at) && at > sinceMs;
})
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
const picked = missed.slice(-REPLAY_MAX_COUNT);
for (const item of picked) pushNotificationToIsland(item);
// 无论补到几条都清掉:这一轮离线已经被处理过了
clearOfflineSince();
console.info(`[离线补弹] 补弹离线消息 ${picked.length} 条(离线起点 ${offlineSince})`);
return picked.length;
}
/**
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
* 由 App.vue 在挂载时订阅一次。
@@ -25,9 +25,9 @@ const ui = useUiStore();
*
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨,
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由;
* 开发者模式下再多一个「开发者」)。6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。
* 折叠后是图标导轨,分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
*
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
*/
@@ -38,20 +38,38 @@ const primaryItems = [
{ path: '/grades', title: '成绩', icon: 'Trophy' },
];
const menuGroups = [
/** 「设置」分组的常规子项(顺序即侧栏顺序) */
const settingItems = [
{ path: '/settings/general', title: '通用', icon: 'Tools' },
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
];
/**
* 菜单分组(**computed**:子项随开发者模式变化,见下)。
*
* 「开发者」(需求 10)只在 `ui.developerMode === true` 时出现 —— 它是个排查问题的入口
* (版本/日志/更新/DevTools),常驻只会让教室机器上的使用者点进一个看不懂的页面。
*
* 图标用 `Connection`:新增图标必须同时进 `renderer/icons.ts` 的注册表,
* 而那是本任务范围外的文件 —— 所以**只用已注册图标**(`scripts/check-icons.mjs`
* 会在构建前拦下未注册的名字)。
*
* 折叠成图标导轨后多一行图标:开启开发者模式是用户自己的选择,默认关闭 ⇒
* 冒烟(默认配置)里的导轨几何与"不出现滚动条"断言不受影响。
*/
const menuGroups = computed(() => [
{
title: '设置',
icon: 'Setting',
items: [
{ path: '/settings/general', title: '通用', icon: 'Tools' },
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
],
items: ui.developerMode
? [...settingItems, { path: '/settings/developer', title: '开发者', icon: 'Connection' }]
: settingItems,
},
];
]);
/* -------------------------------------------------------------- 导航指示器 */
@@ -70,18 +88,46 @@ const menuGroups = [
*/
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
/**
* 底片的**裁剪框**:与 `.menu` 的滚动视口逐像素重合。
*
* 底片挂在 `.aside` 上,而菜单项是在 `.menu` 里滚动的(`.menu` 是 `flex:1 + min-height:0 +
* overflow-y:auto`,设置组展开时内容约 606px 会超过窗口高度)。激活项被滚出视口时,
* 底片会跟着它跑到品牌区或页脚上 —— 套一层视口大小的 `overflow:hidden`,
* 就是让底片"只在菜单看得见的地方存在"。
*/
const clipStyle = ref<Record<string, string>>({
top: '0px',
left: '0px',
width: '0px',
height: '0px',
});
function syncIndicator(): void {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!aside || !active) {
const scroller = aside?.querySelector<HTMLElement>('.menu') ?? null;
if (!aside || !scroller) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const asideRect = aside.getBoundingClientRect();
const boxRect = scroller.getBoundingClientRect();
// 底片的坐标一律相对「菜单视口」,不再相对 `.aside` —— 裁剪框把两者的原点对齐了
clipStyle.value = {
top: `${boxRect.top - asideRect.top}px`,
left: `${boxRect.left - asideRect.left}px`,
width: `${boxRect.width}px`,
height: `${boxRect.height}px`,
};
const active = scroller.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!active) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const rect = active.getBoundingClientRect();
indicator.value = {
x: rect.left - asideRect.left,
y: rect.top - asideRect.top,
x: rect.left - boxRect.left,
y: rect.top - boxRect.top,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0,
@@ -114,6 +160,32 @@ function scheduleIndicatorSync(): void {
});
}
/**
* 菜单内部滚动:底片要**当帧**跟上,而且这一路上不能走过渡动画。
*
* 两点都和"滚动是连续手势"有关:
* 1. 不能用 `scheduleIndicatorSync()`(rAF 推后一帧)—— 快速滚动时底片会肉眼可见地落后;
* 滚动事件本身在布局之后派发,在这里同步量一次不会再插入一次强制布局。
* 2. 不能走 `.nav-indicator` 的 LAYOUT 弹簧(397ms)—— 那是"切换菜单项"的动画参数,
* 用在滚动上就成了"底片慢吞吞地追菜单项"。所以滚动期间挂 `is-following` 关掉过渡,
* 停手 140ms 后恢复,下一次点菜单项照样有滑动动画。
*/
const SCROLL_SETTLE_MS = 140;
let menuScroller: HTMLElement | null = null;
let scrollSettleTimer = 0;
const indicatorFollowing = ref(false);
function handleMenuScroll(): void {
if (!indicatorFollowing.value) indicatorFollowing.value = true;
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
scrollSettleTimer = window.setTimeout(() => {
scrollSettleTimer = 0;
indicatorFollowing.value = false;
}, SCROLL_SETTLE_MS);
syncIndicator();
}
/**
* 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active`
* 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。
@@ -125,6 +197,11 @@ function scheduleIndicatorSync(): void {
onMounted(() => {
syncIndicator();
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
if (aside) {
// 菜单自己的滚动容器:翻菜单时底片必须跟着走(用户反馈的「上下滑动时项目选择块不移动」)
menuScroller = aside.querySelector<HTMLElement>('.menu');
menuScroller?.addEventListener('scroll', handleMenuScroll, { passive: true });
}
if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync);
asideObserver.observe(aside);
@@ -134,6 +211,10 @@ onMounted(() => {
onUnmounted(() => {
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
indicatorFrame = 0;
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
scrollSettleTimer = 0;
menuScroller?.removeEventListener('scroll', handleMenuScroll);
menuScroller = null;
asideObserver?.disconnect();
asideObserver = null;
});
@@ -172,7 +253,7 @@ const activeMenu = computed(() => route.path);
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
const activeGroupTitle = computed(() => {
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
const group = menuGroups.value.find((item) => item.items.some((entry) => route.path === entry.path));
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
return group?.title ?? null;
});
@@ -190,7 +271,7 @@ const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.va
watch(
() => route.path,
async (path) => {
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
const group = menuGroups.value.find((item) => item.items.some((entry) => path === entry.path));
if (!group) return;
// 展开当前组(不主动关闭用户已展开的其它组)
await nextTick();
@@ -215,7 +296,7 @@ function go(name: string): void {
/** 开发期自检:菜单路径必须存在于路由表中 */
if (import.meta.env.DEV) {
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
for (const group of menuGroups) {
for (const group of menuGroups.value) {
for (const item of group.items) {
if (!knownPaths.has(item.path)) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
@@ -301,8 +382,16 @@ onUnmounted(() => {
<span class="brand-text">ClassHelper</span>
</div>
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
外层 .nav-indicator-clip 与 .menu 的视口重合(overflow:hidden),
把"跟着菜单滚出视口"的底片裁掉,不让它盖到品牌区/页脚上。
两层都 pointer-events:none —— 底片盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator-clip" :style="clipStyle" aria-hidden="true">
<div
class="nav-indicator"
:class="{ 'is-following': indicatorFollowing }"
:style="indicatorStyle"
/>
</div>
<el-menu
ref="menuRef"
:default-active="activeMenu"
@@ -444,6 +533,18 @@ onUnmounted(() => {
position: relative;
}
/*
* 指示器的裁剪框:位置与尺寸每帧由 syncIndicator() 量成"与 .menu 视口重合"。
* 少了它,激活项被滚出菜单视口时底片会跑到品牌区/页脚上(用户反馈「上下滑动时
* 项目选择块不移动」的那次修复里一起加的)。
*/
.nav-indicator-clip {
position: absolute;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
/*
* 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。
*
@@ -459,7 +560,6 @@ onUnmounted(() => {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border-radius: var(--ch-radius-control);
background: var(--ch-accent-soft);
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
@@ -472,6 +572,14 @@ onUnmounted(() => {
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
/*
* 菜单滚动期间(含停手后的 140ms)关掉过渡:滚动是连续手势,底片要"贴在"菜单项上一起走,
* 而不是每帧都启动一次 397ms 的弹簧去追。松手后恢复,点菜单项照样有滑动动画。
*/
.nav-indicator.is-following {
transition: none;
}
/* 左侧强调条(原有观感,现在跟着底片一起滑) */
.nav-indicator::before {
content: '';
@@ -1,5 +1,6 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth.js';
import { useUiStore } from '../stores/ui.js';
/**
* Electron 生产环境通过 file:// 加载页面,无法使用 history 模式,
@@ -82,6 +83,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/settings/AboutView.vue'),
meta: { title: '关于' },
},
{
/**
* 开发者(需求 10)。入口只在 `config.developerMode === true` 时出现在侧栏上,
* 但**路由始终注册**:关掉开关后侧栏项消失,已打开的页面靠一条守卫弹回「通用」,
* 不做成条件注册 —— 条件注册会让 `router.getRoutes()` 随配置变化,
* ClientLayout 的开发期自检与直接输 URL 的行为都会变得难以预期。
*/
path: 'settings/developer',
name: 'settings-developer',
component: () => import('../views/settings/DeveloperSettingsView.vue'),
meta: { title: '开发者' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/schedule' },
@@ -99,6 +112,16 @@ router.beforeEach((to) => {
return true;
}
if (!auth.isAuthenticated) return { name: 'login' };
/**
* 开发者页(需求 10)只在开发者模式开启时可进:侧栏项本身也是条件显示的,
* 但配置可能是在**停留在这个页面时**被关掉的(「通用」页的开关),
* 所以需要这条守卫把它弹回「通用」,不让用户在看一个"已经不该存在"的页面。
*
* 这里读的是 ui store 里的状态(由 `ui.init()` 在首屏前从主进程配置恢复),
* 不额外调 `window.desktop.getConfig()` —— 导航守卫是同步高频路径,
* 每次跳转都跨一次 contextBridge 不值得。
*/
if (to.name === 'settings-developer' && !useUiStore().developerMode) return { name: 'settings-general' };
return true;
});
@@ -13,6 +13,51 @@ import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
type RecoveryHandler = () => void;
/**
* 「本次离线从什么时候开始」的本地时间戳键(需求 11)。
*
* 为什么用 localStorage 而不是主进程配置(config.json):
* 它是一个**瞬时状态**,只描述"当前这一轮断网",一旦补弹完成(或进程重启后重新联网)就作废,
* 不需要跨进程读、也不需要跟着设备身份走。放 localStorage 有三点好处:
* - 写入发生在每次请求的**可达性回调**里(高频路径),localStorage 是同步内存写,
* 不会像 saveConfig 那样排一次主进程 IPC + 120ms 合并窗口;
* - 它是**渲染进程自己**的读-改-清闭环(bridge.ts 读它 → 补弹 → 清掉),不跨越 contextBridge;
* - 跨重启也够用:网页存储随 profile 持久化,补弹窗口本来就只关心"最近一次离线"。
*/
const OFFLINE_SINCE_KEY = 'classhelper.offlineSince';
/**
* 记下"本次离线从什么时候开始"(ISO 字符串)。
* 读不到 / 写不进(隐私模式、存储被清)时静默跳过 —— 补弹只是锦上添花,不该影响可达性上报。
*/
function markOfflineSince(): void {
try {
const now = new Date().toISOString();
localStorage.setItem(OFFLINE_SINCE_KEY, now);
console.info(`[离线] 已记录本次离线起点:${now}`);
} catch {
// 存储不可用:这次不补弹,但联网后的恢复流程照常
}
}
/** 读本次离线起点(ISO 字符串);没有记录(首次启动 / 一直在线)返回 null */
export function readOfflineSince(): string | null {
try {
return localStorage.getItem(OFFLINE_SINCE_KEY);
} catch {
return null;
}
}
/** 清掉离线起点:补弹完成后调用,避免同一次离线被重复补弹 */
export function clearOfflineSince(): void {
try {
localStorage.removeItem(OFFLINE_SINCE_KEY);
} catch {
// 清不掉也不影响功能:下一次"由可达变不可达"会覆盖它
}
}
/**
* 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。
* 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。
@@ -48,13 +93,22 @@ export const useAppStore = defineStore('app', () => {
recoveryHandlers.delete(handler);
}
/** 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性 */
/**
* 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性。
*
* 「由可达变不可达」的那一刻顺手记下离线起点(需求 11 的补弹基准):
* 只在**边界**上写,不在"一直不可达"的每次请求上重复覆盖,否则补弹窗口会被不停地
* 推迟到"最后一次失败",离线期间收到的通知就永远落在窗口之外了。
*/
setReachabilityReporter((reachable) => {
if (reachable && !serverReachable.value && initialized.value) {
serverReachable.value = true;
notifyRecovered();
return;
}
if (!reachable && serverReachable.value && initialized.value) {
markOfflineSince();
}
serverReachable.value = reachable;
});
@@ -68,6 +122,9 @@ export const useAppStore = defineStore('app', () => {
if (!serverReachable.value && initialized.value) notifyRecovered();
serverReachable.value = true;
} else {
// 健康探针是机器**空闲时唯一**的可达性信号(没有任何业务请求在跑),
// 所以这条路径也要记离线起点,否则空闲断网期间收到的通知补不回来。
if (serverReachable.value && initialized.value) markOfflineSince();
serverReachable.value = false;
}
}
@@ -3,24 +3,47 @@ import { defineStore } from 'pinia';
import type { DesktopStoredConfig } from '../../types/desktop.js';
/**
* 界面偏好(主题 / 侧边栏折叠)。
* 界面偏好(主题 / 侧边栏折叠 / 关怀模式)。
*
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed / careMode)里,
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
*
* 关怀模式 = 在 <html> 上挂 .care-mode 类(styles/index.css 末尾那个集中覆盖块),
* 只放大正文与控件字号,不动任何几何令牌 —— 理由写在那个块的注释里。
*/
const THEME_KEY = 'classhelper.theme';
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
const CARE_MODE_KEY = 'classhelper.careMode';
const DEVELOPER_MODE_KEY = 'classhelper.developerMode';
function applyThemeClass(theme: 'light' | 'dark'): void {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
/**
* 关怀模式的类名开关。**只碰这一个类**:放大规则全在 CSS 里,
* JS 侧不逐个元素改字号(那样既漏元素又把主题与字号耦合在一起)。
*/
function applyCareModeClass(enabled: boolean): void {
document.documentElement.classList.toggle('care-mode', enabled);
}
export const useUiStore = defineStore('ui', () => {
const theme = ref<'light' | 'dark'>('light');
const sidebarCollapsed = ref(false);
const careMode = ref(false);
/**
* 开发者模式(需求 10)。
*
* 它既是主进程配置(`config.developerMode`,决定日志级别),也是**侧栏的一项** ——
* 所以必须放在 store 里:关掉开关时「开发者」菜单项要立刻消失、路由守卫也要立刻生效,
* 而「通用」页与 `ClientLayout.vue` 是两个不同的组件,靠各自的本地 ref 是传不过去的
* (此前"改完设置要重启客户端才看到"多半就是这个原因)。
*/
const developerMode = ref(false);
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
async function init(): Promise<void> {
@@ -29,10 +52,14 @@ export const useUiStore = defineStore('ui', () => {
if (config) {
theme.value = config.theme === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = config.sidebarCollapsed === true;
applyCareMode(config.careMode === true);
developerMode.value = config.developerMode === true;
}
} else {
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
applyCareMode(localStorage.getItem(CARE_MODE_KEY) === '1');
developerMode.value = localStorage.getItem(DEVELOPER_MODE_KEY) === '1';
}
applyThemeClass(theme.value);
}
@@ -66,8 +93,64 @@ export const useUiStore = defineStore('ui', () => {
void setSidebarCollapsed(!sidebarCollapsed.value);
}
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
/**
* 应用关怀模式(需求 14):只挂类名 + 记状态,**不落盘**。
* 启动时由 `init()` 在首屏渲染前调用,所以刷新页面不会看到"先正常、后放大"的跳变。
*/
function applyCareMode(enabled: boolean): void {
careMode.value = enabled;
applyCareModeClass(enabled);
}
/**
* 切换关怀模式(「通用」设置页的开关):立刻生效并写回配置。
*
* 注意:用户明确开启时**不放大侧栏导轨**(见 styles/index.css 的 care-mode 块),
* 也**不做**任何 AI 助手按钮的联动 —— 本客户端目前没有助手入口,那条联动只存在于
* Web 管理端;这里不为了"看起来对称"去造一个入口。
*/
async function setCareMode(enabled: boolean): Promise<void> {
applyCareMode(enabled);
if (window.desktop) {
await window.desktop.saveConfig({ careMode: enabled }).catch(() => undefined);
} else {
localStorage.setItem(CARE_MODE_KEY, enabled ? '1' : '0');
}
}
/**
* 切换开发者模式(「通用」设置页的开关):立刻生效并写回配置。
*
* 打开之后有三件事同时发生,这里只负责**状态与落盘**:
* 1. 侧栏「设置」分组里出现「开发者」项(`ClientLayout.vue` 按 `ui.developerMode` 渲染);
* 2. `main/index.ts` 在下次启动时把日志级别调到 debug(`logger.setVerbose`)——
* 本次会话的日志级别**不重开**,避免开着日志复现问题时把级别改来改去;
* 3. 「通用」页上给出"debug 级日志已开启"的提示。
*/
async function setDeveloperMode(enabled: boolean): Promise<void> {
developerMode.value = enabled;
if (window.desktop) {
await window.desktop.saveConfig({ developerMode: enabled }).catch(() => undefined);
} else {
localStorage.setItem(DEVELOPER_MODE_KEY, enabled ? '1' : '0');
}
}
return {
theme,
sidebarCollapsed,
careMode,
developerMode,
init,
setTheme,
toggleTheme,
setSidebarCollapsed,
toggleSidebar,
applyCareMode,
setCareMode,
setDeveloperMode,
};
});
/** 供类型检查使用(仅引用类型) */
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed' | 'careMode' | 'developerMode'>;
@@ -95,6 +95,12 @@
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
--el-fill-color-blank: transparent;
--el-font-size-base: 13px;
/* 关怀模式的字号倍率(需求 14)。
* 平时恒为 1:它只作为「当前有没有放大」的单一事实来源,方便 JS 侧读取与断言,
* 真正的放大规则集中在文件末尾的 `html.care-mode` 块里(那里才是覆盖点,
* 且**刻意只动字号**——圆角 / 阴影 / 侧栏宽度等几何令牌一律不碰)。 */
--ch-font-scale: 1;
}
/* ---------------------------------------------------------------- 深色主题 */
@@ -946,3 +952,61 @@ textarea {
::view-transition-new(root) {
z-index: 2;
}
/* ------------------------------------------------------------ 关怀模式(需求 14)
*
* 给视力不佳的用户把**正文与控件**整体放大一档(约 +15%)。由 `stores/ui.ts` 的
* `applyCareMode()` 在首屏渲染前按 `config.careMode` 给 `<html>` 挂/摘 `.care-mode` 类。
*
* 三条刻意的边界,改这个块之前先读:
*
* 1. **只放大字号,不动几何。** 圆角(`--ch-radius-*`)、阴影(`--ch-shadow-*`)、
* 卡片内边距、侧栏宽度一律不碰 —— 放大字号已经会让文字换行,再动几何就是"另一个主题",
* 而且 `verify:desktop` 的多条断言量的是像素几何。
* 2. **侧栏导轨(`.ch-nav`)的条目字号不在这里放大。** §5 第 49 条那套"折叠成图标导轨"
* 的硬要求(导轨宽度、条目高度、折叠态文字必须既无宽度又透明)是按固定几何量出来的,
* 动导航条目字号会让折叠态文字重新占宽而断言变红。导航的可见标签保持原字号。
* 3. **用 `html.care-mode` 而不是 `:root`**:特异性 (0,1,1) 高于普通类选择器,
* 能稳定压过上面那些 `.page-title` / `.el-card__header` 等单类规则,不必写 `!important`。
* 深色主题无关 —— 这里一条颜色都没写。
*/
html.care-mode {
/* 放大倍率放在这里,下面的覆盖全部由它算出来 —— 想再调一档只改这一个数 */
--ch-font-scale: 1.15;
/* 覆盖 Element Plus 的基准字号:按钮、输入框、下拉、分页、消息框等控件都跟着它走
(基准值是 :root 里的 13px,见上面的 Element Plus 变量对齐) */
--el-font-size-base: calc(13px * var(--ch-font-scale));
}
/* 页面标题与副标题(`views/*` 的通用页头) */
html.care-mode .page-title {
font-size: calc(20px * var(--ch-font-scale));
}
html.care-mode .page-subtitle {
font-size: calc(12.5px * var(--ch-font-scale));
}
/* 卡片标题 / 折叠面板标题(这两处在上面被显式写成 13.5px,必须逐条覆盖) */
html.care-mode .el-card__header,
html.care-mode .el-collapse-item__header {
font-size: calc(13.5px * var(--ch-font-scale));
}
/* 表格:表头与单元格一起放大,行高由内容自然撑开(不设固定 height) */
html.care-mode .el-table {
font-size: calc(13px * var(--ch-font-scale));
}
html.care-mode .el-table th.el-table__cell {
font-size: calc(12.5px * var(--ch-font-scale));
}
/* 表单里 Element Plus 自己的标签/说明文字(默认跟着 --el-font-size-base,
* 但 `.el-form-item__label` 在部分页面被行内样式压过,这里统一一档更稳) */
html.care-mode .el-form-item__label,
html.care-mode .el-descriptions__label,
html.care-mode .el-descriptions__content,
html.care-mode .el-message-box__message {
font-size: calc(13px * var(--ch-font-scale));
}
@@ -2,10 +2,13 @@
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
dayKeyLocal,
formatDate,
relativeTime,
shiftDayKey,
type NotificationDto,
type NotificationPriority,
} from '@classhelper/shared';
@@ -24,6 +27,85 @@ const visibleItems = computed(() =>
filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items,
);
/* ------------------------------------------------------- 按天归档 / 按发布人 / 按类型 */
/**
* 三个筛选都在**已加载的列表**上做,不再请求服务端:
* 客户端是离线优先的(AGENTS.md §5 第 10 条),断网回退缓存时归档功能必须照常可用。
* 口径与服务端一致:按 `assignDate`(本地日期)分组 —— 用 createdAt 的 UTC 切片会把晚上
* 发出来的通知算到第二天(AGENTS.md §5 第 19 条)。
*/
const dayFilter = ref('');
const creatorFilter = ref('');
const priorityFilter = ref('');
/**
* 一条通知归到哪一天。
* `assignDate` 是服务端新加的字段,但**离线缓存里可能还留着加字段之前的旧数据**,
* 所以这里按 createdAt 的本地日期兜底,避免分组头显示成 "undefined"。
*/
function assignDay(item: NotificationDto): string {
return item.assignDate || dayKeyLocal(new Date(item.createdAt));
}
const dayOptions = computed(() => {
const map = new Map<string, number>();
for (const item of store.items) {
const day = assignDay(item);
map.set(day, (map.get(day) ?? 0) + 1);
}
return [...map.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([date, count]) => ({ date, count }));
});
const creatorOptions = computed(() => {
const map = new Map<string, { name: string; count: number }>();
for (const item of store.items) {
const previous = map.get(item.createdBy);
map.set(item.createdBy, {
name: item.creator?.name ?? '未知',
count: (previous?.count ?? 0) + 1,
});
}
return [...map.entries()].map(([id, value]) => ({ id, ...value }));
});
const hasFilter = computed(() => Boolean(dayFilter.value || creatorFilter.value || priorityFilter.value));
const filteredItems = computed(() =>
visibleItems.value.filter(
(item) =>
(!dayFilter.value || assignDay(item) === dayFilter.value) &&
(!creatorFilter.value || item.createdBy === creatorFilter.value) &&
(!priorityFilter.value || item.priority === priorityFilter.value),
),
);
/** 按所属日期分块(同一天一块,块头显示"今天/昨天 + 日期 + 条数") */
const groupedItems = computed(() => {
const groups = new Map<string, NotificationDto[]>();
for (const item of filteredItems.value) {
const day = assignDay(item);
const list = groups.get(day);
if (list) list.push(item);
else groups.set(day, [item]);
}
return [...groups.entries()].map(([date, items]) => ({ date, items }));
});
function dayLabel(date: string): string {
const today = dayKeyLocal(new Date());
if (date === today) return '今天';
if (date === shiftDayKey(today, -1)) return '昨天';
return date;
}
function resetFilters(): void {
dayFilter.value = '';
creatorFilter.value = '';
priorityFilter.value = '';
}
async function openDetail(item: NotificationDto): Promise<void> {
current.value = item;
detailVisible.value = true;
@@ -76,6 +158,7 @@ onUnmounted(() => {
<h2 class="page-title">通知</h2>
<p class="page-subtitle">
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
<span v-if="hasFilter">· 筛选后 {{ filteredItems.length }} 条</span>
<el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
@@ -92,37 +175,88 @@ onUnmounted(() => {
</div>
<el-card v-loading="store.loading" shadow="never">
<el-empty v-if="visibleItems.length === 0" description="暂无通知" />
<ul v-else class="notice-list">
<li
v-for="item in visibleItems"
:key="item.id"
class="notice-item"
:class="{ unread: !item.read }"
@click="openDetail(item)"
<!-- 按天归档 + 按发布人 + 按类型;都在本地已加载的列表上筛(离线也能用) -->
<div class="notice-filters">
<el-select v-model="dayFilter" placeholder="按天归档" clearable size="small" style="width: 150px">
<el-option
v-for="item in dayOptions"
:key="item.date"
:label="`${item.date}(${item.count})`"
:value="item.date"
/>
</el-select>
<el-select
v-model="creatorFilter"
placeholder="全部发布人"
clearable
size="small"
style="width: 150px"
>
<div class="notice-main">
<div class="notice-title">
<span v-if="!item.read" class="unread-dot"></span>
<el-tag
:type="PRIORITY_TAG_TYPES[item.priority as NotificationPriority]"
size="small"
effect="light"
>
{{ PRIORITY_LABELS[item.priority as NotificationPriority] }}
</el-tag>
<span class="title-text">{{ item.title }}</span>
</div>
<div class="notice-content">{{ item.content }}</div>
<el-option
v-for="item in creatorOptions"
:key="item.id"
:label="`${item.name}(${item.count})`"
:value="item.id"
/>
</el-select>
<el-select
v-model="priorityFilter"
placeholder="全部类型"
clearable
size="small"
style="width: 130px"
>
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
<el-button v-if="hasFilter" link type="primary" size="small" @click="resetFilters">
清除筛选
</el-button>
</div>
<el-empty v-if="filteredItems.length === 0" description="暂无通知" />
<div v-else class="notice-groups">
<section v-for="group in groupedItems" :key="group.date" class="notice-group">
<div class="notice-group__header">
<span>{{ dayLabel(group.date) }}</span>
<span class="text-muted">{{ group.date }} · {{ group.items.length }} 条</span>
</div>
<div class="notice-meta">
<div>{{ relativeTime(item.createdAt) }}</div>
<div class="text-muted">{{ formatDate(item.createdAt, true) }}</div>
<el-tag v-if="item.read" size="small" type="info" effect="plain">已读</el-tag>
<el-tag v-else size="small" type="danger" effect="plain">未读</el-tag>
</div>
</li>
</ul>
<ul class="notice-list">
<li
v-for="item in group.items"
:key="item.id"
class="notice-item"
:class="{ unread: !item.read }"
@click="openDetail(item)"
>
<div class="notice-main">
<div class="notice-title">
<span v-if="!item.read" class="unread-dot"></span>
<el-tag
:type="PRIORITY_TAG_TYPES[item.priority as NotificationPriority]"
size="small"
effect="light"
>
{{ PRIORITY_LABELS[item.priority as NotificationPriority] }}
</el-tag>
<span class="title-text">{{ item.title }}</span>
</div>
<div class="notice-content">{{ item.content }}</div>
</div>
<div class="notice-meta">
<div>{{ relativeTime(item.createdAt) }}</div>
<div class="text-muted">{{ formatDate(item.createdAt, true) }}</div>
<el-tag v-if="item.read" size="small" type="info" effect="plain">已读</el-tag>
<el-tag v-else size="small" type="danger" effect="plain">未读</el-tag>
</div>
</li>
</ul>
</section>
</div>
</el-card>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'">
@@ -147,6 +281,30 @@ onUnmounted(() => {
</template>
<style scoped>
.notice-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.notice-group + .notice-group {
margin-top: 10px;
}
.notice-group__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 6px 4px;
font-size: 12px;
font-weight: 600;
color: var(--ch-text-secondary);
border-bottom: 1px solid var(--ch-border);
}
.notice-list {
list-style: none;
margin: 0;
@@ -0,0 +1,450 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatDate, relativeTime, type UpdateInfo } from '@classhelper/shared';
import type { ClientAgentStatus, DesktopAppInfo } from '../../../types/desktop.js';
import { useAppStore } from '../../stores/app.js';
import { useUiStore } from '../../stores/ui.js';
/**
* 开发者(需求 10)。
*
* 入口只在 `config.developerMode === true` 时出现在侧栏「设置」分组里(`ClientLayout.vue`),
* 路由守卫也会把"关掉开关后仍停留在这个页面"的用户弹回「通用」。
*
* 本页的定位是**把排查问题时需要的现场集中到一处**,所以刻意不做成"可操作的调试台":
* 每一项都是只读状态 + 一两个既有桥上的动作,数据全部来自 `window.desktop.*`
* 与既有 store,**不新增任何数据源、也不新增文件读写**。
*
* 几条取舍写在各区块的注释里,统一的原则是:
* 「关于」页面向的是所有用户(干净、能截图给管理员),本页面向维护者(原始值、路径、内部开关)。
*/
const appStore = useAppStore();
const ui = useUiStore();
const loading = ref(false);
const appInfo = ref<DesktopAppInfo | null>(null);
const agent = ref<ClientAgentStatus | null>(null);
/* ------------------------------------------------------------ 版本与平台 */
/** 版本、运行时与两个关键路径(用户数据目录 / 配置文件),排查"配置写哪去了"必看 */
async function loadAppInfo(): Promise<void> {
if (!window.desktop?.getAppInfo) return;
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
}
/** 一次性把本页的所有状态拉新(刷新按钮与首次进页面共用) */
async function loadAll(): Promise<void> {
if (!window.desktop) return;
loading.value = true;
try {
await loadAppInfo();
await refreshAgent();
await loadIgnoredVersion();
await appStore.refreshCacheStats();
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 设备与上报 */
/**
* 服务端联动状态:设备尾号 / 最近上报 / 最近错误 / 目标版本 / 待安装版本 / 在线。
*
* 这一块与「通用」页的「服务端联动状态」是同一份数据 —— 有意保留的重叠:
* 排查上报问题时人在「通用」页看现象、在「开发者」页看原因(最近错误原文 + 设备尾号),
* 跳来跳去比重复几个字段更烦。
*/
const beating = ref(false);
const lastHeartbeatText = computed(() => {
const value = agent.value?.lastHeartbeatAt;
return value ? relativeTime(value) : '还没有上报过';
});
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
async function refreshAgent(): Promise<void> {
if (!window.desktop?.getClientAgentStatus) return;
agent.value = await window.desktop.getClientAgentStatus().catch(() => null);
}
async function beatNow(): Promise<void> {
if (!window.desktop?.beatClientNow) return;
beating.value = true;
try {
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
agent.value = await window.desktop.beatClientNow();
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
else ElMessage.success('已向服务端上报本机状态');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
} finally {
beating.value = false;
}
}
/* ------------------------------------------------------------ 日志 */
/**
* 日志文件路径。
*
* 桥上没有"读日志内容"的接口(有意不加:日志是主进程的文件,给渲染进程一个读任意文件的
* 入口在安全边界上没有必要),所以这里只**提示路径** + 一个「打开日志目录」。
* 路径由用户数据目录拼出来:主进程的 logger 固定写在 `<userData>/logs/main.log`,
* 单文件 1MB 轮转(写满即改名为 main.old.log),所以"日志丢了"通常是轮转覆盖,不是没写。
*/
const logPathText = computed(() => {
const base = appInfo.value?.userDataPath;
if (!base) return 'logs/main.log(用户数据目录下)';
const sep = (appInfo.value?.platform ?? '').startsWith('win') ? '\\' : '/';
return `${base}${sep}logs${sep}main.log`;
});
async function openLogDir(): Promise<void> {
if (!window.desktop?.openLogDir) return;
const ok = await window.desktop.openLogDir();
if (!ok) ElMessage.warning('打开日志目录失败,请按上面的路径手动前往');
}
/* ------------------------------------------------------------ 更新 */
/**
* 更新检查。
*
* 「立即检查」走 `checkForUpdates(true)`:**强制绕过主进程那 30 分钟缓存**。
* 这是本页与「关于」页的区别 —— 那边用户点检查也可能是缓存结果(省流量),
* 这边维护者要的是"现在到底拿得到什么",所以按钮默认就是强制的。
*/
const checking = ref(false);
const updateInfo = ref<UpdateInfo | null>(null);
const ignoredUpdateVersion = ref('');
async function checkUpdate(force: boolean): Promise<void> {
if (!window.desktop?.checkForUpdates) return;
checking.value = true;
try {
updateInfo.value = await window.desktop.checkForUpdates(force);
} finally {
checking.value = false;
}
}
async function loadIgnoredVersion(): Promise<void> {
if (!window.desktop?.getConfig) return;
const stored = await window.desktop.getConfig().catch(() => null);
ignoredUpdateVersion.value = stored?.ignoredUpdateVersion ?? '';
}
async function ignoreVersion(): Promise<void> {
const version = updateInfo.value?.latestVersion;
if (!version || !window.desktop?.ignoreUpdateVersion) return;
await window.desktop.ignoreUpdateVersion(version);
ignoredUpdateVersion.value = version;
ElMessage.success(`已忽略 v${version}:启动时的自动检查不再提示它`);
}
/** 忽略是可逆的(排查线上"用户说不再提示"时经常要把它清掉),顺手给一个恢复入口 */
async function clearIgnoredVersion(): Promise<void> {
if (!window.desktop?.saveConfig) return;
const saved = await window.desktop.saveConfig({ ignoredUpdateVersion: '' });
ignoredUpdateVersion.value = saved.ignoredUpdateVersion;
ElMessage.success('已恢复该版本的更新提示');
}
/* ------------------------------------------------------------ 调试工具 */
const openingDevTools = ref(false);
/**
* 打开 DevTools(主进程 detached 独立窗口)。
* 冒烟验证模式下主进程会拒绝(多一个窗口会把自动化断言打乱),返回 false —— 这里如实提示。
*/
async function openDevTools(): Promise<void> {
if (!window.desktop?.openDevTools) return;
openingDevTools.value = true;
try {
const ok = await window.desktop.openDevTools();
if (ok) ElMessage.success('已在独立窗口打开开发者工具');
else ElMessage.warning('当前是冒烟验证模式,主进程不允许打开 DevTools');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '打开开发者工具失败');
} finally {
openingDevTools.value = false;
}
}
/* ------------------------------------------------------------ 离线缓存 */
/** 与「通用」页同一份缓存统计(这里再看一遍是为了"刷新/清空"不用跳页) */
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
onMounted(async () => {
await loadAll();
// 带出启动自动检查的结果(这次命中主进程缓存,不会真的再发一次请求)
void checkUpdate(false);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">开发者</h2>
<p class="page-subtitle">版本与平台、设备上报、日志、更新与调试工具</p>
</div>
<el-button :loading="loading" data-test="dev-refresh" @click="loadAll">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<el-alert
class="mb-12"
type="warning"
:closable="false"
show-icon
title="仅供排查问题"
description="这一页显示的是原始状态与内部路径,不需要给日常使用者看。关闭「通用 → 开发者模式」后它就会从侧栏消失。"
/>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<!-- 版本与平台 -->
<el-card shadow="never">
<template #header><span>版本与平台</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="客户端版本">
{{ appInfo?.appVersion ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="运行时">
Electron {{ appInfo?.electron ?? '—' }} · Chromium {{ appInfo?.chrome ?? '—' }} · Node
{{ appInfo?.node ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="用户数据目录">
<span class="mono">{{ appInfo?.userDataPath ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="配置文件">
<span class="mono">{{ appInfo?.configPath ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="冒烟验证模式">
<el-tag :type="appInfo?.smokeTest ? 'warning' : 'info'" size="small" effect="plain">
{{ appInfo?.smokeTest ? '是(自动化验证中)' : '否' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 设备与上报 -->
<el-card shadow="never" class="mt-12">
<template #header><span>设备与上报</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="连接状态">
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
{{ agent?.online ? '在线(近期上报成功)' : '离线(等待上报)' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="设备标识尾号">
<span class="mono">{{ agent?.deviceKeyTail || '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
<el-descriptions-item label="服务端目标版本">
{{ agent?.updateTargetVersion || '无(保持当前版本)' }}
</el-descriptions-item>
<el-descriptions-item label="待安装版本">
{{ agent?.pendingUpdateVersion || '无' }}
</el-descriptions-item>
<el-descriptions-item label="最近错误">
<span :class="{ 'text-danger': lastErrorText !== '无' }">{{ lastErrorText }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button type="primary" :loading="beating" data-test="dev-beat-now" @click="beatNow">
立即上报
</el-button>
<el-button :loading="loading" @click="loadAll">刷新状态</el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<!-- 日志 -->
<el-card shadow="never">
<template #header><span>日志</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="日志文件">
<span class="mono">{{ logPathText }}</span>
</el-descriptions-item>
<el-descriptions-item label="日志级别">
<el-tag :type="ui.developerMode ? 'success' : 'info'" size="small" effect="plain">
{{ ui.developerMode ? 'debug(开发者模式)' : 'info' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<p class="text-muted mt-12">
单个日志文件 1MB 轮转:写满后改名为 main.old.log,新内容写进 main.log。
所以"日志里找不到某次崩溃"多半是被轮转覆盖了,不是没写。
</p>
<!-- 不在这里用 <strong> 强调:中文整行的显示宽度是字符数的两倍,
而 Vue 模板里夹了行内元素的长句会被 Prettier 折行并插入空格,
渲染出来句子中间就多一个空格。要强调就单独起一行文字。 -->
<p v-if="ui.developerMode" class="text-muted">
debug 级日志已开启:级别在主进程启动时读取,改完开关后需重启客户端才生效。
</p>
<div class="toolbar mt-12">
<el-button data-test="dev-open-log-dir" @click="openLogDir">打开日志目录</el-button>
</div>
</el-card>
<!-- 更新 -->
<el-card shadow="never" class="mt-12">
<template #header><span>更新</span></template>
<div class="toolbar">
<el-button :loading="checking" data-test="dev-check-update" @click="checkUpdate(true)">
强制检查更新
</el-button>
<span class="text-muted">绕过主进程 30 分钟缓存,直接问 GitHub</span>
</div>
<el-descriptions :column="1" border size="small" class="mt-12">
<el-descriptions-item label="本次结果">
<template v-if="checking">正在检查…</template>
<template v-else-if="!updateInfo">尚未检查</template>
<template v-else-if="!updateInfo.ok">
<span class="text-muted">{{ updateInfo.error ?? '暂时无法检查更新' }}</span>
</template>
<template v-else-if="updateInfo.hasUpdate">
<el-tag size="small" type="success" effect="light">
有新版本 v{{ updateInfo.latestVersion }}
</el-tag>
<span class="text-muted">当前 v{{ updateInfo.currentVersion }}</span>
</template>
<template v-else>
<el-tag size="small" type="info" effect="plain">已是最新版本</el-tag>
<span class="text-muted">v{{ updateInfo.currentVersion }}</span>
</template>
</el-descriptions-item>
<el-descriptions-item label="检查时间">
{{ updateInfo?.checkedAt ? formatDate(updateInfo.checkedAt, true) : '—' }}
</el-descriptions-item>
<el-descriptions-item label="已忽略的版本">
<template v-if="ignoredUpdateVersion">
<span class="mono">v{{ ignoredUpdateVersion }}</span>
<el-button link type="primary" size="small" @click="clearIgnoredVersion">恢复提示</el-button>
</template>
<span v-else class="text-muted">无</span>
</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button
:disabled="!updateInfo?.latestVersion"
data-test="dev-ignore-version"
@click="ignoreVersion"
>
不再提示该版本
</el-button>
<span class="text-muted">写入配置后,启动时的自动检查会跳过它</span>
</div>
</el-card>
<!-- 调试工具 -->
<el-card shadow="never" class="mt-12">
<template #header><span>调试工具</span></template>
<div class="toolbar">
<el-button :loading="openingDevTools" data-test="dev-open-devtools" @click="openDevTools">
打开 DevTools
</el-button>
<span class="text-muted">独立窗口;冒烟验证模式下不允许打开</span>
</div>
<el-alert class="mt-12" type="info" :closable="false" show-icon title="灵动岛诊断(命令行)">
<template #default>
<p class="diag-copy">灵动岛开合"闪一下"这类问题要的是整屏逐帧取证,界面上给不出这个按钮。</p>
<p class="diag-copy">跑冒烟时带上环境变量即可:</p>
<p class="diag-copy mono">ELECTRON_SMOKE_ISLAND_DIAG=1 pnpm verify:desktop</p>
<p class="diag-copy">它会在常规断言之外,对触摸/鼠标两种模式各跑一次收回动画的逐帧取证,</p>
<p class="diag-copy">帧图与逐帧数据落在 `.cache/island-closing-frames/`。</p>
</template>
</el-alert>
</el-card>
<!-- 离线缓存 -->
<el-card shadow="never" class="mt-12">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
/*
* 路径与版本号这类"要抄下来发给别人"的值统一用等宽字体(避免 0/O、1/l 看混),
* 并单独放开文本选中 —— body 上关掉了整页选中(更像原生应用),
* 但这一页的路径就是要让人复制的。
*
* 字体栈写死而不新建 `--ch-font-mono` 令牌:全局只有这一处在用,
* 为它加一个主题令牌(还得在 html.dark 里镜像一份)不划算。
*/
.mono {
font-family: 'Cascadia Mono', Consolas, 'Courier New', monospace;
font-size: 12.5px;
word-break: break-all;
user-select: text;
}
.mb-12 {
margin-bottom: 12px;
}
.text-danger {
color: var(--el-color-danger);
}
.diag-copy {
margin: 0 0 6px;
line-height: 1.6;
}
.diag-copy:last-child {
margin-bottom: 0;
}
</style>
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
formatDate,
relativeTime,
} from '@classhelper/shared';
import type { ClientAgentStatus } from '../../../types/desktop.js';
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
import { normalizeServerUrl } from '../../config.js';
import { useAppStore } from '../../stores/app.js';
@@ -14,6 +16,7 @@ import { useAuthStore } from '../../stores/auth.js';
import { useNotificationStore } from '../../stores/notifications.js';
import { useOnboardingStore } from '../../stores/onboarding.js';
import { useRealtimeStore } from '../../stores/realtime.js';
import { useUiStore } from '../../stores/ui.js';
/**
* 通用设置:服务器连接、离线缓存与作业录入短语。
@@ -24,6 +27,7 @@ const auth = useAuthStore();
const onboarding = useOnboardingStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const ui = useUiStore();
const serverInput = ref(appStore.serverUrl);
const testing = ref(false);
@@ -153,9 +157,193 @@ async function resetPhrases(): Promise<void> {
ElMessage.success('已恢复默认');
}
/* ------------------------------------------------------------ 启动与更新 / 服务端联动 */
/**
* 开机自启与自动更新。
*
* 开机自启的"是否生效"以**系统登录项**为准(新增的 `getAutoLaunch` 桥读的是
* `app.getLoginItemSettings()`,不是配置文件里的值)—— 用户可能在任务管理器的「启动」页
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
*/
const autoLaunch = ref(false);
const autoUpdate = ref(true);
const savingLaunch = ref(false);
const savingUpdate = ref(false);
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
const agent = ref<ClientAgentStatus | null>(null);
const agentLoading = ref(false);
/** 本机客户端版本(与服务端下发的目标版本对照着看) */
const appVersion = ref('—');
/** 最近上报时间的相对描述(没有上报过就显示占位符) */
const lastHeartbeatText = computed(() => {
const value = agent.value?.lastHeartbeatAt;
if (!value) return '还没有上报过';
return relativeTime(value);
});
/** 服务端对本机版本的期望:空串表示"不需要更新" */
const updateTargetText = computed(() => {
const target = agent.value?.updateTargetVersion ?? '';
if (!target) return '无(保持当前版本)';
return target;
});
const pendingUpdateText = computed(() => {
const pending = agent.value?.pendingUpdateVersion ?? '';
if (!pending) return '无';
return `${pending}(已下载,空闲时自动安装)`;
});
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
async function loadLaunchSettings(): Promise<void> {
if (!window.desktop?.getConfig) return;
const config = await window.desktop.getConfig();
autoUpdate.value = config.autoUpdate !== false;
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
const info = await window.desktop.getAppInfo?.().catch(() => null);
if (info?.appVersion) appVersion.value = info.appVersion;
}
async function toggleAutoLaunch(value: string | number | boolean): Promise<void> {
if (!window.desktop?.setAutoLaunch) return;
const wanted = value === true;
autoLaunch.value = wanted;
savingLaunch.value = true;
try {
const effective = await window.desktop.setAutoLaunch(wanted);
// 以系统实际生效值为准:写注册表失败时会与用户点的不一致,必须回显真实状态
autoLaunch.value = effective;
if (effective !== wanted) ElMessage.warning('系统未接受开机自启设置,请检查任务管理器里的启动项');
else ElMessage.success(wanted ? '已开启开机自启' : '已关闭开机自启');
} catch (error) {
autoLaunch.value = !wanted;
ElMessage.error(error instanceof Error ? error.message : '设置开机自启失败');
} finally {
savingLaunch.value = false;
}
}
async function toggleAutoUpdate(value: string | number | boolean): Promise<void> {
if (!window.desktop?.saveConfig) return;
const wanted = value === true;
autoUpdate.value = wanted;
savingUpdate.value = true;
try {
// 注意:必须传纯布尔值 —— 跨 contextBridge 传响应式对象会在进入 preload 前就被结构化克隆拒绝
const saved = await window.desktop.saveConfig({ autoUpdate: wanted });
autoUpdate.value = saved.autoUpdate !== false;
ElMessage.success(wanted ? '已开启自动更新' : '已关闭自动更新(仍可在「关于」页手动更新)');
} catch (error) {
autoUpdate.value = !wanted;
ElMessage.error(error instanceof Error ? error.message : '保存自动更新设置失败');
} finally {
savingUpdate.value = false;
}
}
/* ------------------------------------------------------------ 关怀模式 / 开发者模式 */
/**
* 关怀模式(需求 14):放大**正文与控件**字号。
*
* 这里只挂 `<html>.care-mode` 类并落盘,放大规则全在 `styles/index.css` 末尾那个集中覆盖块里
* (`--ch-font-scale` + 少数几个常用类),不在 JS 里逐个元素改字号。
*
* 开关直接读 ui store 的状态,**不用本地 ref 复制一份**:侧栏、路由守卫、主题页读的都是它,
* 复制就会出现"两个开关各说各话"。`ui.init()` 在首屏渲染前已从主进程配置恢复过。
*
* 关于"与助手联动":开启关怀模式会放大 AI 助手的按钮 —— 这条联动**只存在于 Web 管理端**,
* 本客户端目前没有助手入口,所以这里不放大任何按钮,也不为了"看起来对称"去造一个入口。
*/
const savingCareMode = ref(false);
async function toggleCareMode(value: string | number | boolean): Promise<void> {
const wanted = value === true;
savingCareMode.value = true;
try {
await ui.setCareMode(wanted);
ElMessage.success(wanted ? '已开启关怀模式:正文与控件字号已放大' : '已关闭关怀模式');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存关怀模式设置失败');
} finally {
savingCareMode.value = false;
}
}
/**
* 开发者模式(需求 10):显示调试页面与详细日志,仅排查问题时打开。
*
* 打开后侧栏「设置」分组里**立刻**多出「开发者」页(`ClientLayout.vue` 读的是同一个
* ui store 状态),下次启动时主进程会把日志级别调到 debug。
*/
const savingDeveloperMode = ref(false);
async function toggleDeveloperMode(value: string | number | boolean): Promise<void> {
const wanted = value === true;
savingDeveloperMode.value = true;
try {
await ui.setDeveloperMode(wanted);
ElMessage.success(wanted ? '已开启开发者模式:侧栏出现「开发者」页' : '已关闭开发者模式');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存开发者模式设置失败');
} finally {
savingDeveloperMode.value = false;
}
}
async function refreshAgentStatus(): Promise<void> {
if (!window.desktop?.getClientAgentStatus) return;
agentLoading.value = true;
try {
agent.value = await window.desktop.getClientAgentStatus();
} finally {
agentLoading.value = false;
}
}
async function beatNow(): Promise<void> {
if (!window.desktop?.beatClientNow) return;
agentLoading.value = true;
try {
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
agent.value = await window.desktop.beatClientNow();
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
else ElMessage.success('已向服务端上报本机状态');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
} finally {
agentLoading.value = false;
}
}
async function openLogDir(): Promise<void> {
if (!window.desktop?.openLogDir) return;
const ok = await window.desktop.openLogDir();
if (!ok) ElMessage.warning('打开日志目录失败,可在「关于」页复制诊断信息');
}
/** 状态里的相对时间要跟着走,否则挂着的页面会一直显示"刚刚" */
let statusTimer: ReturnType<typeof setInterval> | null = null;
onMounted(async () => {
await appStore.refreshCacheStats();
await loadPhrases();
await loadLaunchSettings();
await refreshAgentStatus();
statusTimer = setInterval(() => {
void refreshAgentStatus();
}, 30_000);
});
onBeforeUnmount(() => {
if (statusTimer !== null) {
clearInterval(statusTimer);
statusTimer = null;
}
});
</script>
@@ -238,6 +426,77 @@ onMounted(async () => {
<el-button @click="resetPhrases">恢复默认</el-button>
</div>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>启动与更新</span></template>
<div class="switch-row">
<div class="switch-main">
<span>开机自启</span>
<p class="text-muted">登录 Windows 后自动启动 ClassHelper;教室机器建议打开。</p>
</div>
<el-switch
:model-value="autoLaunch"
:loading="savingLaunch"
@update:model-value="toggleAutoLaunch"
/>
</div>
<div class="switch-row">
<div class="switch-main">
<span>自动更新</span>
<p class="text-muted">
安装只在
<strong>不上课且窗口已隐藏 5 分钟后</strong>
进行,下载随时可能发生。
</p>
<p class="text-muted">服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。</p>
</div>
<el-switch
:model-value="autoUpdate"
:loading="savingUpdate"
@update:model-value="toggleAutoUpdate"
/>
</div>
<div class="toolbar mt-12">
<el-button @click="openLogDir">打开日志目录</el-button>
<span class="text-muted">日志文件 main.log,排查问题时可发给管理员</span>
</div>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>显示与调试</span></template>
<div class="switch-row">
<div class="switch-main">
<span>关怀模式</span>
<p class="text-muted">
放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。
</p>
<p class="text-muted">
「开启后同时放大 AI 助手按钮」这一条只在 Web 管理端生效 —— 本客户端目前没有助手入口。
</p>
</div>
<el-switch
:model-value="ui.careMode"
:loading="savingCareMode"
data-test="care-mode-switch"
@update:model-value="toggleCareMode"
/>
</div>
<div class="switch-row">
<div class="switch-main">
<span>开发者模式</span>
<p class="text-muted">显示调试页面与详细日志,仅排查问题时打开。</p>
<p v-if="ui.developerMode" class="text-muted">
debug 级日志已开启;侧栏「设置 → 开发者」可查看版本、设备上报、更新检查、日志目录与 DevTools。
</p>
</div>
<el-switch
:model-value="ui.developerMode"
:loading="savingDeveloperMode"
data-test="developer-mode-switch"
@update:model-value="toggleDeveloperMode"
/>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
@@ -265,6 +524,35 @@ onMounted(async () => {
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>服务端联动状态</span></template>
<p class="text-muted">
本机每 30 秒向服务端上报一次状态(是否在上课、当前版本、已下载的更新),
服务端据此下发统一更新与远控命令。本机标识尾号用于在服务端设备列表里核对这台电脑。
</p>
<el-descriptions class="mt-12" :column="1" border size="small">
<el-descriptions-item label="连接状态">
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
{{ agent?.online ? '已连接(近期上报成功)' : '未连接(等待上报)' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="本机版本">
{{ appVersion }}
</el-descriptions-item>
<el-descriptions-item label="设备标识尾号">
{{ agent?.deviceKeyTail || '—' }}
</el-descriptions-item>
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
<el-descriptions-item label="服务端目标版本">{{ updateTargetText }}</el-descriptions-item>
<el-descriptions-item label="已下载待安装">{{ pendingUpdateText }}</el-descriptions-item>
<el-descriptions-item label="最近错误">{{ lastErrorText }}</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button type="primary" :loading="agentLoading" @click="beatNow">立即上报</el-button>
<el-button :loading="agentLoading" @click="refreshAgentStatus">刷新状态</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
@@ -282,4 +570,29 @@ onMounted(async () => {
.ml-8 {
margin-left: 8px;
}
/* 开关行:左说明右开关,说明长时自动换行(「自动更新」的提示是两行) */
.switch-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
}
.switch-row + .switch-row {
border-top: 1px solid var(--ch-border);
}
.switch-main {
display: flex;
flex-direction: column;
gap: 2px;
}
.switch-main p {
margin: 0;
font-size: 12.5px;
line-height: 1.5;
}
</style>
+43
View File
@@ -9,6 +9,10 @@ import type {
IslandState,
UpdateInfo,
} from '@classhelper/shared';
import type { ClientAgentStatus } from '../main/client-agent.js';
/** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */
export type { ClientAgentStatus };
/** 作业页展示偏好(看板 / 列表,看板外观) */
export interface HomeworkBoardSettings {
@@ -66,6 +70,25 @@ export interface DesktopStoredConfig {
* 在「关于」页手动检查时仍会正常显示结果。
*/
ignoredUpdateVersion: string;
/**
* 本机设备标识(UUID):这台机器在服务端的身份。
*
* 服务端按它 upsert 设备记录(在线状态 / 待执行命令 / 已下载版本都挂在它上面),
* **首次读取配置时自动生成并落盘,之后永不变**;渲染进程只读,不允许改写。
*/
deviceKey: string;
/** 是否开机自启(登录 Windows 后静默启动,不弹主窗口) */
autoLaunch: boolean;
/** 是否接受服务端统一下发的自动更新(下载随时进行,安装只在空闲时) */
autoUpdate: boolean;
/** 开发者模式:把日志调到 debug 级,并在设置里显示「开发者」页 */
developerMode: boolean;
/** 关怀模式:整体放大正文与控件字号(不动圆角/阴影/侧栏几何) */
careMode: boolean;
/** 已下载待安装的更新版本(空串 = 无) */
pendingUpdateVersion: string;
/** 已下载待安装的安装包绝对路径(空串 = 无) */
pendingUpdateFile: string;
}
export interface DesktopAppInfo {
@@ -165,6 +188,26 @@ export interface DesktopBridge {
onIslandMarkRead(handler: (id: string) => void): void;
/** 订阅"灵动岛点了标为已读(多条通知的整批)",一次把这一批都标记已读 */
onIslandMarkAllRead(handler: (ids: string[]) => void): void;
/* 开机自启 / 日志 / 服务端联动 */
/** 读当前是否已注册为开机自启(以系统登录项为准,不是配置文件里的值) */
getAutoLaunch(): Promise<boolean>;
/** 设置开机自启:写配置 + 同步系统登录项,返回生效后的值 */
setAutoLaunch(enabled: boolean): Promise<boolean>;
/** 打开日志目录(排查问题用;日志文件是 main.log) */
openLogDir(): Promise<boolean>;
/**
* 打开主窗口的开发者工具(detached 独立窗口)。
*
* 只在**非冒烟模式**下允许:冒烟验证会给窗口注入大量合成事件与截图,
* 多一个 DevTools 窗口会抢焦点、改变窗口尺寸,把那些断言打乱。
* 返回是否真的打开了(冒烟模式下返回 false)。
*/
openDevTools(): Promise<boolean>;
/** 读服务端联动状态(设置页「服务端联动状态」区块) */
getClientAgentStatus(): Promise<ClientAgentStatus>;
/** 立即向服务端上报一次,返回上报后的最新状态 */
beatClientNow(): Promise<ClientAgentStatus>;
}
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */