chore(release): 1.3.3 发布并退役旧联动

This commit is contained in:
LoveChengke committed 2026-10-17 03:06:46 +08:00
1 parent b7cdc7c9ea
commit c0ebf88138
184 files changed
+5450 -11705

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "1.3.2",
"version": "1.3.3",
"private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
+44 -121
View File
@@ -9,10 +9,17 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { provisionSmokeClass } from '../../../scripts/lib/smoke-class.mjs';
import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(root, '..', '..');
const startedAt = Date.now();
const mode = process.argv.includes('--visual') ? 'visual' : (process.env.ELECTRON_SMOKE_MODE ?? 'quick');
if (!['quick', 'visual'].includes(mode)) throw new Error('冒烟模式只能是 quick 或 visual');
const resultFile =
process.env.ELECTRON_SMOKE_RESULT ?? path.join(repoRoot, '.cache', 'desktop-smoke-result.json');
console.log(`[smoke] 模式:${mode === 'visual' ? '视觉验收(会使用桌面)' : '后台快速检查(不占用前台)'}`);
const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron'));
const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')];
const missing = required.filter((file) => !fs.existsSync(file));
@@ -37,124 +44,13 @@ try {
}
console.log(`[smoke] 独立配置目录:${profileDir}`);
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
let online = process.env.ELECTRON_SMOKE_ONLINE;
if (online === undefined) {
online = '0';
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2500);
const response = await fetch('http://127.0.0.1:4000/api/health', { signal: controller.signal });
clearTimeout(timer);
online = response.ok ? '1' : '0';
} catch {
online = '0';
}
console.log(
online === '1'
? '[smoke] 检测到后端在线,将执行联网集成与侧边栏点击测试'
: '[smoke] 后端未在线,仅执行本地检查(离线缓存/界面渲染);如需联网测试请先启动 pnpm dev:server',
);
}
/**
* 准备一个已知可用的班级账号(ClassHelper 班级端已改为班级码 + 班级密码登录):
* 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
* 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
* 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
*/
async function provisionClassAccount() {
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
const username = process.env.ELECTRON_SMOKE_ADMIN ?? 'admin';
const adminPassword = process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123';
const password = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? 'smoke123456';
const CANDIDATES = [...new Set([password, process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456'])];
const tryLogin = async (code, candidate) => {
const response = await fetch(`${base}/auth/class-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, password: candidate }),
});
return response.ok;
};
try {
const loginResponse = await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: adminPassword }),
});
const login = await loginResponse.json();
const token = login?.data?.token;
if (!token) return null;
const authHeaders = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const classesResponse = await fetch(`${base}/classes`, { headers: authHeaders });
const classes = (await classesResponse.json())?.data ?? [];
const target = classes[0];
if (!target) return null;
// 冒烟**永不修改班级码**(班级码是给学生用的,不应被测试工具改写):
// 只有"现有班级码 + 已知密码全部登录失败"时才重置密码,班级码原样保留。
const code = target.code;
if (!code) return null;
for (const candidate of CANDIDATES) {
if (await tryLogin(code, candidate)) return { code, password: candidate, className: target.name };
}
const patchResponse = await fetch(`${base}/classes/${target.id}/class-account`, {
method: 'PATCH',
headers: authHeaders,
body: JSON.stringify({ code, password }),
});
if (!patchResponse.ok) return null;
const patched = (await patchResponse.json())?.data;
// 记下"需要恢复",脚本收尾时改回种子默认密码(已知密码都不通时无法得知原密码)
patchedAccount = { id: target.id, code: patched?.code ?? code, base, authHeaders };
console.log(
`[smoke] 已知密码均不可用,已把班级 ${target.name} 的密码临时重置为 ${password}` +
`(结束时恢复为 ${CANDIDATES[1]})`,
);
return { code: patched?.code ?? code, password, className: target.name };
} catch {
return null;
}
}
/** 冒烟临时重置过的班级账号(结束时恢复) */
let patchedAccount = null;
/** 恢复被冒烟临时改过的班级密码(失败只告警,不影响冒烟结论) */
async function restoreClassAccount() {
if (!patchedAccount) return;
try {
const response = await fetch(`${patchedAccount.base}/classes/${patchedAccount.id}/class-account`, {
method: 'PATCH',
headers: patchedAccount.authHeaders,
body: JSON.stringify({
code: patchedAccount.code,
password: process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456',
}),
});
console.log(
response.ok
? '[smoke] 已恢复班级密码为种子默认 123456(冒烟不留副作用)'
: `[smoke] [WARN] 班级密码恢复失败:status=${response.status},请到后台重新设置`,
);
} catch (error) {
console.log(`[smoke] [WARN] 班级密码恢复失败:${error?.message ?? error}`);
}
}
// 联网验收必须显式指定,避免自动碰到用户正在使用的实例。
const online = process.argv.includes('--online') ? '1' : (process.env.ELECTRON_SMOKE_ONLINE ?? '0');
let classCredentials = null;
if (online === '1') {
classCredentials = await provisionClassAccount();
console.log(
classCredentials
? `[smoke] 班级账号就绪:${classCredentials.code}(${classCredentials.className})`
: '[smoke] 未能准备班级账号,联网集成将使用种子默认凭据',
);
classCredentials = await provisionSmokeClass();
if (!classCredentials) throw new Error('无法建立独立验证班级,请检查测试服务端与管理员凭据');
console.log('[smoke] 已建立独立验证班级:' + classCredentials.code);
}
/** 启动一次冒烟(返回退出码) */
@@ -163,26 +59,48 @@ function launchSmoke(extraEnv) {
const child = spawn(electronPath, ['.'], {
cwd: root,
stdio: 'inherit',
windowsHide: true,
env: resolveElectronEnv({
ELECTRON_SMOKE_TEST: '1',
ELECTRON_SMOKE_MODE: mode,
ELECTRON_SMOKE_RESULT: resultFile,
ELECTRON_SMOKE_ONLINE: online,
ELECTRON_SMOKE_PROFILE: profileDir,
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
// 教师凭据只从这里传入:主进程的冒烟代码从环境变量读,
// 因此安装包(app.asar)里不会留下教师口令字面量(本脚本不进安装包)
ELECTRON_SMOKE_USER: process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
ELECTRON_SMOKE_PASSWORD: process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
ELECTRON_SMOKE_USER:
classCredentials?.teacherUsername ?? process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
ELECTRON_SMOKE_PASSWORD:
classCredentials?.teacherPassword ?? process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
ELECTRON_SMOKE_CLASS_ID: classCredentials?.classId ?? '',
...extraEnv,
}),
});
child.on('exit', (code) => resolve(code ?? 1));
const deadline = setTimeout(
() => {
console.error('[smoke] 超时,结束验证进程');
child.kill();
resolve(124);
},
mode === 'visual' ? 360_000 : 45_000,
);
child.once('exit', (code) => {
clearTimeout(deadline);
resolve(code ?? 1);
});
child.once('error', (error) => {
clearTimeout(deadline);
console.error(error);
resolve(1);
});
});
}
const code = await launchSmoke();
console.log(`\n[smoke] Electron 退出,code=${code}`);
await restoreClassAccount();
await classCredentials?.cleanup();
if (code !== 0) process.exit(code);
/**
@@ -208,7 +126,11 @@ try {
}
console.log('\n[smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
const relaunchCode = await launchSmoke({ ELECTRON_SMOKE_ONLINE: '0', ELECTRON_SMOKE_RESULT: relaunchResult });
const relaunchCode = await launchSmoke({
ELECTRON_SMOKE_ONLINE: '0',
ELECTRON_SMOKE_MODE: 'quick',
ELECTRON_SMOKE_RESULT: relaunchResult,
});
let relaunchPassed = 0;
let relaunchTotal = 0;
@@ -232,4 +154,5 @@ try {
} catch {
// 二次启动可能又建了目录,删除失败不影响结论(首次删除已证明锁已释放)
}
console.log('[smoke] 总耗时:' + ((Date.now() - startedAt) / 1000).toFixed(1) + ' 秒');
process.exit(resourcesOk ? 0 : 1);
@@ -208,7 +208,7 @@ function rowBadgeClass(item: IslandNotification): string {
const itemKind = rowKind(item);
if (itemKind === 'call') return 'badge-call';
if (itemKind === 'homework') return 'badge-homework';
return `badge-${item.priority.toLowerCase()}`;
return `badge-${(item.priority ?? 'NORMAL').toLowerCase()}`;
}
function rowSubText(item: IslandNotification): string {
@@ -674,37 +674,37 @@ onMounted(async () => {
再随卡片变窄被省略号收回(用户反馈的"新消息那行字往右跳一下再缩回")。
-->
<span class="pill-inner">
<span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/>
</svg>
<svg v-else-if="isCall" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
/>
</svg>
</span>
<span class="pill-text">
<span class="pill-title">{{ pillSummaryTitle }}</span>
<span class="pill-sub">
<template v-if="pendingCount > 1">点击查看</template>
<template v-else>{{ notification?.teacherName || '老师' }} · 点击查看</template>
<span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/>
</svg>
<svg v-else-if="isCall" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/>
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
/>
</svg>
</span>
<span class="pill-text">
<span class="pill-title">{{ pillSummaryTitle }}</span>
<span class="pill-sub">
<template v-if="pendingCount > 1">点击查看</template>
<template v-else>{{ notification?.teacherName || '老师' }} · 点击查看</template>
</span>
</span>
<!-- 多类型时把类型也做成小圆点,收起状态一眼看出有什么 -->
<span v-if="pendingCount > 1 && pendingTypes.length > 1" class="pill-types" aria-hidden="true">
<span v-for="item in pendingTypes" :key="item" class="pill-type">{{ item }}</span>
</span>
</span>
<!-- 多类型时把类型也做成小圆点,收起状态一眼看出有什么 -->
<span v-if="pendingCount > 1 && pendingTypes.length > 1" class="pill-types" aria-hidden="true">
<span v-for="item in pendingTypes" :key="item" class="pill-type">{{ item }}</span>
</span>
<span class="pill-chevron" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path
+103 -21
View File
@@ -4,7 +4,12 @@ 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 {
isNewerVersion,
CLIENT_UPDATE_IDLE_MS,
type ClientHeartbeatResponse,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
@@ -13,16 +18,58 @@ import { logger } from './logger.js';
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
*
* 设计要点(都是"教室机器"这个使用场景逼出来的):
* 1. **下载随时进行、安装只在完全空闲时**:教室机的带宽与注意力都有限,上课期间重启应用是灾难,
* 所以安装的三个必要条件缺一不可 —— 不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)、
* 本进程还没装过;
* 1. **下载随时进行,按下发策略安装**:立即安装;放学后到指定时刻且不在上课时安装;
* 空闲安装要求不在上课且主窗口隐藏满五分钟;
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
* 3. **安装脚本脱离本进程**:`start /wait` 等安装器跑完后重新拉起应用,
* 3. **安装脚本脱离本进程**:PowerShell 等旧进程退出、安装器完成后重新拉起应用,
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
*/
let policy: Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> | null =
null;
function policyPath(): string {
return path.join(pendingUpdateDir(), 'policy.json');
}
function loadPolicy(): typeof policy {
if (policy) return policy;
try {
policy = JSON.parse(fs.readFileSync(policyPath(), 'utf8'));
} catch {
/* 尚无下发策略 */
}
return policy;
}
/** 更新包只来自专用目录;清理与执行均不得接受目录外的配置路径。 */
function isManagedInstaller(file: string): boolean {
return path.dirname(path.resolve(file)) === path.resolve(pendingUpdateDir()) && /\.exe$/i.test(file);
}
function removeManagedInstaller(file: string): void {
if (!file || !isManagedInstaller(file)) return;
try {
fs.rmSync(file, { force: true });
} catch (error) {
logger.warn(`清理旧更新包失败:${String(error)}`);
}
}
/** 每次成功心跳先同步策略;取消或更换目标后旧包不得自行安装。 */
export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
policy = {
updateTargetVersion: next.updateTargetVersion,
updateMode: next.updateMode ?? 'idle',
updateNotBefore: next.updateNotBefore ?? null,
};
fs.mkdirSync(pendingUpdateDir(), { recursive: true });
fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8');
if (getConfig().pendingUpdateVersion !== (next.updateTargetVersion ?? '')) {
removeManagedInstaller(getConfig().pendingUpdateFile);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
}
}
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
@@ -77,7 +124,7 @@ export function watchMainWindow(win: BrowserWindow): void {
mainWindowHiddenAt = null;
if (hookedWindow === win) hookedWindow = null;
});
if (win.isVisible()) mainWindowHiddenAt = null;
mainWindowHiddenAt = win.isVisible() ? null : Date.now();
}
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
@@ -187,11 +234,15 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
await fsp.rm(target, { force: true });
await fsp.rename(temp, target);
if (loadPolicy()?.updateTargetVersion !== version) {
removeManagedInstaller(target);
return;
}
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
logger.info(
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
(Date.now() - startedAt) / 1000,
)} 秒),将在空闲时自动安装`,
)} 秒),将按服务端指定的时机安装`,
);
await maybeInstallPendingUpdate();
} catch (error) {
@@ -230,9 +281,8 @@ function formatSize(bytes: number): string {
/**
* 如果条件全部满足就装更新。
*
* 条件(缺一不可):非冒烟模式 / 配置里有待安装版本 / 文件还在 / 不在上课 /
* 主窗口已隐藏满 5 分钟 / 本进程没装过。**每次心跳后与主窗口 hide 后都会调用**,
* 所以"老师下课关掉窗口 → 5 分钟后自动装上"就是靠这里触发的。
* 非冒烟模式、有有效目标与安装包且自动更新开启;其余条件按安装策略判断。
* 每次心跳后与主窗口隐藏后都会调用。
*/
export async function maybeInstallPendingUpdate(): Promise<void> {
if (installing) return;
@@ -241,21 +291,40 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
const config = getConfig();
const version = config.pendingUpdateVersion?.trim() ?? '';
const installer = config.pendingUpdateFile?.trim() ?? '';
if (!version || !installer) return;
if (!version || !installer || config.autoUpdate === false || !config.token) return;
if (!isNewerVersion(app.getVersion(), version)) {
removeManagedInstaller(installer);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
const active = loadPolicy();
if (!active || active.updateTargetVersion !== version) return;
if (active.updateMode === 'after-school') {
const at = Date.parse(active.updateNotBefore ?? '');
if (!Number.isFinite(at) || Date.now() < at) return;
}
if (!fs.existsSync(installer)) {
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
if (island.getState().inClass) {
if (
!isManagedInstaller(installer) ||
path.dirname(fs.realpathSync(installer)) !== fs.realpathSync(pendingUpdateDir())
) {
logger.error('拒绝执行更新目录之外的安装文件');
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
if (active.updateMode !== 'immediate' && island.getState().inClass) {
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
return;
}
if (mainWindowVisible()) {
if (active.updateMode === 'idle' && mainWindowVisible()) {
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
return;
}
if (!mainWindowIdleLongEnough()) {
if (active.updateMode === 'idle' && !mainWindowIdleLongEnough()) {
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
logger.debug(
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
@@ -276,14 +345,27 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
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,
const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
const script =
`$ErrorActionPreference = 'Stop'; Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -Wait -PassThru; ` +
`if ($installer.ExitCode -ne 0 -and (Test-Path -LiteralPath ${quote(exe)})) { Start-Process -FilePath ${quote(exe)} }`;
const encoded = Buffer.from(script, 'utf16le').toString('base64');
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', encoded],
{
detached: true,
stdio: 'ignore',
windowsHide: true,
},
);
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
child.unref();
// 安装脚本会等安装器结束后重新拉起应用;本进程随即退出(复用既有退出路径,保证配置已落盘)
// 安装器从实际安装目录启动新版;免安装版的旧 exe 可能随退出消失,只在安装失败时尝试恢复它。
if (quitHandler) {
quitHandler();
} else {
@@ -293,7 +375,7 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
} catch (error) {
installing = false;
logger.error(
`启动更新安装失败(将在下次空闲时重试):${error instanceof Error ? error.message : String(error)}`,
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -1,17 +1,20 @@
import { spawn } from 'node:child_process';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { BrowserWindow, app, dialog } from 'electron';
import {
CLIENT_COMMAND_LABELS,
CLIENT_HEARTBEAT_INTERVAL_MS,
isNewerVersion,
type ClientCommandDto,
type ApiResponse,
type ClientCommandKind,
type ClientHeartbeatRequest,
type ClientHeartbeatResponse,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { maybeInstallPendingUpdate, considerUpdate } from './auto-update.js';
import { maybeInstallPendingUpdate, considerUpdate, syncUpdatePolicy } from './auto-update.js';
import { getConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
@@ -21,7 +24,7 @@ import { logger } from './logger.js';
*
* **为什么是"客户端定时拉"而不是服务端推**:教室机的连接经常断(代理、休眠、重启),
* 而「这台机器是什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上 —— 所以统一用
* 「服务端记状态 + 客户端定时上报」的范式(与 ClassIsland 插件的上报同一套路),不引入新的长连接握手。
* 「服务端记状态 + 客户端定时上报」的范式,不引入新的长连接握手。
* 每次上报的响应里同时带回「服务端指定的目标版本 + 待执行命令」。
*
* 三条边界:
@@ -111,7 +114,7 @@ function isAgentSuppressed(): boolean {
* 上报里的"这台机器现在在干什么"。
*
* 取值就三种(服务端设备列表直接展示这段文字):
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(此时绝不安装更新);
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(延后策略此时不安装更新);
* - `使用中` —— 主窗口可见,人正在用;
* - `待机` —— 窗口隐藏(最小化到托盘),机器空闲。
*/
@@ -172,10 +175,12 @@ async function beat(): Promise<void> {
let backoff = false;
try {
const body: ClientHeartbeatRequest = {
agentProtocol: 2,
deviceKey: config.deviceKey,
name: os.hostname(),
clientVersion: app.getVersion(),
platform: `${process.platform}-${process.arch}`,
autoUpdate: config.autoUpdate !== false,
pendingVersion: pendingVersionOrNull(),
stateNote: currentStateNote(),
};
@@ -202,7 +207,9 @@ async function beat(): Promise<void> {
return;
}
const payload = (await response.json()) as ClientHeartbeatResponse;
const envelope = (await response.json()) as ApiResponse<ClientHeartbeatResponse>;
if (!envelope.success || !envelope.data?.deviceId) throw new Error('心跳响应格式不正确');
const payload = envelope.data;
state.lastHeartbeatAt = new Date().toISOString();
state.lastError = '';
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
@@ -211,15 +218,19 @@ async function beat(): Promise<void> {
`${state.updateTargetVersion ? `,服务端要求升到 ${state.updateTargetVersion}` : ''})`,
);
void handleCommand(payload.command);
await handleUpdate(payload.update, payload.updateTargetVersion);
void handleCommand(payload.command).catch((error) => logger.error(`远控处理失败:${String(error)}`));
// 下载不能阻塞心跳,否则慢速下载期间远控和在线状态都会失效。
syncUpdatePolicy(payload);
void handleUpdate(payload.update, payload.updateTargetVersion).catch((error) =>
logger.error(`更新处理失败:${String(error)}`),
);
} 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);
}
@@ -233,14 +244,45 @@ const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 100
* 两道护栏缺一不可(用户要求「必须二次确认」):
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
* 2. **同一条命令只弹一次**:它随每次心跳重复下发,用内存集合去重,避免 30 秒弹一个框。
* 2. **同一条命令只执行一次**:等待确认时内存去重,处理结果落盘,重启后也只重试回执。
*
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
* 下一次心跳只重试回执、不重复弹窗。
*/
const inFlightCommands = new Set<string>();
const unackedResults = new Map<string, { ok: boolean; result: string }>();
type CommandOutcome = { ok: boolean; result: string };
const commandResults = new Map<string, CommandOutcome>();
let resultsLoaded = false;
function commandResultsPath(): string {
return path.join(app.getPath('userData'), 'command-results.json');
}
function readCommandResults(): void {
if (resultsLoaded) return;
try {
const entries: unknown = JSON.parse(fs.readFileSync(commandResultsPath(), 'utf8'));
if (Array.isArray(entries))
for (const entry of entries.slice(-200)) {
if (
Array.isArray(entry) &&
typeof entry[0] === 'string' &&
typeof entry[1]?.ok === 'boolean' &&
typeof entry[1]?.result === 'string'
)
commandResults.set(entry[0], entry[1]);
}
} catch {
/* 首次运行尚无记录 */
}
resultsLoaded = true;
}
function rememberCommand(commandId: string, outcome: CommandOutcome): void {
readCommandResults();
commandResults.set(commandId, outcome);
while (commandResults.size > 200) commandResults.delete(commandResults.keys().next().value!);
fs.mkdirSync(app.getPath('userData'), { recursive: true });
fs.writeFileSync(commandResultsPath(), JSON.stringify([...commandResults]), 'utf8');
}
async function handleCommand(command: ClientCommandDto | null): Promise<void> {
if (!command) return;
@@ -255,15 +297,15 @@ async function handleCommand(command: ClientCommandDto | null): Promise<void> {
return;
}
// 正在确认或执行时不能用预存的中间结果抢先回执。
if (inFlightCommands.has(command.id)) return;
// 处理过但回执没送达:只重试回执
const pending = unackedResults.get(command.id);
readCommandResults();
const pending = commandResults.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);
@@ -273,6 +315,11 @@ async function handleCommand(command: ClientCommandDto | null): Promise<void> {
await ackCommand(command.id, false, result);
return;
}
// 注销可能立刻结束本进程,先落盘,重启后不能再次执行同一条指令。
rememberCommand(command.id, {
ok: false,
result: '本机已确认并提交系统执行,最终结果未回传,请核对设备状态',
});
const outcome = await executeCommand(command.kind);
logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`);
await ackCommand(command.id, outcome.ok, outcome.result);
@@ -283,8 +330,11 @@ async function handleCommand(command: ClientCommandDto | null): Promise<void> {
/** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */
async function confirmCommand(label: string, note: string): Promise<boolean> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), COMMAND_CONFIRM_TIMEOUT_MS);
const answered = dialog
.showMessageBox({
signal: controller.signal,
type: 'warning',
title: '来自管理员的远程指令',
message: `管理员要求本机「${label}」`,
@@ -294,13 +344,14 @@ async function confirmCommand(label: string, note: string): Promise<boolean> {
cancelId: 0,
noLink: true,
})
.then((result) => result.response === 1)
.then((result) => !controller.signal.aborted && result.response === 1)
.catch(() => false);
return Promise.race([
answered,
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), COMMAND_CONFIRM_TIMEOUT_MS)),
]);
try {
return await answered;
} finally {
clearTimeout(timer);
}
}
/** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */
@@ -308,32 +359,31 @@ async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; r
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'] },
shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '5'] },
restart: { file: 'shutdown.exe', args: ['/r', '/t', '5'] },
logoff: { file: 'shutdown.exe', args: ['/l'] },
lock: { file: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
};
const plan = commands[kind];
try {
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
// 关机/重启预留 5 秒用于回执;检查系统命令进程的真实退出码。
const child = spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true });
/**
* **必须监听 `error` 事件**:`spawn` 找不到可执行文件(或没权限)时不是抛异常,而是在
* next tick 上发一个 `error` 事件 —— 只包 `try/catch` 的话,这种情况会一路走到
* `return { ok: true }`,回执写着"已下达系统命令",Web 端看起来一切正常,机器却纹丝不动。
* 等一个很短的窗口(200ms)确认没有立刻报错;真执行成功时子进程本来也要跑一会儿。
* 同时检查非零退出码,系统拒绝指令时不能回报成功。
*/
const failure = await new Promise<string | null>((resolve) => {
child.once('error', (error) => resolve(error.message));
setTimeout(() => resolve(null), 200);
return await new Promise((resolve) => {
child.once('error', (error) => resolve({ ok: false, result: error.message }));
child.once('exit', (code) =>
resolve({
ok: code === 0,
result: code === 0 ? '系统已接受指令' : `系统命令执行失败(退出码 ${code ?? '未知'})`,
}),
);
});
child.unref();
if (failure) {
logger.warn(`远控命令「${CLIENT_COMMAND_LABELS[kind] ?? kind}」无法执行:${failure}`);
return { ok: false, result: failure };
}
return { ok: true, result: '已下达系统命令' };
} catch (error) {
return { ok: false, result: error instanceof Error ? error.message : String(error) };
}
@@ -341,6 +391,7 @@ async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; r
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
rememberCommand(commandId, { ok, result });
const config = getConfig();
if (!config.token) return;
try {
@@ -352,16 +403,13 @@ async function ackCommand(commandId: string, ok: boolean, result: string): Promi
});
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 });
}
}
+1 -21
View File
@@ -3,14 +3,11 @@ import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
import {
CLASSISLAND_NOTIFICATION_CHANNELS,
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
DEFAULT_ISLAND_APPEARANCE,
DEFAULT_SERVER_URL,
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
type ClassIslandNotificationChannel,
type IslandAppearance,
} from '@classhelper/shared';
import { logger } from './logger.js';
@@ -33,8 +30,6 @@ interface PersistedConfig {
island?: Partial<IslandAppearance>;
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
homeworkBoard?: Partial<HomeworkBoardSettings>;
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
notificationChannel?: ClassIslandNotificationChannel;
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
homeworkPhrases?: string[];
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
@@ -68,7 +63,6 @@ const DEFAULT_CONFIG: PersistedConfig = {
tokenEncrypted: false,
island: { ...DEFAULT_ISLAND_APPEARANCE },
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
onboardingDone: false,
theme: 'light',
@@ -107,15 +101,6 @@ function normalizeHomeworkPhrases(input?: string[]): string[] {
return result;
}
/** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */
function normalizeNotificationChannel(
input?: ClassIslandNotificationChannel,
): ClassIslandNotificationChannel {
return CLASSISLAND_NOTIFICATION_CHANNELS.includes(input as ClassIslandNotificationChannel)
? (input as ClassIslandNotificationChannel)
: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL;
}
/** 作业页偏好的合法化(模式白名单、字号夹紧) */
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
const mode = input?.mode === 'list' ? 'list' : 'board';
@@ -218,7 +203,6 @@ function readPersistedFromDisk(): PersistedConfig {
tokenEncrypted: parsed.tokenEncrypted === true,
island: normalizeIslandAppearance(parsed.island),
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
onboardingDone: parsed.onboardingDone === true,
theme: normalizeTheme(parsed.theme),
@@ -381,7 +365,6 @@ function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
token: state.token,
island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
onboardingDone: persisted.onboardingDone === true,
theme: normalizeTheme(persisted.theme),
@@ -463,9 +446,6 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.homeworkPhrases !== undefined) {
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
}
if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
}
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
if (patch.onboardingDone === true) {
@@ -493,7 +473,7 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.careMode !== undefined) {
persisted.careMode = patch.careMode === true;
}
// 待安装的更新只由主进程的 auto-update 模块写入;渲染进程传进来也照收(退出登录/重置走 clearConfig)
// 仅供主进程更新模块写入;IPC 层会剔除这两个字段。
if (typeof patch.pendingUpdateVersion === 'string') {
persisted.pendingUpdateVersion = patch.pendingUpdateVersion;
}
@@ -10,8 +10,7 @@ import { logger } from './logger.js';
*
* 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**;
* 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消;
* 3. `uncaughtException` / `unhandledRejection` —— 主进程的未捕获错误,
* 记完整堆栈,并且**只有在本机确认空闲时**才重启自己。
* 3. `uncaughtException` 只在本机空闲时重启;普通 `unhandledRejection` 仅记录堆栈。
*
* 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志):
* **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。
@@ -27,7 +26,7 @@ const UNRESPONSIVE_GRACE_MS = 10_000;
export interface CrashGuardOptions {
/** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */
recoverMainWindow: () => void;
recoverMainWindow: () => void | Promise<void>;
/** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */
isIdle: () => boolean;
/** 退出应用(复用既有的退出路径,保证配置落盘) */
@@ -56,7 +55,7 @@ export function installCrashGuard(guard: CrashGuardOptions): void {
logger.error(
`主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`,
);
recoverWindow(`渲染进程退出(${details.reason})`);
void recoverWindow(`渲染进程退出(${details.reason})`);
});
app.on('child-process-gone', (_event, details) => {
@@ -72,7 +71,6 @@ export function installCrashGuard(guard: CrashGuardOptions): void {
process.on('unhandledRejection', (reason) => {
logger.error('主进程未处理的 Promise 拒绝:', reason);
restartIfIdle('unhandledRejection');
});
}
@@ -107,18 +105,17 @@ function armUnresponsiveTimer(): void {
unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null;
if (!mainWindow || mainWindow.isDestroyed()) return;
recoverWindow('界面无响应超过 10 秒');
void recoverWindow('界面无响应超过 10 秒');
}, UNRESPONSIVE_GRACE_MS);
}
function isMainWindowContents(id: number): boolean {
// 主窗口引用还没建立(启动早期)时按"就是它"处理,避免漏掉重建
if (!mainWindow || mainWindow.isDestroyed()) return true;
if (!mainWindow || mainWindow.isDestroyed()) return false;
return mainWindow.webContents.id === id;
}
/** 静默重建主窗口(限流) */
function recoverWindow(reason: string): void {
async function recoverWindow(reason: string): Promise<void> {
const recover = options?.recoverMainWindow;
if (recovering) return;
if (!recover) {
@@ -145,7 +142,7 @@ function recoverWindow(reason: string): void {
win.hide();
}
logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`);
recover();
await recover();
} catch (error) {
logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
+94 -85
View File
@@ -18,6 +18,7 @@ const currentDir = __dirname;
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
const isQuickSmoke = isSmokeTest && process.env.ELECTRON_SMOKE_MODE !== 'visual';
/**
* 这次启动是否需要"直接进托盘、不弹窗"——开机自启专用(见 `applyAutoLaunch()`)。
@@ -73,6 +74,7 @@ function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
title: 'ClassHelper',
// 冒烟验证时不弹窗,避免打扰使用者;开机自启(--hidden)时直接进托盘
show: !isSmokeTest && options.hidden !== true,
focusable: !isQuickSmoke,
autoHideMenuBar: true,
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: {
@@ -81,6 +83,8 @@ function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
nodeIntegration: false,
sandbox: true,
webSecurity: true,
backgroundThrottling: false,
offscreen: isQuickSmoke,
},
});
@@ -99,6 +103,12 @@ function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
});
win.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
win.hide();
logger.info('主窗口已隐藏到系统托盘(后台继续运行)');
});
// 崩溃自愈与「空闲安装更新」都要盯着主窗口:窗口重建后换的是新实例,因此每次建窗都重挂一次
watchCrashGuardWindow(win);
@@ -117,7 +127,7 @@ function ensureMainWindow(options: { hidden?: boolean } = {}): BrowserWindow | n
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
try {
mainWindow = createWindow(options);
void loadRenderer(mainWindow);
void loadRenderer(mainWindow).catch((error) => logger.error('重建主窗口加载失败', error));
} catch (error) {
logger.error(`创建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
mainWindow = null;
@@ -172,95 +182,95 @@ if (!gotLock) {
showMainWindow();
});
app.whenReady().then(async () => {
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
logger.setVerbose(getConfig().developerMode === true);
registerIpcHandlers();
registerIslandIpc();
app
.whenReady()
.then(async () => {
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
logger.setVerbose(getConfig().developerMode === true);
registerIpcHandlers();
registerIslandIpc();
// 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
applyAutoLaunch();
// 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
applyAutoLaunch();
mainWindow = createWindow({ hidden: startHidden });
await loadRenderer(mainWindow);
mainWindow = createWindow({ hidden: startHidden });
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
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);
// 点击灵动岛里的"打开应用"时把主窗口拉到前台
island.setOpenAppHandler(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
installCrashGuard({
recoverMainWindow: async () => {
const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
if (alive) {
alive.hide();
alive.destroy();
}
mainWindow = createWindow({ hidden: true });
await loadRenderer(mainWindow);
},
isIdle: () =>
island.getState().inClass === false &&
mainWindow?.isVisible() !== true &&
!BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible()),
quitApp: () => quitApp(),
});
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
island.setMarkReadHandler((id) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id);
});
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
island.setMarkAllReadHandler((ids) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-all-read', ids);
});
} catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
await loadRenderer(mainWindow).catch((error) => logger.error('主窗口启动加载失败', error));
// 空闲安装更新:先把手动退出的回调交给 auto-update(它不能再 import 本文件,否则循环导入)
setQuitHandler(() => quitApp());
// 关闭主窗口 → 隐藏到系统托盘(需求 5),只有托盘"退出"才真正结束进程
// 注意:必须在冒烟验证之前挂载,否则 close() 会真的销毁窗口
mainWindow?.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
mainWindow?.hide();
logger.info('主窗口已隐藏到系统托盘(后台继续运行,左键托盘图标可恢复)');
});
try {
createTray({
onShow: () => showMainWindow(),
onHide: () => mainWindow?.hide(),
onQuit: () => quitApp(),
});
} catch (error) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
startClientAgent();
if (isSmokeTest) {
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
try {
await runSmokeTest(mainWindow);
if (!isQuickSmoke)
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
// 点击灵动岛里的"打开应用"时把主窗口拉到前台
island.setOpenAppHandler(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
island.setMarkReadHandler((id) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id);
});
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
island.setMarkAllReadHandler((ids) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-all-read', ids);
});
} catch (error) {
console.error('[SMOKE] 冒烟验证异常', error);
app.exit(1);
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
}
});
try {
if (!isQuickSmoke)
createTray({
onShow: () => showMainWindow(),
onHide: () => mainWindow?.hide(),
onQuit: () => quitApp(),
});
} catch (error) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
startClientAgent();
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
} catch (error) {
console.error('[SMOKE] 冒烟验证异常', error);
app.exit(1);
}
}
})
.catch((error) => {
logger.error('应用初始化失败', error);
if (isSmokeTest) app.exit(1);
});
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动 / 灵动岛"打开应用") */
function showMainWindow(): void {
@@ -326,10 +336,9 @@ if (!gotLock) {
logger.info(`应用退出清理:${summary}`);
});
app.on('activate', async () => {
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
const win = ensureMainWindow();
if (win) await loadRenderer(win);
ensureMainWindow();
}
});
}
+5 -1
View File
@@ -17,7 +17,11 @@ export function registerIpcHandlers(): void {
ipcMain.handle(
IPC_CHANNELS.saveConfig,
(_event, patch: Partial<DesktopStoredConfig>): DesktopStoredConfig => {
return saveConfig(patch ?? {});
// 更新安装记录只允许主进程内部写入,渲染进程不得借配置通道指定可执行文件。
const allowed = { ...patch };
delete allowed.pendingUpdateFile;
delete allowed.pendingUpdateVersion;
return saveConfig(allowed);
},
);
+38 -18
View File
@@ -341,9 +341,7 @@ class IslandController {
if (this.previewId && this.state.active?.id === this.previewId) return;
const sinceActivated = Date.now() - this.state.updatedAt;
if (this.isCursorOnIsland()) {
logger.info(
`灵动岛:忽略失焦(指针仍在岛上,判定为展开时的激活抖动)距上次状态=${sinceActivated}ms`,
);
logger.info(`灵动岛:忽略失焦(指针仍在岛上,判定为展开时的激活抖动)距上次状态=${sinceActivated}ms`);
return;
}
// 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖
@@ -828,10 +826,13 @@ class IslandController {
*/
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));
this.closeTimer = setTimeout(
() => {
this.closeTimer = null;
this.finishClose();
},
Math.round(CLOSE_ANIMATION_MS / speed),
);
}
/**
@@ -876,6 +877,13 @@ class IslandController {
this.win = null;
}
/** 教师撤回通知后,同时移除正在展示和等待下课的副本。 */
removeNotification(id: string): void {
this.state.queued = this.state.queued.filter((item) => item.id !== id);
if (this.state.active?.id === id) this.dismissActive();
else this.setState({});
}
/* ------------------------------------------------------------ 内部实现 */
private dismissActive(): void {
@@ -908,7 +916,6 @@ class IslandController {
this.hide();
}
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
this.clearTimers();
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
@@ -1010,11 +1017,7 @@ class IslandController {
? 'right'
: 'center';
const hValue =
hMode === 'left'
? area.x + marginX
: hMode === 'right'
? area.x + area.width - marginX
: center;
hMode === 'left' ? area.x + marginX : hMode === 'right' ? area.x + area.width - marginX : center;
return { hMode, hValue, vMode, vValue };
}
@@ -1834,7 +1837,6 @@ const PREVIEW_DURATION_MS = 30_000;
*/
const MIN_CARD_HEIGHT = 120;
/**
* 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`):
* 超过这个时间没上报就认为它已经卡死/不响应。
@@ -1866,6 +1868,9 @@ export const island = new IslandController();
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
export function registerIslandIpc(): void {
ipcMain.on('island:remove-notification', (_event, id: string) => {
if (typeof id === 'string') island.removeNotification(id);
});
ipcMain.on(
'island:push',
(_event, payload: { notification: IslandNotification; context?: IslandPushContext }) => {
@@ -1884,13 +1889,28 @@ export function registerIslandIpc(): void {
island.noteRendererAlive();
});
ipcMain.handle('island:get-state', () => island.getState());
let appearancePatch: Partial<IslandAppearance> | null = null;
let appearanceTimer: ReturnType<typeof setTimeout> | null = null;
const flushAppearance = (): void => {
if (appearanceTimer) clearTimeout(appearanceTimer);
appearanceTimer = null;
if (appearancePatch) island.setAppearance(appearancePatch);
appearancePatch = null;
};
ipcMain.handle('island:get-state', () => {
flushAppearance();
return island.getState();
});
ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => {
if (patch) island.setAppearance(patch);
if (!patch) return;
appearancePatch = { ...appearancePatch, ...patch };
if (!appearanceTimer) appearanceTimer = setTimeout(flushAppearance, 16);
});
ipcMain.handle('island:get-appearance', () => island.getAppearance());
ipcMain.handle('island:get-appearance', () => {
flushAppearance();
return island.getAppearance();
});
// 渲染进程命中测试结果 → **只用于立刻打开命中**(比 60ms 轮询更快)。
// 关闭命中一律由主进程按真实光标决定:两边都下发 true/false 会互相覆盖,
@@ -0,0 +1,106 @@
import fs from 'node:fs';
import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import { getConfig } from './config.js';
/** 后台冒烟只检启动、桥接、缓存与窗口生命周期;屏幕像素和动画由显式视觉验收覆盖。 */
export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
const startedAt = Date.now();
const results: Array<{ name: string; ok: boolean; detail: string; durationMs: number }> = [];
async function check(name: string, action: () => Promise<boolean | string> | boolean): Promise<void> {
const start = Date.now();
try {
const result = await action();
results.push({
name,
ok: result !== false,
detail: typeof result === 'string' ? result : '',
durationMs: Date.now() - start,
});
} catch (error) {
results.push({ name, ok: false, detail: String(error), durationMs: Date.now() - start });
}
const last = results.at(-1)!;
console.log(`[${last.ok ? 'PASS' : 'FAIL'}] ${name}(${last.durationMs}ms)${last.detail}`);
}
await check('后台启动并挂载界面', async () => {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if (
await win.webContents.executeJavaScript(
`Boolean(window.__classhelperSmoke__ && document.querySelector('input, .el-menu-item'))`,
)
)
return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return false;
});
await check('所有验证窗口保持隐藏且不获取焦点', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
await check('预加载桥接白名单', async () =>
win.webContents.executeJavaScript(`Boolean(window.desktop?.getConfig && !window.desktop.ipcRenderer)`),
);
await check('渲染进程不能改写安装记录或设备身份', async () => {
const before = getConfig();
const after = await win.webContents.executeJavaScript(
`window.desktop.saveConfig({ pendingUpdateFile: 'C:/unexpected.exe', pendingUpdateVersion: '999.0.0', deviceKey: 'unexpected' })`,
);
return (
after.pendingUpdateFile === before.pendingUpdateFile &&
after.pendingUpdateVersion === before.pendingUpdateVersion &&
after.deviceKey === before.deviceKey
);
});
for (const [name, method] of [
['IndexedDB 缓存读写', 'cacheSelfTest'],
['断网缓存回退', 'offlineScenario'],
['服务端与班级缓存隔离', 'cacheIsolationSelfTest'],
]) {
await check(name!, async () => {
const result = await win.webContents.executeJavaScript(`window.__classhelperSmoke__.${method}()`);
if (!result.ok) throw new Error(result.detail);
return result.detail;
});
}
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
await check('后台登录与数据、实时通道集成', async () => {
const credentials = {
code: process.env.ELECTRON_SMOKE_CLASS_CODE ?? '',
password: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
serverUrl: (process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api').replace(/\/api\/?$/, ''),
};
const result = await win.webContents.executeJavaScript(
`window.__classhelperSmoke__.onlineScenario(${JSON.stringify(credentials)})`,
);
if (!result.ok) throw new Error(result.detail);
await win.webContents.executeJavaScript('window.__classhelperSmoke__.sessionCleanup()');
return result.detail;
});
}
await check('检查结束时没有可见或聚焦的验证窗口', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
await check('关闭窗口仍隐藏而不销毁', () => {
win.close();
return !win.isDestroyed() && !win.isVisible() && app.isReady();
});
const failed = results.filter((result) => !result.ok);
const result = {
passed: results.length - failed.length,
total: results.length,
ok: !failed.length,
mode: 'quick',
durationMs: Date.now() - startedAt,
results,
appVersion: app.getVersion(),
packaged: app.isPackaged,
};
if (process.env.ELECTRON_SMOKE_RESULT) {
fs.mkdirSync(path.dirname(process.env.ELECTRON_SMOKE_RESULT), { recursive: true });
fs.writeFileSync(process.env.ELECTRON_SMOKE_RESULT, JSON.stringify(result, null, 2));
}
console.log(`[SMOKE] 后台快速检查 ${result.passed}/${result.total} 项通过,${result.durationMs}ms`);
app.exit(failed.length ? 1 : 0);
}
+23 -64
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import { runQuickSmoke } from './smoke-quick.js';
import path from 'node:path';
import { app, BrowserWindow, desktopCapturer, screen } from 'electron';
import type { NativeImage } from 'electron';
@@ -1150,7 +1151,10 @@ async function runIslandChecks(
const shotAppearance: Parameters<typeof island.setAppearance>[0] = {
accent: process.env.ELECTRON_SMOKE_ISLAND_ACCENT ?? '#e91e8c',
};
if (process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' || process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass') {
if (
process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' ||
process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass'
) {
shotAppearance.style = process.env.ELECTRON_SMOKE_ISLAND_STYLE;
} else if (!process.env.ELECTRON_SMOKE_ISLAND_STYLE) {
shotAppearance.style = 'tinted';
@@ -4125,67 +4129,17 @@ async function runSettingsPageMarginCheck(
};
}
async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> {
const result = (await win.webContents
.executeJavaScript(
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const before = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/settings/reminder';
// 找到「通知显示位置」那张卡片里的单选组
let group = null;
for (let i = 0; i < 40 && !group; i += 1) {
await wait(100);
group = Array.from(document.querySelectorAll('.el-card')).find((card) =>
(card.querySelector('.el-card__header')?.textContent ?? '').includes('ClassIsland 联动'),
);
}
if (!group) return { ok: false, detail: '设置页没有「ClassIsland 联动」卡片' };
const radios = Array.from(group.querySelectorAll('.el-radio'));
// 注意:这段脚本本身是外层模板字符串,里面不能再出现反引号(会截断外层字符串)
if (radios.length < 3) return { ok: false, detail: '单选项只有 ' + radios.length + ' 个' };
// 点第二个(只在 ClassHelper 客户端弹),断言配置真的写下去了
radios[1].click();
await wait(600);
const afterClient = (await window.desktop.getConfig()).notificationChannel;
const clientChecked = radios[1].classList.contains('is-checked');
// 再点回第一个(两端都弹),避免影响后续用例
radios[0].click();
await wait(600);
const afterBoth = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/schedule';
return { ok: true, before, afterClient, afterBoth, clientChecked, labels: radios.map((n) => n.textContent.trim()) };
})()`,
)
.catch((error: unknown) => ({
ok: false as const,
detail: `执行失败:${error instanceof Error ? error.message : String(error)}`,
}))) as
| {
ok: true;
before: string;
afterClient: string;
afterBoth: string;
clientChecked: boolean;
labels: string[];
}
| { ok: false; detail: string }
| null;
if (result?.ok) {
return {
ok: result.afterClient === 'client' && result.clientChecked && result.afterBoth === 'both',
detail:
`单选项=[${result.labels.join(' / ')}],` +
`选择「只在 ClassHelper 客户端弹」后配置 ${result.before} → ${result.afterClient}(选中=${result.clientChecked}),` +
`切回后 ${result.afterBoth}`,
};
}
return { ok: false, detail: result?.detail ?? '未执行' };
const text = await win.webContents.executeJavaScript(`(async () => {
location.hash = '#/settings/reminder';
await new Promise((resolve) => setTimeout(resolve, 500));
return document.body.innerText;
})()`);
return {
ok: text.includes('通知提醒') && !text.includes('ClassIsland 联动'),
detail: '通知统一由客户端提醒',
};
}
/**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
@@ -4227,6 +4181,7 @@ async function runTrayAndShutdownChecks(mainWin: BrowserWindow, record: Recorder
);
}
export async function runSmokeTest(win: BrowserWindow): Promise<void> {
if (process.env.ELECTRON_SMOKE_MODE !== 'visual') return runQuickSmoke(win);
const results: SmokeResult[] = [];
const record = (name: string, ok: boolean, detail = ''): void => {
results.push({ name, ok, detail });
@@ -4395,10 +4350,14 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
// 班级账号凭据由 scripts/smoke.mjs 通过管理端接口准备(渲染进程读不到 process.env)
const classCode = process.env.ELECTRON_SMOKE_CLASS_CODE ?? '';
const classPassword = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '';
const serverUrl = (process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api').replace(
/\/api\/?$/,
'',
);
const online = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword })});
return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword, serverUrl })});
})()`,
);
record(
@@ -4413,9 +4372,9 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
const settingsAppearance = await runSettingsPageAppearanceCheck(win);
record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail);
// 通知显示位置(客户端自己选):提醒弹在 ClassHelper 还是 ClassIsland
// 提醒设置:仅保留本机通知与教师管理的成绩查询状态
const settingsChannel = await runSettingsPageChannelCheck(win);
record('设置页真实 UI:切换「通知显示位置」写回本地配置', settingsChannel.ok, settingsChannel.detail);
record('设置页真实 UI:提醒规则展示且无退役联动入口', settingsChannel.ok, settingsChannel.detail);
// 改密入口(2026-10-01 用户要求移除:班级密码只由管理员在 Web 端维护)——负断言防退化
const settingsPassword = await runSettingsPagePasswordEntryCheck(win);
@@ -50,6 +50,9 @@ const bridge: DesktopBridge = {
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
ipcRenderer.send('island:push', payload);
},
islandRemoveNotification: (id: string): void => {
ipcRenderer.send('island:remove-notification', id);
},
islandSetClassState: (payload: IslandClassStatePayload): void => {
ipcRenderer.send('island:class-state', payload);
},
@@ -102,14 +102,6 @@ onMounted(async () => {
bootText.value = '正在恢复登录状态...';
const session = await auth.restore();
// 通知显示位置:先用本地配置兜底(断网也要按上次的选择决定本机弹不弹),
// 登录态就绪后再向服务器对齐一次(服务端才是单一事实来源)
const storedConfig = await window.desktop?.getConfig?.();
if (storedConfig?.notificationChannel) {
appStore.applyLocalNotificationChannel(storedConfig.notificationChannel);
}
void appStore.loadNotificationChannel(auth.classId);
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
void appStore.init(session.serverUrl);
@@ -2,7 +2,6 @@ import {
API_PATHS,
type ClassDetailDto,
type ClassDto,
type ClassIslandNotificationChannel,
type CourseDto,
type ExamDto,
type ExamRankingDto,
@@ -47,36 +46,6 @@ export const classApi = {
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
};
/**
* 通知显示位置(both / client / classisland)。
*
* 由**这台教室机器自己**决定"提醒弹在 ClassHelper 还是 ClassIsland":
* 本地存一份用于本机的弹窗/上岛判断,同时写回班级记录,服务端据此决定要不要推 ClassIsland。
*/
export const classChannelApi = {
get: (classId: string): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
api.get(`${API_PATHS.classes}/${classId}/notification-channel`),
set: (
classId: string,
notificationChannel: ClassIslandNotificationChannel,
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }),
};
/** 本班 ClassIsland 联动状态(教室客户端「设置 → ClassIsland 联动」展示) */
export const classIslandStatusApi = {
get: (
classId: string,
): Promise<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
}> => api.get(`${API_PATHS.classes}/${classId}/classisland-status`),
};
export const scheduleApi = {
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
api.get(API_PATHS.schedules, params),
@@ -151,13 +120,11 @@ export const gradeApi = {
/**
* ClassHelper 班级端设置:「是否允许按学号查询本班学生成绩明细」。
* 与通知显示位置同一套模式 —— 由教室机器自己决定,教师/管理员在 Web 端也能改。
* 教师/管理员在 Web 端控制,教室机器只读取当前状态。
*/
export const classGradeQueryApi = {
get: (classId: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
api.get(`${API_PATHS.classes}/${classId}/student-grade-query`),
set: (classId: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
api.patch(`${API_PATHS.classes}/${classId}/student-grade-query`, { enabled }),
};
export const dashboardApi = {
+55 -12
View File
@@ -1,3 +1,5 @@
import { getCacheScope, scopedKey } from './scope.js';
/**
* IndexedDB 缓存层(无第三方依赖)。
*
@@ -37,7 +39,8 @@ let databasePromise: Promise<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> {
if (databasePromise) return databasePromise;
databasePromise = new Promise<IDBDatabase>((resolve, reject) => {
let failed = false;
const opening = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
@@ -49,12 +52,36 @@ function openDatabase(): Promise<IDBDatabase> {
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error('IndexedDB 打开失败'));
request.onblocked = () => reject(new Error('IndexedDB 被其它窗口占用'));
request.onsuccess = () => {
const db = request.result;
if (failed) {
db.close();
return;
}
const reset = (): void => {
if (databasePromise === opening) databasePromise = null;
};
db.onversionchange = () => {
db.close();
reset();
};
db.onclose = reset;
resolve(db);
};
request.onerror = () => {
failed = true;
reject(request.error ?? new Error('IndexedDB 打开失败'));
};
request.onblocked = () => {
failed = true;
reject(new Error('IndexedDB 被其它窗口占用'));
};
});
return databasePromise;
databasePromise = opening;
void opening.catch(() => {
if (databasePromise === opening) databasePromise = null;
});
return opening;
}
function runRequest<T>(
@@ -67,7 +94,8 @@ function runRequest<T>(
new Promise<T>((resolve, reject) => {
const transaction = db.transaction(store, mode);
const request = action(transaction.objectStore(store));
request.onsuccess = () => resolve(request.result as T);
transaction.oncomplete = () => resolve(request.result as T);
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB 事务已中止'));
request.onerror = () => reject(request.error ?? new Error('IndexedDB 操作失败'));
}),
);
@@ -89,14 +117,28 @@ function toPlain<T>(value: T): T {
}
}
export async function cacheSet<T>(store: CacheStore, key: string, value: T): Promise<void> {
const record: CacheRecord<T> = { key, value: toPlain(value), updatedAt: Date.now() };
export async function cacheSet<T>(
store: CacheStore,
key: string,
value: T,
scope = getCacheScope(),
): Promise<void> {
const record: CacheRecord<T> = {
key: store === 'profile' ? key : scopedKey(key, scope),
value: toPlain(value),
updatedAt: Date.now(),
};
await runRequest<IDBValidKey>(store, 'readwrite', (objectStore) => objectStore.put(record));
}
export function cacheGet<T>(store: CacheStore, key: string): Promise<CacheRecord<T> | null> {
export function cacheGet<T>(
store: CacheStore,
key: string,
scope = getCacheScope(),
): Promise<CacheRecord<T> | null> {
const resolvedKey = store === 'profile' ? key : scopedKey(key, scope);
return runRequest<CacheRecord<T> | undefined>(store, 'readonly', (objectStore) =>
objectStore.get(key),
objectStore.get(resolvedKey),
).then((record) => record ?? null);
}
@@ -105,7 +147,8 @@ export function cacheList<T>(store: CacheStore): Promise<Array<CacheRecord<T>>>
}
export async function cacheRemove(store: CacheStore, key: string): Promise<void> {
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(key));
const resolvedKey = store === 'profile' ? key : scopedKey(key);
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(resolvedKey));
}
export async function cacheClearStore(store: CacheStore): Promise<void> {
+9 -2
View File
@@ -1,4 +1,5 @@
import { cacheGet, cacheSet, type CacheStore } from './db.js';
import { getCacheScope } from './scope.js';
export interface CachedFetchResult<T> {
data: T;
@@ -23,13 +24,19 @@ export async function fetchWithCache<T>(
loader: () => Promise<T>,
fallback: T,
): Promise<CachedFetchResult<T>> {
const scope = getCacheScope();
try {
const data = await loader();
await cacheSet(store, key, data);
if (scope !== getCacheScope())
return { data: fallback, fromCache: false, updatedAt: null, error: '会话已切换' };
// 网络成功但本地磁盘不可用时,仍返回刚获取的数据。
await cacheSet(store, key, data, scope).catch(() => undefined);
return { data, fromCache: false, updatedAt: Date.now(), error: null };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const cached = await cacheGet<T>(store, key).catch(() => null);
const cached = await cacheGet<T>(store, key, scope).catch(() => null);
if (scope !== getCacheScope())
return { data: fallback, fromCache: false, updatedAt: null, error: '会话已切换' };
if (cached) {
return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: message };
}
+22
View File
@@ -0,0 +1,22 @@
let activeScope = 'anonymous';
const listeners = new Set<() => void>();
/** 同一台机器可切换服务端与班级,两者都必须纳入缓存命名空间。 */
export function setCacheScope(server: string, identity: string): void {
const next = `${encodeURIComponent(server.replace(/\/+$/, ''))}@${encodeURIComponent(identity)}`;
if (next === activeScope) return;
activeScope = next;
for (const listener of listeners) listener();
}
export function getCacheScope(): string {
return activeScope;
}
export function scopedKey(key: string, scope = activeScope): string {
return `${scope}::${key}`;
}
export function onCacheScopeChanged(listener: () => void): void {
listeners.add(listener);
}
export function profileCacheKey(server: string, username: string): string {
return `${encodeURIComponent(server.replace(/\/+$/, ''))}@${encodeURIComponent(username)}::profile`;
}
+31 -6
View File
@@ -1,5 +1,27 @@
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
import { fetchWithCache } from './index.js';
import { setCacheScope } from './scope.js';
export async function cacheIsolationSelfTest(): Promise<SmokeCheckResult> {
const key = `scope-check-${Date.now()}`;
try {
setCacheScope('http://scope-a.invalid', 'class-a');
await cacheSet('grades', key, ['班级甲']);
setCacheScope('http://scope-a.invalid', 'class-b');
if (await cacheGet('grades', key)) return { ok: false, detail: '跨班级读到了缓存' };
setCacheScope('http://scope-b.invalid', 'class-a');
if (await cacheGet('grades', key)) return { ok: false, detail: '跨服务端读到了缓存' };
setCacheScope('http://scope-a.invalid', 'class-a');
const own = await cacheGet<string[]>('grades', key);
await cacheRemove('grades', key);
return { ok: own?.value[0] === '班级甲', detail: '换班和换服务端均隔离,原班缓存仍可读取' };
} finally {
setCacheScope('', 'anonymous');
}
}
/** 联网自检与造数、清理始终使用同一服务端。 */
let smokeServerUrl = 'http://127.0.0.1:4000';
export interface SmokeCheckResult {
ok: boolean;
@@ -208,7 +230,7 @@ async function seedSmokeContent(
need: { homework: boolean; notification: boolean },
): Promise<SmokeCheckResult> {
if (!classId) return { ok: false, detail: '缺少班级,无法自建数据' };
const serverUrl = 'http://127.0.0.1:4000';
const serverUrl = smokeServerUrl;
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
try {
@@ -275,7 +297,7 @@ async function cleanupTemporaryContent(): Promise<string> {
if (!temporaryContent) return '';
const { token, homeworks, notifications } = temporaryContent;
temporaryContent = null;
const serverUrl = 'http://127.0.0.1:4000';
const serverUrl = smokeServerUrl;
const removals = [
...homeworks.map((id) => `/api/homeworks/${id}`),
...notifications.map((id) => `/api/notifications/${id}`),
@@ -298,6 +320,7 @@ async function cleanupTemporaryContent(): Promise<string> {
export async function onlineScenario(credentials?: {
code: string;
password: string;
serverUrl?: string;
}): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
import('../stores/auth.js'),
@@ -311,7 +334,8 @@ export async function onlineScenario(credentials?: {
const appStore = useAppStore();
const realtime = useRealtimeStore();
const serverUrl = 'http://127.0.0.1:4000';
const serverUrl = credentials?.serverUrl ?? smokeServerUrl;
smokeServerUrl = serverUrl;
const detail: string[] = [];
try {
@@ -441,7 +465,7 @@ export async function islandRealtimeScenario(): Promise<{
import('../island/bridge.js'),
]);
const auth = useAuthStore();
const serverUrl = 'http://127.0.0.1:4000';
const serverUrl = smokeServerUrl;
const classId = auth.classId ?? null;
if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' };
@@ -499,7 +523,7 @@ export async function islandRealtimeCleanup(
teacherToken: string,
): Promise<SmokeCheckResult> {
try {
const response = await fetch(`http://127.0.0.1:4000/api/notifications/${notificationId}`, {
const response = await fetch(`${smokeServerUrl}/api/notifications/${notificationId}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${teacherToken}` },
});
@@ -544,7 +568,7 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
import('../router/index.js'),
]);
const auth = useAuthStore();
const serverUrl = 'http://127.0.0.1:4000';
const serverUrl = smokeServerUrl;
const classId = auth.classId ?? null;
if (!classId) return { ok: false, detail: '当前学生没有班级,无法构造课表' };
@@ -1172,6 +1196,7 @@ export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = {
cacheSelfTest,
cacheIsolationSelfTest,
offlineScenario,
layoutNavigationSelfTest,
onlineScenario,
@@ -102,7 +102,7 @@ function finish(): void {
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') finish();
if (onboarding.visible && event.key === 'Escape') finish();
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onKeydown);
@@ -154,9 +154,7 @@ onUnmounted(() => {
</div>
<div class="onboarding-actions">
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">
下一步
</el-button>
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">下一步</el-button>
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
</div>
</div>
@@ -250,7 +248,7 @@ onUnmounted(() => {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.8;
color: var(--ch-text-regular, #606266);
color: var(--ch-text-secondary);
}
.onboarding-foot {
@@ -0,0 +1,12 @@
import { ElMessageBox } from 'element-plus';
/** 取消是正常交互;真实异常继续交给调用方处理。 */
export async function confirmAction(...args: Parameters<typeof ElMessageBox.confirm>): Promise<boolean> {
try {
await ElMessageBox.confirm(...args);
return true;
} catch (reason) {
if (reason === 'cancel' || reason === 'close') return false;
throw reason;
}
}
@@ -1,3 +1,4 @@
import { watch, type WatchStopHandle } from 'vue';
import {
SOCKET_EVENTS,
resolveClassStatus,
@@ -8,6 +9,7 @@ import {
} from '@classhelper/shared';
import { notificationApi, scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { getCacheScope } from '../cache/scope.js';
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
@@ -30,6 +32,9 @@ let currentPeriod: ClassPeriod | null = null;
let inClass = false;
let tickTimer: ReturnType<typeof setInterval> | null = null;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let stopWeekWatch: WatchStopHandle | null = null;
let scheduleRequest = 0;
let scheduleWeek = 1;
/** 供通知投递时读取:当前是否上课 + 本节课结束时间 */
export function getIslandClassContext(): { inClass: boolean; currentPeriodEnd: string | null } {
@@ -45,21 +50,28 @@ export async function refreshIslandSchedules(): Promise<void> {
const appStore = useAppStore();
const auth = useAuthStore();
if (!auth.token) return;
const scope = getCacheScope();
const classId = auth.classId ?? undefined;
await appStore.refreshTerm(classId);
if (scope !== getCacheScope() || !auth.token) return;
const request = ++scheduleRequest;
const week = appStore.currentWeek;
const result = await fetchWithCache<ScheduleDto[]>(
'schedules',
`week:${week}`,
() => scheduleApi.list({ classId: auth.classId ?? undefined, week }),
() => scheduleApi.list({ classId, week }),
[],
);
if (scope !== getCacheScope() || request !== scheduleRequest) return;
if (result.error && !result.updatedAt) return;
schedules = result.data;
scheduleWeek = week;
recomputeClassState(true);
}
function recomputeClassState(force = false): void {
const appStore = useAppStore();
const status = resolveClassStatus(schedules, new Date(), appStore.currentWeek);
const status = resolveClassStatus(schedules, new Date(), scheduleWeek);
const nextInClass = status.inClass;
currentPeriod = status.current;
@@ -313,6 +325,12 @@ function onScheduleUpdated(): void {
export function startIslandBridge(): void {
if (tickTimer) return;
stopWeekWatch = watch(
() => useAppStore().currentWeek,
() => {
void refreshIslandSchedules();
},
);
void refreshIslandSchedules().catch(() => undefined);
@@ -338,6 +356,9 @@ export function stopIslandBridge(): void {
if (refreshTimer) clearInterval(refreshTimer);
tickTimer = null;
refreshTimer = null;
scheduleRequest += 1;
stopWeekWatch?.();
stopWeekWatch = null;
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
schedules = [];
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { confirmAction } from '../composables/confirm.js';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
import { ElMessage, type MenuInstance } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useThemeReveal } from '../composables/motion.js';
@@ -354,7 +355,7 @@ const connectionText = computed(() => {
});
async function handleLogout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' });
if (!(await confirmAction('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' }))) return;
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
@@ -370,6 +371,10 @@ async function handleLogout(): Promise<void> {
function onNotification(payload: unknown): void {
void notifications.pushRealtime(payload as NotificationDto);
}
function onNotificationDeleted(payload: unknown): void {
void notifications.removeRealtime((payload as { id: string }).id);
window.desktop?.islandRemoveNotification((payload as { id: string }).id);
}
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
function onRecovered(): void {
@@ -378,9 +383,10 @@ function onRecovered(): void {
}
onMounted(async () => {
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
realtime.on(SOCKET_EVENTS.notificationDeleted, onNotificationDeleted);
await notifications.load().catch(() => undefined);
await notifications.refreshUnreadCount();
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
appStore.onServerRecovered(onRecovered);
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
@@ -389,6 +395,7 @@ onMounted(async () => {
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
realtime.off(SOCKET_EVENTS.notificationDeleted, onNotificationDeleted);
appStore.offServerRecovered(onRecovered);
stopIslandBridge();
});
@@ -410,7 +417,10 @@ onUnmounted(() => {
data-test="sidebar-toggle"
@click="ui.toggleSidebar()"
>
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
<el-icon :size="16">
<Expand v-if="ui.sidebarCollapsed" />
<Fold v-else />
</el-icon>
</button>
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
@@ -422,11 +432,7 @@ onUnmounted(() => {
把"跟着菜单滚出视口"的底片裁掉,不让它盖到品牌区/页脚上。
两层都 pointer-events:none —— 底片盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator-clip" :style="clipStyle" aria-hidden="true">
<div
class="nav-indicator"
:class="{ 'is-following': indicatorFollowing }"
:style="indicatorStyle"
/>
<div class="nav-indicator" :class="{ 'is-following': indicatorFollowing }" :style="indicatorStyle" />
</div>
<el-menu
ref="menuRef"
@@ -495,7 +501,10 @@ onUnmounted(() => {
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
@click="handleThemeToggle"
>
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
<el-icon>
<Sunny v-if="ui.theme === 'dark'" />
<Moon v-else />
</el-icon>
</el-button>
</el-tooltip>
<el-dropdown trigger="click">
@@ -1,15 +1,12 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import {
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
STORAGE_KEYS,
formatDate,
type ClassIslandNotificationChannel,
} from '@classhelper/shared';
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
import { classChannelApi } from '../api/index.js';
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
import { dashboardApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { getCacheScope, onCacheScopeChanged } from '../cache/scope.js';
type RecoveryHandler = () => void;
@@ -70,13 +67,26 @@ export const useAppStore = defineStore('app', () => {
const currentWeek = ref(1);
const maxWeek = ref(20);
const initialized = ref(false);
/**
* 通知显示位置:这台教室机器把提醒弹在哪个端。
*
* 单一事实来源是**班级记录**(服务端),本地这份只是缓存:断网时也要能按上次的选择
* 决定本机弹不弹,所以启动时先读本地配置、登录后再向服务器对齐一次。
*/
const notificationChannel = ref<ClassIslandNotificationChannel>(DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL);
let termRequest = 0;
onCacheScopeChanged(() => {
currentWeek.value = 1;
maxWeek.value = 20;
termRequest += 1;
clearOfflineSince();
});
/** 当前教学周的单一写入入口,课表页与灵动岛共用。 */
async function refreshTerm(classId?: string): Promise<void> {
const request = ++termRequest;
const scope = getCacheScope();
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(classId), {
currentWeek: 1,
maxWeek: 20,
});
if (request !== termRequest || scope !== getCacheScope() || (result.error && !result.updatedAt)) return;
currentWeek.value = result.data.currentWeek;
maxWeek.value = result.data.maxWeek;
}
const offline = computed(() => !serverReachable.value);
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
@@ -178,53 +188,6 @@ export const useAppStore = defineStore('app', () => {
void refreshCacheStats();
}
/* ------------------------------------------------------------ 通知显示位置 */
/** 启动时用本地配置兜底(此时可能还没登录/断网) */
function applyLocalNotificationChannel(value: ClassIslandNotificationChannel): void {
notificationChannel.value = value;
}
/**
* 向服务器对齐显示位置。
* 登录后调用:以服务端为准(多台机器/老师改过之后,本地缓存可能已经过期)。
*/
async function loadNotificationChannel(classId: string | null): Promise<void> {
if (!classId) return;
try {
const result = await classChannelApi.get(classId);
notificationChannel.value = result.notificationChannel;
await window.desktop?.saveConfig({ notificationChannel: result.notificationChannel });
} catch {
// 离线/无权限:保留本地值,不影响展示
}
}
/**
* 改显示位置:先落本地(立刻生效),再写回班级记录。
* 服务端写失败时返回 false,由设置页提示"已保存在本机,联网后请重试"。
*/
async function setNotificationChannel(
classId: string | null,
value: ClassIslandNotificationChannel,
): Promise<boolean> {
notificationChannel.value = value;
await window.desktop?.saveConfig({ notificationChannel: value });
if (!classId) return false;
try {
const result = await classChannelApi.set(classId, value);
notificationChannel.value = result.notificationChannel;
return true;
} catch {
return false;
}
}
/** 本次提醒是否要在本机弹(弹窗 + 灵动岛);"只在 ClassIsland 上弹"时为 false */
function shouldPopupLocally(): boolean {
return notificationChannel.value !== 'classisland';
}
return {
serverUrl,
serverReachable,
@@ -236,17 +199,13 @@ export const useAppStore = defineStore('app', () => {
currentWeek,
maxWeek,
initialized,
notificationChannel,
init,
ping,
applyServerUrl,
applyLocalNotificationChannel,
loadNotificationChannel,
setNotificationChannel,
shouldPopupLocally,
refreshCacheStats,
clearCache,
markSynced,
refreshTerm,
onServerRecovered,
offServerRecovered,
stopHealthPolling,
@@ -5,8 +5,9 @@ import { authApi } from '../api/index.js';
import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
import { cacheGet, cacheSet } from '../cache/db.js';
import { normalizeServerUrl } from '../config.js';
const PROFILE_KEY = 'me';
import { profileCacheKey, setCacheScope } from '../cache/scope.js';
let profileKey = '';
let sessionServer = '';
/**
* 登录态。
@@ -58,10 +59,13 @@ export const useAuthStore = defineStore('auth', () => {
loading.value = true;
try {
const payload: LoginResponse = await authApi.classLogin({ code, password });
profileKey = profileCacheKey(normalized, code);
sessionServer = normalized;
setCacheScope(normalized, payload.user.classId ?? payload.user.id);
applyToken(payload.token);
user.value = payload.user;
offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, user.value);
await cacheSet('profile', profileKey, user.value).catch(() => undefined);
await persistSession(normalized, code, payload.token);
} finally {
loading.value = false;
@@ -90,8 +94,11 @@ export const useAuthStore = defineStore('auth', () => {
}
}
const cachedProfile = await cacheGet<SessionUser>('profile', PROFILE_KEY).catch(() => null);
profileKey = profileCacheKey(serverUrl, username);
sessionServer = serverUrl;
const cachedProfile = await cacheGet<SessionUser>('profile', profileKey).catch(() => null);
if (cachedProfile) user.value = cachedProfile.value;
setCacheScope(serverUrl, user.value?.classId ?? user.value?.id ?? `unresolved:${username}`);
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
if (token.value) void refreshProfile();
@@ -102,11 +109,14 @@ export const useAuthStore = defineStore('auth', () => {
/** 后台刷新个人资料;服务器不可达时保持缓存资料与离线会话标记 */
async function refreshProfile(): Promise<void> {
if (!token.value) return;
const requestedToken = token.value;
try {
const profile = await authApi.me();
if (token.value !== requestedToken) return;
user.value = profile;
setCacheScope(sessionServer, profile.classId ?? profile.id);
offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, profile);
await cacheSet('profile', profileKey, profile).catch(() => undefined);
} catch {
offlineSession.value = Boolean(user.value);
}
@@ -120,6 +130,7 @@ export const useAuthStore = defineStore('auth', () => {
}
applyToken(null);
user.value = null;
setCacheScope('', 'anonymous');
offlineSession.value = false;
if (window.desktop) await window.desktop.saveConfig({ token: null });
}
@@ -5,6 +5,7 @@ import { notificationApi } from '../api/index.js';
import { cacheSet } from '../cache/db.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from './app.js';
import { getCacheScope, onCacheScopeChanged } from '../cache/scope.js';
/**
* 通知状态:列表 + 未读数。
@@ -18,6 +19,23 @@ export const useNotificationStore = defineStore('notifications', () => {
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const error = ref<string | null>(null);
let requestId = 0;
let revision = 0;
const changes = new Map<string, { revision: number; value: NotificationDto | null }>();
onCacheScopeChanged(() => {
requestId += 1;
revision = 0;
changes.clear();
items.value = [];
unreadCount.value = 0;
loading.value = false;
fromCache.value = false;
updatedAt.value = null;
error.value = null;
});
function changed(id: string, value: NotificationDto | null): void {
changes.set(id, { revision: ++revision, value });
}
const unreadItems = computed(() => items.value.filter((item) => !item.read));
@@ -34,6 +52,9 @@ export const useNotificationStore = defineStore('notifications', () => {
}
async function load(): Promise<void> {
const request = ++requestId;
const scope = getCacheScope();
const startedAt = revision;
loading.value = true;
try {
const result = await fetchWithCache<NotificationDto[]>(
@@ -42,14 +63,23 @@ export const useNotificationStore = defineStore('notifications', () => {
() => notificationApi.list(),
[],
);
items.value = result.data;
if (request !== requestId || scope !== getCacheScope()) return;
const merged = new Map(result.data.map((item) => [item.id, item]));
for (const [id, change] of changes) {
if (change.revision <= startedAt) continue;
if (change.value) merged.set(id, change.value);
else merged.delete(id);
}
items.value = [...merged.values()].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
changes.clear();
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
error.value = result.error;
recount();
await persist();
if (!result.fromCache) useAppStore().markSynced();
} finally {
loading.value = false;
if (request === requestId) loading.value = false;
}
}
@@ -57,6 +87,14 @@ export const useNotificationStore = defineStore('notifications', () => {
async function pushRealtime(payload: NotificationDto): Promise<void> {
if (items.value.some((item) => item.id === payload.id)) return;
items.value = [{ ...payload, read: false }, ...items.value];
changed(payload.id, items.value[0]!);
recount();
await persist();
}
async function removeRealtime(id: string): Promise<void> {
items.value = items.value.filter((item) => item.id !== id);
changed(id, null);
recount();
await persist();
}
@@ -67,6 +105,7 @@ export const useNotificationStore = defineStore('notifications', () => {
if (target) {
target.read = true;
target.readAt = new Date().toISOString();
changed(id, { ...target });
}
recount();
await persist();
@@ -76,6 +115,7 @@ export const useNotificationStore = defineStore('notifications', () => {
const result = await notificationApi.markAllRead();
const now = new Date().toISOString();
items.value = items.value.map((item) => ({ ...item, read: true, readAt: item.readAt ?? now }));
for (const item of items.value) changed(item.id, item);
recount();
await persist();
return result.marked;
@@ -100,6 +140,7 @@ export const useNotificationStore = defineStore('notifications', () => {
error,
load,
pushRealtime,
removeRealtime,
markRead,
markAllRead,
refreshUnreadCount,
@@ -12,7 +12,6 @@ import {
} from '@classhelper/shared';
import { socketUrlOf } from '../config.js';
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
import { useAppStore } from './app.js';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
@@ -23,18 +22,6 @@ type EventHandler = (payload: unknown) => void;
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
*/
export const useRealtimeStore = defineStore('realtime', () => {
const app = useAppStore();
/**
* 本机要不要弹这次提醒。
*
* 教室在设置页选了「只在 ClassIsland 上弹」时,客户端仍然把通知收进通知中心(列表里有记录、
* 未读红点照常),但**不再弹窗、也不上岛** —— 否则老师会看到两个地方同时炸出来。
*/
function popupLocally(): boolean {
return app.shouldPopupLocally();
}
const connected = ref(false);
const connecting = ref(false);
const lastEventName = ref<string | null>(null);
@@ -76,18 +63,20 @@ export const useRealtimeStore = defineStore('realtime', () => {
dispatch('__connect_error__', error.message);
});
instance.on(SOCKET_EVENTS.notificationDeleted, (payload) => {
mark(SOCKET_EVENTS.notificationDeleted);
dispatch(SOCKET_EVENTS.notificationDeleted, payload);
});
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.notificationNew);
if (popupLocally()) {
ElMessage({
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
type: payload.priority === 'URGENT' ? 'error' : 'info',
duration: 5000,
showClose: true,
});
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
pushNotificationToIsland(payload);
}
ElMessage({
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
type: payload.priority === 'URGENT' ? 'error' : 'info',
duration: 5000,
showClose: true,
});
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
pushNotificationToIsland(payload);
dispatch(SOCKET_EVENTS.notificationNew, payload);
});
@@ -101,16 +90,14 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.callNew);
if (popupLocally()) {
ElMessage({
message: payload.title,
type: 'warning',
duration: 8000,
showClose: true,
});
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
pushCallToIsland(payload);
}
ElMessage({
message: payload.title,
type: 'warning',
duration: 8000,
showClose: true,
});
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
pushCallToIsland(payload);
dispatch(SOCKET_EVENTS.callNew, payload);
});
@@ -384,7 +384,7 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="等级" width="80">
<template #default="{ row }">
{{ gradeLevel(gradePercent(row.score, row.totalScore)) }}
{{ row.level || gradeLevel(gradePercent(row.score, row.totalScore)) }}
</template>
</el-table-column>
<el-table-column label="发布时间" width="170">
@@ -70,8 +70,6 @@ async function submit(): Promise<void> {
try {
await auth.login(form.serverUrl, form.code.trim(), form.password);
await appStore.init(form.serverUrl);
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
await appStore.loadNotificationChannel(auth.classId);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
ElMessage.success(`已进入 ${auth.displayName}`);
await router.replace('/schedule');
@@ -12,7 +12,7 @@ import {
type ScheduleDto,
type ScheduleWeekView,
} from '@classhelper/shared';
import { dashboardApi, scheduleApi } from '../api/index.js';
import { scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
@@ -172,14 +172,9 @@ function remainText(minutes: number): string {
async function loadTerm(): Promise<void> {
// 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周)
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(auth.classId ?? undefined), {
currentWeek: 1,
maxWeek: 20,
});
appStore.currentWeek = result.data.currentWeek;
appStore.maxWeek = result.data.maxWeek;
weekOptions.value = buildWeekOptions(result.data.maxWeek);
if (!week.value || week.value === 1) week.value = result.data.currentWeek;
await appStore.refreshTerm(auth.classId ?? undefined);
weekOptions.value = buildWeekOptions(appStore.maxWeek);
if (!week.value || week.value === 1) week.value = appStore.currentWeek;
}
async function loadSchedules(): Promise<void> {
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage } from 'element-plus';
import { useAuthStore } from '../../stores/auth.js';
import { useRealtimeStore } from '../../stores/realtime.js';
@@ -13,7 +14,7 @@ const auth = useAuthStore();
const realtime = useRealtimeStore();
async function logout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
if (!(await confirmAction('确认退出当前账号?', '退出登录', { type: 'warning' }))) return;
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js';
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage } 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';
@@ -198,9 +199,12 @@ const cacheRows = computed(() =>
);
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
if (
!(await confirmAction('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
}))
)
return;
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage } from 'element-plus';
import {
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
@@ -93,9 +94,12 @@ async function manualSync(): Promise<void> {
}
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
if (
!(await confirmAction('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
}))
)
return;
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
@@ -151,7 +155,7 @@ async function removePhrase(value: string): Promise<void> {
}
async function resetPhrases(): Promise<void> {
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
if (!(await confirmAction('恢复为默认快捷短语?', '恢复默认', { type: 'warning' }))) return;
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
await savePhrases();
ElMessage.success('已恢复默认');
@@ -192,7 +196,7 @@ const updateTargetText = computed(() => {
const pendingUpdateText = computed(() => {
const pending = agent.value?.pendingUpdateVersion ?? '';
if (!pending) return '无';
return `${pending}(已下载,空闲时自动安装)`;
return `${pending}(已下载,等待按服务端指定的时机安装)`;
});
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js';
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage } from 'element-plus';
import {
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
@@ -65,7 +66,7 @@ async function saveIslandAppearance(): Promise<void> {
}
async function resetIslandAppearance(): Promise<void> {
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
if (!(await confirmAction('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' }))) return;
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
await saveIslandAppearance();
}
@@ -1,42 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_CHANNELS,
formatDate,
type ClassIslandNotificationChannel,
} from '@classhelper/shared';
import { classGradeQueryApi, classIslandStatusApi } from '../../api/index.js';
import { useAppStore } from '../../stores/app.js';
import { onMounted, ref } from 'vue';
import { classGradeQueryApi } from '../../api/index.js';
import { useAuthStore } from '../../stores/auth.js';
/**
* 提醒设置:这台教室机器把提醒弹在哪个端(客户端 / ClassIsland / 两端都弹)。
* 值同时落本地配置与班级记录(服务端据此决定是否推送 ClassIsland)。
*/
const appStore = useAppStore();
const auth = useAuthStore();
const channelSaving = ref(false);
/**
* 本班有没有接入 ClassIsland 设备。
*
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
*/
const classIslandConnected = ref(false);
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
const classIslandStatus = ref<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
} | null>(null);
/* ------------------------------------------------------------ 班级端成绩查询状态(只读)
*
* 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关,
@@ -56,43 +22,7 @@ async function loadGradeQuery(): Promise<void> {
}
}
const classIslandStatusText = computed(() => {
const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入';
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
});
const channelOptions = computed(() =>
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
value,
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
disabled: value === 'classisland' && !classIslandConnected.value,
})),
);
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
channelSaving.value = true;
try {
const saved = await appStore.setNotificationChannel(auth.classId, value);
if (saved) ElMessage.success('提醒显示位置已更新');
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
} finally {
channelSaving.value = false;
}
}
onMounted(async () => {
await appStore.loadNotificationChannel(auth.classId);
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
if (auth.classId) {
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
classIslandStatus.value = status;
classIslandConnected.value = status?.connected === true;
}
// 班级端成绩查询开关(存在班级上,与 Web 端共享同一个值)
await loadGradeQuery();
});
onMounted(loadGradeQuery);
</script>
<template>
@@ -100,44 +30,14 @@ onMounted(async () => {
<div class="page-header">
<div>
<h2 class="page-title">提醒</h2>
<p class="page-subtitle">老师发通知 / 叫人时,提醒弹在哪个端</p>
<p class="page-subtitle">通知提醒与成绩查询状态</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>ClassIsland 联动</span></template>
<el-descriptions :column="1" border size="small" class="mb-12">
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
<el-descriptions-item label="插件 / ClassIsland">
{{ classIslandStatus?.pluginVersion ?? '—' }} /
{{ classIslandStatus?.classIslandVersion ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<template #header>通知提醒</template>
<p class="text-muted">
<strong>通知模式</strong>
:老师在 ClassHelper 上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p>
<el-radio-group
:model-value="appStore.notificationChannel"
:disabled="channelSaving"
class="channel-group"
@change="
(value: string | number | boolean | undefined) =>
applyChannel(value as ClassIslandNotificationChannel)
"
>
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
{{ item.label }}
</el-radio>
</el-radio-group>
<p class="text-muted channel-hint">
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected">
<br />
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassHelper 联动」里接入后才能选"只在
ClassIsland 上弹")。
</template>
通知与叫人通过 ClassHelper 客户端和灵动岛提醒。普通通知在上课期间暂存,下课后补弹;紧急通知立即提醒。
</p>
</el-card>
@@ -150,9 +50,7 @@ onMounted(async () => {
打开后,可以在「成绩」页看到本班成绩与排名。学生本身没有账号、不能登录,
查成绩就是由这台教室机器代做。关闭后本机查成绩会被服务端拒绝,教师端不受影响。
</p>
<p class="text-muted">
本开关由管理员在服务端「班级管理」和「客户端设备」里统一设置,教室机器不提供本地开关。
</p>
<p class="text-muted">本开关由管理员在服务端「班级管理」里统一设置,教室机器不提供本地开关。</p>
<el-tag :type="gradeQueryEnabled ? 'success' : 'info'" effect="plain">
{{ gradeQueryEnabled ? '当前:已允许查看成绩与排名' : '当前:已由管理员关闭' }}
</el-tag>
+3 -14
View File
@@ -2,13 +2,7 @@
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
* 渲染进程只依赖这些最小 API,不接触 Node.js。
*/
import type {
ClassIslandNotificationChannel,
IslandAppearance,
IslandNotification,
IslandState,
UpdateInfo,
} from '@classhelper/shared';
import type { IslandAppearance, IslandNotification, IslandState, UpdateInfo } from '@classhelper/shared';
import type { ClientAgentStatus } from '../main/client-agent.js';
/** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */
@@ -45,13 +39,6 @@ export interface DesktopStoredConfig {
* 录入作业时点一下就追加到标题/内容里,少打字(例如 P、大本、背诵)。
*/
homeworkPhrases: string[];
/**
* 通知显示位置(both / client / classisland)。
*
* 这台教室机器"提醒到底弹在哪个端"由它决定:值同时存本地(决定本机要不要弹窗/上岛)
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
*/
notificationChannel: ClassIslandNotificationChannel;
/**
* 是否已看过「初次启动引导」。
*
@@ -170,6 +157,7 @@ export interface DesktopBridge {
/* 灵动岛 */
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
islandRemoveNotification(id: string): void;
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
islandSetClassState(payload: IslandClassStatePayload): void;
/** 读取灵动岛当前状态(设置页/冒烟验证用) */
@@ -255,6 +243,7 @@ declare global {
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
__classhelperSmoke__?: {
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
cacheIsolationSelfTest(): Promise<{ ok: boolean; detail: string }>;
offlineScenario(): Promise<{ ok: boolean; detail: string }>;
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
onlineScenario(credentials?: {