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:
1 parent
e51879667a
commit
817af02357
131 files changed
+12995
-538
No files matched your search
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(' ')}`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
|
||||
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
|
||||
|
||||
Reference in new issue
Block a user