chore(release): 1.3.3 发布并退役旧联动
This commit is contained in:
1 parent
b7cdc7c9ea
commit
c0ebf88138
184 files changed
+5450
-11705
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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?: {
|
||||
|
||||
Reference in new issue
Block a user