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

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

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

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

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

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

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

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

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

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

No files matched your search

+1 -1
View File
@@ -14,6 +14,6 @@ description: |-
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper
version: 1.2.0.0
version: 1.3.0.0
apiVersion: 2.0.0.0
author: ClassHelper
+3 -15
View File
@@ -149,25 +149,14 @@ if (!fs.existsSync(cipxFile)) {
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
// 产物只有 `安装包/*.cipx` + `checksums.md`:**.cipx 本身就是个 zip**,解压即用的那份
// 「免安装目录」与它内容完全一样,纯属重复留档,已不再产出。
const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
const versionDir = path.join(releasesRoot, pluginVersion);
const installerDir = path.join(versionDir, '安装包');
const portableDir = path.join(versionDir, '免安装', 'ClassHelper.ClassIslandPlugin');
fs.rmSync(installerDir, { recursive: true, force: true });
fs.rmSync(path.dirname(portableDir), { recursive: true, force: true });
fs.rmSync(path.join(versionDir, '免安装'), { recursive: true, force: true });
fs.mkdirSync(installerDir, { recursive: true });
fs.mkdirSync(portableDir, { recursive: true });
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
for (const name of fs.readdirSync(outputDir)) {
const from = path.join(outputDir, name);
if (!fs.statSync(from).isFile()) continue;
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
continue;
}
fs.copyFileSync(from, path.join(portableDir, name));
}
for (const file of fs.readdirSync(cipxDir)) {
// .cipx 是插件包本体,校验清单跟着版本目录走
@@ -177,4 +166,3 @@ for (const file of fs.readdirSync(cipxDir)) {
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
for (const file of fs.readdirSync(portableDir)) log(` 免安装/ClassHelper.ClassIslandPlugin/${file}`);
@@ -507,7 +507,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.2.0.0";
private const string PluginVersion = "1.3.0.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion()
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
@@ -14,8 +14,7 @@
"start": "electron .",
"smoke": "node scripts/smoke.mjs",
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
"dist:win": "npm run build && node scripts/dist-win.mjs",
"dist:dir": "npm run build && node scripts/dist-win.mjs --dir"
"dist:win": "npm run build && node scripts/dist-win.mjs"
},
"dependencies": {
"@classhelper/shared": "workspace:*",
+50 -13
View File
@@ -10,12 +10,15 @@
*
* 用法:
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录
*
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
* 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
* 免安装/ electron-builder 的 win-unpacked 内容
* 构建中间/ builder-*.yml、*.nsis.7z
*
* 产物里**没有免安装目录**:electron-builder 的 `win-unpacked/` 只是生成两个 exe 的中间物
* (每版 320MB 上下),打完就删 —— 便携版 `-portable.exe` 已是自解压单文件,交付不需要解包目录。
* 因此也没有 `--dir`:那等于只产出中间物、拿不到任何交付物。需要临时跑一下解包目录时直接调
* electron-builder,别往 releases/ 里塞。
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
*/
@@ -27,9 +30,7 @@ import { resolveNodeRuntime } from '../../../scripts/lib/node-runtime.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(root, '..', '..');
const args = process.argv.slice(2);
const dirOnly = args.includes('--dir');
const passthrough = args.filter((item) => item !== '--dir');
const passthrough = process.argv.slice(2);
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
@@ -74,7 +75,7 @@ const destDir = explicitOutput
: path.join(repoRoot, 'releases', 'client', clientVersion);
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
const cliArgs = [cli, '--win', ...overrides, ...passthrough];
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
// 必须用真正的 node 执行 electron-builder CLI:
@@ -98,7 +99,17 @@ for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
}
}
if (cleaned) console.log('[dist] 已清理上一轮的免安装目录 / 安装包 / 构建中间');
if (cleaned) console.log('[dist] 已清理上一轮的中间物与安装包');
// 打包**之前**就要把输出目录的完整性标签压回 Low(继承),不留到收尾 ——
// 实测过一次:上一轮留下的 win-unpacked 是收尾时用 icacls 标成 Medium 的,
// 而 7za 自身带 Low 标签,pack 时读不动这些 Medium 目录,报
// "cannot open file ... Access is denied",打包直接失败。
try {
execFileSync('icacls', [destDir, '/setintegritylevel', '(OI)(CI)Low'], { stdio: 'pipe', windowsHide: true });
} catch {
/* 宿主目录树本就没有 Low 标签体系时会被系统拒绝,保持继承即可 */
}
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
@@ -138,9 +149,9 @@ function normalizeIntegrityLabel(dir) {
/**
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
* win-unpacked/ → 免安装/
* *.exe / *.blockmap → 安装包/
* *.yml / *.nsis.7z → 构建中间/
* win-unpacked/ → **直接删掉**(见文件头:它只是生成两个 exe 的中间物)
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
*/
@@ -152,11 +163,12 @@ function tidyArtifacts(dir) {
fs.renameSync(from, path.join(to, path.basename(from)));
};
const unpacked = path.join(dir, 'win-unpacked');
const portableDir = path.join(dir, '免安装');
if (fs.existsSync(unpacked)) {
fs.rmSync(portableDir, { recursive: true, force: true });
fs.renameSync(unpacked, portableDir);
// 必须留在最后删:electron-builder 的 portable 目标就是拿它自己解包出来的 app 做自解压包,
// 提前删会让 `-portable.exe` 这一步找不到源文件。
const throwaway = [];
for (const name of ['win-unpacked', '免安装']) {
const from = path.join(dir, name);
if (fs.existsSync(from)) throwaway.push(from);
}
for (const name of fs.readdirSync(dir)) {
@@ -165,6 +177,31 @@ function tidyArtifacts(dir) {
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
}
for (const from of throwaway) {
// 体积必须在删之前量:删完再 walk 只会得到 0
const mb = Math.round(dirSizeMb(from));
try {
fs.rmSync(from, { recursive: true, force: true });
console.log(`[dist] 已删除中间物 ${path.basename(from)}/(约 ${mb} MB)`);
} catch (error) {
console.warn(`[dist] 中间物 ${path.basename(from)}/ 删除失败(不影响交付物):${error.message}`);
}
}
}
/** 交付目录里没有现成的体积统计函数,这里只在打日志时用一次 */
function dirSizeMb(target) {
let total = 0;
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else total += fs.statSync(full).size;
}
};
if (fs.existsSync(target)) walk(target);
return total / 1024 / 1024;
}
child.on('exit', (code) => {
@@ -67,6 +67,15 @@ const isCall = computed(() => kind.value === 'call');
const isHomework = computed(() => kind.value === 'homework');
const queueCount = computed(() => state.value.queued.length);
/**
* 是不是"点了知道了 / 标为已读"之后的**收回动画**阶段(`reason === 'closing'`)。
*
* 这一阶段主进程已经把状态清空(`active` 为 null、`queued` 为空),形态却切回了胶囊:
* 卡片正沿既有的形变路径收回,**胶囊里不该显示任何文字** —— 否则会闪出一句
* "新消息 · 点击查看"(那是"有新消息"的语义,与"正在消失"正好相反)。
*/
const isClosing = computed(() => state.value.reason === 'closing');
/**
* 这一批**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」排序。
* 与主进程 `pendingNotifications()` 同一口径(同一份 `PRIORITY_RANK`)。
@@ -460,6 +469,8 @@ const islandClass = computed(() => ({
call: isCall.value,
homework: isHomework.value,
'after-class': showAfterClassHint.value,
// 收回动画:胶囊里不显示内容(见 isClosing 与 .island-card.closing 的说明)
closing: isClosing.value,
}));
/**
@@ -1048,6 +1059,18 @@ body {
justify-content: flex-end;
}
/**
* 收回动画(`reason === 'closing'`):胶囊收回过程中**不显示任何胶囊内容**。
*
* 这时状态已经在同一帧清空(没有可展示的消息),若照常渲染胶囊层,卡片刚收回就会闪出
* 一句"新消息 · 点击查看" —— 语义正好相反。这里保留 DOM(宽度固定为 `--island-w`,
* 内容盒不会重排,符合"开合时胶囊内容不许重排"的约定)只把内容隐藏,
* 卡片形状照样沿既有弹簧收回,随后由主进程淡出窗口。
*/
.island-card.closing .pill-inner {
opacity: 0;
}
.pill-icon {
display: inline-flex;
align-items: center;
@@ -0,0 +1,304 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { BrowserWindow, app } from 'electron';
import { CLIENT_UPDATE_IDLE_MS, type ClientUpdateInfoDto } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
/**
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
*
* 设计要点(都是"教室机器"这个使用场景逼出来的):
* 1. **下载随时进行、安装只在完全空闲时**:教室机的带宽与注意力都有限,上课期间重启应用是灾难,
* 所以安装的三个必要条件缺一不可 —— 不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)、
* 本进程还没装过;
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
* 3. **安装脚本脱离本进程**:`start /wait` 等安装器跑完后重新拉起应用,
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
*/
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
/** 下载文件名的合法性(服务端给的 fileName 会拼进本地路径,必须挡住路径穿越) */
const SAFE_FILE_NAME = /^[A-Za-z0-9._-]+$/;
/** 待安装更新的目录:`<userData>/updates`(放在 userData 下,随配置一起被卸载/重置清掉) */
export function pendingUpdateDir(): string {
return path.join(app.getPath('userData'), 'updates');
}
/** 主窗口"已隐藏多久"的起点(null = 窗口当前可见或还没建) */
let mainWindowHiddenAt: number | null = null;
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
let installing = false;
/**
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
*
* 为什么用注册而不是直接 `import { quitApp } from './index.js'`:那会形成
* index.ts ⇄ auto-update.ts 的循环导入,ESM 下 `quitApp` 的绑定在求值顺序变化时会变成未初始化引用。
*/
let quitHandler: (() => void) | null = null;
/** 注册"安装前退出本进程"的回调(index.ts 启动时调用一次) */
export function setQuitHandler(handler: () => void): void {
quitHandler = handler;
}
/** 正在下载的版本(同一版本不并发下载) */
const downloading = new Set<string>();
/** 这次运行的主窗口(安装判定要看它可见性;窗口重建后重新挂一遍) */
let hookedWindow: BrowserWindow | null = null;
/**
* 挂主窗口的显示/隐藏监听(index.ts 在 `createWindow()` 之后调用)。
*
* 为什么要"记时间戳"而不是每次现算:`BrowserWindow` 没有"隐藏了多久"这种查询,
* 而「窗口隐藏满 5 分钟」是本功能的核心判据,只能在 hide/show 事件上自己打点。
*/
export function watchMainWindow(win: BrowserWindow): void {
if (hookedWindow === win) return;
hookedWindow = win;
win.on('hide', () => {
if (mainWindowHiddenAt === null) mainWindowHiddenAt = Date.now();
// 刚隐藏正是最合适的安装时机(人已经走了),立即判一次
void maybeInstallPendingUpdate();
});
win.on('show', () => {
mainWindowHiddenAt = null;
});
win.on('closed', () => {
mainWindowHiddenAt = null;
if (hookedWindow === win) hookedWindow = null;
});
if (win.isVisible()) mainWindowHiddenAt = null;
}
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
function mainWindowIdleLongEnough(): boolean {
if (mainWindowHiddenAt === null) return false;
return Date.now() - mainWindowHiddenAt >= CLIENT_UPDATE_IDLE_MS;
}
/** 主窗口是否可见(空闲判定的一部分:可见 ⇒ 有人在用,绝不重启) */
function mainWindowVisible(): boolean {
if (hookedWindow && !hookedWindow.isDestroyed()) return hookedWindow.isVisible();
return BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible());
}
/** 服务端是否要求升级 / 本机是否确实更旧 */
export function shouldConsider(version: string): boolean {
return getConfig().autoUpdate !== false && !!version.trim();
}
/**
* 处理一次服务端下发的更新信息:该下就下、下好了就写进待安装配置。
*
* - `autoUpdate === false`:用户/管理员关掉了自动更新,直接返回(设置页会显示服务端的目标版本,由人工处理);
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
*/
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
const version = targetVersion.trim() || info.version.trim();
if (!shouldConsider(version)) {
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
return;
}
if (downloading.has(version)) {
logger.debug(`更新 ${version} 正在下载中,跳过重复请求`);
return;
}
const config = getConfig();
if (config.pendingUpdateVersion === version) {
const existing = config.pendingUpdateFile;
if (existing && fs.existsSync(existing)) {
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
await maybeInstallPendingUpdate();
return;
}
logger.warn(`更新 ${version} 的安装包已不在磁盘上(${existing || '未记录路径'}),重新下载`);
}
if (!SAFE_FILE_NAME.test(info.fileName)) {
logger.error(`拒绝下载更新:服务端给的文件名不合法(${info.fileName})`);
return;
}
const dir = pendingUpdateDir();
const target = path.join(dir, info.fileName);
const temp = `${target}.part`;
const token = config.token?.trim() ?? '';
if (!token) {
logger.warn(`暂不下载更新 ${version}:尚未登录(没有可用的下载令牌)`);
return;
}
downloading.add(version);
const startedAt = Date.now();
try {
fs.mkdirSync(dir, { recursive: true });
// 先删掉上次中断留下的临时文件
await fsp.rm(temp, { force: true });
const url = `${config.serverUrl}${info.url}`;
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
const response = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`下载失败:HTTP ${response.status}`);
}
if (!response.body) {
throw new Error('下载失败:响应没有内容');
}
// 流式写盘:教室机的安装包几十 MB,不能整个读进内存
const handle = await fsp.open(temp, 'w');
try {
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
await handle.write(chunk);
}
} finally {
await handle.close();
}
const digest = await sha256File(temp);
const expected = info.sha256.trim().toLowerCase();
if (!expected) {
// 服务端没给哈希就没法校验:宁可不装,也不留一个来源不明的安装包在磁盘上
await fsp.rm(temp, { force: true });
logger.error(`更新 ${version} 的安装包没有提供 sha256,已删除(不做信任放行)`);
return;
}
if (digest !== expected) {
await fsp.rm(temp, { force: true });
logger.error(
`更新 ${version} 的安装包校验失败(sha256 不符),已删除:期望 ${expected},实际 ${digest}`,
);
return;
}
await fsp.rm(target, { force: true });
await fsp.rename(temp, target);
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
logger.info(
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
(Date.now() - startedAt) / 1000,
)} 秒),将在空闲时自动安装`,
);
await maybeInstallPendingUpdate();
} catch (error) {
await fsp.rm(temp, { force: true }).catch(() => undefined);
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
downloading.delete(version);
}
}
/** 文件的 sha256(十六进制小写) */
async function sha256File(file: string): Promise<string> {
const hash = crypto.createHash('sha256');
const handle = await fsp.open(file, 'r');
try {
const buffer = Buffer.alloc(1024 * 256);
for (;;) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
if (bytesRead <= 0) break;
hash.update(buffer.subarray(0, bytesRead));
}
} finally {
await handle.close();
}
return hash.digest('hex');
}
/** 人类可读的字节数(日志与界面提示用) */
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '大小未知';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/**
* 如果条件全部满足就装更新。
*
* 条件(缺一不可):非冒烟模式 / 配置里有待安装版本 / 文件还在 / 不在上课 /
* 主窗口已隐藏满 5 分钟 / 本进程没装过。**每次心跳后与主窗口 hide 后都会调用**,
* 所以"老师下课关掉窗口 → 5 分钟后自动装上"就是靠这里触发的。
*/
export async function maybeInstallPendingUpdate(): Promise<void> {
if (installing) return;
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
const config = getConfig();
const version = config.pendingUpdateVersion?.trim() ?? '';
const installer = config.pendingUpdateFile?.trim() ?? '';
if (!version || !installer) return;
if (!fs.existsSync(installer)) {
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
if (island.getState().inClass) {
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
return;
}
if (mainWindowVisible()) {
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
return;
}
if (!mainWindowIdleLongEnough()) {
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
logger.debug(
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
`(需满 ${Math.round(CLIENT_UPDATE_IDLE_MS / 1000)} 秒),暂不安装`,
);
return;
}
if (process.platform !== 'win32') {
// 本项目客户端只发 Windows 版;其它平台(开发机上跑 Linux/macOS)只记日志
logger.warn(
`更新 ${version} 已就绪,但当前平台(${process.platform})不支持静默安装,请手动运行:${installer}`,
);
return;
}
installing = true;
logger.info(`开始静默安装更新 ${version}:${installer}`);
try {
const exe = app.getPath('exe');
const command = `start "" /wait "${installer}" /S && start "" "${exe}"`;
const child = spawn('cmd.exe', ['/c', command], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
// 安装脚本会等安装器结束后重新拉起应用;本进程随即退出(复用既有退出路径,保证配置已落盘)
if (quitHandler) {
quitHandler();
} else {
logger.warn('没有注册退出回调,改为自行退出(配置会在退出前同步落盘)');
app.quit();
}
} catch (error) {
installing = false;
logger.error(
`启动更新安装失败(将在下次空闲时重试):${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** 仅供冒烟/自检使用:当前是否处于"窗口已隐藏满 5 分钟"状态 */
export function isMainWindowIdleLongEnough(): boolean {
return mainWindowIdleLongEnough();
}
@@ -0,0 +1,33 @@
/**
* IPC 通道名集中管理(主进程与 preload 共用同一份字符串)。
*
* **为什么单独一个文件、而且这里一个 import 都不能有**:preload 需要这些字符串,
* 但 preload 在 `sandbox: true` 下运行 —— 沙箱化 preload **不能 require `node:fs` /
* `node:path` / `node:crypto` 这类内建模块**。而 esbuild 会把 preload 的**运行时 import**
* 整条依赖链打进同一个产物:preload 一旦 `import { IPC_CHANNELS } from './ipc.js'`,
* 就会把 `ipc.js` → `client-agent.js` / `config.js` / `logger.js` / `auto-update.js`
* 一起拉进来(它们都要 `node:fs`),于是 preload 在沙箱里**加载即失败**,
* 表现是渲染进程里 `window.desktop === undefined`(冒烟断言「preload contextBridge 注入」直接红,
* 后续所有 `window.desktop.*` 调用抛 `Cannot read properties of undefined`)。
*
* 所以这个文件是**叶子模块**:只有字符串常量,没有任何 import、没有任何副作用。
* 新增通道时加在这里,`main/ipc.ts` 与 `preload/index.ts` 各自 import 它。
*/
export const IPC_CHANNELS = {
getConfig: 'classhelper:config:get',
saveConfig: 'classhelper:config:save',
clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external',
updateCheck: 'classhelper:update:check',
updateIgnore: 'classhelper:update:ignore',
getAutoLaunch: 'classhelper:app:get-autolaunch',
setAutoLaunch: 'classhelper:app:set-autolaunch',
openLogDir: 'classhelper:app:open-log-dir',
openDevTools: 'classhelper:app:open-devtools',
clientStatus: 'classhelper:client:status',
clientBeatNow: 'classhelper:client:beat-now',
} as const;
/** 主进程 → 渲染进程的单向推送:有可用更新(同理,供 preload 与主进程共用) */
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
@@ -0,0 +1,414 @@
import { spawn } from 'node:child_process';
import os from 'node:os';
import { BrowserWindow, app, dialog } from 'electron';
import {
CLIENT_COMMAND_LABELS,
CLIENT_HEARTBEAT_INTERVAL_MS,
isNewerVersion,
type ClientCommandDto,
type ClientCommandKind,
type ClientHeartbeatRequest,
type ClientHeartbeatResponse,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { maybeInstallPendingUpdate, considerUpdate } from './auto-update.js';
import { getConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
/**
* 桌面客户端的服务端联动代理(心跳上报)。
*
* **为什么是"客户端定时拉"而不是服务端推**:教室机的连接经常断(代理、休眠、重启),
* 而「这台机器是什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上 —— 所以统一用
* 「服务端记状态 + 客户端定时上报」的范式(与 ClassIsland 插件的上报同一套路),不引入新的长连接握手。
* 每次上报的响应里同时带回「服务端指定的目标版本 + 待执行命令」。
*
* 三条边界:
* 1. **没有 token 就不上报**:未登录时静默跳过(登录后下一次自然就上报了),
* 不能拿空令牌去打服务端,否则日志里会刷满 401;
* 2. **401/403 退避到 5 分钟**:令牌可能已失效(班级密码被改 / 令牌过期),
* 继续 30 秒一次只是白刷服务端与日志;
* 3. **冒烟模式默认不上报**:每次冒烟都在服务端库里造一条设备记录没有意义,
* 只有显式 `ELECTRON_SMOKE_CLIENT_AGENT=1` 时才照常跑(给需要验证这条链路的用例用)。
*/
/** 启动后第一次上报的延迟:等窗口、登录流程与灵动岛都就绪,也不跟首屏抢带宽 */
const FIRST_BEAT_DELAY_MS = 3_000;
/** 令牌失效(401/403)后的退避间隔:5 分钟一次,直到用户重新登录 */
const UNAUTHORIZED_BACKOFF_MS = 5 * 60_000;
/** 单次上报的超时:教室机网络慢,但也不能挂着不放 */
const HEARTBEAT_TIMEOUT_MS = 10_000;
/**
* 远控命令的「本机二次确认」等待时长:60 秒内没人点确认就按取消处理。
*
* 为什么必须有这个超时:管理员在办公室下发关机,而教室里可能已经没人了 ——
* 一个永远挂着等确认的对话框会让这条命令一直"待执行",既没执行也看不出失败。
*/
const COMMAND_CONFIRM_TIMEOUT_MS = 60_000;
/**
* 「在线」判定窗口:与 shared 的 `CLIENT_ONLINE_WINDOW_MS`(90 秒)同口径 ——
* 连续丢两次心跳(30 秒 × 3)才算离线。这里不复用那个常量是因为它属于**服务端**的判定口径,
* 客户端展示的是"我这边的上报还新不新",单独留一个本地常量更容易看出两边的关系。
*/
const ONLINE_WINDOW_MS = 90_000;
/** 对外暴露的联动状态(设置页「服务端联动状态」与冒烟断言用) */
export interface ClientAgentStatus {
/** 设备标识尾号(服务端设备列表里显示的那串用来对账,完整 UUID 不上界面) */
deviceKeyTail: string;
/** 最近一次上报成功的时刻(ISO 字符串;从未上报过为 null) */
lastHeartbeatAt: string | null;
/** 最近一次失败的原因(空串 = 最近一次是成功的) */
lastError: string;
/** 已下载待安装的版本(空串 = 无) */
pendingUpdateVersion: string;
/** 服务端最近一次要求升到的版本(空串 = 没要求) */
updateTargetVersion: string;
/** 是否接受服务端统一下发的自动更新(配置值,供设置页回显) */
autoUpdate: boolean;
/** 最近 90 秒内上报成功过(与服务端的在线口径一致) */
online: boolean;
}
interface AgentState {
running: boolean;
/** 当前心跳定时器(退避期间与正常节奏共用它) */
timer: ReturnType<typeof setTimeout> | null;
/** 正在上报:避免手动「立即上报」与定时器撞在一起 */
beating: boolean;
lastHeartbeatAt: string | null;
lastError: string;
updateTargetVersion: string;
}
const state: AgentState = {
running: false,
timer: null,
beating: false,
lastHeartbeatAt: null,
lastError: '',
updateTargetVersion: '',
};
/** 设备标识尾号:取后 8 位(足够对账,又不至于把完整 UUID 贴到界面上) */
export function deviceKeyTail(deviceKey: string): string {
const trimmed = deviceKey.trim();
if (!trimmed) return '';
return trimmed.length > 8 ? trimmed.slice(-8) : trimmed;
}
/** 冒烟模式且没有显式打开联动开关时,一律不上报(避免每次冒烟都在库里造设备记录) */
function isAgentSuppressed(): boolean {
return process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_CLIENT_AGENT !== '1';
}
/**
* 上报里的"这台机器现在在干什么"。
*
* 取值就三种(服务端设备列表直接展示这段文字):
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(此时绝不安装更新);
* - `使用中` —— 主窗口可见,人正在用;
* - `待机` —— 窗口隐藏(最小化到托盘),机器空闲。
*/
function currentStateNote(): string {
if (island.getState().inClass) return '上课中';
const win = BrowserWindow.getAllWindows().find((item) => !item.isDestroyed());
return win?.isVisible() ? '使用中' : '待机';
}
/** 已下载待安装的版本;没有则 null(服务端把它记进设备的 `pendingVersion`) */
function pendingVersionOrNull(): string | null {
const pending = getConfig().pendingUpdateVersion.trim();
return pending ? pending : null;
}
/** 清掉待执行的心跳定时器(幂等) */
function clearTimer(): void {
if (state.timer !== null) {
clearTimeout(state.timer);
state.timer = null;
}
}
/** 排下一次上报;`delayMs` 不传时用正常节奏 */
function scheduleNext(delayMs: number = CLIENT_HEARTBEAT_INTERVAL_MS): void {
if (!state.running) return;
clearTimer();
state.timer = setTimeout(() => {
state.timer = null;
void beat();
}, delayMs);
}
/**
* 一次上报(含响应处理)。
*
* 失败一律**不抛**:网络抖动是教室机的常态,只记一行日志并按原节奏重试;
* 只有 401/403 才退避(见文件头说明)。
*/
async function beat(): Promise<void> {
if (state.beating) return;
if (isAgentSuppressed()) {
// 冒烟模式:照原节奏空转(不碰服务端,也不在库里造设备记录)
state.lastError = '';
scheduleNext();
return;
}
const config = getConfig();
const token = config.token?.trim() ?? '';
if (!token) {
// 未登录:不打服务端、不记错误,等下次心跳(登录后自然会带上令牌)
logger.debug('客户端上报:尚未登录(没有令牌),本次跳过');
scheduleNext();
return;
}
state.beating = true;
let backoff = false;
try {
const body: ClientHeartbeatRequest = {
deviceKey: config.deviceKey,
name: os.hostname(),
clientVersion: app.getVersion(),
platform: `${process.platform}-${process.arch}`,
pendingVersion: pendingVersionOrNull(),
stateNote: currentStateNote(),
};
const response = await fetch(`${config.serverUrl}/api/clients/heartbeat`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
});
if (response.status === 401 || response.status === 403) {
// 令牌失效:退避到 5 分钟一次,直到用户重新登录
backoff = true;
state.lastError = `上报被拒绝(HTTP ${response.status}),可能登录已过期,请重新登录`;
logger.warn(`客户端上报:服务端返回 HTTP ${response.status},退避为 5 分钟一次`);
return;
}
if (!response.ok) {
state.lastError = `服务端返回 HTTP ${response.status}`;
logger.warn(`客户端上报失败:HTTP ${response.status}(${HEARTBEAT_INTERVAL_TEXT})`);
return;
}
const payload = (await response.json()) as ClientHeartbeatResponse;
state.lastHeartbeatAt = new Date().toISOString();
state.lastError = '';
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
logger.debug(
`客户端上报成功(设备尾号 ${deviceKeyTail(config.deviceKey)},状态 ${body.stateNote ?? '-'}` +
`${state.updateTargetVersion ? `,服务端要求升到 ${state.updateTargetVersion}` : ''})`,
);
void handleCommand(payload.command);
await handleUpdate(payload.update, payload.updateTargetVersion);
} catch (error) {
// 网络错误 / 超时 / JSON 解析失败:按原节奏重试
state.lastError = error instanceof Error ? error.message : String(error);
logger.warn(`客户端上报失败(将于 ${HEARTBEAT_INTERVAL_TEXT} 后重试):${state.lastError}`);
} finally {
state.beating = false;
// 每次心跳(无论成败)都顺手看一下有没有下载好的更新该装了 —— 上课/窗口可见时会自动跳过
void maybeInstallPendingUpdate();
scheduleNext(backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS);
}
}
const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 1000)} 秒`;
/**
* 服务端下发的远控命令(关机 / 重启 / 注销 / 锁屏)。
*
* 两道护栏缺一不可(用户要求「必须二次确认」):
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
* 2. **同一条命令只弹一次**:它随每次心跳重复下发,用内存集合去重,避免 30 秒弹一个框。
*
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
* 下一次心跳只重试回执、不重复弹窗。
*/
const inFlightCommands = new Set<string>();
const unackedResults = new Map<string, { ok: boolean; result: string }>();
async function handleCommand(command: ClientCommandDto | null): Promise<void> {
if (!command) return;
const label = CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
const expired =
command.expiresAt !== null && Number.isFinite(Date.parse(command.expiresAt))
? Date.parse(command.expiresAt) < Date.now()
: false;
if (expired) {
logger.warn(`收到已过期的远控命令(${label}),已忽略:${command.payload || '无备注'}`);
return;
}
// 处理过但回执没送达:只重试回执
const pending = unackedResults.get(command.id);
if (pending) {
await ackCommand(command.id, pending.ok, pending.result);
return;
}
// 正在等本机确认:不要再弹第二个框
if (inFlightCommands.has(command.id)) return;
inFlightCommands.add(command.id);
try {
const confirmed = await confirmCommand(label, command.payload);
if (!confirmed) {
const result = '本机用户取消(或 60 秒未确认)';
logger.warn(`远控命令「${label}」未执行:${result}`);
await ackCommand(command.id, false, result);
return;
}
const outcome = await executeCommand(command.kind);
logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`);
await ackCommand(command.id, outcome.ok, outcome.result);
} finally {
inFlightCommands.delete(command.id);
}
}
/** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */
async function confirmCommand(label: string, note: string): Promise<boolean> {
const answered = dialog
.showMessageBox({
type: 'warning',
title: '来自管理员的远程指令',
message: `管理员要求本机「${label}」`,
detail: `${note ? `备注:${note}\n` : ''}确认后本机将立即${label};60 秒内未确认视为取消。`,
buttons: ['取消', '确认执行'],
defaultId: 0,
cancelId: 0,
noLink: true,
})
.then((result) => result.response === 1)
.catch(() => false);
return Promise.race([
answered,
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), COMMAND_CONFIRM_TIMEOUT_MS)),
]);
}
/** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */
async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; result: string }> {
if (process.platform !== 'win32') return { ok: false, result: '仅支持 Windows' };
const commands: Record<ClientCommandKind, { file: string; args: string[] }> = {
shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '0'] },
restart: { file: 'shutdown.exe', args: ['/r', '/t', '0'] },
logoff: { file: 'shutdown.exe', args: ['/l'] },
lock: { file: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
};
const plan = commands[kind];
try {
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
return { ok: true, result: '已下达系统命令' };
} catch (error) {
return { ok: false, result: error instanceof Error ? error.message : String(error) };
}
}
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
const config = getConfig();
if (!config.token) return;
try {
const response = await fetch(`${config.serverUrl}/api/clients/commands/${commandId}/ack`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${config.token}` },
body: JSON.stringify({ deviceKey: config.deviceKey, ok, result }),
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
});
if (!response.ok) {
logger.warn(`远控命令回执失败(HTTP ${response.status}),将在下次心跳重试`);
unackedResults.set(commandId, { ok, result });
return;
}
unackedResults.delete(commandId);
logger.info(`远控命令回执已送达(${ok ? '成功' : '失败'}):${result}`);
} catch (error) {
logger.warn(
`远控命令回执失败(${error instanceof Error ? error.message : String(error)}),将在下次心跳重试`,
);
unackedResults.set(commandId, { ok, result });
}
}
/** 响应里的更新信息:服务端指定了目标版本、且该版本确实比本机新,才交给 auto-update */
async function handleUpdate(info: ClientUpdateInfoDto | null, targetVersion: string | null): Promise<void> {
const target = targetVersion?.trim() ?? '';
if (!target) return;
if (!info) {
// 服务端指定了目标版本但还没有这份安装包:只记一行,客户端只能提示手动更新
logger.warn(`服务端要求升到 ${target},但服务端还没有这个版本的安装包(无法自动下载)`);
return;
}
if (!isNewerVersion(app.getVersion(), target)) return;
await considerUpdate(info, target);
}
/** 启动心跳(重复调用无副作用) */
export function startClientAgent(): void {
if (state.running) return;
state.running = true;
logger.info(
isAgentSuppressed()
? '客户端上报代理:冒烟模式下不上报(需要时设 ELECTRON_SMOKE_CLIENT_AGENT=1)'
: `客户端上报代理已启动:${FIRST_BEAT_DELAY_MS / 1000} 秒后首次上报,之后每 ${HEARTBEAT_INTERVAL_TEXT} 一次`,
);
scheduleNext(FIRST_BEAT_DELAY_MS);
}
/** 停止心跳(退出清理调用;幂等) */
export function stopClientAgent(): void {
if (!state.running) return;
state.running = false;
clearTimer();
logger.info('客户端上报代理已停止');
}
/**
* 手动触发一次上报并返回最新状态(设置页「立即上报」按钮)。
* 返回的是**报告之后**的状态,界面直接照它刷新即可。
*/
export async function beatNow(): Promise<ClientAgentStatus> {
if (!state.running) startClientAgent();
if (isAgentSuppressed()) {
logger.info('客户端上报:冒烟模式下跳过手动上报');
return getClientAgentStatus();
}
clearTimer();
await beat();
return getClientAgentStatus();
}
/** 当前联动状态(纯数据,可直接跨 contextBridge 传给渲染进程) */
export function getClientAgentStatus(): ClientAgentStatus {
const config = getConfig();
const lastAt = state.lastHeartbeatAt ? Date.parse(state.lastHeartbeatAt) : Number.NaN;
return {
deviceKeyTail: deviceKeyTail(config.deviceKey),
lastHeartbeatAt: state.lastHeartbeatAt,
lastError: state.lastError,
pendingUpdateVersion: config.pendingUpdateVersion ?? '',
updateTargetVersion: state.updateTargetVersion,
autoUpdate: config.autoUpdate !== false,
online: Number.isFinite(lastAt) && Date.now() - lastAt < ONLINE_WINDOW_MS,
};
}
+118 -2
View File
@@ -1,3 +1,4 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
@@ -44,6 +45,20 @@ interface PersistedConfig {
sidebarCollapsed?: boolean;
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
ignoredUpdateVersion?: string;
/** 本机设备标识(向后兼容:旧配置没有该字段时首次读取生成并落盘,之后永不变) */
deviceKey?: string;
/** 是否开机自启(向后兼容:旧配置没有该字段视为关闭 —— 由用户在设置页自己打开) */
autoLaunch?: boolean;
/** 是否接受服务端统一下发的自动更新(向后兼容:旧配置没有该字段视为开启) */
autoUpdate?: boolean;
/** 开发者模式:日志调到 debug 级 + 设置里多出「开发者」页(向后兼容:旧配置没有该字段视为关闭) */
developerMode?: boolean;
/** 关怀模式:整体放大正文与控件字号,供视力不佳的用户使用(向后兼容:旧配置没有该字段视为关闭) */
careMode?: boolean;
/** 已下载待安装的更新版本(空串 = 无;跨重启保留) */
pendingUpdateVersion?: string;
/** 已下载待安装的安装包绝对路径(空串 = 无;跨重启保留) */
pendingUpdateFile?: string;
}
const DEFAULT_CONFIG: PersistedConfig = {
@@ -59,6 +74,13 @@ const DEFAULT_CONFIG: PersistedConfig = {
theme: 'light',
sidebarCollapsed: false,
ignoredUpdateVersion: '',
deviceKey: '',
autoLaunch: false,
autoUpdate: true,
developerMode: false,
careMode: false,
pendingUpdateVersion: '',
pendingUpdateFile: '',
};
/** 主题白名单:非法值一律回落浅色 */
@@ -203,6 +225,15 @@ function readPersistedFromDisk(): PersistedConfig {
sidebarCollapsed: parsed.sidebarCollapsed === true,
ignoredUpdateVersion:
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
deviceKey: typeof parsed.deviceKey === 'string' ? parsed.deviceKey : '',
autoLaunch: parsed.autoLaunch === true,
// 默认开启:老配置文件里没有这个字段时按"接受服务端统一下发的更新"处理
autoUpdate: parsed.autoUpdate !== false,
developerMode: parsed.developerMode === true,
careMode: parsed.careMode === true,
pendingUpdateVersion:
typeof parsed.pendingUpdateVersion === 'string' ? parsed.pendingUpdateVersion : '',
pendingUpdateFile: typeof parsed.pendingUpdateFile === 'string' ? parsed.pendingUpdateFile : '',
};
} catch {
return { ...DEFAULT_CONFIG };
@@ -356,9 +387,52 @@ function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
theme: normalizeTheme(persisted.theme),
sidebarCollapsed: persisted.sidebarCollapsed === true,
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
deviceKey: ensureDeviceKey(persisted),
autoLaunch: persisted.autoLaunch === true,
autoUpdate: persisted.autoUpdate !== false,
developerMode: persisted.developerMode === true,
careMode: persisted.careMode === true,
pendingUpdateVersion: persisted.pendingUpdateVersion ?? '',
pendingUpdateFile: persisted.pendingUpdateFile ?? '',
};
}
/**
* `crypto.randomUUID()`:Node 18+ / Chromium 里都有,但**不能假设它一定存在**
* (打包后的运行时或某些受限环境里 `crypto.randomUUID` 为 undefined,直接调用会抛)。
* 兜底用时间戳 + 随机字节拼一个同形状的 UUID v4。
*/
function generateDeviceKey(): string {
try {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
} catch {
// 落到下面的兜底
}
const hex = crypto.randomBytes(16).toString('hex');
return (
`${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(12, 15)}` +
`-a${hex.slice(15, 18)}-${hex.slice(18, 30)}`
);
}
/**
* 设备标识:**首次读取时生成并落盘,之后永不变**(它是这台机器在服务端的身份,
* 服务端按 `deviceKey` upsert 设备记录,换了它就等于换了一台机器)。
*
* 为什么放在这里生成:`getConfig()` 是所有读取路径的唯一出口(主进程各处 + 渲染进程 IPC),
* 且此时必然已经过了 `app.setPath('userData', …)`(见 `loadCache()` 的懒初始化说明),
* 写的就是当前 profile 的 config.json。落盘走既有的 120ms 合并窗口,退出时 `flushConfigSync()` 兜底。
*/
function ensureDeviceKey(persisted: PersistedConfig): string {
const existing = typeof persisted.deviceKey === 'string' ? persisted.deviceKey.trim() : '';
if (existing) return existing;
const created = generateDeviceKey();
persisted.deviceKey = created;
scheduleWrite(persisted);
logger.info(`已为本机生成设备标识(服务端据此识别这台电脑):${created}`);
return created;
}
/** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig {
return toDesktopConfig(loadCache());
@@ -407,6 +481,30 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (typeof patch.ignoredUpdateVersion === 'string') {
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
}
if (patch.autoLaunch !== undefined) {
persisted.autoLaunch = patch.autoLaunch === true;
}
if (patch.autoUpdate !== undefined) {
persisted.autoUpdate = patch.autoUpdate === true;
}
if (patch.developerMode !== undefined) {
persisted.developerMode = patch.developerMode === true;
}
if (patch.careMode !== undefined) {
persisted.careMode = patch.careMode === true;
}
// 待安装的更新只由主进程的 auto-update 模块写入;渲染进程传进来也照收(退出登录/重置走 clearConfig)
if (typeof patch.pendingUpdateVersion === 'string') {
persisted.pendingUpdateVersion = patch.pendingUpdateVersion;
}
if (typeof patch.pendingUpdateFile === 'string') {
persisted.pendingUpdateFile = patch.pendingUpdateFile;
}
// deviceKey **不接受外部改写**:它是这台机器在服务端的身份,改了服务端就会当成一台新机器
// (旧记录永远停在上一次心跳、新记录从零开始)。要重置身份只能手工删掉 config.json 里的该字段。
if (typeof patch.deviceKey === 'string' && patch.deviceKey && patch.deviceKey !== persisted.deviceKey) {
logger.warn('忽略对设备标识(deviceKey)的改写:它是这台机器在服务端的身份,不允许变更');
}
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
@@ -419,9 +517,27 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
return toDesktopConfig(state);
}
/** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */
/**
* 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟。
*
* **保留"这台机器"的身份与本地开关**:`deviceKey`(服务端按它识别设备,换了就等于换一台机器)、
* 开机自启 / 自动更新 / 开发者模式 / 关怀模式,以及已下载待安装的更新记录 —— 退出登录不该在服务端
* 多出一台陌生设备,也不该把用户刚打开的开关悄悄关回默认值。
*/
export function clearConfig(): DesktopStoredConfig {
const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null };
const current = cache?.config ?? DEFAULT_CONFIG;
const kept: PersistedConfig = {
...DEFAULT_CONFIG,
deviceKey: current.deviceKey ?? '',
autoLaunch: current.autoLaunch === true,
autoUpdate: current.autoUpdate !== false,
developerMode: current.developerMode === true,
careMode: current.careMode === true,
pendingUpdateVersion: current.pendingUpdateVersion ?? '',
pendingUpdateFile: current.pendingUpdateFile ?? '',
};
ensureDeviceKey(kept);
const state: ConfigCache = { config: kept, token: null };
cache = state;
scheduleWrite(state.config, true);
return toDesktopConfig(state);
@@ -0,0 +1,187 @@
import { app, type BrowserWindow, type RenderProcessGoneDetails } from 'electron';
import { logger } from './logger.js';
/**
* 崩溃自愈(主窗口)。
*
* 教室机的运行环境很不友好:显卡驱动老旧、长期不重启、内存被其它教学软件挤占 ——
* 渲染进程偶发崩溃与无响应是常态。原先这种情况表现为"窗口还在、一片空白、点哪都没反应",
* 只能人工去重启,因此这里补三条自愈路径:
*
* 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**;
* 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消;
* 3. `uncaughtException` / `unhandledRejection` —— 主进程的未捕获错误,
* 记完整堆栈,并且**只有在本机确认空闲时**才重启自己。
*
* 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志):
* **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。
* 另外**冒烟模式下绝不 `app.relaunch()`**:验证实例重启自己会让父进程拿到一个莫名其妙的子进程。
*/
/** 限流:窗口内最多几次 */
const RECOVER_MAX_TIMES = 3;
/** 限流窗口:10 分钟 */
const RECOVER_WINDOW_MS = 10 * 60_000;
/** 界面无响应多久算卡死(Electron 的 `unresponsive` 事件不会再给第二次机会) */
const UNRESPONSIVE_GRACE_MS = 10_000;
export interface CrashGuardOptions {
/** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */
recoverMainWindow: () => void;
/** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */
isIdle: () => boolean;
/** 退出应用(复用既有的退出路径,保证配置落盘) */
quitApp: () => void;
}
const windowRecoverAt: number[] = [];
const restartAt: number[] = [];
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
/** 正在重建:重建过程中窗口又会发 `closed` / `destroyed`,不能再触发一次 */
let recovering = false;
/** 当前主窗口(重建后会换成新实例) */
let mainWindow: BrowserWindow | null = null;
/** install 时拿到的回调集合(事件回调里复用) */
let options: CrashGuardOptions | null = null;
/**
* 安装崩溃自愈(在 `app.whenReady()` 里、主窗口建好之后调用一次)。
*/
export function installCrashGuard(guard: CrashGuardOptions): void {
options = guard;
app.on('render-process-gone', (_event, contents, details: RenderProcessGoneDetails) => {
// 灵动岛窗口有自己的 supervisor(island.ts),这里只管主窗口
if (!isMainWindowContents(contents.id)) return;
logger.error(
`主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`,
);
recoverWindow(`渲染进程退出(${details.reason})`);
});
app.on('child-process-gone', (_event, details) => {
logger.error(
`子进程异常退出:type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`,
);
});
process.on('uncaughtException', (error) => {
logger.error('主进程未捕获异常:', error);
restartIfIdle('uncaughtException');
});
process.on('unhandledRejection', (reason) => {
logger.error('主进程未处理的 Promise 拒绝:', reason);
restartIfIdle('unhandledRejection');
});
}
/**
* 记录当前主窗口并挂上无响应监听。
* `createWindow()` 之后调用;窗口重建后要重新调用一次(新实例、新监听)。
*/
export function watchMainWindow(win: BrowserWindow): void {
mainWindow = win;
win.on('unresponsive', () => {
logger.error('主窗口界面无响应(10 秒内未恢复将重建窗口)');
armUnresponsiveTimer();
});
win.on('responsive', () => {
if (unresponsiveTimer !== null) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
logger.info('主窗口界面已恢复响应,取消重建');
}
});
win.on('closed', () => {
if (mainWindow === win) mainWindow = null;
if (unresponsiveTimer !== null) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
}
});
}
function armUnresponsiveTimer(): void {
if (unresponsiveTimer !== null) clearTimeout(unresponsiveTimer);
unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null;
if (!mainWindow || mainWindow.isDestroyed()) return;
recoverWindow('界面无响应超过 10 秒');
}, UNRESPONSIVE_GRACE_MS);
}
function isMainWindowContents(id: number): boolean {
// 主窗口引用还没建立(启动早期)时按"就是它"处理,避免漏掉重建
if (!mainWindow || mainWindow.isDestroyed()) return true;
return mainWindow.webContents.id === id;
}
/** 静默重建主窗口(限流) */
function recoverWindow(reason: string): void {
const recover = options?.recoverMainWindow;
if (recovering) return;
if (!recover) {
logger.error(`主窗口需要重建(${reason}),但没有可用的重建回调`);
return;
}
const now = Date.now();
while (windowRecoverAt.length > 0 && now - windowRecoverAt[0] > RECOVER_WINDOW_MS) {
windowRecoverAt.shift();
}
if (windowRecoverAt.length >= RECOVER_MAX_TIMES) {
logger.error(
`主窗口在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已重建 ${RECOVER_MAX_TIMES} 次,停止自动重建(${reason})` +
':请检查显卡驱动或教学软件冲突后手动重启应用',
);
return;
}
windowRecoverAt.push(now);
recovering = true;
try {
const win = mainWindow;
if (win && !win.isDestroyed()) {
// 先隐藏再销毁:跟灵动岛的 supervisor 一样,避免留住一帧残影/白屏
win.hide();
}
logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`);
recover();
} catch (error) {
logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
recovering = false;
}
}
/** 主进程崩溃后按需重启自己(限流 + 空闲判定 + 冒烟豁免) */
function restartIfIdle(reason: string): void {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
// 冒烟模式只记日志:验证实例重启自己会污染验证结果
logger.warn(`冒烟模式:不因 ${reason} 重启应用(仅记录日志)`);
return;
}
let idle = false;
try {
idle = options?.isIdle() === true;
} catch (error) {
logger.error(`判断本机是否空闲时出错:${error instanceof Error ? error.message : String(error)}`);
}
if (!idle) {
logger.warn(`本机正在使用中(${reason}),不自动重启应用,以免打断课堂`);
return;
}
const now = Date.now();
while (restartAt.length > 0 && now - restartAt[0] > RECOVER_WINDOW_MS) {
restartAt.shift();
}
if (restartAt.length >= RECOVER_MAX_TIMES) {
logger.error(
`应用在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已自动重启 ${RECOVER_MAX_TIMES} 次,停止自动重启(${reason})`,
);
return;
}
restartAt.push(now);
logger.warn(`本机空闲,因 ${reason} 自动重启应用(本窗口期第 ${restartAt.length} 次)`);
app.relaunch();
options?.quitApp();
}
+103 -18
View File
@@ -1,7 +1,10 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron';
import { setQuitHandler, watchMainWindow as watchAutoUpdateWindow } from './auto-update.js';
import { startClientAgent, stopClientAgent } from './client-agent.js';
import { flushConfigSync, getConfig } from './config.js';
import { installCrashGuard, watchMainWindow as watchCrashGuardWindow } from './crash-guard.js';
import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js';
@@ -16,6 +19,13 @@ const currentDir = __dirname;
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
/**
* 这次启动是否需要"直接进托盘、不弹窗"——开机自启专用(见 `applyAutoLaunch()`)。
* 判定依据是我们自己在登录项里注册的命令行参数 `--hidden`(Electron 44 的
* `setLoginItemSettings` 在 Windows 上**没有** `openAsHidden` 这个开关,只能自己带参数)。
*/
const startHidden = !isSmokeTest && process.argv.includes('--hidden');
// 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁,
// 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。
const smokeProfile = process.env.ELECTRON_SMOKE_PROFILE;
@@ -50,7 +60,7 @@ function isAllowedNavigation(url: string): boolean {
}
}
function createWindow(): BrowserWindow {
function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
const theme = getConfig().theme;
const win = new BrowserWindow({
@@ -61,8 +71,8 @@ function createWindow(): BrowserWindow {
minHeight: 540,
center: true,
title: 'ClassHelper',
// 冒烟验证时不弹窗,避免打扰使用者
show: !isSmokeTest,
// 冒烟验证时不弹窗,避免打扰使用者;开机自启(--hidden)时直接进托盘
show: !isSmokeTest && options.hidden !== true,
autoHideMenuBar: true,
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: {
@@ -87,12 +97,34 @@ function createWindow(): BrowserWindow {
});
win.on('closed', () => {
mainWindow = null;
if (mainWindow === win) mainWindow = null;
});
// 崩溃自愈与「空闲安装更新」都要盯着主窗口:窗口重建后换的是新实例,因此每次建窗都重挂一次
watchCrashGuardWindow(win);
watchAutoUpdateWindow(win);
return win;
}
/**
* 取当前主窗口,必要时重建。
*
* 与 `createWindow()` 的区别:这里会把"窗口已经被崩溃自愈销毁 / 还没建"的情况一并处理掉
* (托盘、二次启动、`activate` 都通过它拿窗口),因此调用方拿到的要么是活着的窗口、要么是 null。
*/
function ensureMainWindow(options: { hidden?: boolean } = {}): BrowserWindow | null {
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
try {
mainWindow = createWindow(options);
void loadRenderer(mainWindow);
} catch (error) {
logger.error(`创建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
mainWindow = null;
}
return mainWindow;
}
async function loadRenderer(win: BrowserWindow): Promise<void> {
if (devServerUrl) {
await win.loadURL(devServerUrl);
@@ -103,24 +135,74 @@ async function loadRenderer(win: BrowserWindow): Promise<void> {
await win.loadFile(path.join(currentDir, '../renderer/index.html'));
}
/**
* 按配置里的 `autoLaunch` 应用「开机自启」。
*
* 两个实测过的坑:
* 1. Electron 44 的 `setLoginItemSettings` 在 **Windows 上没有 `openAsHidden`**
* (那是旧的 macOS 选项),所以"登录后不弹主窗口"只能自己带参数 —— 启动时注册 `--hidden`,
* `createWindow()` 看到它就把窗口建成隐藏态(见 `startHidden`);
* 2. **冒烟验证绝不写登录项**:那会改开发机真实的注册表启动项,属于"验证污染环境"。
*/
function applyAutoLaunch(): void {
if (isSmokeTest) return;
const enabled = getConfig().autoLaunch === true;
try {
app.setLoginItemSettings({
openAtLogin: enabled,
path: process.execPath,
args: enabled ? ['--hidden'] : [],
});
logger.info(enabled ? '已开启开机自启(登录 Windows 后静默启动,不弹主窗口)' : '开机自启未开启');
} catch (error) {
logger.warn(
`设置开机自启失败(不影响主功能,可在设置页重试):${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
// 单实例:重复启动时聚焦已有窗口
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
showMainWindow();
});
app.whenReady().then(async () => {
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
logger.setVerbose(getConfig().developerMode === true);
registerIpcHandlers();
registerIslandIpc();
mainWindow = createWindow();
// 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
applyAutoLaunch();
mainWindow = createWindow({ hidden: startHidden });
await loadRenderer(mainWindow);
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
installCrashGuard({
recoverMainWindow: () => {
const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
if (alive) {
alive.hide();
alive.destroy();
}
mainWindow = ensureMainWindow();
},
isIdle: () =>
island.getState().inClass === false &&
mainWindow?.isVisible() !== true &&
!BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible()),
quitApp: () => quitApp(),
});
// 空闲安装更新:先把手动退出的回调交给 auto-update(它不能再 import 本文件,否则循环导入)
setQuitHandler(() => quitApp());
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
try {
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
@@ -167,6 +249,9 @@ if (!gotLock) {
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
startClientAgent();
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
@@ -177,15 +262,13 @@ if (!gotLock) {
}
});
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动 / 灵动岛"打开应用") */
function showMainWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
mainWindow = createWindow();
void loadRenderer(mainWindow);
}
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
const win = ensureMainWindow();
if (!win) return;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
}
/**
@@ -194,6 +277,8 @@ if (!gotLock) {
*/
function shutdownResources(): string {
const done: string[] = [];
// 上报代理先停:否则退出过程中它还会再排一次心跳(幂等,重复调用无副作用)
stopClientAgent();
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
@@ -243,8 +328,8 @@ if (!gotLock) {
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
await loadRenderer(mainWindow);
const win = ensureMainWindow();
if (win) await loadRenderer(win);
}
});
}
+85 -11
View File
@@ -1,19 +1,15 @@
import { app, ipcMain, shell } from 'electron';
import { app, BrowserWindow, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared';
import { IPC_CHANNELS } from './channels.js';
import { beatNow, getClientAgentStatus } from './client-agent.js';
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
import { logger } from './logger.js';
import { checkForUpdates, ignoreUpdateVersion } from './update.js';
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
export const IPC_CHANNELS = {
getConfig: 'classhelper:config:get',
saveConfig: 'classhelper:config:save',
clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external',
updateCheck: 'classhelper:update:check',
updateIgnore: 'classhelper:update:ignore',
} as const;
// 通道名定义搬到了叶子模块 `channels.ts`(原因见那里的注释:preload 在沙箱里不能碰 node 内建模块);
// 这里原样再导出,保持既有 `import { IPC_CHANNELS } from './ipc.js'` 的调用点不变。
export { IPC_CHANNELS };
export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
@@ -58,6 +54,84 @@ export function registerIpcHandlers(): void {
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
if (typeof version === 'string') ignoreUpdateVersion(version);
});
/** 开机自启:读的是系统登录项,不是配置文件里的值(两者可能被外部改过) */
ipcMain.handle(IPC_CHANNELS.getAutoLaunch, (): boolean => {
try {
return app.getLoginItemSettings().openAtLogin === true;
} catch {
return getConfig().autoLaunch === true;
}
});
/**
* 开机自启:写配置 + 同步系统登录项。
*
* 与 `main/index.ts` 的 `applyAutoLaunch()` 同一套参数(`--hidden` 是"登录后不弹主窗口"的实现方式,
* 因为 Electron 44 在 Windows 上没有 `openAsHidden`),返回**系统实际生效**的值。
*/
ipcMain.handle(IPC_CHANNELS.setAutoLaunch, (_event, enabled: boolean): boolean => {
const wanted = enabled === true;
saveConfig({ autoLaunch: wanted });
try {
app.setLoginItemSettings({
openAtLogin: wanted,
path: process.execPath,
args: wanted ? ['--hidden'] : [],
});
return app.getLoginItemSettings().openAtLogin === true;
} catch (error) {
logger.warn(
`写入开机自启失败(配置已保存,下次启动会重试):${
error instanceof Error ? error.message : String(error)
}`,
);
return wanted;
}
});
/** 打开日志目录(设置页「打开日志目录」;日志文件是 main.log,见 main/logger.ts) */
ipcMain.handle(IPC_CHANNELS.openLogDir, async (): Promise<boolean> => {
try {
const message = await shell.openPath(app.getPath('logs'));
if (message) {
logger.warn(`打开日志目录失败:${message}`);
return false;
}
return true;
} catch (error) {
logger.warn(`打开日志目录失败:${error instanceof Error ? error.message : String(error)}`);
return false;
}
});
/**
* 打开开发者工具(「开发者」页的按钮;detached 独立窗口)。
*
* 两处刻意的限制:
* - **冒烟模式下直接拒绝**。冒烟验证会给窗口注入合成事件、测量侧栏几何、整屏截图取样;
* 多出一个 DevTools 窗口会抢焦点、并且 detached 窗口会改变 `capturePage()` 的取样范围,
* 把既有断言打乱。返回 false 让页面提示"冒烟模式下不可用"。
* - 窗口取自 `event.sender` 而不是模块级变量:IPC 只可能由主窗口(或无关的岛窗口)发起,
* 按发送方取窗口既不需要在 ipc.ts 里持有 mainWindow 引用(避免与 main/index.ts 循环依赖),
* 也保证打开的是**发起调用的那个窗口**的 DevTools。
*/
ipcMain.handle(IPC_CHANNELS.openDevTools, (event): boolean => {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
logger.warn('冒烟验证模式下不允许打开开发者工具(会打乱焦点与截图断言)');
return false;
}
const target = BrowserWindow.fromWebContents(event.sender);
if (!target || target.isDestroyed()) return false;
target.webContents.openDevTools({ mode: 'detach' });
return true;
});
/** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
/** 立即上报一次(设置页「立即上报」按钮) */
ipcMain.handle(IPC_CHANNELS.clientBeatNow, () => beatNow());
}
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
+79 -13
View File
@@ -101,6 +101,15 @@ let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 };
*/
let BBOX_SIZE = { width: 0, height: 0 };
/**
* 「收回动画」时长(毫秒)。
*
* 用户点了「知道了 / 标为已读」之后,主进程把形态切回胶囊、让渲染进程沿**既有的形变路径**
* 把卡片收回去,到点再淡出隐藏。取值与渲染进程的形变弹簧(约 300~500ms)同量级;
* 外观设置里的"动画速度"会同比缩放它(见 `beginClose()`)。
*/
const CLOSE_ANIMATION_MS = 360;
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
const TIMEOUTS = {
pill: 15_000,
@@ -150,6 +159,11 @@ class IslandController {
private ready = false;
private pendingState: IslandState | null = null;
private collapseTimer: ReturnType<typeof setTimeout> | null = null;
/**
* 「收回动画」定时器:点知道了 / 标为已读之后,延时把窗口淡出隐藏。
* 任何让岛重新可见的路径(`activate()` 等)都会经 `clearTimers()` 取消它。
*/
private closeTimer: ReturnType<typeof setTimeout> | null = null;
/** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */
private boundsToken = 0;
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
@@ -786,6 +800,48 @@ class IslandController {
this.setState({ mode: 'hidden', reason: null });
return;
}
this.beginClose();
}
/**
* 收回动画:把形态切回胶囊,让渲染进程沿**既有的胶囊形变路径**把卡片收回去,
* `CLOSE_ANIMATION_MS` 之后再淡出隐藏。
*
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
* (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
*/
private beginClose(): void {
// 关掉动画(含"减少动态效果"):保持原行为,直接淡出
if (!this.appearance.animations) {
this.finishClose();
return;
}
/**
* **必须 `skipWindow`**:收回动画是"卡片沿既有的胶囊形变路径缩回去",
* 而窗口此刻还是展开/列表态的大包围盒 —— 若这里同步缩窗,卡片会在动画的 360ms 里
* 被窗口裁掉(冒烟用例「整批关闭过程中卡片不被窗口裁切」实测最大越界 554.9px,
* 用户看到的是一瞬间的硬裁边)。窗口要变小一律等**隐藏之后**由 `syncWindow()` 做,
* 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md §5 第 30 条)是同一条纪律。
*/
this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true });
const speed = clamp(this.appearance.speed, 0.5, 2, 1);
this.closeTimer = setTimeout(() => {
this.closeTimer = null;
this.finishClose();
}, Math.round(CLOSE_ANIMATION_MS / speed));
}
/**
* 收回动画收尾:淡出窗口并把状态落成 hidden —— 与原来的 `hide()` 完全一致,
* 所以"窗口最终会隐藏"的既有断言(含轮询写法)不受影响。
*/
private finishClose(): void {
// 动画期间又来了新消息 / 被显式唤起:这次收回归属于旧的一批,放弃收尾
if (this.state.mode !== 'pill' || this.state.reason !== 'closing') return;
if (this.state.active || this.state.queued.length > 0) return;
this.fadeOut();
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
}
@@ -908,6 +964,11 @@ class IslandController {
clearTimeout(this.collapseTimer);
this.collapseTimer = null;
}
// 收回动画定时器:岛一旦被重新唤起(新消息/显式展开/预览),必须取消这次收尾
if (this.closeTimer) {
clearTimeout(this.closeTimer);
this.closeTimer = null;
}
if (this.previewTimer) {
clearTimeout(this.previewTimer);
this.previewTimer = null;
@@ -1690,21 +1751,26 @@ export function getSliverSize(): { width: number; height: number } {
return { ...SLIVER_SIZE };
}
/**
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
*
* 注意 `Math.max(min, NaN)` 仍是 NaN、`Math.min(max, NaN)` 也是 NaN —— 也就是说"只夹紧"
* 并不能防住 NaN。而 `setAppearance()` 的入参来自 IPC(`island:set-appearance` 只判了 `if (patch)`),
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
*
* 提到模块作用域:收回动画(`beginClose()`)也要用它按 `appearance.speed` 折算时长,
* 原先它只定义在 `normalizeAppearance()` 内部,`beginClose()` 引用不到(编译报
* `TS2304: Cannot find name 'clamp'`)。
*/
const clamp = (value: number, min: number, max: number, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
};
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
/**
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
*
* 注意 `Math.max(min, NaN)` 仍是 NaN、`Math.min(max, NaN)` 也是 NaN —— 也就是说"只夹紧"
* 并不能防住 NaN。而 `setAppearance()` 的入参来自 IPC(`island:set-appearance` 只判了 `if (patch)`),
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
*/
const clamp = (value: number, min: number, max: number, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
};
const fallbackOf = DEFAULT_ISLAND_APPEARANCE;
return {
height: clamp(input.height, 36, 72, fallbackOf.height),
+106 -5
View File
@@ -1,25 +1,126 @@
import fs from 'node:fs';
import path from 'node:path';
import { app } from 'electron';
/**
* 主进程日志(控制台输出)。
* 主进程日志:控制台输出 + 落盘。
*
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
* - 主窗口右上角的连接状态 / 灵动岛状态
* - **本文件写出的日志文件**(设置页「打开日志目录」直达)
* 对外可见,避免"出了问题却看不到日志"。
*
* 两条实现约束:
* 1. `app.getPath('logs')` **不能在 app ready 之前调用**,所以日志文件路径是**懒解析**的:
* 解析失败(或还没 ready)时只走 console,之后任意一次写入会重新尝试;
* 2. 单文件超过上限就改名成 `main.log.1`(只留一份旧的,更老的直接删除),
* 避免长期运行的教室机器把磁盘写满。
*/
/** 单文件大小上限:超过就轮转(1MB 足够放下很多次崩溃堆栈) */
const LOG_MAX_BYTES = 1024 * 1024;
/** 轮转后的旧文件名 */
const LOG_ROTATED_NAME = 'main.log.1';
/** 日志文件名(在 app ready 之后解析;null = 暂不可用) */
let filePath: string | null = null;
/** verbose(= 配置里的 developerMode)时 debug 级别才输出 */
let verbose = false;
/** 解析日志文件路径:失败只返回 null(不抛),调用方继续走 console */
function resolveLogFile(): string | null {
try {
return path.join(app.getPath('logs'), 'main.log');
} catch {
// app ready 之前调用会抛;此时不写文件即可,控制台输出不受影响
return null;
}
}
/** 文件超过上限就轮转:main.log → main.log.1(覆盖上一份旧的) */
function rotateIfNeeded(target: string): void {
try {
const stat = fs.statSync(target);
if (stat.size <= LOG_MAX_BYTES) return;
const rotated = path.join(path.dirname(target), LOG_ROTATED_NAME);
// 先删旧的再改名:只保留一份历史日志
fs.rmSync(rotated, { force: true });
fs.renameSync(target, rotated);
} catch {
// 文件还不存在(第一次写)/ 被占用:都无需处理
}
}
/** 落盘一行。任何失败都静默忽略 —— 日志不能反过来把主进程搞崩 */
function write(line: string): void {
if (!filePath) filePath = resolveLogFile();
const target = filePath;
if (!target) return;
try {
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.appendFileSync(target, `${line}\n`, 'utf8');
rotateIfNeeded(target);
} catch {
// 忽略:磁盘满 / 权限不足等都不该影响应用运行
}
}
function timestamp(): string {
return new Date().toISOString();
}
export const logger = {
/**
* 设置日志级别(由 `main/index.ts` 按 `config.developerMode` 调用)。
* 开启后 debug 级别的日志才会同时进控制台与日志文件。
*/
setVerbose(enabled: boolean): void {
verbose = enabled === true;
},
/** 当前是否 verbose(供设置页/冒烟断言) */
isVerbose(): boolean {
return verbose;
},
debug(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] DEBUG ${message}`, ...args);
if (!verbose) return;
const line = `[${timestamp()}] DEBUG ${message}`;
console.log(line, ...args);
write(`${line}${formatArgs(args)}`);
},
info(message: string, ...args: unknown[]): void {
console.log(`[${timestamp()}] INFO ${message}`, ...args);
const line = `[${timestamp()}] INFO ${message}`;
console.log(line, ...args);
write(`${line}${formatArgs(args)}`);
},
warn(message: string, ...args: unknown[]): void {
console.warn(`[${timestamp()}] WARN ${message}`, ...args);
const line = `[${timestamp()}] WARN ${message}`;
console.warn(line, ...args);
write(`${line}${formatArgs(args)}`);
},
error(message: string, ...args: unknown[]): void {
console.error(`[${timestamp()}] ERROR ${message}`, ...args);
const line = `[${timestamp()}] ERROR ${message}`;
console.error(line, ...args);
write(`${line}${formatArgs(args)}`);
},
};
/**
* 额外参数转成一行文本(落盘用)。
*
* 崩溃自愈那边传进来的常常是 Error 对象,**必须把完整堆栈写进文件**,
* 否则事后再看 `main.log` 只有一个光秃秃的 message,等于没记。
*/
function formatArgs(args: unknown[]): string {
if (args.length === 0) return '';
const parts = args.map((item) => {
if (item instanceof Error) return item.stack ?? `${item.name}: ${item.message}`;
if (typeof item === 'string') return item;
try {
return JSON.stringify(item);
} catch {
return String(item);
}
});
return ` ${parts.join(' ')}`;
}
+33
View File
@@ -2029,6 +2029,39 @@ async function runIslandChecks(
);
await sleep(120);
const duringClose = { state: island.getState() };
/**
* 收回动画(用户要求:"点知道了 / 标为已读时也要有回收动画")。
*
* 主进程收到 dismiss / dismiss-all / mark-read / mark-all-read 之后:状态**同一帧就清空**,
* 形态先切回胶囊(`reason === 'closing'`)让渲染进程把卡片收回去,`CLOSE_ANIMATION_MS`(360ms,
* 会按外观里的"动画速度"缩放)之后才淡出隐藏。这里在点击后的小窗口内轮询采样,
* 断言"确实经过了这个阶段、且动画期间窗口仍然可见"。
*/
let closingSample: { mode: string; reason: string | null; visible: boolean } | null = null;
for (let attempt = 0; attempt < 20 && !closingSample; attempt += 1) {
const probe = island.getState();
if (probe.reason === 'closing') {
closingSample = {
mode: probe.mode,
reason: probe.reason,
visible: islandWindow?.isVisible() ?? false,
};
break;
}
await sleep(25);
}
record(
'点「知道了」有收回动画(先收回胶囊再淡出,动画期间窗口可见)',
closingSample !== null &&
closingSample.mode === 'pill' &&
closingSample.visible &&
duringClose.state.active === null,
closingSample
? `采样=${closingSample.mode}/${closingSample.reason}/可见:${closingSample.visible}`
: '未捕捉到 closing 阶段(可能动画被关掉或耗时过短)',
);
for (let attempt = 0; attempt < 26; attempt += 1) {
await probeClosingClip();
await sleep(40);
+3 -2
View File
@@ -10,6 +10,7 @@ import {
type UpdateAssetDto,
type UpdateInfo,
} from '@classhelper/shared';
import { UPDATE_AVAILABLE_CHANNEL } from './channels.js';
import { getConfig, saveConfig } from './config.js';
import { logger } from './logger.js';
@@ -25,8 +26,8 @@ import { logger } from './logger.js';
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
*/
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示);定义在叶子模块 `channels.ts` */
export { UPDATE_AVAILABLE_CHANNEL };
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
const STARTUP_DELAY_MS = 5_000;
+11 -2
View File
@@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../main/ipc.js';
import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js';
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
import type { ClientAgentStatus } from '../main/client-agent.js';
import type {
DesktopAppInfo,
DesktopBridge,
@@ -66,6 +66,15 @@ const bridge: DesktopBridge = {
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
},
/* 开机自启 / 日志 / 服务端联动 */
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.getAutoLaunch),
setAutoLaunch: (enabled: boolean): Promise<boolean> =>
ipcRenderer.invoke(IPC_CHANNELS.setAutoLaunch, enabled === true),
openLogDir: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openLogDir),
openDevTools: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openDevTools),
getClientAgentStatus: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientStatus),
beatClientNow: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientBeatNow),
};
contextBridge.exposeInMainWorld('desktop', bridge);
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
import { ElButton, ElNotification } from 'element-plus';
import type { UpdateInfo } from '@classhelper/shared';
import {
replayMissedNotifications,
startIslandBridge,
stopIslandBridge,
subscribeIslandMarkAllRead,
@@ -72,6 +73,20 @@ function showUpdateAvailable(info: UpdateInfo): void {
});
}
/**
* 上线补弹「离线期间收到的消息」(需求 11)。
*
* **为什么订阅放在 App.vue**:与 `onUpdateAvailable` 同一条理由 —— App.vue 整个应用生命周期
* 只挂载一次,而 `ClientLayout` 会随"登出 → 登录"反复挂载。而且恢复事件本身是"可达性由不可达
* 变为可达"的那一刻,一次会话里可能发生多次,处理器必须是**幂等**的(`replayMissedNotifications`
* 推完就清掉离线起点,重复触发时第二次读到空值、直接返回 0)。
*
* 处理器是具名函数:`stores/app.ts` 的订阅表按函数引用 off,匿名箭头函数在 onUnmounted 里摘不掉。
*/
function handleServerRecovered(): void {
void replayMissedNotifications();
}
/**
* 启动流程:
* 1) 从主进程配置恢复服务器地址与登录令牌
@@ -107,6 +122,9 @@ onMounted(async () => {
subscribeIslandMarkRead();
subscribeIslandMarkAllRead();
// 离线期间收到的消息:可达性恢复的那一刻补弹到灵动岛(App.vue 常驻,所以每次恢复只跑一次)
appStore.onServerRecovered(handleServerRecovered);
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
@@ -129,6 +147,7 @@ onMounted(async () => {
});
onUnmounted(() => {
appStore.offServerRecovered(handleServerRecovered);
stopIslandBridge();
});
</script>
@@ -1048,6 +1048,15 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
//
// ⚠️ 量之前必须等页脚真的收成 0 高:宽度到 64px 与页脚收拢各有一条 260ms 过渡,
// 而 `--nav-ease`(Material 曲线)在 74% 处就越过"宽度 ≤ 80px"这个等待条件,
// 此时页脚还剩十几像素 —— 直接量 `.menu` 会读出"溢出 16px"的**假红**
// (实测出现过一次:同一份产物上一次全绿、下一次红)。这里等的是**稳态几何**,
// 与这条断言本来的意图一致(CSS 里那句"量到的是稳态")。
const railFooter = document.querySelector<HTMLElement>('.aside-footer');
await waitUntil(() => (railFooter?.offsetHeight ?? 0) <= 1, 2000);
const settingsItem = Array.from(
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
).find((element) =>
@@ -6,9 +6,9 @@ import {
type NotificationDto,
type ScheduleDto,
} from '@classhelper/shared';
import { scheduleApi } from '../api/index.js';
import { notificationApi, scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js';
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
@@ -190,6 +190,81 @@ export function pushCallToIsland(call: NotificationDto): void {
});
}
/* ------------------------------------------------------------ 补弹离线期间收到的消息(需求 11) */
/**
* 补弹条数上限。
*
* 灵动岛的多条通知列表默认只显示 `ISLAND_LIST_METRICS.defaultVisibleRows`(3)条 +
* 「展开更多(还有 N 条)」。所以一条不补、和一次补十几条,都是同样的阅读体验 ——
* 封顶 5 条 = 3 条可见 + 2 条藏在"展开更多"里,正好是打开就能读完的量;
* 补太多只会让"展开更多"变成一个没有意义的按钮。
*/
const REPLAY_MAX_COUNT = 5;
/**
* 上线后补弹「离线期间收到的消息」(需求 11)。
*
* 调用时机:`App.vue` 订阅 `useAppStore().onServerRecovered`,也就是**可达性由不可达变为可达**
* 的那一刻(断网期间的实时通道是断的,那些通知既没弹过、也没进过用户的视野)。
*
* 数据来源复用既有的通知接口(`notificationApi.list({ unreadOnly: true })`):
* 客户端本来就是离线优先的(`fetchWithCache` 会把数据落 IndexedDB),**不为此新建数据源** ——
* 补弹只需要"服务端现在有哪些未读",而通知列表本来就是这个问题的答案。
*
* 实现上有四条约定:
* 1. **没有离线起点就直接返回 0**(首次启动 / 一直在线):离线起点只由
* `stores/app.ts` 在"由可达变不可达"的那一刻写入,没有它就说明这段时间根本没断过网,
* 此时补弹等于把**历史未读**全弹一遍,那不是「离线期间收到的」。
* 2. **只补离线起点之后创建的**(`createdAt > offlineSince`),并且在服务端排序之外再排一次
* **按时间升序**投递 —— 顺序投递才能让灵动岛的队列保持时间顺序。
* 3. **取最近的 5 条**(升序列表的尾部):断网一天可能积压几十条,取最早的那 5 条等于
* 让用户先看最旧的、最新的反而被"展开更多"挡住。取最近的 5 条更符合"我刚错过了什么"。
* 4. **推完就清掉离线起点**:同一次离线只补一次,否则每次可达性抖动都会重复弹同一批。
*
* @returns 实际补弹的条数(0 = 没补)
*/
export async function replayMissedNotifications(): Promise<number> {
// 冒烟保护:`verify:desktop` / `verify:packaged` 有一条「灵动岛队列」的断言链
// (100+ 条断言量的是卡片的条数、顺序、高度与"展开更多"的分页),
// 冒烟脚本会反复断开/恢复网络,注入的合成通知与这里的补弹会互相打乱队列,
// 让那些断言随机变红。冒烟模式下**直接跳过补弹**(也不动离线起点),
// 补弹本身由人工/单元路径覆盖,不进冒烟。
if (window.desktop?.smokeTest === true) return 0;
const offlineSince = readOfflineSince();
if (!offlineSince) return 0;
const sinceMs = Date.parse(offlineSince);
if (Number.isNaN(sinceMs)) {
// 存储里是坏值(手工改过 / 旧版本写入格式不同):清掉,别让它永远挡住补弹
clearOfflineSince();
return 0;
}
let unread: NotificationDto[];
try {
unread = await notificationApi.list({ unreadOnly: true });
} catch {
// 刚恢复就又断了(或服务端还没真正就绪):保留离线起点,等下一次恢复再补
return 0;
}
const missed = unread
.filter((item) => {
const at = Date.parse(item.createdAt);
return !Number.isNaN(at) && at > sinceMs;
})
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
const picked = missed.slice(-REPLAY_MAX_COUNT);
for (const item of picked) pushNotificationToIsland(item);
// 无论补到几条都清掉:这一轮离线已经被处理过了
clearOfflineSince();
console.info(`[离线补弹] 补弹离线消息 ${picked.length} 条(离线起点 ${offlineSince})`);
return picked.length;
}
/**
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
* 由 App.vue 在挂载时订阅一次。
@@ -25,9 +25,9 @@ const ui = useUiStore();
*
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨,
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由;
* 开发者模式下再多一个「开发者」)。6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。
* 折叠后是图标导轨,分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
*
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
*/
@@ -38,20 +38,38 @@ const primaryItems = [
{ path: '/grades', title: '成绩', icon: 'Trophy' },
];
const menuGroups = [
/** 「设置」分组的常规子项(顺序即侧栏顺序) */
const settingItems = [
{ path: '/settings/general', title: '通用', icon: 'Tools' },
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
];
/**
* 菜单分组(**computed**:子项随开发者模式变化,见下)。
*
* 「开发者」(需求 10)只在 `ui.developerMode === true` 时出现 —— 它是个排查问题的入口
* (版本/日志/更新/DevTools),常驻只会让教室机器上的使用者点进一个看不懂的页面。
*
* 图标用 `Connection`:新增图标必须同时进 `renderer/icons.ts` 的注册表,
* 而那是本任务范围外的文件 —— 所以**只用已注册图标**(`scripts/check-icons.mjs`
* 会在构建前拦下未注册的名字)。
*
* 折叠成图标导轨后多一行图标:开启开发者模式是用户自己的选择,默认关闭 ⇒
* 冒烟(默认配置)里的导轨几何与"不出现滚动条"断言不受影响。
*/
const menuGroups = computed(() => [
{
title: '设置',
icon: 'Setting',
items: [
{ path: '/settings/general', title: '通用', icon: 'Tools' },
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
],
items: ui.developerMode
? [...settingItems, { path: '/settings/developer', title: '开发者', icon: 'Connection' }]
: settingItems,
},
];
]);
/* -------------------------------------------------------------- 导航指示器 */
@@ -70,18 +88,46 @@ const menuGroups = [
*/
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
/**
* 底片的**裁剪框**:与 `.menu` 的滚动视口逐像素重合。
*
* 底片挂在 `.aside` 上,而菜单项是在 `.menu` 里滚动的(`.menu` 是 `flex:1 + min-height:0 +
* overflow-y:auto`,设置组展开时内容约 606px 会超过窗口高度)。激活项被滚出视口时,
* 底片会跟着它跑到品牌区或页脚上 —— 套一层视口大小的 `overflow:hidden`,
* 就是让底片"只在菜单看得见的地方存在"。
*/
const clipStyle = ref<Record<string, string>>({
top: '0px',
left: '0px',
width: '0px',
height: '0px',
});
function syncIndicator(): void {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!aside || !active) {
const scroller = aside?.querySelector<HTMLElement>('.menu') ?? null;
if (!aside || !scroller) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const asideRect = aside.getBoundingClientRect();
const boxRect = scroller.getBoundingClientRect();
// 底片的坐标一律相对「菜单视口」,不再相对 `.aside` —— 裁剪框把两者的原点对齐了
clipStyle.value = {
top: `${boxRect.top - asideRect.top}px`,
left: `${boxRect.left - asideRect.left}px`,
width: `${boxRect.width}px`,
height: `${boxRect.height}px`,
};
const active = scroller.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!active) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const rect = active.getBoundingClientRect();
indicator.value = {
x: rect.left - asideRect.left,
y: rect.top - asideRect.top,
x: rect.left - boxRect.left,
y: rect.top - boxRect.top,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0,
@@ -114,6 +160,32 @@ function scheduleIndicatorSync(): void {
});
}
/**
* 菜单内部滚动:底片要**当帧**跟上,而且这一路上不能走过渡动画。
*
* 两点都和"滚动是连续手势"有关:
* 1. 不能用 `scheduleIndicatorSync()`(rAF 推后一帧)—— 快速滚动时底片会肉眼可见地落后;
* 滚动事件本身在布局之后派发,在这里同步量一次不会再插入一次强制布局。
* 2. 不能走 `.nav-indicator` 的 LAYOUT 弹簧(397ms)—— 那是"切换菜单项"的动画参数,
* 用在滚动上就成了"底片慢吞吞地追菜单项"。所以滚动期间挂 `is-following` 关掉过渡,
* 停手 140ms 后恢复,下一次点菜单项照样有滑动动画。
*/
const SCROLL_SETTLE_MS = 140;
let menuScroller: HTMLElement | null = null;
let scrollSettleTimer = 0;
const indicatorFollowing = ref(false);
function handleMenuScroll(): void {
if (!indicatorFollowing.value) indicatorFollowing.value = true;
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
scrollSettleTimer = window.setTimeout(() => {
scrollSettleTimer = 0;
indicatorFollowing.value = false;
}, SCROLL_SETTLE_MS);
syncIndicator();
}
/**
* 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active`
* 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。
@@ -125,6 +197,11 @@ function scheduleIndicatorSync(): void {
onMounted(() => {
syncIndicator();
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
if (aside) {
// 菜单自己的滚动容器:翻菜单时底片必须跟着走(用户反馈的「上下滑动时项目选择块不移动」)
menuScroller = aside.querySelector<HTMLElement>('.menu');
menuScroller?.addEventListener('scroll', handleMenuScroll, { passive: true });
}
if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync);
asideObserver.observe(aside);
@@ -134,6 +211,10 @@ onMounted(() => {
onUnmounted(() => {
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
indicatorFrame = 0;
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
scrollSettleTimer = 0;
menuScroller?.removeEventListener('scroll', handleMenuScroll);
menuScroller = null;
asideObserver?.disconnect();
asideObserver = null;
});
@@ -172,7 +253,7 @@ const activeMenu = computed(() => route.path);
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
const activeGroupTitle = computed(() => {
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
const group = menuGroups.value.find((item) => item.items.some((entry) => route.path === entry.path));
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
return group?.title ?? null;
});
@@ -190,7 +271,7 @@ const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.va
watch(
() => route.path,
async (path) => {
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
const group = menuGroups.value.find((item) => item.items.some((entry) => path === entry.path));
if (!group) return;
// 展开当前组(不主动关闭用户已展开的其它组)
await nextTick();
@@ -215,7 +296,7 @@ function go(name: string): void {
/** 开发期自检:菜单路径必须存在于路由表中 */
if (import.meta.env.DEV) {
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
for (const group of menuGroups) {
for (const group of menuGroups.value) {
for (const item of group.items) {
if (!knownPaths.has(item.path)) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
@@ -301,8 +382,16 @@ onUnmounted(() => {
<span class="brand-text">ClassHelper</span>
</div>
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
外层 .nav-indicator-clip 与 .menu 的视口重合(overflow:hidden),
把"跟着菜单滚出视口"的底片裁掉,不让它盖到品牌区/页脚上。
两层都 pointer-events:none —— 底片盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator-clip" :style="clipStyle" aria-hidden="true">
<div
class="nav-indicator"
:class="{ 'is-following': indicatorFollowing }"
:style="indicatorStyle"
/>
</div>
<el-menu
ref="menuRef"
:default-active="activeMenu"
@@ -444,6 +533,18 @@ onUnmounted(() => {
position: relative;
}
/*
* 指示器的裁剪框:位置与尺寸每帧由 syncIndicator() 量成"与 .menu 视口重合"。
* 少了它,激活项被滚出菜单视口时底片会跑到品牌区/页脚上(用户反馈「上下滑动时
* 项目选择块不移动」的那次修复里一起加的)。
*/
.nav-indicator-clip {
position: absolute;
z-index: 0;
overflow: hidden;
pointer-events: none;
}
/*
* 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。
*
@@ -459,7 +560,6 @@ onUnmounted(() => {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border-radius: var(--ch-radius-control);
background: var(--ch-accent-soft);
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
@@ -472,6 +572,14 @@ onUnmounted(() => {
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
/*
* 菜单滚动期间(含停手后的 140ms)关掉过渡:滚动是连续手势,底片要"贴在"菜单项上一起走,
* 而不是每帧都启动一次 397ms 的弹簧去追。松手后恢复,点菜单项照样有滑动动画。
*/
.nav-indicator.is-following {
transition: none;
}
/* 左侧强调条(原有观感,现在跟着底片一起滑) */
.nav-indicator::before {
content: '';
@@ -1,5 +1,6 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
import { useAuthStore } from '../stores/auth.js';
import { useUiStore } from '../stores/ui.js';
/**
* Electron 生产环境通过 file:// 加载页面,无法使用 history 模式,
@@ -82,6 +83,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/settings/AboutView.vue'),
meta: { title: '关于' },
},
{
/**
* 开发者(需求 10)。入口只在 `config.developerMode === true` 时出现在侧栏上,
* 但**路由始终注册**:关掉开关后侧栏项消失,已打开的页面靠一条守卫弹回「通用」,
* 不做成条件注册 —— 条件注册会让 `router.getRoutes()` 随配置变化,
* ClientLayout 的开发期自检与直接输 URL 的行为都会变得难以预期。
*/
path: 'settings/developer',
name: 'settings-developer',
component: () => import('../views/settings/DeveloperSettingsView.vue'),
meta: { title: '开发者' },
},
],
},
{ path: '/:pathMatch(.*)*', redirect: '/schedule' },
@@ -99,6 +112,16 @@ router.beforeEach((to) => {
return true;
}
if (!auth.isAuthenticated) return { name: 'login' };
/**
* 开发者页(需求 10)只在开发者模式开启时可进:侧栏项本身也是条件显示的,
* 但配置可能是在**停留在这个页面时**被关掉的(「通用」页的开关),
* 所以需要这条守卫把它弹回「通用」,不让用户在看一个"已经不该存在"的页面。
*
* 这里读的是 ui store 里的状态(由 `ui.init()` 在首屏前从主进程配置恢复),
* 不额外调 `window.desktop.getConfig()` —— 导航守卫是同步高频路径,
* 每次跳转都跨一次 contextBridge 不值得。
*/
if (to.name === 'settings-developer' && !useUiStore().developerMode) return { name: 'settings-general' };
return true;
});
@@ -13,6 +13,51 @@ import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
type RecoveryHandler = () => void;
/**
* 「本次离线从什么时候开始」的本地时间戳键(需求 11)。
*
* 为什么用 localStorage 而不是主进程配置(config.json):
* 它是一个**瞬时状态**,只描述"当前这一轮断网",一旦补弹完成(或进程重启后重新联网)就作废,
* 不需要跨进程读、也不需要跟着设备身份走。放 localStorage 有三点好处:
* - 写入发生在每次请求的**可达性回调**里(高频路径),localStorage 是同步内存写,
* 不会像 saveConfig 那样排一次主进程 IPC + 120ms 合并窗口;
* - 它是**渲染进程自己**的读-改-清闭环(bridge.ts 读它 → 补弹 → 清掉),不跨越 contextBridge;
* - 跨重启也够用:网页存储随 profile 持久化,补弹窗口本来就只关心"最近一次离线"。
*/
const OFFLINE_SINCE_KEY = 'classhelper.offlineSince';
/**
* 记下"本次离线从什么时候开始"(ISO 字符串)。
* 读不到 / 写不进(隐私模式、存储被清)时静默跳过 —— 补弹只是锦上添花,不该影响可达性上报。
*/
function markOfflineSince(): void {
try {
const now = new Date().toISOString();
localStorage.setItem(OFFLINE_SINCE_KEY, now);
console.info(`[离线] 已记录本次离线起点:${now}`);
} catch {
// 存储不可用:这次不补弹,但联网后的恢复流程照常
}
}
/** 读本次离线起点(ISO 字符串);没有记录(首次启动 / 一直在线)返回 null */
export function readOfflineSince(): string | null {
try {
return localStorage.getItem(OFFLINE_SINCE_KEY);
} catch {
return null;
}
}
/** 清掉离线起点:补弹完成后调用,避免同一次离线被重复补弹 */
export function clearOfflineSince(): void {
try {
localStorage.removeItem(OFFLINE_SINCE_KEY);
} catch {
// 清不掉也不影响功能:下一次"由可达变不可达"会覆盖它
}
}
/**
* 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。
* 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。
@@ -48,13 +93,22 @@ export const useAppStore = defineStore('app', () => {
recoveryHandlers.delete(handler);
}
/** 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性 */
/**
* 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性。
*
* 「由可达变不可达」的那一刻顺手记下离线起点(需求 11 的补弹基准):
* 只在**边界**上写,不在"一直不可达"的每次请求上重复覆盖,否则补弹窗口会被不停地
* 推迟到"最后一次失败",离线期间收到的通知就永远落在窗口之外了。
*/
setReachabilityReporter((reachable) => {
if (reachable && !serverReachable.value && initialized.value) {
serverReachable.value = true;
notifyRecovered();
return;
}
if (!reachable && serverReachable.value && initialized.value) {
markOfflineSince();
}
serverReachable.value = reachable;
});
@@ -68,6 +122,9 @@ export const useAppStore = defineStore('app', () => {
if (!serverReachable.value && initialized.value) notifyRecovered();
serverReachable.value = true;
} else {
// 健康探针是机器**空闲时唯一**的可达性信号(没有任何业务请求在跑),
// 所以这条路径也要记离线起点,否则空闲断网期间收到的通知补不回来。
if (serverReachable.value && initialized.value) markOfflineSince();
serverReachable.value = false;
}
}
@@ -3,24 +3,47 @@ import { defineStore } from 'pinia';
import type { DesktopStoredConfig } from '../../types/desktop.js';
/**
* 界面偏好(主题 / 侧边栏折叠)。
* 界面偏好(主题 / 侧边栏折叠 / 关怀模式)。
*
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed / careMode)里,
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
*
* 关怀模式 = 在 <html> 上挂 .care-mode 类(styles/index.css 末尾那个集中覆盖块),
* 只放大正文与控件字号,不动任何几何令牌 —— 理由写在那个块的注释里。
*/
const THEME_KEY = 'classhelper.theme';
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
const CARE_MODE_KEY = 'classhelper.careMode';
const DEVELOPER_MODE_KEY = 'classhelper.developerMode';
function applyThemeClass(theme: 'light' | 'dark'): void {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
/**
* 关怀模式的类名开关。**只碰这一个类**:放大规则全在 CSS 里,
* JS 侧不逐个元素改字号(那样既漏元素又把主题与字号耦合在一起)。
*/
function applyCareModeClass(enabled: boolean): void {
document.documentElement.classList.toggle('care-mode', enabled);
}
export const useUiStore = defineStore('ui', () => {
const theme = ref<'light' | 'dark'>('light');
const sidebarCollapsed = ref(false);
const careMode = ref(false);
/**
* 开发者模式(需求 10)。
*
* 它既是主进程配置(`config.developerMode`,决定日志级别),也是**侧栏的一项** ——
* 所以必须放在 store 里:关掉开关时「开发者」菜单项要立刻消失、路由守卫也要立刻生效,
* 而「通用」页与 `ClientLayout.vue` 是两个不同的组件,靠各自的本地 ref 是传不过去的
* (此前"改完设置要重启客户端才看到"多半就是这个原因)。
*/
const developerMode = ref(false);
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
async function init(): Promise<void> {
@@ -29,10 +52,14 @@ export const useUiStore = defineStore('ui', () => {
if (config) {
theme.value = config.theme === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = config.sidebarCollapsed === true;
applyCareMode(config.careMode === true);
developerMode.value = config.developerMode === true;
}
} else {
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
applyCareMode(localStorage.getItem(CARE_MODE_KEY) === '1');
developerMode.value = localStorage.getItem(DEVELOPER_MODE_KEY) === '1';
}
applyThemeClass(theme.value);
}
@@ -66,8 +93,64 @@ export const useUiStore = defineStore('ui', () => {
void setSidebarCollapsed(!sidebarCollapsed.value);
}
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
/**
* 应用关怀模式(需求 14):只挂类名 + 记状态,**不落盘**。
* 启动时由 `init()` 在首屏渲染前调用,所以刷新页面不会看到"先正常、后放大"的跳变。
*/
function applyCareMode(enabled: boolean): void {
careMode.value = enabled;
applyCareModeClass(enabled);
}
/**
* 切换关怀模式(「通用」设置页的开关):立刻生效并写回配置。
*
* 注意:用户明确开启时**不放大侧栏导轨**(见 styles/index.css 的 care-mode 块),
* 也**不做**任何 AI 助手按钮的联动 —— 本客户端目前没有助手入口,那条联动只存在于
* Web 管理端;这里不为了"看起来对称"去造一个入口。
*/
async function setCareMode(enabled: boolean): Promise<void> {
applyCareMode(enabled);
if (window.desktop) {
await window.desktop.saveConfig({ careMode: enabled }).catch(() => undefined);
} else {
localStorage.setItem(CARE_MODE_KEY, enabled ? '1' : '0');
}
}
/**
* 切换开发者模式(「通用」设置页的开关):立刻生效并写回配置。
*
* 打开之后有三件事同时发生,这里只负责**状态与落盘**:
* 1. 侧栏「设置」分组里出现「开发者」项(`ClientLayout.vue` 按 `ui.developerMode` 渲染);
* 2. `main/index.ts` 在下次启动时把日志级别调到 debug(`logger.setVerbose`)——
* 本次会话的日志级别**不重开**,避免开着日志复现问题时把级别改来改去;
* 3. 「通用」页上给出"debug 级日志已开启"的提示。
*/
async function setDeveloperMode(enabled: boolean): Promise<void> {
developerMode.value = enabled;
if (window.desktop) {
await window.desktop.saveConfig({ developerMode: enabled }).catch(() => undefined);
} else {
localStorage.setItem(DEVELOPER_MODE_KEY, enabled ? '1' : '0');
}
}
return {
theme,
sidebarCollapsed,
careMode,
developerMode,
init,
setTheme,
toggleTheme,
setSidebarCollapsed,
toggleSidebar,
applyCareMode,
setCareMode,
setDeveloperMode,
};
});
/** 供类型检查使用(仅引用类型) */
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed' | 'careMode' | 'developerMode'>;
@@ -95,6 +95,12 @@
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
--el-fill-color-blank: transparent;
--el-font-size-base: 13px;
/* 关怀模式的字号倍率(需求 14)。
* 平时恒为 1:它只作为「当前有没有放大」的单一事实来源,方便 JS 侧读取与断言,
* 真正的放大规则集中在文件末尾的 `html.care-mode` 块里(那里才是覆盖点,
* 且**刻意只动字号**——圆角 / 阴影 / 侧栏宽度等几何令牌一律不碰)。 */
--ch-font-scale: 1;
}
/* ---------------------------------------------------------------- 深色主题 */
@@ -946,3 +952,61 @@ textarea {
::view-transition-new(root) {
z-index: 2;
}
/* ------------------------------------------------------------ 关怀模式(需求 14)
*
* 给视力不佳的用户把**正文与控件**整体放大一档(约 +15%)。由 `stores/ui.ts` 的
* `applyCareMode()` 在首屏渲染前按 `config.careMode` 给 `<html>` 挂/摘 `.care-mode` 类。
*
* 三条刻意的边界,改这个块之前先读:
*
* 1. **只放大字号,不动几何。** 圆角(`--ch-radius-*`)、阴影(`--ch-shadow-*`)、
* 卡片内边距、侧栏宽度一律不碰 —— 放大字号已经会让文字换行,再动几何就是"另一个主题",
* 而且 `verify:desktop` 的多条断言量的是像素几何。
* 2. **侧栏导轨(`.ch-nav`)的条目字号不在这里放大。** §5 第 49 条那套"折叠成图标导轨"
* 的硬要求(导轨宽度、条目高度、折叠态文字必须既无宽度又透明)是按固定几何量出来的,
* 动导航条目字号会让折叠态文字重新占宽而断言变红。导航的可见标签保持原字号。
* 3. **用 `html.care-mode` 而不是 `:root`**:特异性 (0,1,1) 高于普通类选择器,
* 能稳定压过上面那些 `.page-title` / `.el-card__header` 等单类规则,不必写 `!important`。
* 深色主题无关 —— 这里一条颜色都没写。
*/
html.care-mode {
/* 放大倍率放在这里,下面的覆盖全部由它算出来 —— 想再调一档只改这一个数 */
--ch-font-scale: 1.15;
/* 覆盖 Element Plus 的基准字号:按钮、输入框、下拉、分页、消息框等控件都跟着它走
(基准值是 :root 里的 13px,见上面的 Element Plus 变量对齐) */
--el-font-size-base: calc(13px * var(--ch-font-scale));
}
/* 页面标题与副标题(`views/*` 的通用页头) */
html.care-mode .page-title {
font-size: calc(20px * var(--ch-font-scale));
}
html.care-mode .page-subtitle {
font-size: calc(12.5px * var(--ch-font-scale));
}
/* 卡片标题 / 折叠面板标题(这两处在上面被显式写成 13.5px,必须逐条覆盖) */
html.care-mode .el-card__header,
html.care-mode .el-collapse-item__header {
font-size: calc(13.5px * var(--ch-font-scale));
}
/* 表格:表头与单元格一起放大,行高由内容自然撑开(不设固定 height) */
html.care-mode .el-table {
font-size: calc(13px * var(--ch-font-scale));
}
html.care-mode .el-table th.el-table__cell {
font-size: calc(12.5px * var(--ch-font-scale));
}
/* 表单里 Element Plus 自己的标签/说明文字(默认跟着 --el-font-size-base,
* 但 `.el-form-item__label` 在部分页面被行内样式压过,这里统一一档更稳) */
html.care-mode .el-form-item__label,
html.care-mode .el-descriptions__label,
html.care-mode .el-descriptions__content,
html.care-mode .el-message-box__message {
font-size: calc(13px * var(--ch-font-scale));
}
@@ -2,10 +2,13 @@
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
dayKeyLocal,
formatDate,
relativeTime,
shiftDayKey,
type NotificationDto,
type NotificationPriority,
} from '@classhelper/shared';
@@ -24,6 +27,85 @@ const visibleItems = computed(() =>
filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items,
);
/* ------------------------------------------------------- 按天归档 / 按发布人 / 按类型 */
/**
* 三个筛选都在**已加载的列表**上做,不再请求服务端:
* 客户端是离线优先的(AGENTS.md §5 第 10 条),断网回退缓存时归档功能必须照常可用。
* 口径与服务端一致:按 `assignDate`(本地日期)分组 —— 用 createdAt 的 UTC 切片会把晚上
* 发出来的通知算到第二天(AGENTS.md §5 第 19 条)。
*/
const dayFilter = ref('');
const creatorFilter = ref('');
const priorityFilter = ref('');
/**
* 一条通知归到哪一天。
* `assignDate` 是服务端新加的字段,但**离线缓存里可能还留着加字段之前的旧数据**,
* 所以这里按 createdAt 的本地日期兜底,避免分组头显示成 "undefined"。
*/
function assignDay(item: NotificationDto): string {
return item.assignDate || dayKeyLocal(new Date(item.createdAt));
}
const dayOptions = computed(() => {
const map = new Map<string, number>();
for (const item of store.items) {
const day = assignDay(item);
map.set(day, (map.get(day) ?? 0) + 1);
}
return [...map.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([date, count]) => ({ date, count }));
});
const creatorOptions = computed(() => {
const map = new Map<string, { name: string; count: number }>();
for (const item of store.items) {
const previous = map.get(item.createdBy);
map.set(item.createdBy, {
name: item.creator?.name ?? '未知',
count: (previous?.count ?? 0) + 1,
});
}
return [...map.entries()].map(([id, value]) => ({ id, ...value }));
});
const hasFilter = computed(() => Boolean(dayFilter.value || creatorFilter.value || priorityFilter.value));
const filteredItems = computed(() =>
visibleItems.value.filter(
(item) =>
(!dayFilter.value || assignDay(item) === dayFilter.value) &&
(!creatorFilter.value || item.createdBy === creatorFilter.value) &&
(!priorityFilter.value || item.priority === priorityFilter.value),
),
);
/** 按所属日期分块(同一天一块,块头显示"今天/昨天 + 日期 + 条数") */
const groupedItems = computed(() => {
const groups = new Map<string, NotificationDto[]>();
for (const item of filteredItems.value) {
const day = assignDay(item);
const list = groups.get(day);
if (list) list.push(item);
else groups.set(day, [item]);
}
return [...groups.entries()].map(([date, items]) => ({ date, items }));
});
function dayLabel(date: string): string {
const today = dayKeyLocal(new Date());
if (date === today) return '今天';
if (date === shiftDayKey(today, -1)) return '昨天';
return date;
}
function resetFilters(): void {
dayFilter.value = '';
creatorFilter.value = '';
priorityFilter.value = '';
}
async function openDetail(item: NotificationDto): Promise<void> {
current.value = item;
detailVisible.value = true;
@@ -76,6 +158,7 @@ onUnmounted(() => {
<h2 class="page-title">通知</h2>
<p class="page-subtitle">
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
<span v-if="hasFilter">· 筛选后 {{ filteredItems.length }} 条</span>
<el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p>
</div>
@@ -92,37 +175,88 @@ onUnmounted(() => {
</div>
<el-card v-loading="store.loading" shadow="never">
<el-empty v-if="visibleItems.length === 0" description="暂无通知" />
<ul v-else class="notice-list">
<li
v-for="item in visibleItems"
:key="item.id"
class="notice-item"
:class="{ unread: !item.read }"
@click="openDetail(item)"
<!-- 按天归档 + 按发布人 + 按类型;都在本地已加载的列表上筛(离线也能用) -->
<div class="notice-filters">
<el-select v-model="dayFilter" placeholder="按天归档" clearable size="small" style="width: 150px">
<el-option
v-for="item in dayOptions"
:key="item.date"
:label="`${item.date}(${item.count})`"
:value="item.date"
/>
</el-select>
<el-select
v-model="creatorFilter"
placeholder="全部发布人"
clearable
size="small"
style="width: 150px"
>
<div class="notice-main">
<div class="notice-title">
<span v-if="!item.read" class="unread-dot"></span>
<el-tag
:type="PRIORITY_TAG_TYPES[item.priority as NotificationPriority]"
size="small"
effect="light"
>
{{ PRIORITY_LABELS[item.priority as NotificationPriority] }}
</el-tag>
<span class="title-text">{{ item.title }}</span>
</div>
<div class="notice-content">{{ item.content }}</div>
<el-option
v-for="item in creatorOptions"
:key="item.id"
:label="`${item.name}(${item.count})`"
:value="item.id"
/>
</el-select>
<el-select
v-model="priorityFilter"
placeholder="全部类型"
clearable
size="small"
style="width: 130px"
>
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
<el-button v-if="hasFilter" link type="primary" size="small" @click="resetFilters">
清除筛选
</el-button>
</div>
<el-empty v-if="filteredItems.length === 0" description="暂无通知" />
<div v-else class="notice-groups">
<section v-for="group in groupedItems" :key="group.date" class="notice-group">
<div class="notice-group__header">
<span>{{ dayLabel(group.date) }}</span>
<span class="text-muted">{{ group.date }} · {{ group.items.length }} 条</span>
</div>
<div class="notice-meta">
<div>{{ relativeTime(item.createdAt) }}</div>
<div class="text-muted">{{ formatDate(item.createdAt, true) }}</div>
<el-tag v-if="item.read" size="small" type="info" effect="plain">已读</el-tag>
<el-tag v-else size="small" type="danger" effect="plain">未读</el-tag>
</div>
</li>
</ul>
<ul class="notice-list">
<li
v-for="item in group.items"
:key="item.id"
class="notice-item"
:class="{ unread: !item.read }"
@click="openDetail(item)"
>
<div class="notice-main">
<div class="notice-title">
<span v-if="!item.read" class="unread-dot"></span>
<el-tag
:type="PRIORITY_TAG_TYPES[item.priority as NotificationPriority]"
size="small"
effect="light"
>
{{ PRIORITY_LABELS[item.priority as NotificationPriority] }}
</el-tag>
<span class="title-text">{{ item.title }}</span>
</div>
<div class="notice-content">{{ item.content }}</div>
</div>
<div class="notice-meta">
<div>{{ relativeTime(item.createdAt) }}</div>
<div class="text-muted">{{ formatDate(item.createdAt, true) }}</div>
<el-tag v-if="item.read" size="small" type="info" effect="plain">已读</el-tag>
<el-tag v-else size="small" type="danger" effect="plain">未读</el-tag>
</div>
</li>
</ul>
</section>
</div>
</el-card>
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'">
@@ -147,6 +281,30 @@ onUnmounted(() => {
</template>
<style scoped>
.notice-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.notice-group + .notice-group {
margin-top: 10px;
}
.notice-group__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 6px 4px;
font-size: 12px;
font-weight: 600;
color: var(--ch-text-secondary);
border-bottom: 1px solid var(--ch-border);
}
.notice-list {
list-style: none;
margin: 0;
@@ -0,0 +1,450 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { formatDate, relativeTime, type UpdateInfo } from '@classhelper/shared';
import type { ClientAgentStatus, DesktopAppInfo } from '../../../types/desktop.js';
import { useAppStore } from '../../stores/app.js';
import { useUiStore } from '../../stores/ui.js';
/**
* 开发者(需求 10)。
*
* 入口只在 `config.developerMode === true` 时出现在侧栏「设置」分组里(`ClientLayout.vue`),
* 路由守卫也会把"关掉开关后仍停留在这个页面"的用户弹回「通用」。
*
* 本页的定位是**把排查问题时需要的现场集中到一处**,所以刻意不做成"可操作的调试台":
* 每一项都是只读状态 + 一两个既有桥上的动作,数据全部来自 `window.desktop.*`
* 与既有 store,**不新增任何数据源、也不新增文件读写**。
*
* 几条取舍写在各区块的注释里,统一的原则是:
* 「关于」页面向的是所有用户(干净、能截图给管理员),本页面向维护者(原始值、路径、内部开关)。
*/
const appStore = useAppStore();
const ui = useUiStore();
const loading = ref(false);
const appInfo = ref<DesktopAppInfo | null>(null);
const agent = ref<ClientAgentStatus | null>(null);
/* ------------------------------------------------------------ 版本与平台 */
/** 版本、运行时与两个关键路径(用户数据目录 / 配置文件),排查"配置写哪去了"必看 */
async function loadAppInfo(): Promise<void> {
if (!window.desktop?.getAppInfo) return;
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
}
/** 一次性把本页的所有状态拉新(刷新按钮与首次进页面共用) */
async function loadAll(): Promise<void> {
if (!window.desktop) return;
loading.value = true;
try {
await loadAppInfo();
await refreshAgent();
await loadIgnoredVersion();
await appStore.refreshCacheStats();
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 设备与上报 */
/**
* 服务端联动状态:设备尾号 / 最近上报 / 最近错误 / 目标版本 / 待安装版本 / 在线。
*
* 这一块与「通用」页的「服务端联动状态」是同一份数据 —— 有意保留的重叠:
* 排查上报问题时人在「通用」页看现象、在「开发者」页看原因(最近错误原文 + 设备尾号),
* 跳来跳去比重复几个字段更烦。
*/
const beating = ref(false);
const lastHeartbeatText = computed(() => {
const value = agent.value?.lastHeartbeatAt;
return value ? relativeTime(value) : '还没有上报过';
});
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
async function refreshAgent(): Promise<void> {
if (!window.desktop?.getClientAgentStatus) return;
agent.value = await window.desktop.getClientAgentStatus().catch(() => null);
}
async function beatNow(): Promise<void> {
if (!window.desktop?.beatClientNow) return;
beating.value = true;
try {
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
agent.value = await window.desktop.beatClientNow();
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
else ElMessage.success('已向服务端上报本机状态');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
} finally {
beating.value = false;
}
}
/* ------------------------------------------------------------ 日志 */
/**
* 日志文件路径。
*
* 桥上没有"读日志内容"的接口(有意不加:日志是主进程的文件,给渲染进程一个读任意文件的
* 入口在安全边界上没有必要),所以这里只**提示路径** + 一个「打开日志目录」。
* 路径由用户数据目录拼出来:主进程的 logger 固定写在 `<userData>/logs/main.log`,
* 单文件 1MB 轮转(写满即改名为 main.old.log),所以"日志丢了"通常是轮转覆盖,不是没写。
*/
const logPathText = computed(() => {
const base = appInfo.value?.userDataPath;
if (!base) return 'logs/main.log(用户数据目录下)';
const sep = (appInfo.value?.platform ?? '').startsWith('win') ? '\\' : '/';
return `${base}${sep}logs${sep}main.log`;
});
async function openLogDir(): Promise<void> {
if (!window.desktop?.openLogDir) return;
const ok = await window.desktop.openLogDir();
if (!ok) ElMessage.warning('打开日志目录失败,请按上面的路径手动前往');
}
/* ------------------------------------------------------------ 更新 */
/**
* 更新检查。
*
* 「立即检查」走 `checkForUpdates(true)`:**强制绕过主进程那 30 分钟缓存**。
* 这是本页与「关于」页的区别 —— 那边用户点检查也可能是缓存结果(省流量),
* 这边维护者要的是"现在到底拿得到什么",所以按钮默认就是强制的。
*/
const checking = ref(false);
const updateInfo = ref<UpdateInfo | null>(null);
const ignoredUpdateVersion = ref('');
async function checkUpdate(force: boolean): Promise<void> {
if (!window.desktop?.checkForUpdates) return;
checking.value = true;
try {
updateInfo.value = await window.desktop.checkForUpdates(force);
} finally {
checking.value = false;
}
}
async function loadIgnoredVersion(): Promise<void> {
if (!window.desktop?.getConfig) return;
const stored = await window.desktop.getConfig().catch(() => null);
ignoredUpdateVersion.value = stored?.ignoredUpdateVersion ?? '';
}
async function ignoreVersion(): Promise<void> {
const version = updateInfo.value?.latestVersion;
if (!version || !window.desktop?.ignoreUpdateVersion) return;
await window.desktop.ignoreUpdateVersion(version);
ignoredUpdateVersion.value = version;
ElMessage.success(`已忽略 v${version}:启动时的自动检查不再提示它`);
}
/** 忽略是可逆的(排查线上"用户说不再提示"时经常要把它清掉),顺手给一个恢复入口 */
async function clearIgnoredVersion(): Promise<void> {
if (!window.desktop?.saveConfig) return;
const saved = await window.desktop.saveConfig({ ignoredUpdateVersion: '' });
ignoredUpdateVersion.value = saved.ignoredUpdateVersion;
ElMessage.success('已恢复该版本的更新提示');
}
/* ------------------------------------------------------------ 调试工具 */
const openingDevTools = ref(false);
/**
* 打开 DevTools(主进程 detached 独立窗口)。
* 冒烟验证模式下主进程会拒绝(多一个窗口会把自动化断言打乱),返回 false —— 这里如实提示。
*/
async function openDevTools(): Promise<void> {
if (!window.desktop?.openDevTools) return;
openingDevTools.value = true;
try {
const ok = await window.desktop.openDevTools();
if (ok) ElMessage.success('已在独立窗口打开开发者工具');
else ElMessage.warning('当前是冒烟验证模式,主进程不允许打开 DevTools');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '打开开发者工具失败');
} finally {
openingDevTools.value = false;
}
}
/* ------------------------------------------------------------ 离线缓存 */
/** 与「通用」页同一份缓存统计(这里再看一遍是为了"刷新/清空"不用跳页) */
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
onMounted(async () => {
await loadAll();
// 带出启动自动检查的结果(这次命中主进程缓存,不会真的再发一次请求)
void checkUpdate(false);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">开发者</h2>
<p class="page-subtitle">版本与平台、设备上报、日志、更新与调试工具</p>
</div>
<el-button :loading="loading" data-test="dev-refresh" @click="loadAll">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<el-alert
class="mb-12"
type="warning"
:closable="false"
show-icon
title="仅供排查问题"
description="这一页显示的是原始状态与内部路径,不需要给日常使用者看。关闭「通用 → 开发者模式」后它就会从侧栏消失。"
/>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<!-- 版本与平台 -->
<el-card shadow="never">
<template #header><span>版本与平台</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="客户端版本">
{{ appInfo?.appVersion ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="运行时">
Electron {{ appInfo?.electron ?? '—' }} · Chromium {{ appInfo?.chrome ?? '—' }} · Node
{{ appInfo?.node ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="用户数据目录">
<span class="mono">{{ appInfo?.userDataPath ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="配置文件">
<span class="mono">{{ appInfo?.configPath ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="冒烟验证模式">
<el-tag :type="appInfo?.smokeTest ? 'warning' : 'info'" size="small" effect="plain">
{{ appInfo?.smokeTest ? '是(自动化验证中)' : '否' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 设备与上报 -->
<el-card shadow="never" class="mt-12">
<template #header><span>设备与上报</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="连接状态">
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
{{ agent?.online ? '在线(近期上报成功)' : '离线(等待上报)' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="设备标识尾号">
<span class="mono">{{ agent?.deviceKeyTail || '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
<el-descriptions-item label="服务端目标版本">
{{ agent?.updateTargetVersion || '无(保持当前版本)' }}
</el-descriptions-item>
<el-descriptions-item label="待安装版本">
{{ agent?.pendingUpdateVersion || '无' }}
</el-descriptions-item>
<el-descriptions-item label="最近错误">
<span :class="{ 'text-danger': lastErrorText !== '无' }">{{ lastErrorText }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button type="primary" :loading="beating" data-test="dev-beat-now" @click="beatNow">
立即上报
</el-button>
<el-button :loading="loading" @click="loadAll">刷新状态</el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<!-- 日志 -->
<el-card shadow="never">
<template #header><span>日志</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="日志文件">
<span class="mono">{{ logPathText }}</span>
</el-descriptions-item>
<el-descriptions-item label="日志级别">
<el-tag :type="ui.developerMode ? 'success' : 'info'" size="small" effect="plain">
{{ ui.developerMode ? 'debug(开发者模式)' : 'info' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<p class="text-muted mt-12">
单个日志文件 1MB 轮转:写满后改名为 main.old.log,新内容写进 main.log。
所以"日志里找不到某次崩溃"多半是被轮转覆盖了,不是没写。
</p>
<!-- 不在这里用 <strong> 强调:中文整行的显示宽度是字符数的两倍,
而 Vue 模板里夹了行内元素的长句会被 Prettier 折行并插入空格,
渲染出来句子中间就多一个空格。要强调就单独起一行文字。 -->
<p v-if="ui.developerMode" class="text-muted">
debug 级日志已开启:级别在主进程启动时读取,改完开关后需重启客户端才生效。
</p>
<div class="toolbar mt-12">
<el-button data-test="dev-open-log-dir" @click="openLogDir">打开日志目录</el-button>
</div>
</el-card>
<!-- 更新 -->
<el-card shadow="never" class="mt-12">
<template #header><span>更新</span></template>
<div class="toolbar">
<el-button :loading="checking" data-test="dev-check-update" @click="checkUpdate(true)">
强制检查更新
</el-button>
<span class="text-muted">绕过主进程 30 分钟缓存,直接问 GitHub</span>
</div>
<el-descriptions :column="1" border size="small" class="mt-12">
<el-descriptions-item label="本次结果">
<template v-if="checking">正在检查…</template>
<template v-else-if="!updateInfo">尚未检查</template>
<template v-else-if="!updateInfo.ok">
<span class="text-muted">{{ updateInfo.error ?? '暂时无法检查更新' }}</span>
</template>
<template v-else-if="updateInfo.hasUpdate">
<el-tag size="small" type="success" effect="light">
有新版本 v{{ updateInfo.latestVersion }}
</el-tag>
<span class="text-muted">当前 v{{ updateInfo.currentVersion }}</span>
</template>
<template v-else>
<el-tag size="small" type="info" effect="plain">已是最新版本</el-tag>
<span class="text-muted">v{{ updateInfo.currentVersion }}</span>
</template>
</el-descriptions-item>
<el-descriptions-item label="检查时间">
{{ updateInfo?.checkedAt ? formatDate(updateInfo.checkedAt, true) : '—' }}
</el-descriptions-item>
<el-descriptions-item label="已忽略的版本">
<template v-if="ignoredUpdateVersion">
<span class="mono">v{{ ignoredUpdateVersion }}</span>
<el-button link type="primary" size="small" @click="clearIgnoredVersion">恢复提示</el-button>
</template>
<span v-else class="text-muted">无</span>
</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button
:disabled="!updateInfo?.latestVersion"
data-test="dev-ignore-version"
@click="ignoreVersion"
>
不再提示该版本
</el-button>
<span class="text-muted">写入配置后,启动时的自动检查会跳过它</span>
</div>
</el-card>
<!-- 调试工具 -->
<el-card shadow="never" class="mt-12">
<template #header><span>调试工具</span></template>
<div class="toolbar">
<el-button :loading="openingDevTools" data-test="dev-open-devtools" @click="openDevTools">
打开 DevTools
</el-button>
<span class="text-muted">独立窗口;冒烟验证模式下不允许打开</span>
</div>
<el-alert class="mt-12" type="info" :closable="false" show-icon title="灵动岛诊断(命令行)">
<template #default>
<p class="diag-copy">灵动岛开合"闪一下"这类问题要的是整屏逐帧取证,界面上给不出这个按钮。</p>
<p class="diag-copy">跑冒烟时带上环境变量即可:</p>
<p class="diag-copy mono">ELECTRON_SMOKE_ISLAND_DIAG=1 pnpm verify:desktop</p>
<p class="diag-copy">它会在常规断言之外,对触摸/鼠标两种模式各跑一次收回动画的逐帧取证,</p>
<p class="diag-copy">帧图与逐帧数据落在 `.cache/island-closing-frames/`。</p>
</template>
</el-alert>
</el-card>
<!-- 离线缓存 -->
<el-card shadow="never" class="mt-12">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
/*
* 路径与版本号这类"要抄下来发给别人"的值统一用等宽字体(避免 0/O、1/l 看混),
* 并单独放开文本选中 —— body 上关掉了整页选中(更像原生应用),
* 但这一页的路径就是要让人复制的。
*
* 字体栈写死而不新建 `--ch-font-mono` 令牌:全局只有这一处在用,
* 为它加一个主题令牌(还得在 html.dark 里镜像一份)不划算。
*/
.mono {
font-family: 'Cascadia Mono', Consolas, 'Courier New', monospace;
font-size: 12.5px;
word-break: break-all;
user-select: text;
}
.mb-12 {
margin-bottom: 12px;
}
.text-danger {
color: var(--el-color-danger);
}
.diag-copy {
margin: 0 0 6px;
line-height: 1.6;
}
.diag-copy:last-child {
margin-bottom: 0;
}
</style>
@@ -1,12 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
formatDate,
relativeTime,
} from '@classhelper/shared';
import type { ClientAgentStatus } from '../../../types/desktop.js';
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
import { normalizeServerUrl } from '../../config.js';
import { useAppStore } from '../../stores/app.js';
@@ -14,6 +16,7 @@ import { useAuthStore } from '../../stores/auth.js';
import { useNotificationStore } from '../../stores/notifications.js';
import { useOnboardingStore } from '../../stores/onboarding.js';
import { useRealtimeStore } from '../../stores/realtime.js';
import { useUiStore } from '../../stores/ui.js';
/**
* 通用设置:服务器连接、离线缓存与作业录入短语。
@@ -24,6 +27,7 @@ const auth = useAuthStore();
const onboarding = useOnboardingStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const ui = useUiStore();
const serverInput = ref(appStore.serverUrl);
const testing = ref(false);
@@ -153,9 +157,193 @@ async function resetPhrases(): Promise<void> {
ElMessage.success('已恢复默认');
}
/* ------------------------------------------------------------ 启动与更新 / 服务端联动 */
/**
* 开机自启与自动更新。
*
* 开机自启的"是否生效"以**系统登录项**为准(新增的 `getAutoLaunch` 桥读的是
* `app.getLoginItemSettings()`,不是配置文件里的值)—— 用户可能在任务管理器的「启动」页
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
*/
const autoLaunch = ref(false);
const autoUpdate = ref(true);
const savingLaunch = ref(false);
const savingUpdate = ref(false);
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
const agent = ref<ClientAgentStatus | null>(null);
const agentLoading = ref(false);
/** 本机客户端版本(与服务端下发的目标版本对照着看) */
const appVersion = ref('—');
/** 最近上报时间的相对描述(没有上报过就显示占位符) */
const lastHeartbeatText = computed(() => {
const value = agent.value?.lastHeartbeatAt;
if (!value) return '还没有上报过';
return relativeTime(value);
});
/** 服务端对本机版本的期望:空串表示"不需要更新" */
const updateTargetText = computed(() => {
const target = agent.value?.updateTargetVersion ?? '';
if (!target) return '无(保持当前版本)';
return target;
});
const pendingUpdateText = computed(() => {
const pending = agent.value?.pendingUpdateVersion ?? '';
if (!pending) return '无';
return `${pending}(已下载,空闲时自动安装)`;
});
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
async function loadLaunchSettings(): Promise<void> {
if (!window.desktop?.getConfig) return;
const config = await window.desktop.getConfig();
autoUpdate.value = config.autoUpdate !== false;
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
const info = await window.desktop.getAppInfo?.().catch(() => null);
if (info?.appVersion) appVersion.value = info.appVersion;
}
async function toggleAutoLaunch(value: string | number | boolean): Promise<void> {
if (!window.desktop?.setAutoLaunch) return;
const wanted = value === true;
autoLaunch.value = wanted;
savingLaunch.value = true;
try {
const effective = await window.desktop.setAutoLaunch(wanted);
// 以系统实际生效值为准:写注册表失败时会与用户点的不一致,必须回显真实状态
autoLaunch.value = effective;
if (effective !== wanted) ElMessage.warning('系统未接受开机自启设置,请检查任务管理器里的启动项');
else ElMessage.success(wanted ? '已开启开机自启' : '已关闭开机自启');
} catch (error) {
autoLaunch.value = !wanted;
ElMessage.error(error instanceof Error ? error.message : '设置开机自启失败');
} finally {
savingLaunch.value = false;
}
}
async function toggleAutoUpdate(value: string | number | boolean): Promise<void> {
if (!window.desktop?.saveConfig) return;
const wanted = value === true;
autoUpdate.value = wanted;
savingUpdate.value = true;
try {
// 注意:必须传纯布尔值 —— 跨 contextBridge 传响应式对象会在进入 preload 前就被结构化克隆拒绝
const saved = await window.desktop.saveConfig({ autoUpdate: wanted });
autoUpdate.value = saved.autoUpdate !== false;
ElMessage.success(wanted ? '已开启自动更新' : '已关闭自动更新(仍可在「关于」页手动更新)');
} catch (error) {
autoUpdate.value = !wanted;
ElMessage.error(error instanceof Error ? error.message : '保存自动更新设置失败');
} finally {
savingUpdate.value = false;
}
}
/* ------------------------------------------------------------ 关怀模式 / 开发者模式 */
/**
* 关怀模式(需求 14):放大**正文与控件**字号。
*
* 这里只挂 `<html>.care-mode` 类并落盘,放大规则全在 `styles/index.css` 末尾那个集中覆盖块里
* (`--ch-font-scale` + 少数几个常用类),不在 JS 里逐个元素改字号。
*
* 开关直接读 ui store 的状态,**不用本地 ref 复制一份**:侧栏、路由守卫、主题页读的都是它,
* 复制就会出现"两个开关各说各话"。`ui.init()` 在首屏渲染前已从主进程配置恢复过。
*
* 关于"与助手联动":开启关怀模式会放大 AI 助手的按钮 —— 这条联动**只存在于 Web 管理端**,
* 本客户端目前没有助手入口,所以这里不放大任何按钮,也不为了"看起来对称"去造一个入口。
*/
const savingCareMode = ref(false);
async function toggleCareMode(value: string | number | boolean): Promise<void> {
const wanted = value === true;
savingCareMode.value = true;
try {
await ui.setCareMode(wanted);
ElMessage.success(wanted ? '已开启关怀模式:正文与控件字号已放大' : '已关闭关怀模式');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存关怀模式设置失败');
} finally {
savingCareMode.value = false;
}
}
/**
* 开发者模式(需求 10):显示调试页面与详细日志,仅排查问题时打开。
*
* 打开后侧栏「设置」分组里**立刻**多出「开发者」页(`ClientLayout.vue` 读的是同一个
* ui store 状态),下次启动时主进程会把日志级别调到 debug。
*/
const savingDeveloperMode = ref(false);
async function toggleDeveloperMode(value: string | number | boolean): Promise<void> {
const wanted = value === true;
savingDeveloperMode.value = true;
try {
await ui.setDeveloperMode(wanted);
ElMessage.success(wanted ? '已开启开发者模式:侧栏出现「开发者」页' : '已关闭开发者模式');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存开发者模式设置失败');
} finally {
savingDeveloperMode.value = false;
}
}
async function refreshAgentStatus(): Promise<void> {
if (!window.desktop?.getClientAgentStatus) return;
agentLoading.value = true;
try {
agent.value = await window.desktop.getClientAgentStatus();
} finally {
agentLoading.value = false;
}
}
async function beatNow(): Promise<void> {
if (!window.desktop?.beatClientNow) return;
agentLoading.value = true;
try {
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
agent.value = await window.desktop.beatClientNow();
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
else ElMessage.success('已向服务端上报本机状态');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
} finally {
agentLoading.value = false;
}
}
async function openLogDir(): Promise<void> {
if (!window.desktop?.openLogDir) return;
const ok = await window.desktop.openLogDir();
if (!ok) ElMessage.warning('打开日志目录失败,可在「关于」页复制诊断信息');
}
/** 状态里的相对时间要跟着走,否则挂着的页面会一直显示"刚刚" */
let statusTimer: ReturnType<typeof setInterval> | null = null;
onMounted(async () => {
await appStore.refreshCacheStats();
await loadPhrases();
await loadLaunchSettings();
await refreshAgentStatus();
statusTimer = setInterval(() => {
void refreshAgentStatus();
}, 30_000);
});
onBeforeUnmount(() => {
if (statusTimer !== null) {
clearInterval(statusTimer);
statusTimer = null;
}
});
</script>
@@ -238,6 +426,77 @@ onMounted(async () => {
<el-button @click="resetPhrases">恢复默认</el-button>
</div>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>启动与更新</span></template>
<div class="switch-row">
<div class="switch-main">
<span>开机自启</span>
<p class="text-muted">登录 Windows 后自动启动 ClassHelper;教室机器建议打开。</p>
</div>
<el-switch
:model-value="autoLaunch"
:loading="savingLaunch"
@update:model-value="toggleAutoLaunch"
/>
</div>
<div class="switch-row">
<div class="switch-main">
<span>自动更新</span>
<p class="text-muted">
安装只在
<strong>不上课且窗口已隐藏 5 分钟后</strong>
进行,下载随时可能发生。
</p>
<p class="text-muted">服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。</p>
</div>
<el-switch
:model-value="autoUpdate"
:loading="savingUpdate"
@update:model-value="toggleAutoUpdate"
/>
</div>
<div class="toolbar mt-12">
<el-button @click="openLogDir">打开日志目录</el-button>
<span class="text-muted">日志文件 main.log,排查问题时可发给管理员</span>
</div>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>显示与调试</span></template>
<div class="switch-row">
<div class="switch-main">
<span>关怀模式</span>
<p class="text-muted">
放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。
</p>
<p class="text-muted">
「开启后同时放大 AI 助手按钮」这一条只在 Web 管理端生效 —— 本客户端目前没有助手入口。
</p>
</div>
<el-switch
:model-value="ui.careMode"
:loading="savingCareMode"
data-test="care-mode-switch"
@update:model-value="toggleCareMode"
/>
</div>
<div class="switch-row">
<div class="switch-main">
<span>开发者模式</span>
<p class="text-muted">显示调试页面与详细日志,仅排查问题时打开。</p>
<p v-if="ui.developerMode" class="text-muted">
debug 级日志已开启;侧栏「设置 → 开发者」可查看版本、设备上报、更新检查、日志目录与 DevTools。
</p>
</div>
<el-switch
:model-value="ui.developerMode"
:loading="savingDeveloperMode"
data-test="developer-mode-switch"
@update:model-value="toggleDeveloperMode"
/>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
@@ -265,6 +524,35 @@ onMounted(async () => {
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>服务端联动状态</span></template>
<p class="text-muted">
本机每 30 秒向服务端上报一次状态(是否在上课、当前版本、已下载的更新),
服务端据此下发统一更新与远控命令。本机标识尾号用于在服务端设备列表里核对这台电脑。
</p>
<el-descriptions class="mt-12" :column="1" border size="small">
<el-descriptions-item label="连接状态">
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
{{ agent?.online ? '已连接(近期上报成功)' : '未连接(等待上报)' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="本机版本">
{{ appVersion }}
</el-descriptions-item>
<el-descriptions-item label="设备标识尾号">
{{ agent?.deviceKeyTail || '—' }}
</el-descriptions-item>
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
<el-descriptions-item label="服务端目标版本">{{ updateTargetText }}</el-descriptions-item>
<el-descriptions-item label="已下载待安装">{{ pendingUpdateText }}</el-descriptions-item>
<el-descriptions-item label="最近错误">{{ lastErrorText }}</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-12">
<el-button type="primary" :loading="agentLoading" @click="beatNow">立即上报</el-button>
<el-button :loading="agentLoading" @click="refreshAgentStatus">刷新状态</el-button>
</div>
</el-card>
</el-col>
</el-row>
</div>
@@ -282,4 +570,29 @@ onMounted(async () => {
.ml-8 {
margin-left: 8px;
}
/* 开关行:左说明右开关,说明长时自动换行(「自动更新」的提示是两行) */
.switch-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
}
.switch-row + .switch-row {
border-top: 1px solid var(--ch-border);
}
.switch-main {
display: flex;
flex-direction: column;
gap: 2px;
}
.switch-main p {
margin: 0;
font-size: 12.5px;
line-height: 1.5;
}
</style>
+43
View File
@@ -9,6 +9,10 @@ import type {
IslandState,
UpdateInfo,
} from '@classhelper/shared';
import type { ClientAgentStatus } from '../main/client-agent.js';
/** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */
export type { ClientAgentStatus };
/** 作业页展示偏好(看板 / 列表,看板外观) */
export interface HomeworkBoardSettings {
@@ -66,6 +70,25 @@ export interface DesktopStoredConfig {
* 在「关于」页手动检查时仍会正常显示结果。
*/
ignoredUpdateVersion: string;
/**
* 本机设备标识(UUID):这台机器在服务端的身份。
*
* 服务端按它 upsert 设备记录(在线状态 / 待执行命令 / 已下载版本都挂在它上面),
* **首次读取配置时自动生成并落盘,之后永不变**;渲染进程只读,不允许改写。
*/
deviceKey: string;
/** 是否开机自启(登录 Windows 后静默启动,不弹主窗口) */
autoLaunch: boolean;
/** 是否接受服务端统一下发的自动更新(下载随时进行,安装只在空闲时) */
autoUpdate: boolean;
/** 开发者模式:把日志调到 debug 级,并在设置里显示「开发者」页 */
developerMode: boolean;
/** 关怀模式:整体放大正文与控件字号(不动圆角/阴影/侧栏几何) */
careMode: boolean;
/** 已下载待安装的更新版本(空串 = 无) */
pendingUpdateVersion: string;
/** 已下载待安装的安装包绝对路径(空串 = 无) */
pendingUpdateFile: string;
}
export interface DesktopAppInfo {
@@ -165,6 +188,26 @@ export interface DesktopBridge {
onIslandMarkRead(handler: (id: string) => void): void;
/** 订阅"灵动岛点了标为已读(多条通知的整批)",一次把这一批都标记已读 */
onIslandMarkAllRead(handler: (ids: string[]) => void): void;
/* 开机自启 / 日志 / 服务端联动 */
/** 读当前是否已注册为开机自启(以系统登录项为准,不是配置文件里的值) */
getAutoLaunch(): Promise<boolean>;
/** 设置开机自启:写配置 + 同步系统登录项,返回生效后的值 */
setAutoLaunch(enabled: boolean): Promise<boolean>;
/** 打开日志目录(排查问题用;日志文件是 main.log) */
openLogDir(): Promise<boolean>;
/**
* 打开主窗口的开发者工具(detached 独立窗口)。
*
* 只在**非冒烟模式**下允许:冒烟验证会给窗口注入大量合成事件与截图,
* 多一个 DevTools 窗口会抢焦点、改变窗口尺寸,把那些断言打乱。
* 返回是否真的打开了(冒烟模式下返回 false)。
*/
openDevTools(): Promise<boolean>;
/** 读服务端联动状态(设置页「服务端联动状态」区块) */
getClientAgentStatus(): Promise<ClientAgentStatus>;
/** 立即向服务端上报一次,返回上报后的最新状态 */
beatClientNow(): Promise<ClientAgentStatus>;
}
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
@@ -0,0 +1,173 @@
-- 权限组 / 全局设置 / 客户端设备与远控 / 客户端安装包缓存 / 大模型审计
--
-- 一、权限组(需求:「允许 admin 设置各用户权限,或创建权限组」)
-- `PermissionGroup` 权限组本体(名字 + 说明 + 是否内置)
-- `PermissionGroupItem` 组里的一条权限键(键目录在 shared 的 PERMISSION_KEYS)
-- `UserPermissionGroup` 账号与组的分配关系(一个账号可属多个组)
-- 语义:账号没有任何分组时回落角色默认模板(ROLE_DEFAULT_PERMISSIONS,逐条镜像改造前的
-- 路由 requireRole);一旦分组,生效集合只由组的并集决定(因此也能用来收回权限)。
--
-- 二、全局设置 `AppSetting`(大模型开关与配置、开发者模式等)——值一律字符串,密钥加密后存。
--
-- 三、客户端设备与统一更新(需求:「服务端显示各班 ClassHelper 版本,可一键推送更新」)
-- `ClientDevice` 每台客户端一条:班级 / 版本 / 平台 / 是否自动更新 / 目标版本 / 心跳时间
-- `ClientCommand` 远控指令(关机 / 重启 / 注销 / 锁屏):心跳拉取 + 回执,不新增握手通道
-- `ClientRelease` 服务端缓存的安装包:教室机器没有外网也能更新
-- 在线判定看 `lastHeartbeatAt`(心跳 60 秒一次,窗口见 shared 的 CLASS_HELPER_ONLINE_WINDOW_MS)。
--
-- 四、通知按天归档(需求:「通知发布按天归档」)
-- `Notification.assignDate` 与 `Homework.assignDate` 同口径(本地日期 YYYY-MM-DD,不带时区)。
-- 回填:历史数据按 createdAt 的**本地日期**补写,避免归档页出现一堆"无日期"的旧通知。
--
-- 五、关怀模式 `User.careMode`(需求:「关怀模式(部分老师年纪大使用不习惯)」)。
--
-- 注意:本仓库的 SQLite / MySQL 双库约束是「只用 String / Int / Float / Boolean / DateTime」,
-- 因此上面这些"枚举"(指令类型、审计状态)都是 String + 服务端 zod 校验。
-- AlterTable
ALTER TABLE "User" ADD COLUMN "careMode" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT '';
-- 历史通知按本地日期回填(date() 解不了的值兜底成"今天",避免留空)
UPDATE "Notification" SET "assignDate" = date("createdAt", 'localtime') WHERE "assignDate" = '';
UPDATE "Notification" SET "assignDate" = date('now', 'localtime') WHERE "assignDate" = '';
-- CreateTable
CREATE TABLE "PermissionGroup" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"builtin" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "PermissionGroupItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"groupId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PermissionGroupItem_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "PermissionGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "UserPermissionGroup" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserPermissionGroup_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserPermissionGroup_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "PermissionGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AppSetting" (
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "ClientDevice" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT,
"deviceKey" TEXT NOT NULL,
"name" TEXT NOT NULL DEFAULT '',
"clientVersion" TEXT NOT NULL DEFAULT '',
"platform" TEXT NOT NULL DEFAULT '',
"autoUpdate" BOOLEAN NOT NULL DEFAULT true,
"updateTargetVersion" TEXT NOT NULL DEFAULT '',
"pendingVersion" TEXT NOT NULL DEFAULT '',
"lastHeartbeatAt" DATETIME,
"lastIp" TEXT NOT NULL DEFAULT '',
"stateNote" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "ClientDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ClientCommand" (
"id" TEXT NOT NULL PRIMARY KEY,
"deviceId" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"payload" TEXT NOT NULL DEFAULT '',
"createdBy" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME,
"ackedAt" DATETIME,
"result" TEXT NOT NULL DEFAULT '',
"cancelledAt" DATETIME,
CONSTRAINT "ClientCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "ClientDevice" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ClientRelease" (
"id" TEXT NOT NULL PRIMARY KEY,
"version" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"sha256" TEXT NOT NULL DEFAULT '',
"size" INTEGER NOT NULL DEFAULT 0,
"notes" TEXT NOT NULL DEFAULT '',
"createdBy" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "AiAudit" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"prompt" TEXT NOT NULL,
"intent" TEXT NOT NULL DEFAULT '',
"status" TEXT NOT NULL DEFAULT 'pending',
"message" TEXT NOT NULL DEFAULT '',
"rawReply" TEXT NOT NULL DEFAULT '',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "PermissionGroup_name_key" ON "PermissionGroup"("name");
-- CreateIndex
CREATE UNIQUE INDEX "PermissionGroupItem_groupId_key_key" ON "PermissionGroupItem"("groupId", "key");
-- CreateIndex
CREATE INDEX "PermissionGroupItem_groupId_idx" ON "PermissionGroupItem"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "UserPermissionGroup_userId_groupId_key" ON "UserPermissionGroup"("userId", "groupId");
-- CreateIndex
CREATE INDEX "UserPermissionGroup_userId_idx" ON "UserPermissionGroup"("userId");
-- CreateIndex
CREATE INDEX "UserPermissionGroup_groupId_idx" ON "UserPermissionGroup"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "ClientDevice_deviceKey_key" ON "ClientDevice"("deviceKey");
-- CreateIndex
CREATE INDEX "ClientDevice_classId_idx" ON "ClientDevice"("classId");
-- CreateIndex
CREATE INDEX "ClientDevice_lastHeartbeatAt_idx" ON "ClientDevice"("lastHeartbeatAt");
-- CreateIndex
CREATE INDEX "ClientCommand_deviceId_ackedAt_idx" ON "ClientCommand"("deviceId", "ackedAt");
-- CreateIndex
CREATE INDEX "ClientCommand_createdAt_idx" ON "ClientCommand"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "ClientRelease_version_key" ON "ClientRelease"("version");
-- CreateIndex
CREATE INDEX "AiAudit_userId_createdAt_idx" ON "AiAudit"("userId", "createdAt");
-- CreateIndex
CREATE INDEX "Notification_classId_assignDate_idx" ON "Notification"("classId", "assignDate");
+180
View File
@@ -43,6 +43,8 @@ model User {
role String @default("TEACHER")
/// 手机号(教师联系方式,可空)
phone String @default("")
/// 关怀模式(大字版界面):本人开关,Web 端与客户端各记一份
careMode Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -52,6 +54,8 @@ model User {
courses Course[]
createdHomeworks Homework[] @relation("HomeworkCreator")
createdNotices Notification[] @relation("NotificationCreator")
/// 分配的权限组;**没有任何分组时**回落角色默认模板(见 shared 的 ROLE_DEFAULT_PERMISSIONS)
permissionGroups UserPermissionGroup[]
@@index([role])
}
@@ -240,6 +244,7 @@ model Class {
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
clientDevices ClientDevice[]
@@index([teacherId])
@@index([enrollmentYear])
@@ -353,6 +358,9 @@ model Notification {
title String
content String
priority String @default("NORMAL")
/// 发布所属日期(**本地日期 YYYY-MM-DD,不带时区**):按天归档的唯一依据。
/// 与 Homework.assignDate 同口径(shared 的 dayKeyLocal),**不要**用 createdAt 切 UTC。
assignDate String @default("")
createdBy String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -362,6 +370,7 @@ model Notification {
reads NotificationRead[]
@@index([classId, createdAt])
@@index([classId, assignDate])
}
/// 通知已读记录
@@ -516,3 +525,174 @@ model ClassIslandPush {
@@index([classId, createdAt])
@@index([deviceId, ackedAt])
}
/* -------------------------------------------------------------------------- */
/* 权限组 / 全局设置 / 客户端设备 / 远控命令 / 客户端安装包 / 大模型审计 */
/* -------------------------------------------------------------------------- */
/// 权限组:管理员自定义的一组**权限键**(`shared` 的 `PERMISSION_KEYS`),可分配给多个账号。
///
/// 语义(与 `packages/shared/src/permissions.ts` 的目录一一对应):
/// - 账号**没有**任何 `UserPermissionGroup` 时,生效集合 = 角色默认模板(`ROLE_DEFAULT_PERMISSIONS`);
/// - 一旦分了组,生效集合 = 这些组的键并集(可以比默认更少 ⇒ 也能用来**收回**权限);
/// - `ADMIN` 永远补上 `permission:manage` / `system:manage`,避免把自己锁死。
model PermissionGroup {
id String @id @default(cuid())
name String @unique
description String @default("")
/// 内置组:只在种子/引导时创建,管理员不可删除(可改成员与键)
builtin Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items PermissionGroupItem[]
users UserPermissionGroup[]
}
/// 权限组里的一个权限键(一行一个键,便于查询与去重)
model PermissionGroupItem {
id String @id @default(cuid())
groupId String
/// 权限键,取值必须是 shared 的 PERMISSION_KEYS 之一(服务端 zod 校验)
key String
createdAt DateTime @default(now())
group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([groupId, key])
@@index([groupId])
}
/// 账号 ←→ 权限组 的分配关系(一个账号可属多个组)
model UserPermissionGroup {
id String @id @default(cuid())
userId String
groupId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId])
@@index([userId])
@@index([groupId])
}
/// 全局键值设置(大模型配置、开发者模式等)。
///
/// 值一律存字符串(复杂结构存 JSON 文本),键名常量在 `src/lib/settings.ts`;
/// 密钥类值(如大模型 API Key)加密后存这里,接口**只进不出**。
model AppSetting {
key String @id
value String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// ClassHelper 客户端设备(装在学生机/教室机上的桌面客户端)。
///
/// 身份由客户端主进程生成并持久化的 `deviceKey`(UUID)标识,心跳每 30 秒一次:
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
/// 指令按「查库 + 心跳拉取」的范式下发(不新增长连接握手,照抄
/// `IntegrationDevice.requestScheduleReport` 的做法)。
model ClientDevice {
id String @id @default(cuid())
/// 该设备当前登录的班级(未登录时为空)
classId String?
/// 设备唯一标识(客户端主进程生成,UUID)
deviceKey String @unique
/// 设备名(默认取 os.hostname(),管理员可改名)
name String @default("")
/// 客户端版本号(来自 app.getVersion())
clientVersion String @default("")
/// 系统与架构,如 win32-x64
platform String @default("")
/// 是否接受服务端统一下发的自动更新
autoUpdate Boolean @default(true)
/// 服务端指定的目标版本('' = 不指定,按最新版走)
updateTargetVersion String @default("")
/// 已下载待安装的版本('' = 无)
pendingVersion String @default("")
/// 最近一次心跳时间(**在线判定看它**)
lastHeartbeatAt DateTime?
/// 最近一次心跳来源 IP(排查用)
lastIp String @default("")
/// 客户端上报的当前状态说明(如"上课中/已隐藏",仅展示用)
stateNote String @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
commands ClientCommand[]
@@index([classId])
@@index([lastHeartbeatAt])
}
/// 下发给客户端设备的指令(远控:关机 / 重启 / 注销 / 锁屏)。
///
/// 客户端心跳时拉取未确认且未过期的指令,**执行前必须在界面上二次确认**,
/// 执行后回执(`ackedAt` + `result`)。
model ClientCommand {
id String @id @default(cuid())
deviceId String
/// shutdown | restart | logoff | lock
kind String
/// 附加参数(JSON 文本,预留:延时秒数、提示语)
payload String @default("")
createdBy String?
createdAt DateTime @default(now())
/// 过期时间:超过后不再下发(避免机器离线后几天突然关机)
expiresAt DateTime?
/// 客户端确认执行的时间
ackedAt DateTime?
/// 执行结果说明(成功 / 失败原因)
result String @default("")
/// 管理员撤销的时间(非空即不再下发)
cancelledAt DateTime?
device ClientDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@index([deviceId, ackedAt])
@@index([createdAt])
}
/// 服务端缓存的客户端安装包:教室机器**没有外网**时也能统一更新。
///
/// 管理员上传后落在 `data/client-releases/`,客户端从
/// `GET /api/update/client/download/:version` 下载(继承守卫与限流)。
model ClientRelease {
id String @id @default(cuid())
version String @unique
fileName String
/// 安装包 sha256(客户端下载后必须校验)
sha256 String @default("")
/// 字节数(管理员上传时记录,供下载进度与展示用)
size Int @default(0)
/// 发布说明(展示给管理员)
notes String @default("")
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
/// 大模型指令审计:谁在什么时候用"一句话"做了什么、结果如何。
///
/// 只记**意图与结果**,不记密钥;`rawReply` 供排查模型输出格式问题时查看。
model AiAudit {
id String @id @default(cuid())
userId String
/// 用户原话
prompt String
/// 模型解析出的意图(如 homework.publish / notification.publish / unknown)
intent String @default("")
/// pending | confirmed | rejected | failed
status String @default("pending")
/// 结果说明(失败原因、生成的标题等)
message String @default("")
/// 模型原始回复(截断保存,排查用)
rawReply String @default("")
createdAt DateTime @default(now())
@@index([userId, createdAt])
}
+37
View File
@@ -70,6 +70,17 @@ function createRandom(seed: number): () => number {
async function resetDatabase(): Promise<void> {
// 顺序敏感:先删子表,再删主表
// 权限组 / 客户端设备 / 远端指令 / 安装包缓存 / 大模型审计(2026-10-10 新增表)
// 注意:ClientDevice.classId 是 SetNull,不显式删会留下"孤儿设备"(班级已删、设备还在)
await prisma.clientCommand.deleteMany();
await prisma.clientDevice.deleteMany();
await prisma.clientRelease.deleteMany();
await prisma.aiAudit.deleteMany();
await prisma.userPermissionGroup.deleteMany();
await prisma.permissionGroupItem.deleteMany();
await prisma.permissionGroup.deleteMany();
// 全局设置(大模型配置 / 开发者模式)不属于业务数据,但演示库重置时一并清掉,保证可复现
await prisma.appSetting.deleteMany();
await prisma.grade.deleteMany();
await prisma.homeworkStatus.deleteMany();
await prisma.homework.deleteMany();
@@ -117,6 +128,32 @@ async function main(): Promise<void> {
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
});
// 内置权限组示例:**没有分配给任何账号**,所以不影响默认行为(演示「创建权限组」这条需求)。
// 一旦把某个老师加进组,他的生效权限就**只由组的并集决定**(可以比角色默认更少 ⇒ 也能收回权限)。
await prisma.permissionGroup.create({
data: {
name: '教务协作(示例)',
description: '可查看班级与课表、发布作业与通知、录入成绩;不能创建班级或管理教师账号',
builtin: true,
items: {
create: [
'class:read',
'schedule:read',
'homework:read',
'homework:write',
'notification:read',
'notification:publish',
'grade:read',
'grade:write',
'dashboard:read',
'term:read',
'client:read',
'ai:use',
].map((key) => ({ key })),
},
},
});
// 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
//
// 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。
@@ -68,6 +68,18 @@ const COLUMNS = [
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
note: '教师手机号',
},
{
table: 'User',
column: 'careMode',
sql: `ALTER TABLE "User" ADD COLUMN "careMode" BOOLEAN NOT NULL DEFAULT false`,
note: '关怀模式(大字版界面)',
},
{
table: 'Notification',
column: 'assignDate',
sql: `ALTER TABLE "Notification" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT ''`,
note: '通知所属日期(本地日期 YYYY-MM-DD,按天归档用)',
},
];
/**
+782 -9
View File
@@ -167,9 +167,35 @@ async function main() {
method: 'POST',
body: { username: 'teacher2', password: 'teacher123' },
});
const teacher2Token = teacher2Login.payload?.data?.token;
const teacher2Id = teacher2Login.payload?.data?.user?.id;
let teacher2Token = teacher2Login.payload?.data?.token;
let teacher2Id = teacher2Login.payload?.data?.user?.id;
const teacher1ClassIds = new Set(classes.map((item) => item.id));
// 演示库里的密码可能被人工改过(本机 teacher1 就有被改成 123456 的情况):登录失败时由管理员
// 重置为验证环境已知值再重登。**teacher2 是"科任老师/班级隔离"整组用例的主体**,
// 上一版漏了这条兜底,密码一改后面整片用例都只能跑空分支。
if (!teacher2Token) {
const staffList = await api('/teachers', { token: adminToken });
const fallbackTeacher2Id =
teacher2Id ?? (staffList.payload?.data ?? []).find((item) => item.username === 'teacher2')?.id;
if (fallbackTeacher2Id) {
await api(`/teachers/${fallbackTeacher2Id}/reset-password`, {
method: 'POST',
token: adminToken,
body: { newPassword: 'e2e-teacher2-123456' },
});
const retryTeacher2 = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: 'e2e-teacher2-123456' },
});
teacher2Token = retryTeacher2.payload?.data?.token;
teacher2Id = retryTeacher2.payload?.data?.user?.id;
record(
'教师 teacher2 密码被改过,已由管理员重置为验证环境已知值(登录环境自愈)',
Boolean(teacher2Token),
`status=${retryTeacher2.status}`,
);
}
}
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = [];
@@ -1191,6 +1217,13 @@ async function main() {
if (subjectCourse) {
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
.payload?.data?.find((item) => item.teacherId !== teacher2Id);
// 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目"
// 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。
record(
'存在"别人任教科目"用于越权校验',
Boolean(otherCourse?.id),
`科目=${otherCourse?.name ?? '-'}`,
);
const foreignHomework = await api('/homeworks', {
method: 'POST',
token: teacher2Token,
@@ -1285,6 +1318,641 @@ async function main() {
}
}
// ---------------------------------------------------------------- 6.5 通知按天归档 + 批量发布
{
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
// 按天归档:通知也有"所属日期"(assignDate,本地日期),晚间发布不会被 UTC 切片算到第二天
const daysRes = await api(`/notifications/days?classId=${classId}`, { token: teacherToken });
const days = daysRes.payload?.data?.days ?? [];
record(
'通知按天归档 GET /notifications/days(按所属日期汇总条数)',
daysRes.status === 200 &&
days.length > 0 &&
days.every((item) => DAY_KEY.test(String(item.date)) && item.count > 0),
`status=${daysRes.status} 天数=${days.length} 样例=${JSON.stringify(days[0] ?? null)}`,
);
const creatorsRes = await api(`/notifications/creators?classId=${classId}`, { token: teacherToken });
const creators = creatorsRes.payload?.data?.creators ?? [];
record(
'通知按发布人归档 GET /notifications/creators(带名字与条数)',
creatorsRes.status === 200 &&
creators.length > 0 &&
creators.every((item) => item.id && item.name && item.count > 0),
`status=${creatorsRes.status} 发布人=${creators.length} 样例=${JSON.stringify(creators[0] ?? null)}`,
);
// 按天筛选:返回的每条通知 assignDate 必须等于筛选的日期
const targetDay = days[0]?.date;
if (targetDay) {
const byDay = await api(`/notifications?classId=${classId}&date=${targetDay}`, { token: teacherToken });
const rows = byDay.payload?.data ?? [];
record(
'通知按天筛选(?date=)只返回该天的通知',
byDay.status === 200 && rows.length > 0 && rows.every((item) => item.assignDate === targetDay),
`date=${targetDay} 条数=${rows.length}`,
);
}
const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, { token: teacherToken });
record('通知归档接口拒绝非法日期(422)', badDay.status === 422, `status=${badDay.status}`);
// 批量发布通知:一次提交多个班,返回逐班结果
const bulkClassIds = foreignClass ? [classId, foreignClass.id] : [classId];
const bulkNotice = await api('/notifications/bulk', {
method: 'POST',
token: adminToken,
body: {
classIds: bulkClassIds,
title: `批量发布回归 ${Date.now()}`,
content: '批量发布通知用例(管理员对多个班级一次发布)',
priority: 'NORMAL',
},
});
const bulkNoticeData = bulkNotice.payload?.data;
record(
'批量发布通知 POST /notifications/bulk(逐班结果)',
bulkNotice.status === 201 &&
bulkNoticeData?.total === bulkClassIds.length &&
bulkNoticeData?.succeeded === bulkClassIds.length &&
(bulkNoticeData?.results ?? []).every((item) => item.ok && item.id),
`status=${bulkNotice.status} total=${bulkNoticeData?.total} 成功=${bulkNoticeData?.succeeded}`,
);
for (const item of bulkNoticeData?.results ?? []) {
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken });
}
if (foreignClass) {
// 教师批量发到"别人的班":那个班逐班失败,自己的班照常发布,整批不能变成 500
const mixed = await api('/notifications/bulk', {
method: 'POST',
token: teacherToken,
body: {
classIds: [classId, foreignClass.id],
title: `批量越权回归 ${Date.now()}`,
content: '其中一个班没有发布权限',
priority: 'NORMAL',
},
});
const mixedData = mixed.payload?.data;
const denied = (mixedData?.results ?? []).find((item) => item.classId === foreignClass.id);
record(
'批量发布通知:无权限的班逐班失败、有权限的班照常发布',
mixed.status === 201 && mixedData?.succeeded === 1 && denied?.ok === false,
`status=${mixed.status} 成功=${mixedData?.succeeded} 失败code=${denied?.code ?? '-'} 失败原因=${denied?.message ?? '-'}`,
);
for (const item of mixedData?.results ?? []) {
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: teacherToken });
}
// 批量布置作业:跨班按**科目名称**匹配课程(课程是班级内的,不可能共用一个 courseId)
const bulkHomework = await api('/homeworks/bulk', {
method: 'POST',
token: adminToken,
body: {
classIds: [classId, foreignClass.id],
subjectName: '语文',
title: `批量布置回归 ${Date.now()}`,
content: '批量布置作业用例(按科目名称跨班匹配)',
},
});
const bulkHomeworkData = bulkHomework.payload?.data;
record(
'批量布置作业 POST /homeworks/bulk(逐班结果 + 按科目名称匹配课程)',
bulkHomework.status === 201 &&
bulkHomeworkData?.total === 2 &&
(bulkHomeworkData?.results ?? []).every((item) => typeof item.ok === 'boolean'),
`status=${bulkHomework.status} 成功=${bulkHomeworkData?.succeeded} 失败=${bulkHomeworkData?.failed} ` +
`逐班=${JSON.stringify((bulkHomeworkData?.results ?? []).map((item) => item.code ?? 'ok'))}`,
);
for (const item of bulkHomeworkData?.results ?? []) {
if (item.id) await api(`/homeworks/${item.id}`, { method: 'DELETE', token: adminToken });
}
}
}
// ---------------------------------------------------------------- 6.6 权限组
{
const meTeacher = await api('/permissions/me', { token: teacherToken });
const meTeacherData = meTeacher.payload?.data;
const meAdmin = await api('/permissions/me', { token: adminToken });
const meAdminData = meAdmin.payload?.data;
record(
'当前账号生效权限 GET /permissions/me(教师有 homework:write、无 permission:manage)',
meTeacher.status === 200 &&
Array.isArray(meTeacherData?.permissions) &&
meTeacherData.permissions.includes('homework:write') &&
!meTeacherData.permissions.includes('permission:manage'),
`status=${meTeacher.status} 权限数=${(meTeacherData?.permissions ?? []).length} ` +
`fromGroups=${meTeacherData?.fromGroups}`,
);
record(
'管理员生效权限是全集(含 permission:manage 与 remote:control)',
meAdmin.status === 200 &&
(meAdminData?.permissions ?? []).includes('permission:manage') &&
(meAdminData?.permissions ?? []).includes('remote:control'),
`status=${meAdmin.status} 权限数=${(meAdminData?.permissions ?? []).length}`,
);
// 权限键目录是 permission:manage 专属:教师 403、管理员拿到整棵树
const catalogDenied = await api('/permissions/catalog', { token: teacherToken });
const catalog = await api('/permissions/catalog', { token: adminToken });
const catalogData = catalog.payload?.data;
record(
'权限目录 GET /permissions/catalog(教师 403 / 管理员拿到 keys+categories+defaultRoles)',
catalogDenied.status === 403 &&
catalog.status === 200 &&
Array.isArray(catalogData?.keys) &&
catalogData.keys.includes('homework:write') &&
Array.isArray(catalogData?.categories) &&
typeof catalogData?.defaultRoles === 'object' &&
catalogData.defaultRoles !== null,
`教师=${catalogDenied.status} 管理员=${catalog.status} 键数=${(catalogData?.keys ?? []).length} ` +
`分类数=${(catalogData?.categories ?? []).length}`,
);
// 权限组可以比角色默认**更少**:只给 dashboard:read 的组分配给 teacher2 后,他只剩这一个键
const groupName = `e2e-只读-${Date.now().toString().slice(-6)}`;
const createdGroup = await api('/permissions/groups', {
method: 'POST',
token: adminToken,
body: { name: groupName, description: '验证用临时权限组(收尾删除)', keys: ['dashboard:read'] },
});
const permissionGroupId = createdGroup.payload?.data?.id;
// 成员列表是"账号 id"的权威来源:登录响应里的 user.id 只是会话用户,
// 权限页的成员 id 要按 username 从 GET /permissions/users 取(管理员本人也要在这里找)
const membersRes = await api('/permissions/users', { token: adminToken });
const members = membersRes.payload?.data ?? [];
const teacher2Member = members.find((item) => item.username === 'teacher2') ?? null;
const adminMember = members.find((item) => item.username === 'admin') ?? null;
// teacher2 的密码按脚本既有做法自愈(本机可能被人工改过),失败就重置成已知值再登
const teacher2Password = 'teacher123';
let teacher2Auth = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: teacher2Password },
});
if (teacher2Auth.status !== 200 && teacher2Member) {
await api(`/teachers/${teacher2Member.id}/reset-password`, {
method: 'POST',
token: adminToken,
body: { newPassword: teacher2Password },
});
teacher2Auth = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: teacher2Password },
});
}
const permissionTestToken = teacher2Auth.payload?.data?.token;
const assignLess = permissionGroupId
? await api(`/permissions/users/${teacher2Member?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [permissionGroupId] },
})
: { status: 0, payload: null };
const meLess = permissionTestToken
? await api('/permissions/me', { token: permissionTestToken })
: { status: 0, payload: null };
const teacher2LessPermissions = meLess.payload?.data?.permissions ?? [];
record(
'权限组可以比角色默认更少(只给 dashboard:read 的组 → 生效权限恰好只有这一个键)',
Boolean(permissionGroupId) &&
assignLess.status === 200 &&
teacher2LessPermissions.length === 1 &&
teacher2LessPermissions[0] === 'dashboard:read',
`建组=${createdGroup.status} 分配=${assignLess.status} 生效权限=${JSON.stringify(teacher2LessPermissions)}`,
);
const assignDefault = await api(`/permissions/users/${teacher2Member?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
const meReset = permissionTestToken
? await api('/permissions/me', { token: permissionTestToken })
: { status: 0, payload: null };
const teacher2ResetPermissions = meReset.payload?.data?.permissions ?? [];
record(
'清空权限组后回到角色默认模板(PUT /users/:id/groups 传空数组 → 含 homework:write)',
assignDefault.status === 200 &&
teacher2ResetPermissions.includes('homework:write') &&
!teacher2ResetPermissions.includes('permission:manage'),
`status=${assignDefault.status} 生效权限数=${teacher2ResetPermissions.length} ` +
`含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`,
);
// 管理员固定拥有全部权限,不接受分配
const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: permissionGroupId ? [permissionGroupId] : [] },
});
record(
'管理员不接受权限组分配(PUT /users/:id/groups → 400)',
Boolean(adminMember) &&
assignAdmin.status === 400 &&
String(assignAdmin.payload?.message ?? '').includes('权限组'),
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
);
// 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败)
const groupsList = await api('/permissions/groups', { token: adminToken });
const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null;
if (builtinGroup) {
const deleteBuiltin = await api(`/permissions/groups/${builtinGroup.id}`, { method: 'DELETE', token: adminToken });
record(
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
deleteBuiltin.status === 400,
`内置组=${builtinGroup.name} status=${deleteBuiltin.status} 响应=${deleteBuiltin.payload?.message ?? ''}`,
);
} else {
record(
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
true,
`跳过:当前库里没有任何 builtin=true 的权限组(GET /permissions/groups 返回 ${(groupsList.payload?.data ?? []).length} 个,种子里的「教务协作(示例)」不在库里)`,
);
}
// 收尾:teacher2 必须回到角色默认(否则后面所有以他为科任老师的用例全被权限组污染),临时组删掉
await api(`/permissions/users/${teacher2Member?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
if (permissionGroupId) {
await api(`/permissions/groups/${permissionGroupId}`, { method: 'DELETE', token: adminToken });
}
const groupsAfter = await api('/permissions/groups', { token: adminToken });
record(
'权限组用例收尾:teacher2 分组已清空、临时权限组已删除',
!(groupsAfter.payload?.data ?? []).some((item) => item.id === permissionGroupId),
`剩余权限组=${(groupsAfter.payload?.data ?? []).length}`,
);
}
// ---------------------------------------------------------------- 6.7 客户端设备与远控
{
const deviceKey = `e2e-device-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
const heartbeatBase = {
deviceKey,
name: 'E2E 测试机',
clientVersion: '1.0.0',
platform: 'win32',
stateNote: '待机',
};
// 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401
const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase });
const heartbeat = await api('/clients/heartbeat', { method: 'POST', token: adminToken, body: heartbeatBase });
const heartbeatData = heartbeat.payload?.data;
record(
'客户端心跳 POST /clients/heartbeat(未登录 401 / 登录后 200 且返回 deviceId 与 command 字段)',
heartbeatNoAuth.status === 401 &&
heartbeat.status === 200 &&
Boolean(heartbeatData?.deviceId) &&
'command' in (heartbeatData ?? {}),
`未登录=${heartbeatNoAuth.status} 登录=${heartbeat.status} deviceId=${heartbeatData?.deviceId ?? '-'} ` +
`command=${JSON.stringify(heartbeatData?.command ?? null)}`,
);
// 越权:remote:control 默认只有管理员有
const teacherCommandDenied = await api('/clients/commands', {
method: 'POST',
token: teacherToken,
body: { deviceIds: [heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: '越权用例' },
});
record(
'远控命令越权被拒(教师无 remote:control → 403)',
teacherCommandDenied.status === 403,
`status=${teacherCommandDenied.status} 响应=${teacherCommandDenied.payload?.message ?? ''}`,
);
// 设备列表:刚心跳的这台要在线
const devices = await api('/clients/devices', { token: adminToken });
const device = (devices.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
record(
'设备列表 GET /clients/devices(刚心跳的设备在线)',
devices.status === 200 && Boolean(device) && device.online === true,
`status=${devices.status} 设备=${device?.name ?? '未找到'} online=${device?.online}`,
);
// 下发锁屏命令(带备注)→ 下一次心跳必须带回这条命令
const command = await api('/clients/commands', {
method: 'POST',
token: adminToken,
body: { deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: 'E2E 远控用例' },
});
const heartbeatWithCommand = await api('/clients/heartbeat', {
method: 'POST',
token: adminToken,
body: { ...heartbeatBase, stateNote: '待机' },
});
const delivered = heartbeatWithCommand.payload?.data?.command;
record(
'远控命令下发 POST /clients/commands(lock)并在下次心跳送达(kind=lock)',
command.status === 201 && Boolean(delivered?.id) && delivered?.kind === 'lock',
`下发=${command.status} 送达=${JSON.stringify(delivered ?? null)}`,
);
const ackBody = { deviceKey, ok: true, result: 'e2e' };
const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST',
token: adminToken,
body: ackBody,
});
// 幂等:客户端重试回执不报错、也不覆盖第一次结果
const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST',
token: adminToken,
body: { deviceKey, ok: false, result: '重试不应覆盖结果' },
});
record(
'命令回执 POST /clients/commands/:id/ack(首次 200,重复回执幂等仍 200)',
ackFirst.status === 200 && ackSecond.status === 200,
`首次=${ackFirst.status}(${ackFirst.payload?.data?.result ?? '-'}) 重复=${ackSecond.status}(${ackSecond.payload?.data?.result ?? '-'})`,
);
const devicesAfterAck = await api('/clients/devices', { token: adminToken });
const ackedDevice = (devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
record(
'回执后设备不再有待执行命令(pendingCommand 为空)',
Boolean(ackedDevice) && ackedDevice.pendingCommand === null,
`pendingCommand=${JSON.stringify(ackedDevice?.pendingCommand ?? null)}`,
);
// 归属校验:别人拿自己的 deviceKey 回执同一 id 必须被拒
const otherDeviceKey = `${deviceKey}-other`;
const otherHeartbeat = await api('/clients/heartbeat', {
method: 'POST',
token: adminToken,
body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' },
});
const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null;
const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, {
method: 'POST',
token: adminToken,
body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' },
});
record(
'回执归属校验(别的 deviceKey 回执同一命令 → 403)',
ackWrongDevice.status === 403,
`status=${ackWrongDevice.status} code=${ackWrongDevice.payload?.code ?? '-'} ` +
`响应=${ackWrongDevice.payload?.message ?? ''}`,
);
// 收尾:两台测试机都清掉(否则会长期挂在设备列表里)
const deleteTestDevice = device
? await api(`/clients/devices/${device.id}`, { method: 'DELETE', token: adminToken })
: { status: 0, payload: null };
const deleteOtherDevice = otherDeviceId
? await api(`/clients/devices/${otherDeviceId}`, { method: 'DELETE', token: adminToken })
: { status: 0, payload: null };
const deviceAfterDelete = await api('/clients/devices', { token: adminToken });
const leftoverKeys = (deviceAfterDelete.payload?.data ?? [])
.filter((item) => item.deviceKey === deviceKey || item.deviceKey === otherDeviceKey)
.map((item) => item.deviceKey);
record(
'客户端设备用例收尾:两台测试机都已移除',
deleteTestDevice.status === 200 && deleteOtherDevice.status === 200 && leftoverKeys.length === 0,
`删除测试机=${deleteTestDevice.status} 删除另一台=${deleteOtherDevice.status} ` +
`残留=${leftoverKeys.length ? leftoverKeys.join(',') : '无'}`,
);
}
// ---------------------------------------------------------------- 6.8 客户端安装包
{
const releases = await api('/update/client/releases', { token: adminToken });
record(
'客户端安装包列表 GET /update/client/releases',
releases.status === 200 && Array.isArray(releases.payload?.data),
`status=${releases.status} 版本数=${(releases.payload?.data ?? []).length}`,
);
// 登记只写元数据,**但要求磁盘上真的有这个版本的安装包**:不存在的版本必须被拒(400)
const fakeVersion = '9.9.9-e2e';
const registerMissing = await api('/update/client/releases', {
method: 'POST',
token: adminToken,
body: { version: fakeVersion },
});
const releasesAfterRegister = await api('/update/client/releases', { token: adminToken });
record(
'登记不存在的客户端安装包版本被拒(400,且不会落库)',
registerMissing.status === 400 &&
!(releasesAfterRegister.payload?.data ?? []).some((item) => item.version === fakeVersion),
`status=${registerMissing.status} code=${registerMissing.payload?.code ?? '-'} ` +
`响应=${registerMissing.payload?.message ?? ''}`,
);
// 版本白名单:含路径分隔符的版本号连 schema 都过不去
const badVersion = await api('/update/client/releases', {
method: 'POST',
token: adminToken,
body: { version: '../evil' },
});
record(
'版本号白名单(POST /update/client/releases 传 ../evil → 400/422)',
badVersion.status === 400 || badVersion.status === 422,
`status=${badVersion.status} code=${badVersion.payload?.code ?? '-'} ` +
`响应=${badVersion.payload?.message ?? ''}`,
);
// 下载接口本身不挂权限键,但**要登录**:不带 token 会被 401 挡在鉴权层
const downloadNoAuth = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`);
const downloadMissing = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`, {
token: adminToken,
});
record(
'下载不存在的客户端安装包(未登录 401 / 已登录 404)',
downloadNoAuth.status === 401 && downloadMissing.status === 404,
`未登录=${downloadNoAuth.status} 已登录=${downloadMissing.status} ` +
`响应=${downloadMissing.payload?.message ?? ''}`,
);
}
// ---------------------------------------------------------------- 6.9 大模型助手(一律 mock,绝不联网)
{
const aiSettingsDenied = await api('/ai/settings', { token: teacherToken });
const aiSettings = await api('/ai/settings', { token: adminToken });
const aiSettingsData = aiSettings.payload?.data ?? {};
const aiSettingsText = JSON.stringify(aiSettingsData);
record(
'大模型设置 GET /ai/settings(教师 403 / 管理员 200,且响应里没有任何密钥字段)',
aiSettingsDenied.status === 403 &&
aiSettings.status === 200 &&
typeof aiSettingsData.enabled === 'boolean' &&
'provider' in aiSettingsData &&
'baseUrl' in aiSettingsData &&
'model' in aiSettingsData &&
'hasApiKey' in aiSettingsData &&
'keyHint' in aiSettingsData &&
'systemPromptExtra' in aiSettingsData &&
!aiSettingsText.includes('apiKey'),
`教师=${aiSettingsDenied.status} 管理员=${aiSettings.status} 字段=${Object.keys(aiSettingsData).join(',')}`,
);
// 助手没开启时 /interpret 会 400:先开启,且**强制走离线 mock 解析**(绝不联网)
const enableAi = await api('/ai/settings', {
method: 'PUT',
token: adminToken,
body: { enabled: true, provider: 'mock' },
});
record(
'开启大模型助手并固定为离线 mock(PUT /ai/settings)',
enableAi.status === 200 && enableAi.payload?.data?.enabled === true && enableAi.payload?.data?.provider === 'mock',
`status=${enableAi.status} enabled=${enableAi.payload?.data?.enabled} provider=${enableAi.payload?.data?.provider}`,
);
const promptText = '给2026级1班布置作业,把卷子写完';
const interpret = await api('/ai/interpret', {
method: 'POST',
token: teacherToken,
body: { text: promptText },
});
const interpretData = interpret.payload?.data;
record(
'助手解析布置作业(POST /ai/interpret → create_homework,需确认、班级非空、有预览)',
interpret.status === 200 &&
interpretData?.action === 'create_homework' &&
interpretData?.needConfirm === true &&
Boolean(interpretData?.params?.classId) &&
Boolean(interpretData?.preview?.summary),
`status=${interpret.status} action=${interpretData?.action} needConfirm=${interpretData?.needConfirm} ` +
`classId=${interpretData?.params?.classId ?? '-'} summary=${interpretData?.preview?.summary ?? '-'}`,
);
const unknownInterpret = await api('/ai/interpret', {
method: 'POST',
token: teacherToken,
body: { text: '今天天气不错' },
});
record(
'助手听不懂时返回 unknown(不猜动作)',
unknownInterpret.status === 200 && unknownInterpret.payload?.data?.action === 'unknown',
`status=${unknownInterpret.status} action=${unknownInterpret.payload?.data?.action}`,
);
const audit = await api('/ai/audit', { token: adminToken });
const auditRows = audit.payload?.data ?? [];
record(
'助手使用审计 GET /ai/audit(能看到刚写入的那句话)',
audit.status === 200 && auditRows.some((item) => item.prompt === promptText),
`status=${audit.status} 记录数=${auditRows.length}`,
);
// 收尾:助手必须还原成关闭(否则别的东西会以为已经配好了模型)
const disableAi = await api('/ai/settings', {
method: 'PUT',
token: adminToken,
body: { enabled: false },
});
record(
'大模型助手用例收尾:enabled 已还原为 false',
disableAi.status === 200 && disableAi.payload?.data?.enabled === false,
`status=${disableAi.status} enabled=${disableAi.payload?.data?.enabled}`,
);
}
// ---------------------------------------------------------------- 6.10 系统设置
{
const systemDenied = await api('/system/settings', { token: teacherToken });
const systemSettings = await api('/system/settings', { token: adminToken });
record(
'系统设置 GET /system/settings(教师 403 / 管理员 200)',
systemDenied.status === 403 &&
systemSettings.status === 200 &&
typeof systemSettings.payload?.data?.developerMode === 'boolean',
`教师=${systemDenied.status} 管理员=${systemSettings.status} ` +
`developerMode=${systemSettings.payload?.data?.developerMode}`,
);
// 开发者模式:开启后错误响应会带上堆栈,所以**必须**还原
const enableDeveloper = await api('/system/settings', {
method: 'PUT',
token: adminToken,
body: { developerMode: true },
});
const readBackDeveloper = await api('/system/settings', { token: adminToken });
const disableDeveloper = await api('/system/settings', {
method: 'PUT',
token: adminToken,
body: { developerMode: false },
});
const readBackDisabled = await api('/system/settings', { token: adminToken });
record(
'开发者模式可开关并回读(PUT /system/settings → GET 一致,随后还原为 false)',
enableDeveloper.status === 200 &&
readBackDeveloper.payload?.data?.developerMode === true &&
disableDeveloper.status === 200 &&
readBackDisabled.payload?.data?.developerMode === false,
`开启=${enableDeveloper.status} 回读=${readBackDeveloper.payload?.data?.developerMode} ` +
`还原=${readBackDisabled.payload?.data?.developerMode}`,
);
const autostart = await api('/system/autostart', { token: adminToken });
const autostartData = autostart.payload?.data;
const enableAutostart = await api('/system/autostart', {
method: 'PUT',
token: adminToken,
body: { enabled: true },
});
record(
'开机自启:开发机不支持(GET supported=false,PUT 拒绝 400)',
autostart.status === 200 &&
autostartData?.supported === false &&
typeof autostartData?.detail === 'string' &&
autostartData.detail.length > 0 &&
enableAutostart.status === 400,
`GET=${autostart.status} supported=${autostartData?.supported} mechanism=${autostartData?.mechanism} ` +
`detail=${autostartData?.detail ?? '-'} PUT=${enableAutostart.status} ` +
`响应=${enableAutostart.payload?.message ?? ''}`,
);
const diagnostics = await api('/system/diagnostics', { token: adminToken });
const diagnosticsText = JSON.stringify(diagnostics.payload?.data ?? {});
record(
'运行诊断 GET /system/diagnostics(200 且不泄漏 apiKey/password/token/secret)',
diagnostics.status === 200 &&
!/apiKey|password|token|secret/i.test(diagnosticsText),
`status=${diagnostics.status} 字段=${Object.keys(diagnostics.payload?.data ?? {}).join(',')}`,
);
}
// ---------------------------------------------------------------- 6.11 批量发布补漏
{
// 一个不存在的班级 + 一个真实班级:单班失败不能把整批带崩(逐班结果,不整批回滚)
const missingClassId = `not-exist-${Date.now().toString(36)}`;
const mixedBulk = await api('/notifications/bulk', {
method: 'POST',
token: adminToken,
body: {
classIds: [missingClassId, classId],
title: `批量失败隔离回归 ${Date.now()}`,
content: '其中一个班不存在,另一个班照常发布',
priority: 'NORMAL',
},
});
const mixedBulkData = mixedBulk.payload?.data;
const missingResult = (mixedBulkData?.results ?? []).find((item) => item.classId === missingClassId);
record(
'批量发布:不存在的班级逐班失败、真实班级照常成功(failed>=1 且 succeeded>=1)',
mixedBulk.status === 201 &&
(mixedBulkData?.failed ?? 0) >= 1 &&
(mixedBulkData?.succeeded ?? 0) >= 1 &&
missingResult?.ok === false,
`status=${mixedBulk.status} total=${mixedBulkData?.total} 成功=${mixedBulkData?.succeeded} ` +
`失败=${mixedBulkData?.failed} 失败code=${missingResult?.code ?? '-'}`,
);
// 清理:只删成功落库的那几条(失败的班没有 id)
for (const item of mixedBulkData?.results ?? []) {
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken });
}
}
// ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
@@ -1684,6 +2352,76 @@ async function main() {
`status=${gradesByHeadTeacher.status} 跳过=${gradesByHeadTeacher.payload?.data?.skipped} 新增=${gradesByHeadTeacher.payload?.data?.inserted}`,
);
// 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"):
// ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师,
// 后面那道 assertCanManageSubjectContent 的科目门随即失效;
// ② 班主任导入**别人任教科目**的成绩必须逐行拒绝(原先只判到班级这一层)。
{
const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`;
const firstCourse = await api('/courses', {
method: 'POST',
token: adminToken,
body: { classId, name: guardedCourseName },
});
const firstCourseId = firstCourse.payload?.data?.id;
record(
'新建科目成功(用于越权校验)',
firstCourse.status === 201 && Boolean(firstCourseId),
`status=${firstCourse.status} id=${firstCourseId ?? '-'}`,
);
const duplicateCourse = await api('/courses', {
method: 'POST',
token: adminToken,
body: { classId, name: guardedCourseName },
});
record(
'同名课程被拒绝并返回已有课程 id(COURSE_EXISTS)',
duplicateCourse.status === 409 &&
duplicateCourse.payload?.code === 'COURSE_EXISTS' &&
duplicateCourse.payload?.details?.courseId === firstCourseId,
`status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`,
);
// 这门课的任课老师是**管理员**(course.create 会把创建者写成任课老师):
// 班主任导入它的成绩必须被拒,且不能落库。
const guardedCsv = [
'学号,考试名称,分数,总分,科目',
`${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`,
].join(NL);
const guardedImport = await api('/imports/table/commit', {
method: 'POST',
token: teacherToken,
body: {
kind: 'grades',
classId,
fileName: 'guarded.csv',
contentBase64: Buffer.from(`\ufeff${guardedCsv}${NL}`, 'utf8').toString('base64'),
mapping: {
studentNo: '学号',
examName: '考试名称',
score: '分数',
totalScore: '总分',
courseName: '科目',
},
mode: 'append',
},
});
const guardedErrors = guardedImport.payload?.data?.errors ?? [];
record(
'班主任导入他人任教科目成绩被逐行拒绝(不落库)',
guardedImport.status === 200 &&
guardedImport.payload?.data?.inserted === 0 &&
guardedImport.payload?.data?.failed === 1 &&
String(guardedErrors[0]?.message ?? '').includes('不是你任教的科目'),
`新增=${guardedImport.payload?.data?.inserted} 失败=${guardedImport.payload?.data?.failed} 提示=${guardedErrors[0]?.message ?? '-'}`,
);
if (firstCourseId) {
await api(`/courses/${firstCourseId}`, { method: 'DELETE', token: adminToken });
}
}
// 8.4 学生名单导入(仅管理员)+ 数据清理
const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
// 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
@@ -2433,17 +3171,48 @@ async function main() {
// ---------------------------------------------------------------- 数据库管理(仅管理员)
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
// 数据表清单随模型演进增长(2026-10-10 加了权限组 / 客户端管理 8 张表后共 23 张)。
// **不写死总数**:断言"服务端快照枚举 == 这份清单",加模型时只改这一处。
const REQUIRED_DB_TABLES = [
'User',
'ArchivedYear',
'Class',
'Student',
'Course',
'StudentClassTransfer',
'Schedule',
'TimeLayout',
'Homework',
'HomeworkStatus',
'Notification',
'NotificationRead',
'Grade',
'IntegrationDevice',
'ClassIslandPush',
'PermissionGroup',
'PermissionGroupItem',
'UserPermissionGroup',
'AppSetting',
'ClientDevice',
'ClientCommand',
'ClientRelease',
'AiAudit',
];
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
const dbStatusTableNames = Array.isArray(dbStatusData.tables)
? dbStatusData.tables.map((item) => item.name)
: [];
record(
'数据库状态(管理员):已连接且返回 15 张表行数',
'数据库状态(管理员):已连接且返回全部数据表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 15 &&
dbStatusData.tables.every((item) => item.count >= 0),
dbStatusData.tables.every((item) => item.count >= 0) &&
dbStatusTableNames.length === REQUIRED_DB_TABLES.length &&
REQUIRED_DB_TABLES.every((name) => dbStatusTableNames.includes(name)),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
dbStatusTableNames.length
}`,
);
@@ -2556,13 +3325,17 @@ async function main() {
} catch {
snapshotParsed = null;
}
const snapshotTableNames = Object.keys(snapshotParsed?.data ?? {});
record(
'导出快照(JSON 格式正确、15 张表、账号数 > 0)',
'导出快照(JSON 格式正确、全部数据表、账号数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 15 &&
snapshotTableNames.length === REQUIRED_DB_TABLES.length &&
REQUIRED_DB_TABLES.every((name) => snapshotTableNames.includes(name)) &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 表=${snapshotTableNames.length} 用户=${
snapshotParsed?.counts?.User ?? '-'
}`,
);
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
+4
View File
@@ -7,6 +7,7 @@ import { env } from './config/env.js';
import { disconnectPrisma, pingDatabase } from './lib/db.js';
import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
import { logger } from './lib/logger.js';
import { refreshDeveloperMode } from './lib/settings.js';
import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js';
import { startBackupScheduler } from './modules/database/database.service.js';
@@ -51,6 +52,9 @@ async function prepareDatabase(): Promise<void> {
await prepareDatabase();
// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts)
await refreshDeveloperMode();
const app = createApp();
const httpServer = createServer(app);
const io = initRealtime(httpServer);
+38
View File
@@ -0,0 +1,38 @@
import type { BulkPublishClassResult, BulkPublishResult } from '@classhelper/shared';
import { ApiError } from './http.js';
import { logger } from './logger.js';
/**
* 批量发布的公共收尾:把单个班级的失败翻译成可直接展示给用户的结果。
*
* 批量发布是**逐班判定**的 —— 某个班没有权限、或正在上课而通知是紧急的,
* 都不该让整批失败:成功的班照常落库并实时推送,失败的班把自己的错误码与中文原因带回来,
* 由 Web 端在结果弹窗里逐行列出。因此这里保留 ApiError 的 code/details 原样透传。
*/
export function toBulkFailure(classId: string, error: unknown, action = '发布'): BulkPublishClassResult {
if (error instanceof ApiError) {
return {
classId,
ok: false,
code: error.code,
message: error.message,
...(error.details === undefined ? {} : { details: error.details as Record<string, unknown> }),
};
}
logger.error(
`批量${action}出现未预期错误:班级=${classId} 原因=${error instanceof Error ? error.message : String(error)}`,
);
return { classId, ok: false, code: 'INTERNAL_ERROR', message: `${action}失败,请稍后重试` };
}
/** 汇总逐班结果(succeeded/failed 由结果本身统计,调用方不必自己数) */
export function summarizeBulk(results: BulkPublishClassResult[]): BulkPublishResult {
const succeeded = results.filter((item) => item.ok).length;
return { total: results.length, succeeded, failed: results.length - succeeded, results };
}
/** 批量目标班级:去重并保持调用方给的顺序(多选控件重复提交时会带重复 id) */
export function normalizeBulkClassIds(classIds: readonly string[]): string[] {
return [...new Set(classIds)];
}
+8
View File
@@ -19,6 +19,14 @@ export type ApiErrorCode =
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'CONFLICT'
/** 课程:该班级已有同名课程(前端应复用 `details.courseId`,而不是另建一门) */
| 'COURSE_EXISTS'
/** 大模型:功能未开启 / 还没配置服务地址与密钥 */
| 'AI_NOT_CONFIGURED'
/** 大模型:调用超时或网络不可达 */
| 'AI_TIMEOUT'
/** 大模型:返回的内容不是可用的结构化动作(已记日志,不把原始回复回传给前端) */
| 'AI_INVALID_OUTPUT'
| 'TOO_MANY_REQUESTS'
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
| 'URGENT_DURING_CLASS'
+4
View File
@@ -378,6 +378,8 @@ export interface NotificationLike {
priority: string;
createdBy: string;
createdAt: Date;
/** 通知所属日期(YYYY-MM-DD);老库可能为空串,DTO 层按 createdAt 本地日期兜底 */
assignDate?: string;
creator?: TeacherBriefLike | null;
reads?: NotificationReadLike[];
}
@@ -403,6 +405,8 @@ export function toNotificationDto(
priority: item.priority as NotificationPriority,
createdBy: item.createdBy,
createdAt: toIso(item.createdAt),
// 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值
assignDate: item.assignDate || dayKeyLocal(item.createdAt),
creator: item.creator ? toTeacherBrief(item.creator) : null,
read: own ? true : false,
readAt: toIsoOrNull(own?.readAt),
+67
View File
@@ -0,0 +1,67 @@
import {
PERMISSION_KEYS,
PERMISSION_LABELS,
ROLE_DEFAULT_PERMISSIONS,
defaultPermissionsForRole,
type PermissionKey,
} from '@classhelper/shared';
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
/**
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
*
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
* - `ADMIN`:全集。另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
* 把自己(或最后一个管理员)锁在权限页之外;
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
* 等)决定,给某个班级端"分配权限组"没有意义;
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 `requireRole`)。
*
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
*/
export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<PermissionKey>> {
const permissions = new Set<PermissionKey>();
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
if (user.role === 'ADMIN') {
for (const key of PERMISSION_KEYS) permissions.add(key);
return permissions;
}
const assignments = await prisma.userPermissionGroup.findMany({
where: { userId: user.sub },
select: { group: { select: { items: { select: { key: true } } } } },
});
if (assignments.length === 0) {
for (const key of defaultPermissionsForRole(user.role)) permissions.add(key);
return permissions;
}
const known = new Set<string>(PERMISSION_KEYS);
for (const assignment of assignments) {
for (const item of assignment.group.items) {
// 库里可能留着已下线的键(旧版本写入的),这里静默忽略,避免整账号权限异常
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
}
}
return permissions;
}
/** 管理员不可被剥夺的权限键(防锁死) */
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
/** 角色默认模板(供权限页展示"没分组时会是什么") */
export function roleDefaultPermissions(role: TokenPayload['role']): readonly PermissionKey[] {
return ROLE_DEFAULT_PERMISSIONS[role];
}
/** 把权限键翻译成中文提示(403 文案与权限页共用) */
export function permissionLabel(key: PermissionKey): string {
return PERMISSION_LABELS[key];
}
+37
View File
@@ -0,0 +1,37 @@
import crypto from 'node:crypto';
import { env } from '../config/env.js';
/**
* 全局设置里"密钥类"值的加解密(目前只有大模型的 API Key)。
*
* 为什么不用明文存 `AppSetting`:那张表会进备份/导出/跨库迁移的快照,
* 明文密钥等于把它写进了每一次备份。这里用 **AES-256-GCM**,密钥由 `JWT_SECRET`
* 经 scrypt 派生(不需要新的环境变量),密文带 `v1:` 前缀便于将来轮换算法。
*
* 代价:`JWT_SECRET` 一改,旧密文就解不开了 —— 这是有意的(换密钥本来就该重新填密钥),
* `decryptSecret()` 解不开时返回空串,AI 设置页会显示"未配置"。
*/
const KEY = crypto.scryptSync(env.jwtSecret, 'classhelper-app-setting', 32);
export function encryptSecret(plain: string): string {
if (!plain) return '';
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${encrypted.toString('base64')}`;
}
export function decryptSecret(payload: string): string {
if (!payload.startsWith('v1:')) return '';
const [, ivBase64 = '', tagBase64 = '', dataBase64 = ''] = payload.split(':');
try {
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, Buffer.from(ivBase64, 'base64'));
decipher.setAuthTag(Buffer.from(tagBase64, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(dataBase64, 'base64')),
decipher.final(),
]).toString('utf8');
} catch {
return '';
}
}
+77
View File
@@ -0,0 +1,77 @@
import { prisma } from './db.js';
/**
* 全局设置(`AppSetting` 表的唯一读写入口)。
*
* 值一律存字符串:布尔存 `'true'`/`'false'`,复杂结构存 JSON 文本。
* 键名集中在这里,避免各模块手写字符串导致拼错后"设置静默不生效"。
*/
export const SETTING_KEYS = {
/** 服务端开发者模式(仅 ADMIN 可改):错误响应带堆栈、日志降到 debug 级 */
developerMode: 'system.developerMode',
/** 大模型功能开关与配置(见 modules/ai) */
aiEnabled: 'ai.enabled',
/** 服务端解析器的实现:openai=真接口 / mock=离线确定性结果(自动化验收用) */
aiProvider: 'ai.provider',
aiBaseUrl: 'ai.baseUrl',
aiModel: 'ai.model',
/** 加密后的 API Key(见 lib/secrets.ts;接口只进不出) */
aiApiKey: 'ai.apiKey',
/** 追加到系统提示词末尾的学校自定义要求(可空) */
aiSystemPromptExtra: 'ai.systemPromptExtra',
} as const;
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
export async function getSetting(key: SettingKey): Promise<string> {
const row = await prisma.appSetting.findUnique({ where: { key }, select: { value: true } });
return row?.value ?? '';
}
/** 批量读(避免逐键一条 SQL) */
export async function getSettings(keys: readonly SettingKey[]): Promise<Record<string, string>> {
const rows = await prisma.appSetting.findMany({
where: { key: { in: [...keys] } },
select: { key: true, value: true },
});
const map: Record<string, string> = {};
for (const key of keys) map[key] = '';
for (const row of rows) map[row.key] = row.value;
return map;
}
export async function setSetting(key: SettingKey, value: string): Promise<void> {
await prisma.appSetting.upsert({
where: { key },
create: { key, value },
update: { value },
});
}
export async function getBooleanSetting(key: SettingKey, fallback = false): Promise<boolean> {
const raw = await getSetting(key);
if (raw === '') return fallback;
return raw === 'true' || raw === '1';
}
export async function setBooleanSetting(key: SettingKey, value: boolean): Promise<void> {
await setSetting(key, value ? 'true' : 'false');
if (key === SETTING_KEYS.developerMode) developerModeCache = value;
}
/**
* 开发者模式的内存缓存。
*
* 为什么缓存:错误中间件与日志级别是**热路径**(每个出错请求都要问一次),
* 不可能为此每请求查一次 `AppSetting`。缓存由三处维护:启动时 `refreshDeveloperMode()`、
* 管理员改设置时(`setBooleanSetting` 顺手更新)、以及需要时可手动刷新。
*/
let developerModeCache = false;
export function isDeveloperMode(): boolean {
return developerModeCache;
}
export async function refreshDeveloperMode(): Promise<void> {
developerModeCache = await getBooleanSetting(SETTING_KEYS.developerMode, false);
}
+13
View File
@@ -26,9 +26,18 @@ export const SNAPSHOT_VERSION = 1;
* `StudentClassTransfer` 存调班与转出历史;`ArchivedYear` 是毕业归档的届别档案。
* 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写)
* 已随 2026-10-06 的重构删除。
*
* 2026-10-10 新增:权限组(`PermissionGroup` / `PermissionGroupItem` / `UserPermissionGroup`)、
* 全局设置 `AppSetting`(大模型配置与开发者模式开关)、客户端设备与远控
* (`ClientDevice` / `ClientCommand`)、客户端安装包缓存 `ClientRelease`、大模型审计 `AiAudit`。
* **加新表一定同步这里**,否则备份/换库会静默丢表。
*/
export const SNAPSHOT_TABLES = [
'User',
'PermissionGroup',
'PermissionGroupItem',
'UserPermissionGroup',
'AppSetting',
'ArchivedYear',
'Class',
'Student',
@@ -43,6 +52,10 @@ export const SNAPSHOT_TABLES = [
'Grade',
'IntegrationDevice',
'ClassIslandPush',
'ClientDevice',
'ClientCommand',
'ClientRelease',
'AiAudit',
] as const;
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
+79 -22
View File
@@ -1,12 +1,17 @@
import type { Request, RequestHandler } from 'express';
import type { UserRole } from '@classhelper/shared';
import type { PermissionKey, UserRole } from '@classhelper/shared';
import { PERMISSION_LABELS } from '@classhelper/shared';
import { prisma } from '../lib/db.js';
import { ApiError } from '../lib/http.js';
import { loadEffectivePermissions } from '../lib/permissions.js';
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
/**
* 认证中间件:解析 Bearer token 并回查数据库,
* 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。
*
* 2026-10-10 起**顺带装载生效权限集合**(`req.auth.permissions`):
* 角色判据换成权限键判据(`requirePermission`),角色只用来决定"没有分组时的默认模板"。
*/
export function authenticate(): RequestHandler {
return async (req, _res, next) => {
@@ -24,18 +29,16 @@ export function authenticate(): RequestHandler {
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
req.auth = {
token,
user: {
sub: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
},
const user: TokenPayload = {
sub: record.id,
username: record.code,
name: record.name,
role: 'CLASS_DEVICE',
classId: record.id,
classSession: true,
classCode: record.code,
};
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
next();
return;
}
@@ -47,16 +50,14 @@ export function authenticate(): RequestHandler {
});
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
req.auth = {
token,
user: {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: null,
},
const authUser: TokenPayload = {
sub: user.id,
username: user.username,
name: user.name,
role: user.role as UserRole,
classId: null,
};
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
next();
} catch (error) {
next(error);
@@ -64,7 +65,7 @@ export function authenticate(): RequestHandler {
};
}
/** 角色守卫 */
/** 角色守卫(仍保留:少数场景要按角色而不是按权限键判断,如班级端专属入口) */
export function requireRole(...roles: UserRole[]): RequestHandler {
return (req, _res, next) => {
const user = req.auth?.user;
@@ -80,8 +81,64 @@ export function requireRole(...roles: UserRole[]): RequestHandler {
};
}
/**
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
*
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
*/
export function requirePermission(...keys: PermissionKey[]): RequestHandler {
return (req, _res, next) => {
if (!req.auth?.user) {
next(ApiError.unauthorized());
return;
}
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
const missing = keys.filter((key) => !permissions.has(key));
if (missing.length > 0) {
const labels = missing.map((key) => `「${PERMISSION_LABELS[key]}」`).join('');
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
return;
}
next();
};
}
/**
* 权限守卫(任一满足):只要拥有其中**一个**权限键就放行。
*
* 用在"同一入口服务多种能力"的接口上:如导入模板按 `kind` 区分能力,
* 只要有一种导入能力就能取模板(具体哪种 kind 能不能导,由 service 里的 kind 断言决定)。
*/
export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
return (req, _res, next) => {
if (!req.auth?.user) {
next(ApiError.unauthorized());
return;
}
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
if (keys.length > 0 && !keys.some((key) => permissions.has(key))) {
const labels = keys.map((key) => `「${PERMISSION_LABELS[key]}」`).join('、');
next(ApiError.forbidden(`当前账号缺少权限(${labels} 中至少需要一项),请联系管理员在「权限组」里分配`));
return;
}
next();
};
}
/** 取出当前登录用户,未认证时抛 401 */
export function getAuthUser(req: Request): TokenPayload {
if (!req.auth?.user) throw ApiError.unauthorized();
return req.auth.user;
}
/** 取出当前登录主体的生效权限集合(未认证时抛 401) */
export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
if (!req.auth?.user) throw ApiError.unauthorized();
return req.auth.permissions ?? new Set<PermissionKey>();
}
/** 当前主体是否有某个权限键(页面/服务里做条件分支用,找不到也当无权限) */
export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
}
+16 -1
View File
@@ -3,6 +3,7 @@ import { ZodError } from 'zod';
import type { ApiErrorResponse } from '@classhelper/shared';
import { ApiError } from '../lib/http.js';
import { logger } from '../lib/logger.js';
import { isDeveloperMode } from '../lib/settings.js';
import { env } from '../config/env.js';
function sendError(
@@ -36,7 +37,21 @@ export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
if (error instanceof ApiError) {
if (error.status >= 500) logger.error(error.message, error.details);
sendError(res, error.status, error.message, error.code, error.details);
/**
* 开发者模式:把 `error.stack` 一并回传,否则"服务端 500"在浏览器里只有一句中文,
* 排查时还得去服务器上翻日志。**500 才带**、且只在管理员显式打开开发者模式时生效
* (开关见 `modules/system`),普通用户看到的东西一个字段都不多。
*/
const withStack =
isDeveloperMode() && error.status >= 500 && error.stack
? {
...(error.details && typeof error.details === 'object'
? (error.details as Record<string, unknown>)
: { details: error.details }),
stack: error.stack,
}
: error.details;
sendError(res, error.status, error.message, error.code, withStack);
return;
}
@@ -0,0 +1,62 @@
import { Router } from 'express';
import { API_PATHS } from '@classhelper/shared';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
aiAuditQuerySchema,
aiInterpretSchema,
updateAiSettingsSchema,
type AiAuditQuery,
type AiInterpretInput,
type UpdateAiSettingsInput,
} from './ai.schemas.js';
import * as aiService from './ai.service.js';
const router = Router();
router.use(authenticate());
/**
* 大模型助手。
*
* 权限分成两把:`ai:manage`(设置,默认只有管理员)与 `ai:use`(用一句话办事,默认教师也有)。
* **`/interpret` 只解析、绝不落库**:真正发布由前端确认后调既有端点,权限与上课时段保护
* 都在那条路径上(AGENTS.md §5 第 5/14 条)。
*/
router.get('/settings', requirePermission('ai:manage'), async (_req, res) => {
sendOk(res, await aiService.getAiSettings(), '获取大模型设置成功');
});
router.put(
'/settings',
requirePermission('ai:manage'),
validate({ body: updateAiSettingsSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<UpdateAiSettingsInput>(req);
sendOk(res, await aiService.updateAiSettings(user, input), '大模型设置已保存');
},
);
router.post(
'/interpret',
requirePermission('ai:use'),
validate({ body: aiInterpretSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<AiInterpretInput>(req);
sendOk(res, await aiService.interpret(user, input), '助手已解析,请确认后发布');
},
);
router.get('/audit', requirePermission('ai:manage'), validate({ query: aiAuditQuerySchema }), async (req, res) => {
const query = validatedQuery<AiAuditQuery>(req);
sendOk(res, await aiService.listAudit(query.limit ?? 50), '获取助手使用记录成功');
});
export const aiModule = defineModule({
name: 'ai',
basePath: API_PATHS.ai,
router,
});
@@ -0,0 +1,28 @@
import { z } from 'zod';
/** 大模型服务端实现:openai=真接口 / mock=离线确定性结果(自动化验收用,不联网) */
export const AI_PROVIDERS = ['openai', 'mock'] as const;
/** `PUT /api/ai/settings`:字段都可选;`apiKey` 传字符串=换新、null=清除、不传=保持 */
export const updateAiSettingsSchema = z.object({
enabled: z.boolean().optional(),
provider: z.enum(AI_PROVIDERS).optional(),
baseUrl: z.string().trim().max(300).optional(),
model: z.string().trim().max(120).optional(),
apiKey: z.string().trim().max(300).nullable().optional(),
systemPromptExtra: z.string().trim().max(1000).optional(),
});
/** `POST /api/ai/interpret`:一句中文原话 */
export const aiInterpretSchema = z.object({
text: z.string().trim().min(1, '请说一句要做的事').max(500, '一句话最多 500 个字'),
classId: z.string().min(1).optional(),
});
export const aiAuditQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).optional(),
});
export type UpdateAiSettingsInput = z.infer<typeof updateAiSettingsSchema>;
export type AiInterpretInput = z.infer<typeof aiInterpretSchema>;
export type AiAuditQuery = z.infer<typeof aiAuditQuerySchema>;
@@ -0,0 +1,367 @@
import { dayKeyLocal, type AiActionKind, type AiAuditDto, type AiInterpretResult, type AiSettingsDto } from '@classhelper/shared';
import { z } from 'zod';
import { getAccessibleClassIds } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { decryptSecret, encryptSecret } from '../../lib/secrets.js';
import { SETTING_KEYS, getSetting, getSettings, setSetting } from '../../lib/settings.js';
import { createHomeworkSchema } from '../homeworks/homeworks.schemas.js';
import { createNotificationSchema } from '../notifications/notifications.schemas.js';
import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
/**
* 大模型助手:**只解析、不落库**。
*
* 设计边界(照 approved 计划):
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
* 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用(AGENTS.md §5 第 5/14 条);
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
*/
const DEFAULT_TIMEOUT_MS = 20_000;
/** 与手动入口同源的参数校验(助手不得放宽任何字段约束) */
const homeworkParamsSchema = createHomeworkSchema.pick({
classId: true,
courseId: true,
title: true,
content: true,
assignDate: true,
});
const notificationParamsSchema = createNotificationSchema.pick({
classId: true,
title: true,
content: true,
priority: true,
});
const callParamsSchema = z.object({
classId: z.string().min(1),
studentId: z.string().min(1).optional(),
studentName: z.string().trim().max(40).optional(),
message: z.string().trim().max(200).optional(),
});
const modelReplySchema = z.object({
action: z.enum(['create_homework', 'create_notification', 'call_student', 'unknown']),
params: z.record(z.string(), z.unknown()).default({}),
confidence: z.number().min(0).max(1).optional(),
reason: z.string().max(200).optional(),
});
export async function getAiSettings(): Promise<AiSettingsDto> {
const values = await getSettings([
SETTING_KEYS.aiEnabled,
SETTING_KEYS.aiProvider,
SETTING_KEYS.aiBaseUrl,
SETTING_KEYS.aiModel,
SETTING_KEYS.aiApiKey,
SETTING_KEYS.aiSystemPromptExtra,
]);
const apiKey = decryptSecret(values[SETTING_KEYS.aiApiKey] ?? '');
return {
enabled: values[SETTING_KEYS.aiEnabled] === 'true',
provider: values[SETTING_KEYS.aiProvider] === 'mock' ? 'mock' : 'openai',
baseUrl: values[SETTING_KEYS.aiBaseUrl] ?? '',
model: values[SETTING_KEYS.aiModel] ?? '',
hasApiKey: apiKey.length > 0,
keyHint: apiKey.length >= 4 ? apiKey.slice(-4) : '',
systemPromptExtra: values[SETTING_KEYS.aiSystemPromptExtra] ?? '',
};
}
export async function updateAiSettings(
user: TokenPayload,
input: UpdateAiSettingsInput,
): Promise<AiSettingsDto> {
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
if (input.model !== undefined) await setSetting(SETTING_KEYS.aiModel, input.model.trim());
if (input.systemPromptExtra !== undefined) {
await setSetting(SETTING_KEYS.aiSystemPromptExtra, input.systemPromptExtra.trim());
}
if (input.apiKey !== undefined) {
// 传 null = 清除;传字符串 = 换成新密钥(空串按清除处理)
await setSetting(SETTING_KEYS.aiApiKey, input.apiKey ? encryptSecret(input.apiKey.trim()) : '');
}
logger.info(`大模型设置已更新(操作人=${user.username},密钥${input.apiKey ? '已更新' : '未改'})`);
return getAiSettings();
}
interface AssistantContext {
classes: { id: string; name: string }[];
courses: { id: string; name: string }[];
}
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
const classIds = await getAccessibleClassIds(user);
const classes = await prisma.class.findMany({
where: { id: { in: classIds } },
select: { id: true, name: true },
orderBy: { name: 'asc' },
take: 80,
});
const courses = classId
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
: [];
return { classes, courses };
}
function buildSystemPrompt(context: AssistantContext, extra: string): string {
return [
'你是班级管理系统的指令解析器。把老师的一句中文转成严格 JSON,不要输出任何解释。',
'可选动作(action):create_homework(布置作业)、create_notification(发通知)、call_student(叫学生来)、unknown(看不懂)。',
'输出格式:{"action":"…","params":{…},"confidence":0~1}。',
'create_homework 的 params:{classId, courseId?, title, content, assignDate?(YYYY-MM-DD)}。',
'create_notification 的 params:{classId, title, content, priority?(LOW|NORMAL|HIGH|URGENT)}。',
'call_student 的 params:{classId, studentName, message?}。',
`今天日期:${dayKeyLocal(new Date())}。`,
`可用班级:${context.classes.map((item) => `${item.name}(${item.id})`).join('、') || '无'}。`,
context.courses.length
? `候选班级的科目:${context.courses.map((item) => `${item.name}(${item.id})`).join('、')}。`
: '未指定班级时 courseId 留空。',
'信息不足时把 confidence 压低并在 reason 里说明缺什么;不要编造不存在于上面的班级/科目 id。',
extra ? `学校额外要求:${extra}` : '',
]
.filter(Boolean)
.join('\n');
}
/** 离线确定性解析(provider=mock):只做关键词判断,供自动化与"没配模型时试跑"用 */
function mockInterpret(text: string, context: AssistantContext): z.infer<typeof modelReplySchema> {
// 优先用句子里**真正提到的**班级(「给2026级1班布置作业」);都没提到才退回按名称升序的第一个。
// (曾经只取第一个,结果本机取到了已归档的 2023级1班,与句子里说的班级无关。)
const mentioned = context.classes.find((item) => text.includes(item.name));
const classId = mentioned?.id ?? context.classes[0]?.id ?? '';
if (!classId) return { action: 'unknown', params: {}, confidence: 0.1, reason: '当前账号没有可发布的班级' };
const course = context.courses.find((item) => text.includes(item.name));
if (/作业|布置|卷子|练习|预习|背诵/.test(text)) {
return {
action: 'create_homework',
params: {
classId,
courseId: course?.id ?? null,
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
content: text,
assignDate: dayKeyLocal(new Date()),
},
confidence: 0.55,
};
}
if (/通知|告知|提醒|传达/.test(text)) {
return {
action: 'create_notification',
params: {
classId,
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
content: text,
priority: /紧急|立即|马上/.test(text) ? 'URGENT' : 'NORMAL',
},
confidence: 0.55,
};
}
if (/叫|找|来一趟|办公室/.test(text)) {
const matched = /\S{2,4}(?=同学|来|到)/.exec(text);
return {
action: 'call_student',
params: { classId, studentName: matched?.[0] ?? '', message: text },
confidence: 0.4,
reason: '离线示例解析只按关键词猜测学生姓名',
};
}
return { action: 'unknown', params: {}, confidence: 0.2, reason: '没听懂要做什么' };
}
async function callOpenAi(
input: AiInterpretInput,
extension: { baseUrl: string; model: string; apiKey: string; prompt: string },
): Promise<z.infer<typeof modelReplySchema>> {
let response: Response;
try {
response = await fetch(`${extension.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${extension.apiKey}`,
},
body: JSON.stringify({
model: extension.model || 'gpt-4o-mini',
temperature: 0.2,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: extension.prompt },
{ role: 'user', content: input.text },
],
}),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
} catch (error) {
throw new ApiError(504, 'AI_TIMEOUT', `无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`);
}
if (!response.ok) {
throw ApiError.badRequest(`大模型服务返回 HTTP ${response.status}(请检查服务地址、模型名与密钥)`);
}
const body = (await response.json()) as { choices?: { message?: { content?: string } }[] };
const content = body.choices?.[0]?.message?.content ?? '';
try {
return modelReplySchema.parse(JSON.parse(content));
} catch {
logger.warn(`大模型返回无法解析:${content.slice(0, 200)}`);
throw new ApiError(502, 'AI_INVALID_OUTPUT', '大模型返回的内容不是可用的指令,请换一种说法');
}
}
/** 把校验通过的参数解析成"给人看的预览"(班级/科目/学生名都换成中文) */
async function buildPreview(
action: AiActionKind,
params: Record<string, unknown>,
): Promise<AiInterpretResult['preview']> {
const warnings: string[] = [];
const classId = typeof params.classId === 'string' ? params.classId : '';
const classRecord = classId
? await prisma.class.findUnique({ where: { id: classId }, select: { name: true } })
: null;
if (action === 'create_homework') {
const courseId = typeof params.courseId === 'string' ? params.courseId : '';
const course = courseId
? await prisma.course.findUnique({ where: { id: courseId }, select: { name: true } })
: null;
if (!course) warnings.push('没有指定科目:只有班主任/管理员能发不指定科目的作业');
return {
summary: `给${classRecord?.name ?? '未知班级'}布置作业:${String(params.title ?? '')}`,
className: classRecord?.name ?? null,
courseName: course?.name ?? null,
warnings,
};
}
if (action === 'create_notification') {
return {
summary: `给${classRecord?.name ?? '未知班级'}发通知:${String(params.title ?? '')}`,
className: classRecord?.name ?? null,
warnings,
};
}
if (action === 'call_student') {
const studentName = typeof params.studentName === 'string' ? params.studentName : '';
const student = studentName && classId
? await prisma.student.findFirst({
where: { classId, name: studentName },
select: { id: true, name: true },
})
: null;
if (!student) warnings.push(studentName ? `没找到学生「${studentName}」` : '没识别出学生姓名');
return {
summary: `叫${student?.name ?? (studentName || '某位同学')}到老师这里`,
className: classRecord?.name ?? null,
studentName: student?.name ?? (studentName || null),
warnings,
};
}
return { summary: '没听懂要做什么,请换一种说法', warnings: ['助手没有解析出可用动作'] };
}
/**
* POST /api/ai/interpret - 解析一句中文,返回**待确认**的结构化动作。
* 绝不写业务表;只写一条 `AiAudit`(谁在什么时候让助手做了什么)。
*/
export async function interpret(user: TokenPayload, input: AiInterpretInput): Promise<AiInterpretResult> {
const settings = await getAiSettings();
if (!settings.enabled) {
throw new ApiError(400, 'AI_NOT_CONFIGURED', '助手功能还没开启,请管理员在「大模型设置」里打开');
}
if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
throw new ApiError(400, 'AI_NOT_CONFIGURED', '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写');
}
const context = await resolveContext(user, input.classId);
const reply =
settings.provider === 'mock'
? mockInterpret(input.text, context)
: await callOpenAi(input, {
baseUrl: settings.baseUrl,
model: settings.model,
apiKey: decryptSecret(await getSetting(SETTING_KEYS.aiApiKey)),
prompt: buildSystemPrompt(context, settings.systemPromptExtra),
});
// 用与手动入口同一份 zod 再校验一次:模型给的东西**不可信**
let action: AiActionKind = 'unknown';
let params: Record<string, unknown> = {};
const warnings: string[] = [];
try {
if (reply.action === 'create_homework') {
const parsed = homeworkParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
action = 'create_homework';
params = parsed as unknown as Record<string, unknown>;
} else if (reply.action === 'create_notification') {
const parsed = notificationParamsSchema.parse({
...reply.params,
classId: reply.params.classId ?? input.classId,
});
action = 'create_notification';
params = parsed as unknown as Record<string, unknown>;
} else if (reply.action === 'call_student') {
const parsed = callParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
action = 'call_student';
params = parsed as unknown as Record<string, unknown>;
}
} catch (error) {
warnings.push('助手给出的参数不完整,请手动补全后再发布');
logger.warn(`助手参数校验失败:${error instanceof Error ? error.message : String(error)}`);
}
const preview = await buildPreview(action, params);
preview.warnings = [...preview.warnings, ...warnings];
if (settings.provider === 'mock') preview.warnings.push('当前是离线示例解析(未配置真实模型),请仔细核对');
await prisma.aiAudit.create({
data: {
userId: user.sub,
prompt: input.text,
intent: action,
status: 'pending',
message: preview.summary,
rawReply: JSON.stringify(reply).slice(0, 2000),
},
});
return {
action,
params,
preview,
confidence: reply.confidence ?? 0.5,
needConfirm: true,
rawReply: null,
};
}
/** GET /api/ai/audit - 助手使用审计(最近 N 条) */
export async function listAudit(limit = 50): Promise<AiAuditDto[]> {
const rows = await prisma.aiAudit.findMany({ orderBy: { createdAt: 'desc' }, take: limit });
// `AiAudit.userId` 是普通字符串列(schema 里没有建 User 关系),所以姓名另外查一次
const userIds = [...new Set(rows.map((row) => row.userId))];
const users = userIds.length
? await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })
: [];
return rows.map((row) => ({
id: row.id,
userId: row.userId,
userName: users.find((user) => user.id === row.userId)?.name ?? '',
prompt: row.prompt,
intent: row.intent,
status: row.status,
message: row.message,
createdAt: row.createdAt.toISOString(),
}));
}
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -25,13 +25,18 @@ router.use(authenticate());
*/
/** GET /api/archives - 届别列表 */
router.get('/', requireRole('ADMIN'), validate({ query: listArchivesQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
});
router.get(
'/',
requirePermission('archive:manage'),
validate({ query: listArchivesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
},
);
/** GET /api/archives/summary - 归档页顶部汇总 + 可归档的届别建议 */
router.get('/summary', requireRole('ADMIN'), async (req, res) => {
router.get('/summary', requirePermission('archive:manage'), async (req, res) => {
sendOk(
res,
{
@@ -43,16 +48,21 @@ router.get('/summary', requireRole('ADMIN'), async (req, res) => {
});
/** GET /api/archives/:id - 某届详情(班级 + 毕业生 + 转出的学生) */
router.get('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
});
router.get(
'/:id',
requirePermission('archive:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
},
);
/** GET /api/archives/:id/classes/:classId/content - 归档班级的作业与通知(只读) */
router.get(
'/:id/classes/:classId/content',
requireRole('ADMIN'),
requirePermission('archive:manage'),
validate({ params: archiveClassParamSchema, query: archivedContentQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -67,19 +77,29 @@ router.get(
);
/** POST /api/archives - 执行毕业归档(按届别整届归档) */
router.post('/', requireRole('ADMIN'), validate({ body: createArchiveSchema }), async (req, res) => {
const user = getAuthUser(req);
const result = await archiveService.createArchive(user, validatedBody(req));
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
});
router.post(
'/',
requirePermission('archive:manage'),
validate({ body: createArchiveSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await archiveService.createArchive(user, validatedBody(req));
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
},
);
/** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await archiveService.deleteArchive(user, id);
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
});
router.delete(
'/:id',
requirePermission('archive:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await archiveService.deleteArchive(user, id);
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
},
);
export const archivesModule = defineModule({
name: 'archives',
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { sendCreated } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
@@ -13,7 +13,7 @@ router.use(authenticate());
* POST /api/calls - 叫人(老师点名让学生过来)
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
*/
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createCallSchema }), async (req, res) => {
router.post('/', requirePermission('call:create'), validate({ body: createCallSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
});
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import {
validate,
validatedBody,
@@ -56,7 +56,7 @@ router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) =>
*/
router.post(
'/promote',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ body: promoteClassesSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -73,7 +73,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
});
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => {
router.post('/', requirePermission('class:manage'), validate({ body: createClassSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
});
@@ -81,7 +81,7 @@ router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), as
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
router.patch(
'/:id',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: updateClassSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -93,7 +93,7 @@ router.patch(
/** DELETE /api/classes/:id - 删除班级 */
router.delete(
'/:id',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -109,7 +109,7 @@ router.delete(
*/
router.patch(
'/:id/class-account',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: updateClassAccountSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -128,7 +128,7 @@ router.patch(
*/
router.patch(
'/:id/head-teacher',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -147,7 +147,7 @@ router.patch(
*/
router.put(
'/:id/subject-teachers',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -244,7 +244,7 @@ router.get('/:id/students', validate({ params: idParamSchema }), async (req, res
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
router.post(
'/:id/students',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: idParamSchema, body: addStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -256,7 +256,7 @@ router.post(
/** DELETE /api/classes/:id/students/:studentId - 移出学生(回到「未分班」,名单记录保留) */
router.delete(
'/:id/students/:studentId',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: classStudentParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -0,0 +1,101 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
clientCommandAckSchema,
clientHeartbeatSchema,
createClientCommandSchema,
listClientDevicesQuerySchema,
pushClientUpdateSchema,
type ClientCommandAckInput,
type ClientHeartbeatInput,
type CreateClientCommandInput,
type ListClientDevicesQuery,
type PushClientUpdateInput,
} from './clients.schemas.js';
import * as clientService from './clients.service.js';
const router = Router();
router.use(authenticate());
/**
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
*
* **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。
* 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。
*/
router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<ClientHeartbeatInput>(req);
sendOk(res, await clientService.heartbeat(user, input, req.ip ?? ''), '心跳已上报');
});
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
router.post(
'/commands/:id/ack',
validate({ params: idParamSchema, body: clientCommandAckSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<ClientCommandAckInput>(req);
sendOk(res, await clientService.ackCommand(id, input), '回执已记录');
},
);
/**
* POST /api/clients/commands - 下发远控命令(关机 / 重启 / 注销 / 锁屏)
*
* 权限键 `remote:control`(默认只有管理员有):这是唯一能远程动别人电脑的接口。
*/
router.post(
'/commands',
requirePermission('remote:control'),
validate({ body: createClientCommandSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<CreateClientCommandInput>(req);
sendCreated(res, await clientService.createCommands(user, input), '命令已下发,将在这台电脑下次心跳时送达');
},
);
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
router.get(
'/devices',
requirePermission('client:manage'),
validate({ query: listClientDevicesQuerySchema }),
async (req, res) => {
const query = validatedQuery<ListClientDevicesQuery>(req);
sendOk(res, await clientService.listDevices({ classId: query.classId }), '获取设备列表成功');
},
);
/** POST /api/clients/updates - 统一下发客户端更新 */
router.post(
'/updates',
requirePermission('client:update-push'),
validate({ body: pushClientUpdateSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<PushClientUpdateInput>(req);
sendOk(res, await clientService.pushUpdate(user, input), '更新已下发,将在这台电脑下次心跳时送达');
},
);
/** DELETE /api/clients/devices/:id - 移除设备记录(换机 / 旧机器清理) */
router.delete(
'/devices/:id',
requirePermission('client:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await clientService.deleteDevice(id), '设备已移除');
},
);
export const clientsModule = defineModule({
name: 'clients',
basePath: '/clients',
router,
});
@@ -0,0 +1,62 @@
import { CLIENT_COMMAND_KINDS, CLIENT_COMMAND_RESULT_MAX, type ClientCommandKind } from '@classhelper/shared';
import { z } from 'zod';
/**
* 远控命令种类。用 `CLIENT_COMMAND_KINDS` 而不是再写一遍字面量数组:
* 常量是 `readonly`,`z.enum` 要的是可变元组,所以这里显式转一次类型。
*/
const clientCommandKindSchema = z.enum(
CLIENT_COMMAND_KINDS as unknown as [ClientCommandKind, ...ClientCommandKind[]],
);
/** 设备标识(客户端主进程生成并持久化的 UUID) */
const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80, '设备标识不合法');
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
export const clientHeartbeatSchema = z.object({
deviceKey: deviceKeySchema,
name: z.string().trim().max(60).optional(),
clientVersion: z.string().trim().max(40).optional(),
platform: z.string().trim().max(40).optional(),
pendingVersion: z.string().trim().max(40).nullable().optional(),
stateNote: z.string().trim().max(120).optional(),
});
/**
* `POST /api/clients/commands/:id/ack`:执行回执。
*
* 带上 `deviceKey` 是为了"命令回执必须是那台机器签的字":一个班的班级端令牌
* 全班共用,光凭命令 id 无法区分是班里哪一台。
*/
export const clientCommandAckSchema = z.object({
deviceKey: deviceKeySchema,
ok: z.boolean(),
result: z.string().trim().max(CLIENT_COMMAND_RESULT_MAX).optional(),
});
/** `POST /api/clients/commands`:给选中的电脑下发远控命令 */
export const createClientCommandSchema = z.object({
deviceIds: z.array(z.string().min(1)).min(1, '请选择要操作的电脑').max(200, '一次最多操作 200 台'),
kind: clientCommandKindSchema,
note: z.string().trim().max(80, '备注最多 80 个字').optional(),
});
/** `GET /api/clients/devices?classId=` */
export const listClientDevicesQuerySchema = z.object({
classId: z.string().trim().min(1).optional(),
});
/** `POST /api/clients/updates`:推送更新(按设备 / 按班级 / 全部) */
export const pushClientUpdateSchema = z.object({
deviceIds: z.array(z.string().min(1)).max(500).optional(),
classIds: z.array(z.string().min(1)).max(200).optional(),
all: z.boolean().optional(),
version: z.string().trim().max(40).nullable().optional(),
clear: z.boolean().optional(),
});
export type ClientHeartbeatInput = z.infer<typeof clientHeartbeatSchema>;
export type ClientCommandAckInput = z.infer<typeof clientCommandAckSchema>;
export type CreateClientCommandInput = z.infer<typeof createClientCommandSchema>;
export type ListClientDevicesQuery = z.infer<typeof listClientDevicesQuerySchema>;
export type PushClientUpdateInput = z.infer<typeof pushClientUpdateSchema>;
@@ -0,0 +1,372 @@
import {
CLIENT_COMMAND_EXPIRE_MS,
CLIENT_COMMAND_LABELS,
CLIENT_ONLINE_WINDOW_MS,
type ClientCommandAckRequest,
type ClientCommandDto,
type ClientCommandKind,
type ClientDeviceDto,
type ClientHeartbeatResponse,
type ClientPushResult,
type ClientTargetResult,
} from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import * as clientReleases from '../update/client-releases.service.js';
import type { ClientHeartbeatInput, CreateClientCommandInput, PushClientUpdateInput } from './clients.schemas.js';
/**
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
*
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
* 与 `IntegrationDevice.requestScheduleReport` 同一套路,不新增长连接握手。
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
* 由客户端下一次心跳(30 秒内)带走。
*/
type DeviceRow = {
id: string;
classId: string | null;
deviceKey: string;
name: string;
clientVersion: string;
platform: string;
autoUpdate: boolean;
updateTargetVersion: string;
pendingVersion: string;
lastHeartbeatAt: Date | null;
lastIp: string;
stateNote: string;
createdAt: Date;
updatedAt: Date;
class?: { id: string; name: string } | null;
};
type CommandRow = {
id: string;
deviceId: string;
kind: string;
payload: string;
createdAt: Date;
expiresAt: Date | null;
ackedAt: Date | null;
result: string;
};
/** 备注存 JSON 文本(`payload` 字段是预留结构,见 schema 注释) */
function encodePayload(note: string): string {
return JSON.stringify({ note });
}
function decodePayload(raw: string): string {
if (!raw) return '';
try {
const parsed = JSON.parse(raw) as { note?: unknown };
return typeof parsed.note === 'string' ? parsed.note : '';
} catch {
// 兼容手工写进库的纯文本
return raw;
}
}
function toCommandDto(row: CommandRow): ClientCommandDto {
const kind = row.kind as ClientCommandKind;
const label = CLIENT_COMMAND_LABELS[kind] ?? row.kind;
const note = decodePayload(row.payload);
return {
id: row.id,
deviceId: row.deviceId,
kind,
payload: note ? `${label}(${note})` : label,
createdAt: row.createdAt.toISOString(),
expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null,
ackedAt: row.ackedAt ? row.ackedAt.toISOString() : null,
result: row.result,
};
}
/** 待执行的命令:未回执、未撤销、未过期 */
function pendingCommandWhere(deviceIds: string[], now: Date) {
return {
deviceId: { in: deviceIds },
ackedAt: null,
cancelledAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
};
}
function summarize(results: ClientTargetResult[]): ClientPushResult {
const succeeded = results.filter((item) => item.ok).length;
return { total: results.length, succeeded, failed: results.length - succeeded, results };
}
/** 班级端会话(含班级账号)的代表主体就是那个班;教师/管理员个人电脑不绑定班级 */
function resolveClassId(user: TokenPayload): string | null {
return user.classSession || user.role === 'CLASS_DEVICE' ? user.sub : null;
}
async function findPendingCommand(deviceId: string, now: Date): Promise<CommandRow | null> {
const rows = (await prisma.clientCommand.findMany({
where: pendingCommandWhere([deviceId], now),
orderBy: { createdAt: 'asc' },
take: 1,
})) as unknown as CommandRow[];
return rows[0] ?? null;
}
/**
* POST /api/clients/heartbeat - 客户端上报心跳,服务端在同一响应里带回
* 「要不要更新 + 目标版本的安装包信息 + 一条待执行命令」。
*/
export async function heartbeat(
user: TokenPayload,
input: ClientHeartbeatInput,
ip: string,
): Promise<ClientHeartbeatResponse> {
const now = new Date();
const heartbeatAt = {
classId: resolveClassId(user),
name: input.name?.trim() || '未命名设备',
clientVersion: input.clientVersion?.trim() ?? '',
platform: input.platform?.trim() ?? '',
pendingVersion: input.pendingVersion?.trim() ?? '',
stateNote: input.stateNote?.trim() ?? '',
lastHeartbeatAt: now,
lastIp: ip,
};
const device = (await prisma.clientDevice.upsert({
where: { deviceKey: input.deviceKey },
create: { deviceKey: input.deviceKey, ...heartbeatAt },
update: heartbeatAt,
})) as unknown as DeviceRow;
const targetVersion = device.updateTargetVersion.trim();
const [command, update] = await Promise.all([
findPendingCommand(device.id, now),
targetVersion ? clientReleases.resolveUpdateInfo(targetVersion) : Promise.resolve(null),
]);
return {
deviceId: device.id,
serverTime: now.toISOString(),
updateTargetVersion: targetVersion || null,
update,
command: command ? toCommandDto(command) : null,
};
}
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
export async function ackCommand(
commandId: string,
input: ClientCommandAckRequest,
): Promise<ClientCommandDto> {
const command = (await prisma.clientCommand.findUnique({
where: { id: commandId },
include: { device: { select: { deviceKey: true, name: true } } },
})) as (CommandRow & { device: { deviceKey: string; name: string } }) | null;
if (!command) throw ApiError.notFound('命令不存在');
if (command.device.deviceKey !== input.deviceKey) {
throw ApiError.forbidden('这条命令不是发给这台电脑的');
}
// 幂等:客户端重试回执不报错,也不覆盖第一次的结果
if (command.ackedAt) return toCommandDto(command);
const detail = input.result?.trim();
const result = `${input.ok ? '已执行' : '执行失败'}${detail ? `:${detail}` : ''}`;
const updated = (await prisma.clientCommand.update({
where: { id: commandId },
data: { ackedAt: new Date(), result },
})) as unknown as CommandRow;
logger.info(`远控命令回执:${command.device.name || command.device.deviceKey} ${result}`);
return toCommandDto(updated);
}
/** GET /api/clients/devices - Web 端「客户端设备」列表(含待执行命令) */
export async function listDevices(options: { classId?: string } = {}): Promise<ClientDeviceDto[]> {
const rows = (await prisma.clientDevice.findMany({
where: options.classId ? { classId: options.classId } : {},
include: { class: { select: { id: true, name: true } } },
orderBy: [{ lastHeartbeatAt: 'desc' }, { createdAt: 'desc' }],
})) as unknown as DeviceRow[];
const now = new Date();
const commands = (await prisma.clientCommand.findMany({
where: pendingCommandWhere(
rows.map((row) => row.id),
now,
),
orderBy: { createdAt: 'asc' },
})) as unknown as CommandRow[];
// 每台设备只留最早的一条(与心跳取的是同一条,界面上看到的才是真会执行的那条)
const pendingByDevice = new Map<string, CommandRow>();
for (const command of commands) {
if (!pendingByDevice.has(command.deviceId)) pendingByDevice.set(command.deviceId, command);
}
return rows.map((row) => {
const heartbeat = row.lastHeartbeatAt;
const pending = pendingByDevice.get(row.id);
return {
id: row.id,
classId: row.classId,
className: row.class?.name ?? null,
deviceKey: row.deviceKey,
name: row.name,
clientVersion: row.clientVersion,
platform: row.platform,
autoUpdate: row.autoUpdate,
updateTargetVersion: row.updateTargetVersion,
pendingVersion: row.pendingVersion,
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
lastIp: row.lastIp,
stateNote: row.stateNote,
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
pendingCommand: pending ? toCommandDto(pending) : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
});
}
/** 命令去重后的目标设备(保持请求顺序,便于结果一一对应) */
async function findTargetDevices(deviceIds: string[]): Promise<Map<string, DeviceRow>> {
const wanted = [...new Set(deviceIds)];
const rows = (await prisma.clientDevice.findMany({
where: { id: { in: wanted } },
select: { id: true, name: true, deviceKey: true },
})) as unknown as DeviceRow[];
return new Map(rows.map((row) => [row.id, row]));
}
/**
* POST /api/clients/commands - 下发远控命令(关机/重启/注销/锁屏)
*
* 同一台机器上还没执行的旧命令会被**自动撤销**:否则"先关机、后重启"会排队执行,
* 机器刚关就被下一条叫起来,看起来像失控。客户端侧还有一次弹窗二次确认。
*/
export async function createCommands(
user: TokenPayload,
input: CreateClientCommandInput,
): Promise<ClientPushResult> {
const targets = await findTargetDevices(input.deviceIds);
const expiresAt = new Date(Date.now() + CLIENT_COMMAND_EXPIRE_MS);
const note = input.note?.trim() ?? '';
const results: ClientTargetResult[] = [];
for (const deviceId of [...new Set(input.deviceIds)]) {
const device = targets.get(deviceId);
if (!device) {
results.push({
deviceId,
deviceName: '',
ok: false,
code: 'DEVICE_NOT_FOUND',
message: '设备不存在(可能已被移除)',
});
continue;
}
await prisma.clientCommand.updateMany({
where: { deviceId, ackedAt: null, cancelledAt: null },
data: { cancelledAt: new Date(), result: '已被新的命令取代' },
});
await prisma.clientCommand.create({
data: { deviceId, kind: input.kind, payload: encodePayload(note), createdBy: user.sub, expiresAt },
});
results.push({ deviceId, deviceName: device.name, ok: true });
}
const summary = summarize(results);
logger.info(
`下发远控命令 ${input.kind}:目标 ${summary.total} 台,成功 ${summary.succeeded} 台(发起人=${user.username})`,
);
return summary;
}
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
async function resolveUpdateTargets(input: PushClientUpdateInput): Promise<DeviceRow[]> {
const deviceIds = [...new Set(input.deviceIds ?? [])];
if (deviceIds.length > 0) {
const rows = (await prisma.clientDevice.findMany({
where: { id: { in: deviceIds } },
select: { id: true, name: true },
})) as unknown as DeviceRow[];
return rows;
}
const classIds = [...new Set(input.classIds ?? [])];
if (classIds.length > 0) {
return (await prisma.clientDevice.findMany({
where: { classId: { in: classIds } },
select: { id: true, name: true },
})) as unknown as DeviceRow[];
}
if (input.all) {
return (await prisma.clientDevice.findMany({ select: { id: true, name: true } })) as unknown as DeviceRow[];
}
throw ApiError.badRequest('请选择要推送的设备、班级,或选择"全部设备"');
}
/**
* POST /api/clients/updates - 统一下发客户端更新
*
* `version` 省略时用服务端缓存里最新的那个版本;`clear: true` 取消下发。
* 只改 `updateTargetVersion`,真正的下载与安装由客户端按自己的空闲判据决定
* (上课时段 / 窗口可见都不装)。
*/
export async function pushUpdate(
user: TokenPayload,
input: PushClientUpdateInput,
): Promise<ClientPushResult> {
const clear = input.clear === true;
let target = '';
if (!clear) {
target = (input.version ?? '').trim() || (await clientReleases.latestReleaseVersion());
if (!target) {
throw ApiError.badRequest('服务端还没有缓存任何客户端安装包,请先下载或登记一份再推送');
}
const info = await clientReleases.resolveUpdateInfo(target);
if (!info) throw ApiError.badRequest(`服务端缺少 v${target} 的安装包文件,请重新下载或登记`);
}
const devices = await resolveUpdateTargets(input);
if (devices.length === 0) throw ApiError.badRequest('没有匹配到任何客户端设备');
const results: ClientTargetResult[] = [];
for (const device of devices) {
await prisma.clientDevice.update({
where: { id: device.id },
data: { updateTargetVersion: target },
});
results.push({ deviceId: device.id, deviceName: device.name, ok: true });
}
const summary = summarize(results);
logger.info(
clear
? `取消更新下发:${summary.total} 台设备(发起人=${user.username})`
: `推送客户端更新 v${target}:${summary.total} 台设备(发起人=${user.username})`,
);
return summary;
}
/** DELETE /api/clients/devices/:id - 移除一台设备记录(换机 / 旧机器清理) */
export async function deleteDevice(deviceId: string): Promise<{ id: string }> {
const device = await prisma.clientDevice.findUnique({
where: { id: deviceId },
select: { id: true, name: true, deviceKey: true },
});
if (!device) throw ApiError.notFound('设备不存在');
await prisma.clientDevice.delete({ where: { id: deviceId } });
logger.info(`移除客户端设备:${device.name || device.deviceKey}`);
return { id: deviceId };
}
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -26,7 +26,7 @@ router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) =>
/** POST /api/courses - 新增课程 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ body: createCourseSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -37,7 +37,7 @@ router.post(
/** PATCH /api/courses/:id - 修改课程 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ params: idParamSchema, body: updateCourseSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -49,7 +49,7 @@ router.patch(
/** DELETE /api/courses/:id - 删除课程 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,4 +1,4 @@
import type { CourseDto } from '@classhelper/shared';
import type { CourseDto } from '@classhelper/shared';
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
@@ -21,6 +21,28 @@ export async function listCourses(user: TokenPayload, classId?: string): Promise
export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise<CourseDto> {
await assertCanManageSchedule(user, input.classId);
/*
* 同名去重(一个班级里科目名唯一)。
*
* 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师,
* 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 ——
* 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设
* (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师
* 能布置其他科作业"的入口。
*
* 处理办法是把**已有**课程的 id 回给前端(409 + details.courseId),让前端复用它;
* 权限判定于是回到"你是不是这门课的任课老师"这个唯一事实上。
*/
const existing = await prisma.course.findFirst({
where: { classId: input.classId, name: input.name },
select: { id: true },
});
if (existing) {
throw new ApiError(409, 'COURSE_EXISTS', `该班级已有名为「${input.name}」的课程`, {
courseId: existing.id,
});
}
const created = await prisma.course.create({
data: { name: input.name, classId: input.classId, teacherId: user.sub },
include: { teacher: teacherSelect },
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, requireRole } from '../../middleware/auth.js';
import { authenticate, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -22,7 +22,7 @@ import * as databaseService from './database.service.js';
/**
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
*
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下;
* 改动数据库属于最高危操作,全部接口挂在 requirePermission('database:manage') 之下;
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
*
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
@@ -31,7 +31,7 @@ import * as databaseService from './database.service.js';
const router = Router();
router.use(authenticate());
router.use(requireRole('ADMIN'));
router.use(requirePermission('database:manage'));
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
router.get('/status', async (_req, res) => {
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -56,7 +56,7 @@ router.get(
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
router.get(
'/stats',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:read'),
validate({ query: statsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -68,7 +68,7 @@ router.get(
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:read'),
validate({ query: listGradesQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -83,7 +83,7 @@ router.get(
);
/** POST /api/grades - 录入单条成绩(仅自己任教科目) */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
router.post('/', requirePermission('grade:write'), validate({ body: createGradeSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
@@ -100,7 +100,7 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc
*/
router.patch(
'/levels',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:write'),
validate({ body: updateGradeLevelsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -112,7 +112,7 @@ router.patch(
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
router.post(
'/bulk',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:write'),
validate({ body: bulkCreateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -124,7 +124,7 @@ router.post(
/** PATCH /api/grades/:id - 修改成绩(含单个改等级) */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:write'),
validate({ params: idParamSchema, body: updateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -136,7 +136,7 @@ router.patch(
/** DELETE /api/grades/:id - 删除成绩 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('grade:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,16 +1,18 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
bulkCreateHomeworkSchema,
createHomeworkSchema,
listHomeworkDaysQuerySchema,
listHomeworksQuerySchema,
updateHomeworkSchema,
updateHomeworkStatusSchema,
updateHomeworkSubmissionsSchema,
type BulkCreateHomeworkInput,
type CreateHomeworkInput,
type ListHomeworkDaysInput,
type UpdateHomeworkInput,
@@ -71,10 +73,32 @@ router.post(
},
);
/**
* POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果)
*
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
* 批量不是绕过科目门的后门(§5 第 5 条)。
*/
router.post(
'/bulk',
requirePermission('homework:write'),
validate({ body: bulkCreateHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await homeworkService.bulkCreateHomeworks(
user,
req.body as BulkCreateHomeworkInput,
);
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
},
);
/** PATCH /api/homeworks/:id - 修改作业 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('homework:write'),
validate({ params: idParamSchema, body: updateHomeworkSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -90,7 +114,7 @@ router.patch(
/** DELETE /api/homeworks/:id - 删除作业 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('homework:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -37,6 +37,26 @@ export const createHomeworkSchema = z.object({
assignDate: dayKeySchema.nullish(),
});
/**
* 一次给多个班级布置同一份作业(批量发布)。
*
* 上限 50 个班是刻意设的:逐班串行落库 + 逐班判定科目归属,
* 一批塞几百个班会长时间占住请求。
*/
export const bulkCreateHomeworkSchema = z.object({
classIds: z
.array(z.string().min(1))
.min(1, '请至少选择一个班级')
.max(50, '一次最多发布 50 个班级'),
courseId: z.string().min(1).nullish(),
/** 按科目名称指定(跨班只能用名称:课程是班级内的,同一门课在每个班是不同的记录) */
subjectName: z.string().trim().min(1).max(50).nullish(),
title: z.string().trim().min(1, '请输入作业标题').max(120),
content: z.string().min(1, '请输入作业内容').max(5000),
assignDate: dayKeySchema.nullish(),
attachmentUrl: attachmentUrlSchema,
});
export const updateHomeworkSchema = z.object({
courseId: z.string().min(1).nullish(),
/** 改作业所属日期(把作业挪到另一天) */
@@ -62,6 +82,7 @@ export const updateHomeworkSubmissionsSchema = z.object({
export type ListHomeworkDaysInput = z.infer<typeof listHomeworkDaysQuerySchema>;
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
export type BulkCreateHomeworkInput = z.infer<typeof bulkCreateHomeworkSchema>;
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
export type UpdateHomeworkSubmissionsInput = z.infer<typeof updateHomeworkSubmissionsSchema>;
@@ -2,6 +2,9 @@ import {
SOCKET_EVENTS,
dayKeyLocal,
shiftDayKey,
type BulkCreateHomeworkRequest,
type BulkPublishClassResult,
type BulkPublishResult,
type HomeworkDaysDto,
type HomeworkDto,
type HomeworkStatusDto,
@@ -15,9 +18,11 @@ import {
isClassDevice,
resolveClassScope,
} from '../../lib/access.js';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js';
@@ -182,6 +187,63 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
return dto;
}
/**
* 批量布置作业:一次给多个班级发同一份内容。
*
* 逐班串行调用 `createHomework`(复用同一套科目归属断言 + 实时广播 + 班级端归属规则),
* **某个班失败只记进结果**:例如批量里含一个"语文由别的老师任教"的班,
* 那个班带着「不是你任教的科目」的失败原因回来,其余班照常发布。
*
* 科目用 `subjectName` 而不是 courseId:课程是班级内的,同一门课在每个班是不同的记录,
* 跨班只能用名称逐个查(查不到只让那个班失败,不自动建课)。
*/
export async function bulkCreateHomeworks(
user: TokenPayload,
input: BulkCreateHomeworkRequest,
): Promise<BulkPublishResult> {
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
for (const classId of classIds) {
try {
// 批量跨班只能用科目「名称」定位课程:同一门课在每个班是不同的 Course 记录。
// 该班没有这门课时只让这个班失败 —— 不自动建课(否则一次批量会在 N 个班里凭空
// 建出课程,还会让发起人在这些班里变成"任课老师",绕过科目维度的写权限)。
let courseId = input.courseId ?? null;
if (input.subjectName) {
const course = await prisma.course.findFirst({
where: { classId, name: input.subjectName },
select: { id: true },
});
if (!course) {
throw ApiError.badRequest(
`该班级没有「${input.subjectName}」这门课程(跨班发布按科目名称匹配,请先让班主任/管理员在本班建课并分配任课老师,或改选其他科目)`,
);
}
courseId = course.id;
}
const dto = await createHomework(user, {
classId,
courseId,
title: input.title,
content: input.content,
assignDate: input.assignDate ?? null,
attachmentUrl: input.attachmentUrl ?? null,
});
results.push({ classId, ok: true, id: dto.id });
} catch (error) {
results.push(toBulkFailure(classId, error, '布置作业'));
}
}
const summary = summarizeBulk(results);
logger.info(
`批量布置作业:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`,
);
return summary;
}
export async function updateHomework(
user: TokenPayload,
homeworkId: string,
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { ApiError, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requireAnyPermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -48,7 +48,7 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
*/
router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
router.get('/template', requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'), (req, res) => {
const kind = resolveImportKind(req.query.kind);
assertImportKindAllowed(kind, getAuthUser(req).role);
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
@@ -72,7 +72,7 @@ router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
*/
router.post(
'/table/preview',
requireRole('ADMIN', 'TEACHER'),
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
validate({ body: tableFileSchema }),
(req, res) => {
const input = validatedBody<TableFileInput>(req);
@@ -89,7 +89,7 @@ router.post(
*/
router.post(
'/table/commit',
requireRole('ADMIN', 'TEACHER'),
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
validate({ body: tableCommitSchema }),
async (req, res) => {
const input = validatedBody<TableCommitInput>(req);
@@ -7,7 +7,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { hashPassword } from '../../lib/password.js';
import { resolveUserClassRole } from '../../lib/access.js';
import { canImport } from '@classhelper/shared';
import { canImport, canManageSubjectContent } from '@classhelper/shared';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/**
@@ -337,9 +337,19 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({
where: { classId },
select: { id: true, name: true },
select: { id: true, name: true, teacherId: true },
});
/*
* 科目维度的第二道门(`assertImportAllowed` 只到"班级"这一层)。
*
* 成绩是**逐行落到具体科目**上的,而班主任并不自动是本班所有科目的老师 ——
* 少了这道检查,班主任就能导入别的老师任教科目的成绩,班里会平白多出一份
* 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。
* 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。
*/
const classRole = await resolveUserClassRole(user, classId);
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
@@ -390,6 +400,23 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
if (courseName && !course)
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
// 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师)
const canWriteCourse = canManageSubjectContent({
classRole,
hasSubject: Boolean(course),
isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false,
allowClassDevice: false,
});
if (!canWriteCourse) {
errors.push({
row: rowNumber,
message: course
? `「${course.name}」不是你任教的科目,不能导入该科目的成绩`
: '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入',
});
continue;
}
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
const levelType = levelText ? 'custom' : 'percent';
const existing = await prisma.grade.findFirst({
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import * as gradeService from '../grades/grades.service.js';
@@ -117,7 +117,7 @@ router.use(authenticate());
*/
router.get(
'/devices',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ query: listDevicesQuerySchema }),
async (req, res) => {
const { classId } = validatedQuery<{ classId?: string }>(req);
@@ -131,14 +131,14 @@ router.get(
* 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。
* 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。
*/
router.get('/classisland/status', requireRole('ADMIN', 'TEACHER'), async (req, res) => {
router.get('/classisland/status', requirePermission('device:manage'), async (req, res) => {
sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
});
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
router.post(
'/devices',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ body: createDeviceSchema }),
async (req, res) => {
const result = await integrationService.createDevice(
@@ -152,7 +152,7 @@ router.post(
/** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */
router.patch(
'/devices/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema, body: updateDeviceSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -174,7 +174,7 @@ router.patch(
*/
router.post(
'/devices/:id/request-schedule',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -186,7 +186,7 @@ router.post(
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
router.post(
'/devices/:id/token',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -197,7 +197,7 @@ router.post(
/** DELETE /api/integrations/devices/:id - 删除设备 */
router.delete(
'/devices/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('device:manage'),
validate({ params: deviceIdParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -209,7 +209,7 @@ router.delete(
/** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */
router.post(
'/classisland/notify',
requireRole('ADMIN', 'TEACHER'),
requirePermission('classisland:notify'),
validate({ body: sendNotificationSchema }),
async (req, res) => {
const result = await integrationService.sendNotification(
@@ -1,32 +1,46 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
bulkCreateNotificationSchema,
createNotificationSchema,
listNotificationsQuerySchema,
notificationArchiveQuerySchema,
notificationScopeQuerySchema,
type BulkCreateNotificationInput,
type CreateNotificationInput,
type ListNotificationQuery,
type NotificationArchiveQuery,
} from './notifications.schemas.js';
import * as notificationService from './notifications.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/notifications?classId=&priority=&unreadOnly= - 通知列表 */
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{
classId?: string;
priority?: string;
unreadOnly: boolean;
keyword?: string;
}>(req);
const query = validatedQuery<ListNotificationQuery>(req);
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
});
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
router.get('/days', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(req);
sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功');
});
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
router.get('/creators', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<NotificationArchiveQuery>(req);
sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功');
});
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -44,7 +58,7 @@ router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('notification:publish'),
validate({ body: createNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -53,6 +67,21 @@ router.post(
},
);
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
router.post(
'/bulk',
requirePermission('notification:publish'),
validate({ body: bulkCreateNotificationSchema }),
async (req, res) => {
const user = getAuthUser(req);
const result = await notificationService.bulkCreateNotifications(
user,
req.body as BulkCreateNotificationInput,
);
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
},
);
/** POST /api/notifications/:id/read - 标记已读 */
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -63,7 +92,7 @@ router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) =
/** DELETE /api/notifications/:id - 删除通知 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('notification:delete'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,13 +1,36 @@
import { z } from 'zod';
import { isDayKey } from '@classhelper/shared';
import { booleanFlagSchema } from '../../lib/schemas.js';
export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
/** 本地日期(YYYY-MM-DD):按天归档/筛选用它,不要用 createdAt 的 UTC 切片 */
const dayKeySchema = z
.string()
.trim()
.refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' });
export const listNotificationsQuerySchema = z.object({
classId: z.string().min(1).optional(),
priority: notificationPrioritySchema.optional(),
unreadOnly: booleanFlagSchema,
keyword: z.string().trim().max(64).optional(),
/** 只看某一天;与 from/to 二选一 */
date: dayKeySchema.optional(),
/** 日期区间(含边界) */
from: dayKeySchema.optional(),
to: dayKeySchema.optional(),
/** 只看某位发布人 */
createdBy: z.string().min(1).optional(),
});
/** GET /notifications/days 与 GET /notifications/creators 的筛选条件(不含 date 自身) */
export const notificationArchiveQuerySchema = z.object({
classId: z.string().min(1).optional(),
priority: notificationPrioritySchema.optional(),
createdBy: z.string().min(1).optional(),
from: dayKeySchema.optional(),
to: dayKeySchema.optional(),
});
export const createNotificationSchema = z.object({
@@ -26,5 +49,25 @@ export const notificationScopeQuerySchema = z.object({
classId: z.string().min(1).optional(),
});
/**
* 一次给多个班级发同一条通知(批量发布)。
*
* 上限 50 个班是刻意设的:批量发布是逐班串行落库 + 逐班推 ClassIsland,
* 一次塞几百个班会长时间占住请求,也会把教室大屏刷屏。
*/
export const bulkCreateNotificationSchema = z.object({
classIds: z
.array(z.string().min(1))
.min(1, '请至少选择一个班级')
.max(50, '一次最多发布 50 个班级'),
title: z.string().trim().min(1, '请输入通知标题').max(120),
content: z.string().min(1, '请输入通知内容').max(5000),
priority: notificationPrioritySchema.optional(),
confirmDuringClass: z.boolean().optional(),
});
export type CreateNotificationInput = z.infer<typeof createNotificationSchema>;
export type NotificationPriorityInput = z.infer<typeof notificationPrioritySchema>;
export type ListNotificationQuery = z.infer<typeof listNotificationsQuerySchema>;
export type NotificationArchiveQuery = z.infer<typeof notificationArchiveQuerySchema>;
export type BulkCreateNotificationInput = z.infer<typeof bulkCreateNotificationSchema>;
@@ -1,4 +1,15 @@
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import {
SOCKET_EVENTS,
dayKeyLocal,
type BulkCreateNotificationRequest,
type BulkPublishClassResult,
type BulkPublishResult,
type NotificationCreatorSummary,
type NotificationCreatorsDto,
type NotificationDaysDto,
type NotificationDto,
} from '@classhelper/shared';
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
import {
assertClassAccess,
assertCanPublishNotification,
@@ -23,6 +34,37 @@ export interface ListNotificationOptions {
priority?: string;
unreadOnly?: boolean;
keyword?: string;
date?: string;
from?: string;
to?: string;
createdBy?: string;
}
/** 归档筛选条件:按天 / 日期区间 / 发布人 / 优先级(assignDate 是本地日期字符串,字典序即时间序) */
function archiveWhere(options: {
priority?: string;
createdBy?: string;
date?: string;
from?: string;
to?: string;
}): Record<string, unknown> {
if (options.date) {
return {
...(options.priority ? { priority: options.priority } : {}),
...(options.createdBy ? { createdBy: options.createdBy } : {}),
assignDate: options.date,
};
}
const range = {
...(options.from ? { gte: options.from } : {}),
...(options.to ? { lte: options.to } : {}),
};
return {
...(options.priority ? { priority: options.priority } : {}),
...(options.createdBy ? { createdBy: options.createdBy } : {}),
...(Object.keys(range).length > 0 ? { assignDate: range } : {}),
};
}
export async function listNotifications(
@@ -38,7 +80,7 @@ export async function listNotifications(
const notifications = await prisma.notification.findMany({
where: {
...classScopeWhere(scope),
...(options.priority ? { priority: options.priority } : {}),
...archiveWhere(options),
...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}),
@@ -55,6 +97,98 @@ export async function listNotifications(
return notifications.map((item) => toNotificationDto(item, { studentIds: personalIds, withStatus: isStaff }));
}
/**
* 按天归档:列出「有通知的日期 + 当天条数」,供 Web 端左侧日期栏使用。
*
* 用 `assignDate`(本地日期)而不是 `createdAt`:晚上发的通知不该被算到第二天。
* 传了 `date` 也刻意忽略 —— 日期栏是"归档索引",不该被当前选中的那天过滤掉。
*/
export async function listNotificationDays(
user: TokenPayload,
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
): Promise<NotificationDaysDto> {
const scope = await resolveClassScope(user, options.classId);
const grouped = await prisma.notification.groupBy({
by: ['assignDate'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
_count: { _all: true },
});
const days = grouped
.map((item) => ({ date: item.assignDate, count: item._count._all }))
.filter((item) => item.date !== '')
.sort((left, right) => right.date.localeCompare(left.date));
return { days };
}
/** 按发布人归档:列出当前筛选范围内的发布人及其条数(供"按发布人看通知"用) */
export async function listNotificationCreators(
user: TokenPayload,
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
): Promise<NotificationCreatorsDto> {
const scope = await resolveClassScope(user, options.classId);
const grouped = await prisma.notification.groupBy({
by: ['createdBy'],
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
_count: { _all: true },
});
const owners = await prisma.user.findMany({
where: { id: { in: grouped.map((item) => item.createdBy) } },
select: { id: true, name: true, username: true },
});
const creators: NotificationCreatorSummary[] = grouped
.map((item) => {
const owner = owners.find((candidate) => candidate.id === item.createdBy);
return {
id: item.createdBy,
name: owner?.name ?? '未知用户',
username: owner?.username ?? '',
count: item._count._all,
};
})
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name, 'zh-CN'));
return { creators };
}
/**
* 批量发布通知:一次给多个班级发同一条内容。
*
* 逐班串行调用 `createNotification`(复用同一套权限断言 + 上课保护 + 实时广播 + ClassIsland 推送),
* 某个班失败只记进结果,不影响其他班 —— 前端拿到逐班结果后自己决定怎么展示与重试。
*/
export async function bulkCreateNotifications(
user: TokenPayload,
input: BulkCreateNotificationRequest,
): Promise<BulkPublishResult> {
const classIds = normalizeBulkClassIds(input.classIds);
const results: BulkPublishClassResult[] = [];
for (const classId of classIds) {
try {
const dto = await createNotification(user, {
classId,
title: input.title,
content: input.content,
priority: input.priority,
confirmDuringClass: input.confirmDuringClass,
});
results.push({ classId, ok: true, id: dto.id });
} catch (error) {
results.push(toBulkFailure(classId, error, '发布通知'));
}
}
const summary = summarizeBulk(results);
logger.info(
`批量发布通知:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`,
);
return summary;
}
/**
* 发布通知并实时广播到 class:{classId} 房间。
*
@@ -92,6 +226,8 @@ export async function createNotification(
content: input.content,
priority,
createdBy: user.sub,
// 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径)
assignDate: dayKeyLocal(new Date()),
},
include: { creator: creatorSelect, reads: true },
});
@@ -0,0 +1,97 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
assignUserGroupsSchema,
createPermissionGroupSchema,
updatePermissionGroupSchema,
type AssignUserGroupsInput,
type CreatePermissionGroupInput,
type UpdatePermissionGroupInput,
} from './permissions.schemas.js';
import * as permissionService from './permissions.service.js';
const router = Router();
router.use(authenticate());
/**
* GET /api/permissions/me - 当前账号的生效权限。
*
* **故意不挂权限键**:任何登录账号都要能读自己的权限(两端据此决定入口显不显示),
* 而"能读自己有什么权限"本身不是什么机密。班级端会话返回空集。
*/
router.get('/me', async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功');
});
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */
router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, permissionService.permissionCatalog(), '获取权限目录成功');
});
/** GET /api/permissions/groups - 权限组列表 */
router.get('/groups', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
});
/** POST /api/permissions/groups - 新建权限组 */
router.post(
'/groups',
requirePermission('permission:manage'),
validate({ body: createPermissionGroupSchema }),
async (req, res) => {
const input = validatedBody<CreatePermissionGroupInput>(req);
sendCreated(res, await permissionService.createGroup(input), '权限组已创建');
},
);
/** PATCH /api/permissions/groups/:id - 改名 / 改描述 / 整体替换权限键 */
router.patch(
'/groups/:id',
requirePermission('permission:manage'),
validate({ params: idParamSchema, body: updatePermissionGroupSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<UpdatePermissionGroupInput>(req);
sendOk(res, await permissionService.updateGroup(id, input), '权限组已更新');
},
);
/** DELETE /api/permissions/groups/:id - 删除权限组(内置组不允许删除) */
router.delete(
'/groups/:id',
requirePermission('permission:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await permissionService.deleteGroup(id);
sendOk(res, { id }, '权限组已删除,原成员回到角色默认权限');
},
);
/** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */
router.get('/users', requirePermission('permission:manage'), async (_req, res) => {
sendOk(res, await permissionService.listMembers(), '获取成员成功');
});
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
router.put(
'/users/:id/groups',
requirePermission('permission:manage'),
validate({ params: idParamSchema, body: assignUserGroupsSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const input = validatedBody<AssignUserGroupsInput>(req);
sendOk(res, await permissionService.assignUserGroups(id, input), '权限组分配已保存');
},
);
export const permissionsModule = defineModule({
name: 'permissions',
basePath: '/permissions',
router,
});
@@ -0,0 +1,47 @@
import { PERMISSION_KEYS, type PermissionKey } from '@classhelper/shared';
import { z } from 'zod';
/**
* 权限键校验:必须是 shared 的 `PERMISSION_KEYS` 之一。
*
* 用 refine 而不是 `z.enum(PERMISSION_KEYS)`:后者需要一个非空元组类型,
* 而 `PERMISSION_KEYS` 是 `as const` 推导出来的只读元组,转换写法又长又脆。
*
* 写入口一律拒绝未知键;**读出口静默忽略**(库里可能留着已下线的键)——
* 见 `lib/permissions.ts` 的 known 集合。
*/
export const permissionKeySchema = z
.string()
.refine((value): value is PermissionKey => (PERMISSION_KEYS as readonly string[]).includes(value), {
message: '未知的权限键',
});
/** 一次最多 200 个键(目录现在不到 40 个,留余量同时挡住离谱的请求体) */
const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200 个权限键');
export const createPermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'),
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
keys: keysSchema.optional(),
});
/**
* 改权限组:字段都是可选的,**传了就整体替换**。
*
* `keys` 是"整体替换"而不是增删(前端权限页本来就是一次提交整棵勾选树),
* 这样不会出现"看着没勾上、其实还生效"的幽灵权限。
*/
export const updatePermissionGroupSchema = z.object({
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(),
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
keys: keysSchema.optional(),
});
/** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */
export const assignUserGroupsSchema = z.object({
groupIds: z.array(z.string().min(1)).max(50, '一次最多分配 50 个权限组'),
});
export type CreatePermissionGroupInput = z.infer<typeof createPermissionGroupSchema>;
export type UpdatePermissionGroupInput = z.infer<typeof updatePermissionGroupSchema>;
export type AssignUserGroupsInput = z.infer<typeof assignUserGroupsSchema>;
@@ -0,0 +1,326 @@
import {
PERMISSION_CATEGORIES,
PERMISSION_DEFAULT_ROLES,
PERMISSION_KEYS,
PERMISSION_LABELS,
ROLE_DEFAULT_PERMISSIONS,
type AssignUserGroupsRequest,
type CreatePermissionGroupRequest,
type MyPermissionsDto,
type PermissionCatalogDto,
type PermissionGroupDto,
type PermissionKey,
type PermissionMemberDto,
type UpdatePermissionGroupRequest,
type UserRole,
} from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { ADMIN_UNREMOVABLE_PERMISSIONS, loadEffectivePermissions } from '../../lib/permissions.js';
/**
* 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。
*
* 生效权限的计算**不在本文件**,而在 `lib/permissions.ts` 的 `loadEffectivePermissions`
* (鉴权中间件每请求调用一次);本文件只负责"编辑这些组",两者共用同一份 shared 常量。
*/
const GROUP_SELECT = {
id: true,
name: true,
description: true,
builtin: true,
createdAt: true,
updatedAt: true,
items: { select: { key: true } },
_count: { select: { users: true } },
} as const;
type GroupRow = {
id: string;
name: string;
description: string;
builtin: boolean;
createdAt: Date;
updatedAt: Date;
items: { key: string }[];
_count: { users: number };
};
const MEMBER_SELECT = {
id: true,
username: true,
name: true,
role: true,
permissionGroups: { select: { groupId: true } },
} as const;
type MemberRow = {
id: string;
username: string;
name: string;
role: string;
permissionGroups: { groupId: string }[];
};
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
function knownKey(key: string): key is PermissionKey {
return KEY_ORDER.has(key);
}
function byCatalogOrder(a: string, b: string): number {
return (KEY_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) - (KEY_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
}
/** 去重 + 丢掉已下线的键 + 按目录顺序排序 */
function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] {
const unique = new Set<PermissionKey>();
for (const key of keys ?? []) if (knownKey(key)) unique.add(key);
return [...unique].sort(byCatalogOrder);
}
function toGroupDto(row: GroupRow): PermissionGroupDto {
return {
id: row.id,
name: row.name,
description: row.description,
builtin: row.builtin,
keys: row.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
memberCount: row._count.users,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
export function permissionCatalog(): PermissionCatalogDto {
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS) as UserRole[];
return {
keys: [...PERMISSION_KEYS],
labels: { ...PERMISSION_LABELS },
categories: PERMISSION_CATEGORIES.map((category) => ({
title: category.title,
keys: [...category.keys],
})),
defaultRoles: Object.fromEntries(
PERMISSION_KEYS.map((key) => [key, [...PERMISSION_DEFAULT_ROLES[key]]]),
) as Record<PermissionKey, UserRole[]>,
roleDefaults: Object.fromEntries(
roles.map((role) => [role, [...ROLE_DEFAULT_PERMISSIONS[role]]]),
) as Record<UserRole, PermissionKey[]>,
unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS],
};
}
/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */
export async function listGroups(): Promise<PermissionGroupDto[]> {
const rows = await prisma.permissionGroup.findMany({
select: GROUP_SELECT,
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
});
return (rows as GroupRow[]).map(toGroupDto);
}
export async function createGroup(input: CreatePermissionGroupRequest): Promise<PermissionGroupDto> {
const name = input.name.trim();
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
const keys = normalizeKeys(input.keys);
const created = await prisma.permissionGroup.create({
data: {
name,
description: input.description?.trim() ?? '',
items: { create: keys.map((key) => ({ key })) },
},
select: GROUP_SELECT,
});
logger.info(`新建权限组「${name}」(${keys.length} 个权限键)`);
return toGroupDto(created as GroupRow);
}
export async function updateGroup(
groupId: string,
input: UpdatePermissionGroupRequest,
): Promise<PermissionGroupDto> {
const current = await prisma.permissionGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true },
});
if (!current) throw ApiError.notFound('权限组不存在');
const name = input.name?.trim();
if (name && name !== current.name) {
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
}
const keys = input.keys ? normalizeKeys(input.keys) : null;
await prisma.$transaction(async (tx) => {
await tx.permissionGroup.update({
where: { id: groupId },
data: {
...(name ? { name } : {}),
...(input.description !== undefined ? { description: input.description.trim() } : {}),
},
});
// 键是整体替换:先清后建。漏传某个键就等于"取消勾选",不会留下幽灵权限。
if (keys) {
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
if (keys.length > 0) {
await tx.permissionGroupItem.createMany({ data: keys.map((key) => ({ groupId, key })) });
}
}
});
const updated = await prisma.permissionGroup.findUniqueOrThrow({
where: { id: groupId },
select: GROUP_SELECT,
});
logger.info(
`更新权限组「${(updated as GroupRow).name}」(${keys ? `权限键 ${keys.length} 个` : '未改权限键'})`,
);
return toGroupDto(updated as GroupRow);
}
export async function deleteGroup(groupId: string): Promise<{ id: string }> {
const current = await prisma.permissionGroup.findUnique({
where: { id: groupId },
select: { id: true, name: true, builtin: true, _count: { select: { users: true } } },
});
if (!current) throw ApiError.notFound('权限组不存在');
if (current.builtin) {
throw ApiError.badRequest(`「${current.name}」是内置权限组,不能删除(可以改名或调整里面的权限)`);
}
// 键与成员关系都是 onDelete: Cascade,直接删组即可;
// 组内成员随后"没有任何分组",会回落角色默认模板(不会变成空权限)。
await prisma.permissionGroup.delete({ where: { id: groupId } });
logger.info(
`删除权限组「${current.name}」(原成员 ${current._count.users} 个,已回到角色默认权限)`,
);
return { id: groupId };
}
/** 把一行 User(含分组关联)转成权限页用的成员 DTO */
function toMemberDto(
row: MemberRow,
keysOfGroup: Map<string, PermissionKey[]>,
): PermissionMemberDto {
const groupIds = row.permissionGroups.map((link) => link.groupId);
const union = new Set<PermissionKey>();
for (const groupId of groupIds) for (const key of keysOfGroup.get(groupId) ?? []) union.add(key);
const role = row.role as UserRole;
const permissionCount =
role === 'ADMIN'
? PERMISSION_KEYS.length
: groupIds.length === 0
? ROLE_DEFAULT_PERMISSIONS[role].length
: union.size;
return {
id: row.id,
username: row.username,
name: row.name,
role,
groupIds,
permissionCount,
fixed: role === 'ADMIN',
};
}
/** GET /api/permissions/users - 成员列表(管理员/教师账号;班级端账号不参与权限组) */
export async function listMembers(): Promise<PermissionMemberDto[]> {
const [rows, groups] = await Promise.all([
prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
select: MEMBER_SELECT,
orderBy: [{ role: 'asc' }, { name: 'asc' }],
}),
prisma.permissionGroup.findMany({ select: { id: true, items: { select: { key: true } } } }),
]);
const keysOfGroup = new Map<string, PermissionKey[]>(
groups.map((group) => [
group.id,
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
]),
);
return (rows as MemberRow[]).map((row) => toMemberDto(row, keysOfGroup));
}
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
export async function assignUserGroups(
userId: string,
input: AssignUserGroupsRequest,
): Promise<PermissionMemberDto> {
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
if (!user) throw ApiError.notFound('账号不存在');
const row = user as MemberRow;
if (row.role === 'ADMIN') {
throw ApiError.badRequest('管理员固定拥有全部权限,不需要(也不能)分配权限组');
}
if (row.role === 'CLASS_DEVICE') {
throw ApiError.badRequest(
'ClassHelper 班级端账号的能力由"班级内角色"决定(见权限矩阵),不参与权限组分配',
);
}
const groupIds = [...new Set(input.groupIds)];
if (groupIds.length > 0) {
const found = await prisma.permissionGroup.count({ where: { id: { in: groupIds } } });
if (found !== groupIds.length) throw ApiError.badRequest('有权限组不存在,请刷新后重试');
}
await prisma.$transaction(async (tx) => {
await tx.userPermissionGroup.deleteMany({ where: { userId } });
if (groupIds.length > 0) {
await tx.userPermissionGroup.createMany({
data: groupIds.map((groupId) => ({ userId, groupId })),
});
}
});
logger.info(
`分配权限组:${row.username} → ${groupIds.length > 0 ? `${groupIds.length} 个组` : '角色默认模板'}`,
);
const groups = await prisma.permissionGroup.findMany({
select: { id: true, items: { select: { key: true } } },
});
const keysOfGroup = new Map<string, PermissionKey[]>(
groups.map((group) => [
group.id,
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
]),
);
return toMemberDto({ ...row, permissionGroups: groupIds.map((groupId) => ({ groupId })) }, keysOfGroup);
}
/**
* GET /api/permissions/me - 当前账号的生效权限。
*
* 两端按它决定"入口显不显示"(真正的拦截始终在服务端)。
* 班级端会话恒为空集:它的能力由班级维度断言决定。
*/
export async function myPermissions(user: TokenPayload): Promise<MyPermissionsDto> {
const permissions = await loadEffectivePermissions(user);
const fromGroups =
user.classSession || user.role === 'CLASS_DEVICE' || user.role === 'ADMIN'
? false
: (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0;
return {
role: user.role,
permissions: [...permissions].sort(byCatalogOrder),
fromGroups,
};
}
+8
View File
@@ -1,7 +1,9 @@
import { aiModule } from './ai/ai.module.js';
import { archivesModule } from './archives/archives.module.js';
import { authModule } from './auth/auth.module.js';
import { callsModule } from './calls/calls.module.js';
import { classesModule } from './classes/classes.module.js';
import { clientsModule } from './clients/clients.module.js';
import { coursesModule } from './courses/courses.module.js';
import { databaseModule } from './database/database.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js';
@@ -10,8 +12,10 @@ import { homeworksModule } from './homeworks/homeworks.module.js';
import { importsModule } from './imports/imports.module.js';
import { integrationsModule } from './integrations/integrations.module.js';
import { notificationsModule } from './notifications/notifications.module.js';
import { permissionsModule } from './permissions/permissions.module.js';
import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js';
import { systemModule } from './system/system.module.js';
import { teachersModule } from './teachers/teachers.module.js';
import { termModule } from './term/term.module.js';
import { updateModule } from './update/update.module.js';
@@ -26,16 +30,20 @@ import type { ApiModule } from './module.types.js';
*/
export const apiModules: ApiModule[] = [
authModule,
aiModule,
classesModule,
clientsModule,
coursesModule,
schedulesModule,
homeworksModule,
notificationsModule,
permissionsModule,
callsModule,
importsModule,
integrationsModule,
gradesModule,
studentsModule,
systemModule,
teachersModule,
archivesModule,
termModule,
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -58,7 +58,7 @@ router.get('/current', validate({ query: classStatusQuerySchema }), async (req,
/** POST /api/schedules - 新增课表 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ body: createScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -73,7 +73,7 @@ router.post(
/** PATCH /api/schedules/:id - 修改课表 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ params: idParamSchema, body: updateScheduleSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -89,7 +89,7 @@ router.patch(
/** DELETE /api/schedules/:id - 删除课表 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('schedule:write'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -33,7 +33,7 @@ router.use(authenticate());
*/
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ query: listStudentsQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -45,7 +45,7 @@ router.get(
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
router.get(
'/transfers',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ query: listTransfersQuerySchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -62,7 +62,7 @@ router.get(
*/
router.post(
'/transfer',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ body: transferStudentsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -74,7 +74,7 @@ router.post(
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
router.post(
'/transfer-out',
requireRole('ADMIN'),
requirePermission('class:manage'),
validate({ body: transferOutStudentsSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -86,7 +86,7 @@ router.post(
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
router.get(
'/:id/transfers',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -99,7 +99,7 @@ router.get(
/** POST /api/students - 新建学生(名单记录,不是账号) */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ body: createStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -114,7 +114,7 @@ router.post(
/** PATCH /api/students/:id - 修改学生信息 / 调班 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: idParamSchema, body: updateStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -130,7 +130,7 @@ router.patch(
/** DELETE /api/students/:id - 删除学生名单记录 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requirePermission('roster:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -0,0 +1,62 @@
import { Router } from 'express';
import { API_PATHS } from '@classhelper/shared';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
updateAutostartSchema,
updateSystemSettingsSchema,
type UpdateAutostartInput,
type UpdateSystemSettingsInput,
} from './system.schemas.js';
import * as systemService from './system.service.js';
const router = Router();
router.use(authenticate());
/**
* 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。
*
* 这些接口改的是"这台服务器怎么运行"(开发者模式、开机自启),
* 与业务数据无关,因此不走任何班级维度断言。
*/
router.get('/settings', requirePermission('system:manage'), async (_req, res) => {
sendOk(res, await systemService.getSystemSettings(), '获取系统设置成功');
});
router.put(
'/settings',
requirePermission('system:manage'),
validate({ body: updateSystemSettingsSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<UpdateSystemSettingsInput>(req);
sendOk(res, await systemService.updateSystemSettings(user, input), '系统设置已保存');
},
);
router.get('/autostart', requirePermission('system:manage'), async (_req, res) => {
sendOk(res, await systemService.getAutostartStatus(), '获取开机自启状态成功');
});
router.put(
'/autostart',
requirePermission('system:manage'),
validate({ body: updateAutostartSchema }),
async (req, res) => {
const input = validatedBody<UpdateAutostartInput>(req);
sendOk(res, await systemService.updateAutostart(input), '开机自启设置已生效');
},
);
/** 运行诊断:开发者模式下排查"服务端到底跑的哪份代码、数据在哪" */
router.get('/diagnostics', requirePermission('system:manage'), async (_req, res) => {
sendOk(res, await systemService.getDiagnostics(), '获取运行诊断成功');
});
export const systemModule = defineModule({
name: 'system',
basePath: API_PATHS.system,
router,
});
@@ -0,0 +1,14 @@
import { z } from 'zod';
/** `PUT /api/system/settings`:目前只有开发者模式(字段都可选,传了就改) */
export const updateSystemSettingsSchema = z.object({
developerMode: z.boolean().optional(),
});
/** `PUT /api/system/autostart`:开/关服务端开机自启 */
export const updateAutostartSchema = z.object({
enabled: z.boolean(),
});
export type UpdateSystemSettingsInput = z.infer<typeof updateSystemSettingsSchema>;
export type UpdateAutostartInput = z.infer<typeof updateAutostartSchema>;
@@ -0,0 +1,153 @@
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import {
CLIENT_ONLINE_WINDOW_MS,
type AutostartStatusDto,
type SystemDiagnosticsDto,
type SystemSettingsDto,
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js';
import { APP_VERSION } from '../../lib/version.js';
import type { UpdateAutostartInput, UpdateSystemSettingsInput } from './system.schemas.js';
/**
* 服务端系统设置:开发者模式、开机自启、运行诊断。
*
* 为什么"开机自启"要做成接口而不是只在安装器里勾一下:教室服务器换机器、改过安装目录、
* 或者管理员当时没勾,事后都得能补 —— 安装器只负责**默认值**,运行期由这里改。
* 它改的是**操作系统**的东西(注册表 Run 项 / systemd 单元),所以:
* 1. 只在"装好的形态"里放行(开发机、免安装包、容器里返回 `supported:false`);
* 2. 失败原因是运维性质的(权限不足、单元文件不存在),一律带原文回给管理员,不吞成 500。
*/
const RUN_KEY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
const RUN_KEY_NAME = 'ClassHelperServer';
const SYSTEMD_UNIT = 'classhelper';
function runCommand(file: string, args: string[]): { ok: boolean; output: string } {
const result = spawnSync(file, args, { encoding: 'utf8', windowsHide: true });
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
return { ok: result.status === 0, output: output || (result.error ? result.error.message : '') };
}
/** 安装器写在安装目录里的启动脚本:它存在才算"装好的形态" */
function startCmdPath(): string {
return path.join(path.dirname(process.execPath), 'start.cmd');
}
export async function getSystemSettings(): Promise<SystemSettingsDto> {
return { developerMode: await getBooleanSetting(SETTING_KEYS.developerMode, false) };
}
export async function updateSystemSettings(
user: TokenPayload,
input: UpdateSystemSettingsInput,
): Promise<SystemSettingsDto> {
if (input.developerMode !== undefined) {
await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode);
logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`);
}
return getSystemSettings();
}
export async function getAutostartStatus(): Promise<AutostartStatusDto> {
if (process.platform === 'win32') {
const cmd = startCmdPath();
if (!fs.existsSync(cmd)) {
return {
supported: false,
enabled: false,
mechanism: 'unsupported',
detail: '未检测到安装形态(缺少 start.cmd):开发环境或免安装目录不提供开机自启切换',
};
}
const query = runCommand('reg.exe', ['query', RUN_KEY_PATH, '/v', RUN_KEY_NAME]);
return {
supported: true,
enabled: query.ok,
mechanism: 'run-key',
detail: `注册表 HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\${RUN_KEY_NAME} → "${cmd}"`,
};
}
if (process.platform === 'linux') {
const unit = `/etc/systemd/system/${SYSTEMD_UNIT}.service`;
if (!fs.existsSync(unit)) {
return {
supported: false,
enabled: false,
mechanism: 'unsupported',
detail: `未找到 ${unit}(不是 systemd 安装形态)`,
};
}
const query = runCommand('systemctl', ['is-enabled', SYSTEMD_UNIT]);
return {
supported: true,
enabled: query.ok,
mechanism: 'systemd',
detail: `systemctl is-enabled ${SYSTEMD_UNIT}`,
};
}
return {
supported: false,
enabled: false,
mechanism: 'unsupported',
detail: `当前平台(${process.platform})不支持切换开机自启`,
};
}
export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> {
const current = await getAutostartStatus();
if (!current.supported) throw ApiError.badRequest(current.detail);
if (current.mechanism === 'run-key') {
const cmd = startCmdPath();
const result = input.enabled
? runCommand('reg.exe', ['add', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/t', 'REG_SZ', '/d', `"${cmd}"`, '/f'])
: runCommand('reg.exe', ['delete', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/f']);
if (!result.ok) throw ApiError.internal(`写入开机自启项失败:${result.output}`);
} else {
const result = runCommand('systemctl', [input.enabled ? 'enable' : 'disable', SYSTEMD_UNIT]);
if (!result.ok) {
throw ApiError.badRequest(
`systemctl ${input.enabled ? 'enable' : 'disable'} ${SYSTEMD_UNIT} 失败:${result.output}(服务是否以特权用户运行?)`,
);
}
}
logger.info(`服务端开机自启已${input.enabled ? '开启' : '关闭'}(机制=${current.mechanism})`);
return getAutostartStatus();
}
export async function getDiagnostics(): Promise<SystemDiagnosticsDto> {
const now = Date.now();
// 延迟 import:registry 会 import 本模块,顶层互相引用在 ESM 下会拿到未初始化的绑定
const { apiModules } = await import('../registry.js');
const [clientReleaseCount, onlineClientCount, settings] = await Promise.all([
prisma.clientRelease.count(),
prisma.clientDevice.count({
where: { lastHeartbeatAt: { gt: new Date(now - CLIENT_ONLINE_WINDOW_MS) } },
}),
getSystemSettings(),
]);
return {
version: APP_VERSION,
nodeVersion: process.versions.node,
platform: `${process.platform}-${process.arch}`,
uptimeSeconds: Math.round(process.uptime()),
databaseProvider: env.databaseProvider,
serverRoot: env.serverRoot,
dataDir: path.resolve(env.serverRoot, '..', 'data'),
clientReleaseCount,
onlineClientCount,
moduleCount: apiModules.length,
developerMode: settings.developerMode,
};
}
@@ -1,7 +1,7 @@
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, requireRole } from '../../middleware/auth.js';
import { authenticate, requirePermission } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -25,20 +25,30 @@ router.use(authenticate());
*/
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
});
router.get(
'/',
requirePermission('teacher:manage'),
validate({ query: listTeachersQuerySchema }),
async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
},
);
/** POST /api/teachers - 录入教师账号(仅管理员) */
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
});
router.post(
'/',
requirePermission('teacher:manage'),
validate({ body: createTeacherSchema }),
async (req, res) => {
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
},
);
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
router.patch(
'/:id',
requireRole('ADMIN'),
requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: updateTeacherSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -49,7 +59,7 @@ router.patch(
/** POST /api/teachers/:id/reset-password - 重置教师密码 */
router.post(
'/:id/reset-password',
requireRole('ADMIN'),
requirePermission('teacher:manage'),
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
@@ -59,11 +69,16 @@ router.post(
);
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id);
sendOk(res, { id }, '教师账号已删除');
});
router.delete(
'/:id',
requirePermission('teacher:manage'),
validate({ params: idParamSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id);
sendOk(res, { id }, '教师账号已删除');
},
);
export const teachersModule = defineModule({
name: 'teachers',
@@ -11,7 +11,7 @@ import type {
} from './teachers.schemas.js';
/**
* 教师管理(**仅管理员**,路由层已用 requireRole('ADMIN') 兜住):
* 教师管理(**仅管理员**,路由层已用 requirePermission('teacher:manage') 兜住):
* 录入教师是管理员专属操作,班主任与科任老师都没有该权限。
* 学生名单在 students 模块,两个模块结构保持一致(列表 / 新建 / 编辑 / 重置密码 / 删除)。
*/
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
@@ -23,7 +23,7 @@ router.use(authenticate());
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
*/
router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySchema }), async (req, res) => {
router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const { classId } = validatedQuery<{ classId?: string }>(req);
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
@@ -32,7 +32,7 @@ router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySche
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
router.put(
'/',
requireRole('ADMIN'),
requirePermission('term:write'),
validate({ body: updateTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -51,7 +51,7 @@ router.put(
*/
router.post(
'/auto',
requireRole('ADMIN'),
requirePermission('term:write'),
validate({ body: autoTermWeeksSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -70,7 +70,7 @@ router.post(
*/
router.get(
'/holidays',
requireRole('ADMIN'),
requirePermission('term:write'),
validate({ query: holidayQuerySchema }),
async (req, res) => {
const { year } = validatedQuery<{ year?: number }>(req);
@@ -0,0 +1,220 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import {
API_PREFIX,
CLIENT_RELEASE_DIR,
clientReleaseDownloadUrl,
clientReleaseFileName,
compareVersions,
type ClientReleaseDto,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { logger } from '../../lib/logger.js';
import type { FetchClientReleaseInput, RegisterClientReleaseInput } from './update.schemas.js';
/**
* 客户端安装包在服务端的缓存。
*
* 为什么要有这一层:教室机器常常**没有外网**,而"统一下发客户端更新"必须能落到
* 那些机器上。于是服务端替它们代下(或由管理员手工放一份)并缓存到
* `data/client-releases/<版本>/`,客户端再从 `GET /api/update/client/download/:version` 拉。
*
* 两个入口都**不传二进制**(服务端没有 multipart,也不打算为这一件事加):
* - `fetch`:服务端自己去 GitHub Releases 下(Web 端受 CSP 约束不能直连 GitHub,见 §5 第 52 条);
* - `register`:文件已由管理员放进磁盘(内网分发、U 盘、CI 产物),服务端只算 sha256 并登记。
*/
/** 代下大文件时的超时(安装包约 100MB,机房线路慢要留足) */
const FETCH_TIMEOUT_MS = 10 * 60 * 1000;
type ReleaseRow = {
id: string;
version: string;
fileName: string;
sha256: string;
size: number;
notes: string;
createdAt: Date;
updatedAt: Date;
};
function releaseRoot(): string {
// 与 database 模块的备份目录同口径:<serverRoot>/../data
const dir = path.join(env.serverRoot, '..', 'data', CLIENT_RELEASE_DIR);
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function releaseDir(version: string): string {
return path.join(releaseRoot(), version);
}
function releaseFilePath(version: string, fileName: string): string {
return path.join(releaseDir(version), fileName);
}
function toReleaseDto(row: ReleaseRow): ClientReleaseDto {
return {
version: row.version,
fileName: row.fileName,
size: row.size,
sha256: row.sha256,
notes: row.notes,
fileReady: fs.existsSync(releaseFilePath(row.version, row.fileName)),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async function sha256Of(file: string): Promise<string> {
const hash = crypto.createHash('sha256');
await pipeline(fs.createReadStream(file), hash);
return hash.digest('hex');
}
/** GET /api/update/client/releases - 已缓存的安装包(按版本从新到旧) */
export async function listReleases(): Promise<ClientReleaseDto[]> {
const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[];
return rows
.sort((a, b) => compareVersions(b.version, a.version))
.map((row) => toReleaseDto(row));
}
/** 服务端缓存的**最新**版本(推送更新时省略 version 就用它) */
export async function latestReleaseVersion(): Promise<string> {
const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[];
const ready = rows.filter((row) => fs.existsSync(releaseFilePath(row.version, row.fileName)));
ready.sort((a, b) => compareVersions(b.version, a.version));
return ready[0]?.version ?? '';
}
/**
* 某个版本的下载信息(心跳响应里带给客户端)。
* 文件不在磁盘上时返回 null —— 客户端据此提示"服务端还没有这份安装包"。
*/
export async function resolveUpdateInfo(version: string): Promise<ClientUpdateInfoDto | null> {
const wanted = version.trim();
if (!wanted) return null;
const row = (await prisma.clientRelease.findUnique({ where: { version: wanted } })) as ReleaseRow | null;
if (!row) return null;
if (!fs.existsSync(releaseFilePath(row.version, row.fileName))) return null;
return {
version: row.version,
fileName: row.fileName,
size: row.size,
sha256: row.sha256,
notes: row.notes,
// 含 /api 前缀:客户端用「服务器地址 + 它」直接下载
url: `${API_PREFIX}/update/client/download/${encodeURIComponent(row.version)}`,
};
}
/** 绝对路径(下载路由用);文件不存在返回 null */
export async function resolveReleaseFile(version: string): Promise<{ file: string; fileName: string } | null> {
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
if (!row) return null;
const file = releaseFilePath(row.version, row.fileName);
if (!fs.existsSync(file)) return null;
return { file, fileName: row.fileName };
}
/** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */
export async function registerRelease(
userId: string,
input: RegisterClientReleaseInput,
): Promise<ClientReleaseDto> {
const version = input.version;
// 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致)
const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
const fileName = input.fileName ?? known?.fileName ?? clientReleaseFileName(version);
const file = releaseFilePath(version, fileName);
if (!fs.existsSync(file)) {
throw ApiError.badRequest(
`没找到安装包文件「${fileName}」,请先把它放进 data/${CLIENT_RELEASE_DIR}/${version}/ 再登记`,
);
}
const stat = fs.statSync(file);
const sha256 = await sha256Of(file);
const row = (await prisma.clientRelease.upsert({
where: { version },
create: {
version,
fileName,
sha256,
size: stat.size,
notes: input.notes?.trim() ?? '',
createdBy: userId,
},
update: {
fileName,
sha256,
size: stat.size,
...(input.notes !== undefined ? { notes: input.notes.trim() } : {}),
},
})) as ReleaseRow;
logger.info(`登记客户端安装包 v${version}(${fileName},${stat.size} 字节,sha256=${sha256.slice(0, 12)}…)`);
return toReleaseDto(row);
}
/** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */
export async function fetchReleaseFromGithub(
userId: string,
input: FetchClientReleaseInput,
): Promise<ClientReleaseDto> {
const version = input.version;
const fileName = clientReleaseFileName(version);
const url = clientReleaseDownloadUrl(version);
const target = releaseFilePath(version, fileName);
fs.mkdirSync(releaseDir(version), { recursive: true });
logger.info(`开始从 GitHub 拉取客户端安装包 v${version}:${url}`);
let response: Response;
try {
response = await fetch(url, {
redirect: 'follow',
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
headers: { 'User-Agent': 'ClassHelper-Server' },
});
} catch (error) {
throw ApiError.badRequest(
`下载失败:${error instanceof Error ? error.message : String(error)}。` +
`可以把安装包手工放进 data/${CLIENT_RELEASE_DIR}/${version}/ 再点「登记」`,
);
}
if (!response.ok || !response.body) {
throw ApiError.badRequest(
`GitHub 上没有 v${version} 的客户端安装包(HTTP ${response.status},文件名应为 ${fileName})`,
);
}
try {
await pipeline(Readable.fromWeb(response.body as never), fs.createWriteStream(target));
} catch (error) {
fs.rmSync(target, { force: true });
throw ApiError.badRequest(`写入安装包失败:${error instanceof Error ? error.message : String(error)}`);
}
// 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算
return registerRelease(userId, { version, fileName });
}
/** 删除一份缓存的安装包(元数据 + 磁盘文件) */
export async function deleteRelease(version: string): Promise<{ version: string }> {
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
if (!row) throw ApiError.notFound('服务端没有这个版本的安装包');
await prisma.clientRelease.delete({ where: { version } });
fs.rmSync(releaseDir(version), { recursive: true, force: true });
logger.info(`删除客户端安装包 v${version}(${row.fileName})`);
return { version };
}
@@ -1,8 +1,17 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser } from '../../middleware/auth.js';
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import * as clientReleases from './client-releases.service.js';
import * as updateService from './update.service.js';
import {
clientReleaseVersionParamSchema,
fetchClientReleaseSchema,
registerClientReleaseSchema,
type FetchClientReleaseInput,
type RegisterClientReleaseInput,
} from './update.schemas.js';
const router = Router();
router.use(authenticate());
@@ -25,6 +34,86 @@ router.get('/check', async (req, res) => {
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
});
/* ------------------------------- 客户端安装包的服务端缓存(统一下发更新的基础) */
/**
* GET /api/update/client/releases - 服务端已缓存了哪些客户端版本
*
* 教室机器没有外网,所以"统一推送更新"必须有这一层中继:服务端替它们把安装包拉下来,
* 客户端再从 `/client/download/:version` 取。
*/
router.get('/client/releases', requirePermission('client:manage'), async (_req, res) => {
sendOk(res, await clientReleases.listReleases(), '获取安装包列表成功');
});
/**
* POST /api/update/client/fetch - 让服务端从 GitHub 下载某个版本的客户端安装包并登记
*
* 服务端(Node)不受浏览器 CSP 约束,这是唯一能"一键代下"的地方;
* 下不动(学校出口没有外网)时退回 `POST /client/releases` 手工登记。
*/
router.post(
'/client/fetch',
requirePermission('client:update-push'),
validate({ body: fetchClientReleaseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<FetchClientReleaseInput>(req);
sendCreated(res, await clientReleases.fetchReleaseFromGithub(user.sub, input), '安装包已下载并登记');
},
);
/**
* POST /api/update/client/releases - 登记一份已经放进磁盘的安装包
*
* 文件要求放在 `<serverRoot>/../data/client-releases/<版本>/` 下(文件名默认按发布约定推导)。
* 这条路径也是自动化验收用的:不需要外网、不需要传 100MB 二进制。
*/
router.post(
'/client/releases',
requirePermission('client:update-push'),
validate({ body: registerClientReleaseSchema }),
async (req, res) => {
const user = getAuthUser(req);
const input = validatedBody<RegisterClientReleaseInput>(req);
sendCreated(res, await clientReleases.registerRelease(user.sub, input), '安装包已登记');
},
);
/** DELETE /api/update/client/releases/:version - 删除缓存(元数据 + 磁盘文件) */
router.delete(
'/client/releases/:version',
requirePermission('client:update-push'),
validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => {
const { version } = validatedParams<{ version: string }>(req);
sendOk(res, await clientReleases.deleteRelease(version), '安装包已删除');
},
);
/**
* GET /api/update/client/download/:version - 下载安装包(客户端自动更新走它)
*
* **故意不挂权限键**:下载方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空),
* 它代表那个班;真正的保护是"必须已登录 + 限流中间件"。
* `res.download` 自带 Content-Length / Range 支持,不需要另写流式助手。
*/
router.get(
'/client/download/:version',
validate({ params: clientReleaseVersionParamSchema }),
async (req, res) => {
const { version } = validatedParams<{ version: string }>(req);
const found = await clientReleases.resolveReleaseFile(version);
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
res.download(found.file, found.fileName, (error) => {
if (error) {
// 客户端中途取消下载也会走到这里,只记日志,不要往已发出的响应里再写东西
res.locals.downloadError = error instanceof Error ? error.message : String(error);
}
});
},
);
export const updateModule = defineModule({
name: 'update',
basePath: '/update',
@@ -0,0 +1,41 @@
import { z } from 'zod';
/**
* 安装包文件名/版本号只允许这些字符。
*
* 它们会被拼进磁盘路径(`data/client-releases/<版本>/<文件名>`),
* 所以必须挡住 `..`、`/`、`\` —— 否则一个 `version=../../` 就能写到仓库外面去。
*/
const SAFE_SEGMENT = /^[0-9A-Za-z._-]+$/;
export const clientReleaseVersionSchema = z
.string()
.trim()
.min(1, '请输入版本号')
.max(40, '版本号最多 40 个字符')
.refine((value) => SAFE_SEGMENT.test(value), {
message: '版本号只能包含字母、数字、点、下划线与短横线',
});
/** `POST /api/update/client/fetch`:让服务端替教室机器从 GitHub 拉一份安装包 */
export const fetchClientReleaseSchema = z.object({
version: clientReleaseVersionSchema,
});
/** `POST /api/update/client/releases`:登记一份已经放进磁盘的安装包(手工兜底与自动化用) */
export const registerClientReleaseSchema = z.object({
version: clientReleaseVersionSchema,
fileName: z
.string()
.trim()
.min(1)
.max(120)
.refine((value) => SAFE_SEGMENT.test(value), { message: '文件名只能包含字母、数字、点、下划线与短横线' })
.optional(),
notes: z.string().trim().max(500).optional(),
});
export const clientReleaseVersionParamSchema = z.object({ version: clientReleaseVersionSchema });
export type FetchClientReleaseInput = z.infer<typeof fetchClientReleaseSchema>;
export type RegisterClientReleaseInput = z.infer<typeof registerClientReleaseSchema>;
+4 -1
View File
@@ -1,8 +1,9 @@
import type { PermissionKey } from '@classhelper/shared';
import type { TokenPayload } from '../lib/jwt.js';
/**
* 扩展 Express 的 Request 类型。
* - auth:认证中间件解析出的当前用户
* - auth:认证中间件解析出的当前用户 + 生效权限集合
* - validatedQuery / validatedParams:校验中间件产出的类型安全数据
* (Express 5 中 req.query 是只读 getter,不能直接覆盖,因此单独存放)
*/
@@ -12,6 +13,8 @@ declare global {
auth?: {
token: string;
user: TokenPayload;
/** 生效权限集合(权限组并集,或角色默认模板;班级端为空集) */
permissions?: ReadonlySet<PermissionKey>;
};
validatedQuery?: unknown;
validatedParams?: unknown;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
"type": "module",
+56 -1
View File
@@ -1,4 +1,11 @@
import type { GradeLevelType, NotificationPriority, StudentGender, StudentStatus, UserRole } from './types.js';
import type {
ClientCommandKind,
GradeLevelType,
NotificationPriority,
StudentGender,
StudentStatus,
UserRole,
} from './types.js';
/** API 前缀 */
export const API_PREFIX = '/api';
@@ -33,6 +40,14 @@ export const API_PATHS = {
database: '/database',
/** 更新检查:Web 端经服务端转发查 GitHub 上的最新 Release(浏览器受 CSP 限制不能直连) */
update: '/update',
/** 权限组:权限目录、组的增删改、成员分配、当前账号生效权限 */
permissions: '/permissions',
/** ClassHelper 客户端设备:心跳、设备清单、统一下发更新与远控指令 */
clients: '/clients',
/** 大模型助手:设置(仅管理员)、一句话解析、使用记录 */
ai: '/ai',
/** 系统设置与诊断:开发者模式、服务端开机自启、运行诊断(仅管理员) */
system: '/system',
dashboard: '/dashboard/summary',
} as const;
@@ -654,3 +669,43 @@ export const UPDATE_CHECK_CACHE_TTL_MS = 30 * 60 * 1000;
/** 单次检查的超时:教室机 / 机房的网络常常很慢,但也不能把界面卡住 */
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
/* -------------------------------------------- 客户端设备(ClassHelper 桌面端) */
/**
* 桌面客户端向服务端上报心跳的间隔(30 秒,与班级端在线状态刷新一致)。
*
* 为什么**不用 Socket.IO 事件**下发更新与远控:教室机的连接经常断(代理、休眠、重启),
* 而「这台机器什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上,
* 所以统一用「DB 记状态 + 客户端定时拉取」的范式(与 `IntegrationDevice.requestScheduleReport` 同一套路),
* 不引入新的长连接握手。
*/
export const CLIENT_HEARTBEAT_INTERVAL_MS = 30_000;
/** 「在线」判定窗口:容忍连续丢两次心跳(30 秒 × 3) */
export const CLIENT_ONLINE_WINDOW_MS = 90_000;
/** 远控命令种类:只有这四种,全部用系统命令实现 */
export const CLIENT_COMMAND_KINDS: readonly ClientCommandKind[] = ['shutdown', 'restart', 'logoff', 'lock'];
export const CLIENT_COMMAND_LABELS: Record<ClientCommandKind, string> = {
shutdown: '关机',
restart: '重启',
logoff: '注销',
lock: '锁屏',
};
/** 命令有效期:超过这个时间还没被拉走就作废(避免机器离线几天后一开机就被昨天的命令关掉) */
export const CLIENT_COMMAND_EXPIRE_MS = 10 * 60 * 1000;
/** 客户端回执里 result 的长度上限(一句中文足够) */
export const CLIENT_COMMAND_RESULT_MAX = 200;
/**
* 自动安装更新前的「空闲」判据:主窗口已隐藏满这么久才装。
* 上课时段(渲染进程上报的课表状态)与窗口可见都会把安装往后推。
*/
export const CLIENT_UPDATE_IDLE_MS = 5 * 60 * 1000;
/** 服务端缓存客户端安装包的目录(相对 `<serverRoot>/../data`,见 update 模块) */
export const CLIENT_RELEASE_DIR = 'client-releases';
+243
View File
@@ -145,6 +145,249 @@ export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind
return canManageGrades(classRole);
}
/* -------------------------------------------------------------------------- */
/* 权限键目录(权限组的唯一事实来源) */
/* -------------------------------------------------------------------------- */
/**
* 权限键:三端共用的**唯一权限标识**。
*
* - 服务端:路由级用 `requirePermission(key)`,班级维度的细门仍走下面那些
* `canXxx`(科目归属这类**数据完整性**规则故意不可被权限组覆盖);
* - Web 管理端:按钮/菜单显隐、权限组管理页的勾选项;
* - 桌面客户端:功能入口显隐。
*
* 键名用 `领域:动作` 的形式,新增功能要**先在这里加键**,再去两端用。
* **不要**在页面或服务里硬编码角色判断来代替它(AGENTS.md §5 第 3 条)。
*/
export const PERMISSION_KEYS = [
// 班级与人员
'class:read',
'class:manage',
'roster:manage',
'teacher:manage',
'schedule:read',
'schedule:write',
// 内容发布
'homework:read',
'homework:write',
'notification:read',
'notification:publish',
'notification:delete',
'call:create',
// 成绩与导入
'grade:read',
'grade:write',
'grade:import',
'student:grade-query',
'import:students',
'import:teachers',
'import:schedule',
'import:class-teachers',
// 设备与联动
'device:manage',
'classisland:notify',
'client:read',
'client:manage',
'client:update-push',
'remote:control',
// 学期与归档
'term:read',
'term:write',
'archive:manage',
'dashboard:read',
// 系统与安全
'database:manage',
'update:check',
'update:force',
'ai:use',
'ai:manage',
'permission:manage',
'system:manage',
'log:read',
'developer:mode',
] as const;
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
/** 权限键的中文名(权限组管理页与文档展示,别再各写一份) */
export const PERMISSION_LABELS: Record<PermissionKey, string> = {
'class:read': '查看班级与班级列表',
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)',
'roster:manage': '学生名单管理(增删 / 调班 / 导入)',
'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)',
'schedule:read': '查看课表',
'schedule:write': '课表与课程编辑',
'homework:read': '查看作业',
'homework:write': '发布 / 修改 / 删除作业(仍按任教科目录入)',
'notification:read': '查看通知',
'notification:publish': '发布通知',
'notification:delete': '删除通知',
'call:create': '叫人',
'grade:read': '查看成绩与成绩汇总',
'grade:write': '录入 / 修改成绩(仍按任教科目录入)',
'grade:import': '导入成绩',
'student:grade-query': '按学号查询学生成绩明细',
'import:students': '导入学生名单',
'import:teachers': '导入教师名单',
'import:schedule': '导入课表时间配置',
'import:class-teachers': '导入班级任课关系',
'device:manage': '联动设备管理(ClassIsland 设备令牌 / 课表上报)',
'classisland:notify': '向 ClassIsland 下发提醒',
'client:read': '查看 ClassHelper 客户端在线状态与版本',
'client:manage': '管理客户端设备(改名 / 停用 / 删除)',
'client:update-push': '向客户端统一下发更新',
'remote:control': '远控客户端电脑(关机 / 重启 / 注销 / 锁屏)',
'term:read': '查看学期周次',
'term:write': '修改学期周次与开学日期',
'archive:manage': '毕业归档',
'dashboard:read': '查看统计看板',
'database:manage': '数据库快照 / 导出 / 切库',
'update:check': '检查服务端更新',
'update:force': '强制检查更新(绕过缓存)',
'ai:use': '使用大模型指令(一句话发布)',
'ai:manage': '大模型设置(开关 / 服务地址 / 密钥 / 模型)',
'permission:manage': '权限组与用户权限管理',
'system:manage': '全局系统设置(含开发者模式)',
'log:read': '查看服务端日志',
'developer:mode': '开发者模式',
};
/** 权限组管理页的分组展示(只影响排序与标题,不改变语义) */
export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly PermissionKey[] }[] = [
{
title: '班级与人员',
keys: ['class:read', 'class:manage', 'roster:manage', 'teacher:manage', 'schedule:read', 'schedule:write'],
},
{
title: '内容发布',
keys: [
'homework:read',
'homework:write',
'notification:read',
'notification:publish',
'notification:delete',
'call:create',
],
},
{
title: '成绩与导入',
keys: [
'grade:read',
'grade:write',
'grade:import',
'student:grade-query',
'import:students',
'import:teachers',
'import:schedule',
'import:class-teachers',
],
},
{
title: '设备与联动',
keys: ['device:manage', 'classisland:notify', 'client:read', 'client:manage', 'client:update-push', 'remote:control'],
},
{ title: '学期与统计', keys: ['term:read', 'term:write', 'archive:manage', 'dashboard:read'] },
{
title: '系统与安全',
keys: [
'database:manage',
'update:check',
'update:force',
'ai:use',
'ai:manage',
'permission:manage',
'system:manage',
'log:read',
'developer:mode',
],
},
];
/**
* 每个权限键的**默认角色**(镜像改造前各路由的 `requireRole(...)`,逐条对齐)。
*
* 这是"没有分过任何权限组"时的回落模板:行为与权限组功能上线前完全一致。
* 一旦管理员给某个账号分了组,生效集合就**只由组的并集决定**(可以比默认更少)。
*
* 注意 `CLASS_DEVICE` 不出现在任何默认里:班级端的能力由班级维度断言
* (`canManageSubjectContent(..., { allowClassDevice })` 等)决定,不受权限组影响。
*/
export const PERMISSION_DEFAULT_ROLES: Record<PermissionKey, readonly UserRole[]> = {
'class:read': ['ADMIN', 'TEACHER'],
'class:manage': ['ADMIN'],
'roster:manage': ['ADMIN', 'TEACHER'],
'teacher:manage': ['ADMIN'],
'schedule:read': ['ADMIN', 'TEACHER'],
'schedule:write': ['ADMIN', 'TEACHER'],
'homework:read': ['ADMIN', 'TEACHER'],
'homework:write': ['ADMIN', 'TEACHER'],
'notification:read': ['ADMIN', 'TEACHER'],
'notification:publish': ['ADMIN', 'TEACHER'],
'notification:delete': ['ADMIN', 'TEACHER'],
'call:create': ['ADMIN', 'TEACHER'],
'grade:read': ['ADMIN', 'TEACHER'],
'grade:write': ['ADMIN', 'TEACHER'],
'grade:import': ['ADMIN', 'TEACHER'],
'student:grade-query': ['ADMIN', 'TEACHER'],
'import:students': ['ADMIN', 'TEACHER'],
'import:teachers': ['ADMIN'],
'import:schedule': ['ADMIN', 'TEACHER'],
'import:class-teachers': ['ADMIN'],
'device:manage': ['ADMIN', 'TEACHER'],
'classisland:notify': ['ADMIN', 'TEACHER'],
'client:read': ['ADMIN', 'TEACHER'],
'client:manage': ['ADMIN'],
'client:update-push': ['ADMIN'],
'remote:control': ['ADMIN'],
'term:read': ['ADMIN', 'TEACHER'],
'term:write': ['ADMIN'],
'archive:manage': ['ADMIN'],
'dashboard:read': ['ADMIN', 'TEACHER'],
'database:manage': ['ADMIN'],
'update:check': ['ADMIN', 'TEACHER'],
'update:force': ['ADMIN'],
'ai:use': ['ADMIN', 'TEACHER'],
'ai:manage': ['ADMIN'],
'permission:manage': ['ADMIN'],
'system:manage': ['ADMIN'],
'log:read': ['ADMIN'],
'developer:mode': ['ADMIN'],
};
/** 全部权限键(管理员的生效集合;也用在校验 zod 的枚举上) */
export const ALL_PERMISSION_KEYS: readonly PermissionKey[] = PERMISSION_KEYS;
/**
* 角色默认权限清单(由 {@link PERMISSION_DEFAULT_ROLES} 派生)。
*
* `ADMIN` 是全集(服务端还会强制补上 `permission:manage` / `system:manage`,防止把自己锁死)。
*/
export const ROLE_DEFAULT_PERMISSIONS: Record<UserRole, readonly PermissionKey[]> = {
ADMIN: PERMISSION_KEYS,
TEACHER: PERMISSION_KEYS.filter((key) => PERMISSION_DEFAULT_ROLES[key].includes('TEACHER')),
CLASS_DEVICE: [],
};
/** 取某个角色的默认权限集合(无分组时的回落模板) */
export function defaultPermissionsForRole(role: UserRole): PermissionKey[] {
return [...ROLE_DEFAULT_PERMISSIONS[role]];
}
/** 判断权限集合里是否有某个键(`Set` 或数组都接受) */
export function hasPermission(permissions: ReadonlySet<string> | readonly string[], key: PermissionKey): boolean {
const list: readonly string[] = Array.isArray(permissions) ? permissions : [...permissions];
return list.includes(key);
}
/** 判断是否含任意一个键(服务端 requirePermission 是"全部满足",这里留给页面做"任一"判断) */
export function hasAnyPermission(
permissions: ReadonlySet<string> | readonly string[],
keys: readonly PermissionKey[],
): boolean {
return keys.some((key) => hasPermission(permissions, key));
}
/** 人类可读的权限矩阵(README / 设置页展示,单一事实来源) */
export const PERMISSION_MATRIX: readonly {
action: string;
+436 -2
View File
@@ -4,6 +4,10 @@
* 后端返回值与前端调用方都必须依赖它,避免契约漂移。
*/
// 只导入类型(PermissionKey 由 permissions.ts 的 PERMISSION_KEYS 推导):
// 两个文件之间是**类型层面**的相互引用,编译后都会被抹掉,运行时不存在循环依赖。
import type { PermissionKey } from './permissions.js';
/**
* 账号角色:管理员 / 教师 / ClassHelper 班级端。
*
@@ -733,6 +737,13 @@ export interface NotificationDto {
priority: NotificationPriority;
createdBy: string;
createdAt: string;
/**
* 通知所属日期(YYYY-MM-DD,**不带时区**)。
*
* 与 createdAt 的区别:createdAt 是"什么时候发的",assignDate 是"这天的通知"。
* Web 端与客户端的按天归档、日期筛选都用它(晚间发布不会被 UTC 切片算到第二天)。
*/
assignDate: string;
creator?: ClassTeacherBrief | null;
/** 当前用户是否已读 */
read?: boolean;
@@ -777,6 +788,99 @@ export interface NotificationQueryParams {
priority?: NotificationPriority;
unreadOnly?: boolean;
keyword?: string;
/** 只看某一天(YYYY-MM-DD),与 from/to 二选一 */
date?: string;
/** 日期区间起点(YYYY-MM-DD,含当天) */
from?: string;
/** 日期区间终点(YYYY-MM-DD,含当天) */
to?: string;
/** 只看某位发布人的通知 */
createdBy?: string;
}
/** 某一天有通知(用于按天归档的日期列表) */
export interface NotificationDaySummary {
/** YYYY-MM-DD */
date: string;
/** 当天通知条数 */
count: number;
}
/** GET /api/notifications/days 的返回 */
export interface NotificationDaysDto {
days: NotificationDaySummary[];
}
/** 通知发布人汇总(按发布人归档时的筛选项) */
export interface NotificationCreatorSummary {
id: string;
name: string;
username: string;
/** 该发布人在当前筛选范围内的通知条数 */
count: number;
}
/** GET /api/notifications/creators 的返回 */
export interface NotificationCreatorsDto {
creators: NotificationCreatorSummary[];
}
/**
* 批量发布的单班结果。
*
* 批量发布**逐班判定**:某个班没有权限、或正在上课而通知是紧急的,都不会让整批失败 ——
* 失败的那个班带着错误码与中文原因回到这里,成功的班照常落库并实时推送。
*/
export interface BulkPublishClassResult {
classId: string;
ok: boolean;
/** 成功时:新建的实体 id(通知 / 作业) */
id?: string;
/** 失败时:错误码(FORBIDDEN / URGENT_DURING_CLASS / CLASS_NOT_FOUND …) */
code?: string;
/** 失败时:可直接展示给用户的中文原因 */
message?: string;
/** 失败时的附加信息(如 URGENT_DURING_CLASS 的当前课程与下一次课) */
details?: Record<string, unknown>;
}
/** 批量发布结果汇总 */
export interface BulkPublishResult {
total: number;
succeeded: number;
failed: number;
results: BulkPublishClassResult[];
}
/** POST /api/notifications/bulk:一次给多个班级发同一条通知 */
export interface BulkCreateNotificationRequest {
/** 目标班级 id 列表(1~50 个) */
classIds: string[];
title: string;
content: string;
priority?: NotificationPriority;
/** 有班级正在上课且优先级为紧急时必须置 true(前端二次确认后重试) */
confirmDuringClass?: boolean;
}
/** POST /api/homeworks/bulk:一次给多个班级布置同一份作业 */
export interface BulkCreateHomeworkRequest {
/** 目标班级 id 列表(1~50 个) */
classIds: string[];
courseId?: string | null;
/**
* 按**科目名称**指定科目(批量场景用这个,而不是 courseId)。
*
* 课程是"班级内"的:同一门「语文」在每个班是不同的 Course 记录,跨班不可能共用一个 courseId。
* 传名称后由服务端在每个目标班里查同名课程;某个班没有这门课时**只让那个班失败**
* (结果里带「该班级没有…这门课程」),**不会自动建课**。与 courseId 同时传时以本字段为准。
*/
subjectName?: string | null;
title: string;
content: string;
/** 作业所属日期(YYYY-MM-DD);不传按服务器当天处理 */
assignDate?: string | null;
attachmentUrl?: string | null;
}
/* ------------------------------------------------------------------ 成绩 */
@@ -979,8 +1083,15 @@ export interface IslandState {
inClass: boolean;
/** 当前课的结束时间(HH:mm),用于"下课后自动弹出" */
currentPeriodEnd: string | null;
/** 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 */
reason: 'new' | 'after-class' | 'urgent' | 'call' | null;
/**
* 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 / 收回中。
*
* `closing` 是"用户点了知道了 / 标为已读"之后的**收回动画**阶段:状态在同一帧就已经落地
* (`active` 清空、`queued` 清空),主进程只是把形态切回胶囊让渲染进程把卡片收回去,
* 待 `CLOSE_ANIMATION_MS` 之后再淡出隐藏。它不是跨进程握手,渲染进程也不需要任何回执
* (见 AGENTS.md §5 第 34 条)。
*/
reason: 'new' | 'after-class' | 'urgent' | 'call' | 'closing' | null;
/**
* 多条通知时是否已点过"展开更多"(展开后显示全部放得下的通知)。
* 由主进程持有:卡片高度与窗口包围盒都由它决定,渲染进程只负责照此渲染。
@@ -1601,3 +1712,326 @@ export interface UpdateInfo {
assets: UpdateAssetDto[];
checkedAt: string;
}
/* ------------------------------------------------------------------ 权限组 */
/**
* 一个权限组:名字 + 描述 + 一组权限键(`PermissionGroupItem`)。
*
* 生效规则见服务端 `lib/permissions.ts` 的 `loadEffectivePermissions`:
* 账号**有分组**时取所属组的键并集(因此能比角色默认更少 ⇒ 可以用来收权);
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。
*/
export interface PermissionGroupDto {
id: string;
name: string;
description: string;
keys: PermissionKey[];
memberCount: number;
/** 内置组(种子创建)不可删除,只允许改成员与键 */
builtin: boolean;
createdAt: string;
updatedAt: string;
}
/** 权限页里的一个成员(管理员/教师账号) */
export interface PermissionMemberDto {
id: string;
username: string;
name: string;
role: UserRole;
/** 所属权限组;空数组 = 走角色默认模板 */
groupIds: string[];
/** 生效权限数量(ADMIN 恒为全集;班级端账号恒为 0,见权限页的说明) */
permissionCount: number;
/** 该账号固定拥有全部权限,不接受分配(管理员) */
fixed: boolean;
}
/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */
export interface PermissionCatalogDto {
keys: PermissionKey[];
labels: Record<PermissionKey, string>;
categories: { title: string; keys: PermissionKey[] }[];
/** 每个键"默认给哪些角色"(权限页的提示文案用) */
defaultRoles: Record<PermissionKey, UserRole[]>;
/** 每个角色的默认模板("没有分组时会是什么") */
roleDefaults: Record<UserRole, PermissionKey[]>;
/** 管理员不可被剥夺的键(前端置灰、服务端也会合并回去) */
unremovable: PermissionKey[];
}
export interface CreatePermissionGroupRequest {
name: string;
description?: string;
keys?: PermissionKey[];
}
export interface UpdatePermissionGroupRequest {
name?: string;
description?: string;
keys?: PermissionKey[];
}
/** `PUT /api/permissions/users/:id/groups`:整体替换某账号的权限组 */
export interface AssignUserGroupsRequest {
groupIds: string[];
}
/**
* `GET /api/permissions/me`:当前账号的生效权限。
*
* 两端用它决定"入口显不显示",而**不是**再写一套权限规则(AGENTS.md §5 第 3 条):
* 真正的拦截始终在服务端。
*/
export interface MyPermissionsDto {
role: UserRole;
permissions: PermissionKey[];
/** true = 权限来自权限组;false = 走角色默认模板(未分配任何组) */
fromGroups: boolean;
}
/* ------------------------------------- 客户端设备 / 远控 / 客户端更新 */
/** 远控命令种类(与 shared 的常量 `CLIENT_COMMAND_KINDS` 一一对应) */
export type ClientCommandKind = 'shutdown' | 'restart' | 'logoff' | 'lock';
/** 一条下发给教室客户端电脑的命令 */
export interface ClientCommandDto {
id: string;
deviceId: string;
kind: ClientCommandKind;
/** 给人看的说明:动作描述 + 管理员填的备注 */
payload: string;
createdAt: string;
/** 过期时间:客户端拉到时已过期就丢弃并回执「已过期」(避免机器离线几天后突然关机) */
expiresAt: string | null;
ackedAt: string | null;
/** 执行结果说明(未回执为空串) */
result: string;
}
/** Web 端「客户端设备」列表里的一台机器 */
export interface ClientDeviceDto {
id: string;
classId: string | null;
className: string | null;
deviceKey: string;
name: string;
/** 本机版本(客户端主进程 `app.getVersion()`) */
clientVersion: string;
platform: string;
/** 是否接受服务端统一下发的自动更新 */
autoUpdate: boolean;
/** 服务端指定的目标版本;`''` = 不指定 */
updateTargetVersion: string;
/** 已下载待安装的版本;`''` = 无 */
pendingVersion: string;
lastHeartbeatAt: string | null;
lastIp: string;
/** 客户端上报的状态说明(如「上课中」「窗口已隐藏」) */
stateNote: string;
/** 由最后一次心跳与 `CLIENT_ONLINE_WINDOW_MS` 判定 */
online: boolean;
/** 该设备当前待执行(未回执、未撤销、未过期)的命令;没有则 null */
pendingCommand: ClientCommandDto | null;
createdAt: string;
updatedAt: string;
}
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
export interface ClientHeartbeatRequest {
deviceKey: string;
name?: string;
clientVersion?: string;
platform?: string;
pendingVersion?: string | null;
stateNote?: string;
}
/** 服务端为某个版本缓存好的安装包信息(教室没有外网时也走服务端下载) */
export interface ClientUpdateInfoDto {
version: string;
fileName: string;
size: number;
sha256: string;
notes: string;
/** 下载路径(**含 `/api` 前缀**;客户端用「服务器地址 + 它」直接下载) */
url: string;
}
/** `POST /api/clients/heartbeat` 的返回:更新下发与远控命令都在这里带回 */
export interface ClientHeartbeatResponse {
deviceId: string;
serverTime: string;
/** 服务端要求升到的版本;null = 不要求 */
updateTargetVersion: string | null;
/** 目标版本的安装包信息;null = 服务端还没有这份安装包(客户端只能提示手动更新) */
update: ClientUpdateInfoDto | null;
/** 待执行命令(最早的一条);null = 无 */
command: ClientCommandDto | null;
}
/** `POST /api/clients/commands/:id/ack`:客户端执行后的回执 */
export interface ClientCommandAckRequest {
/** 既是身份也是护栏:班级端令牌全班共用,靠它确认回执来自命令指向的那台机器 */
deviceKey: string;
ok: boolean;
result?: string;
}
/** 一台设备上的操作结果(远控命令 / 更新推送共用) */
export interface ClientTargetResult {
deviceId: string;
deviceName: string;
ok: boolean;
code?: string;
message?: string;
}
/** 批量下发结果(远控命令 / 更新推送共用) */
export interface ClientPushResult {
total: number;
succeeded: number;
failed: number;
results: ClientTargetResult[];
}
/** `POST /api/clients/commands`:给选中的电脑下发远控命令 */
export interface CreateClientCommandRequest {
deviceIds: string[];
kind: ClientCommandKind;
/** 备注(会显示在被控机器上的二次确认弹窗里) */
note?: string;
}
/** `POST /api/clients/updates`:推送更新(按设备、按班级或全部) */
export interface PushClientUpdateRequest {
deviceIds?: string[];
classIds?: string[];
all?: boolean;
/** 目标版本;省略则用服务端缓存的最新版本 */
version?: string | null;
/** true = 取消下发(回到"不要求更新"的状态) */
clear?: boolean;
}
/** `GET /api/update/client/releases`:服务端已缓存的客户端安装包 */
export interface ClientReleaseDto {
version: string;
fileName: string;
size: number;
sha256: string;
notes: string;
/** 文件是否真的在磁盘上(登记的元数据可能因为手工清理而失效) */
fileReady: boolean;
createdAt: string;
updatedAt: string;
}
/* ------------------------------------------------- 全局设置 / 服务端系统状态 */
/** `GET/PUT /api/system/settings`:全局设置(目前只有开发者模式,仅 ADMIN 可改) */
export interface SystemSettingsDto {
/** 开发者模式:错误响应带堆栈、日志降到 debug 级 */
developerMode: boolean;
}
/** `GET/PUT /api/system/autostart`:服务端开机自启状态与切换 */
export interface AutostartStatusDto {
/** 当前形态是否支持切换(开发机/免安装/容器里不支持 —— 避免把开发机的自启项写坏) */
supported: boolean;
enabled: boolean;
/** run-key=Windows 注册表 Run 项 / scheduled-task / systemd / unsupported */
mechanism: 'run-key' | 'scheduled-task' | 'systemd' | 'unsupported';
/** 给人看的说明:命令、路径,或"为什么不支持" */
detail: string;
}
/** `GET /api/system/diagnostics`:开发者模式下的运行诊断(**不含任何密钥**) */
export interface SystemDiagnosticsDto {
version: string;
nodeVersion: string;
platform: string;
uptimeSeconds: number;
databaseProvider: string;
serverRoot: string;
dataDir: string;
clientReleaseCount: number;
onlineClientCount: number;
moduleCount: number;
developerMode: boolean;
}
/* ------------------------------------------------------------------ 大模型助手 */
/** 大模型服务端实现:openai=真接口 / mock=离线确定性结果(自动化验收用,不联网) */
export type AiProvider = 'openai' | 'mock';
/** `GET/PUT /api/ai/settings`:大模型设置(**API Key 只进不出**) */
export interface AiSettingsDto {
enabled: boolean;
provider: AiProvider;
baseUrl: string;
model: string;
/** 是否已配置密钥(界面据此显示"已配置/未配置",永不回传密钥本身) */
hasApiKey: boolean;
/** 密钥尾 4 位(便于确认换对了,空串表示未配置) */
keyHint: string;
/** 追加到系统提示词的学校自定义要求 */
systemPromptExtra: string;
}
export interface UpdateAiSettingsRequest {
enabled?: boolean;
provider?: AiProvider;
baseUrl?: string;
model?: string;
/** 传字符串=换成新密钥;传 null=清除;不传=保持不变 */
apiKey?: string | null;
systemPromptExtra?: string;
}
/** 助手能执行的动作(都是既有发布入口的封装,绝不直接写库) */
export type AiActionKind = 'create_homework' | 'create_notification' | 'call_student' | 'unknown';
/** `POST /api/ai/interpret`:只解析、不落库;确认后由前端调既有端点 */
export interface AiInterpretRequest {
/** 老师原话,例如「给高一二班布置作业,把卷子写完」 */
text: string;
/** 有上下文班级时一并给出(助手据此缩小候选) */
classId?: string;
}
/** 助手解析出的一次动作(params 用与手动入口**同一份 zod 校验**后再返回) */
export interface AiInterpretResult {
action: AiActionKind;
/** 可直接提交给既有端点的参数(已通过 zod 校验) */
params: Record<string, unknown>;
/** 给人看的预览(班级名 / 科目名 / 学生名等已解析成中文) */
preview: {
summary: string;
className?: string | null;
courseName?: string | null;
studentName?: string | null;
warnings: string[];
};
/** 模型自评置信度 0~1(低于 0.5 时界面提示"请仔细核对") */
confidence: number;
/** 一律为 true:助手的结果必须由老师确认后才落库 */
needConfirm: boolean;
/** 模型原始回复(仅在开发者模式下回传,便于排查格式问题) */
rawReply?: string | null;
}
/** `GET /api/ai/audit`:助手使用审计(谁在什么时候让助手做了什么) */
export interface AiAuditDto {
id: string;
userId: string;
userName: string;
prompt: string;
intent: string;
status: string;
message: string;
createdAt: string;
}
+21
View File
@@ -524,3 +524,24 @@ export function isNewerVersion(
if (!normalizeVersion(latest)) return false;
return compareVersions(latest, current) > 0;
}
/**
* 客户端安装包的**文件名约定**。
*
* 与 GitHub Release 上上传的 ASCII 名、以及官网下载页的离线兜底直链
* **必须一致**(AGENTS.md §4.4:改名要三处一起改)。
*/
export function clientReleaseFileName(version: string): string {
return `ClassHelper-${normalizeVersion(version)}-x64-client-setup.exe`;
}
/**
* 服务端从 GitHub 拉取客户端安装包的直链。
*
* 服务端替教室机器"代下"再缓存(教室常常没有外网)—— Web 端**不能**直连 GitHub
* (CSP 的 connect-src 只有 self / ws / CORS_ORIGIN,见 AGENTS.md §5 第 52 条)。
*/
export function clientReleaseDownloadUrl(version: string): string {
const tag = `v${normalizeVersion(version)}`;
return `https://github.com/LoveChengke/ClassHelper/releases/download/${tag}/${clientReleaseFileName(version)}`;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/web-admin",
"version": "1.2.0",
"version": "1.3.0",
"private": true,
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
"type": "module",
+2 -1
View File
@@ -58,7 +58,8 @@ async function request<T>(config: AxiosRequestConfig): Promise<T> {
export const api = {
get: <T>(url: string, params?: Record<string, unknown>): Promise<T> =>
request<T>({ method: 'GET', url, params }),
post: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'POST', url, data }),
post: <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> =>
request<T>({ method: 'POST', url, data, ...config }),
patch: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PATCH', url, data }),
put: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PUT', url, data }),
delete: <T>(url: string): Promise<T> => request<T>({ method: 'DELETE', url }),
+218 -1
View File
@@ -1,8 +1,13 @@
import {
API_PATHS,
type AiAuditDto,
type AiInterpretResult,
type AiSettingsDto,
type ApiErrorResponse,
type ArchivedClassContentDto,
type ArchivedYearDetailDto,
type ArchivedYearDto,
type AssignUserGroupsRequest,
type ClassDetailDto,
type ClassDto,
type AutoTermWeeksRequest,
@@ -10,15 +15,25 @@ import {
type ClassPlanImportResultDto,
type ClassPlanPreviewDto,
type ClassStatusDto,
type ClientDeviceDto,
type ClientPushResult,
type ClientReleaseDto,
type CourseDto,
type CreateArchiveRequest,
type CreateClassRequest,
type CreateCallRequest,
type CreateClassRequest,
type CreateClientCommandRequest,
type CreateCourseRequest,
type CreateGradeRequest,
type CreateHomeworkRequest,
type BulkCreateHomeworkRequest,
type BulkCreateNotificationRequest,
type BulkPublishResult,
type NotificationCreatorsDto,
type NotificationDaysDto,
type CreateIntegrationDeviceRequest,
type CreateNotificationRequest,
type CreatePermissionGroupRequest,
type CreateScheduleRequest,
type CreateStudentRequest,
type DashboardSummary,
@@ -38,8 +53,13 @@ import {
type IntegrationDeviceTokenDto,
type LoginRequest,
type LoginResponse,
type MyPermissionsDto,
type NotificationDto,
type PaginatedResult,
type PermissionCatalogDto,
type PermissionGroupDto,
type PermissionMemberDto,
type PushClientUpdateRequest,
type ScheduleDto,
type ScheduleWeekView,
type SendClassIslandNotificationRequest,
@@ -50,16 +70,20 @@ import {
type StudentStatus,
type StudentTransferDto,
type SubjectTeacherDto,
type SystemDiagnosticsDto,
type SystemSettingsDto,
type TableImportPreview,
type TableImportResult,
type TermWeeksDto,
type TimeLayoutDto,
type TimeLayoutImportResult,
type TimeLayoutParsePreview,
type UpdateAiSettingsRequest,
type UpdateClassAccountRequest,
type UpdateClassRequest,
type UpdateInfo,
type UpdateIntegrationDeviceRequest,
type UpdatePermissionGroupRequest,
type UpdateStudentRequest,
type UpdateTermWeeksRequest,
type UserDto,
@@ -131,6 +155,22 @@ export const courseApi = {
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.courses}/${id}`),
};
/**
* 从「同名课程已存在」的失败里取出那门已有课程的 id。
*
* 服务端对 `POST /courses` 做了 (班级 + 科目名) 去重(返回 409 `COURSE_EXISTS` +
* `details.courseId`),因为课程创建会把创建者写成任课老师 —— 若放行同名第二门课,
* 谁都能借"新建科目"把自己变成别人的科目的老师,绕过科目维度的写权限。
* 前端拿到这个 id 就**复用既有课程**:权限判定随即回到"你是不是这门课的老师",
* 该 403 的地方就 403(而不是悄悄多出一门归属自己的同名课)。
*/
export function courseIdFromExistsError(error: unknown): string | null {
const data = (error as { response?: { data?: ApiErrorResponse } })?.response?.data;
if (!data || data.code !== 'COURSE_EXISTS') return null;
const details = data.details as { courseId?: unknown } | null | undefined;
return typeof details?.courseId === 'string' ? details.courseId : null;
}
/* ------------------------------------------------------------------ 课表 */
export const scheduleApi = {
@@ -163,6 +203,9 @@ export const homeworkApi = {
api.get(`${API_PATHS.homeworks}/days`, params),
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
create: (payload: CreateHomeworkRequest): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
/** 一次给多个班级布置同一份作业(服务端逐班判定并返回逐班结果) */
bulkCreate: (payload: BulkCreateHomeworkRequest): Promise<BulkPublishResult> =>
api.post(`${API_PATHS.homeworks}/bulk`, payload),
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
api.patch(`${API_PATHS.homeworks}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.homeworks}/${id}`),
@@ -181,9 +224,35 @@ export const notificationApi = {
priority?: string;
unreadOnly?: boolean;
keyword?: string;
/** 只看某一天(YYYY-MM-DD,按通知所属日期 assignDate) */
date?: string;
/** 日期区间(含边界) */
from?: string;
to?: string;
/** 只看某位发布人 */
createdBy?: string;
}): Promise<NotificationDto[]> => api.get(API_PATHS.notifications, params),
/** 按天归档:有通知的日期 + 当天条数 */
days: (params: {
classId?: string;
priority?: string;
createdBy?: string;
from?: string;
to?: string;
}): Promise<NotificationDaysDto> => api.get(`${API_PATHS.notifications}/days`, params),
/** 按发布人归档:发布人 + 各人的通知条数 */
creators: (params: {
classId?: string;
priority?: string;
date?: string;
from?: string;
to?: string;
}): Promise<NotificationCreatorsDto> => api.get(`${API_PATHS.notifications}/creators`, params),
create: (payload: CreateNotificationRequest): Promise<NotificationDto> =>
api.post(API_PATHS.notifications, payload),
/** 一次给多个班级发同一条通知(服务端逐班判定并返回逐班结果) */
bulkCreate: (payload: BulkCreateNotificationRequest): Promise<BulkPublishResult> =>
api.post(`${API_PATHS.notifications}/bulk`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.notifications}/${id}`),
unreadCount: (classId?: string): Promise<{ count: number }> =>
api.get(`${API_PATHS.notifications}/unread-count`, classId ? { classId } : undefined),
@@ -504,3 +573,151 @@ export const updateApi = {
check: (force = false): Promise<UpdateInfo> =>
api.get(`${API_PATHS.update}/check${force ? '?force=1' : ''}`),
};
/* ------------------------------------------------------------------ 权限组与用户权限 */
/**
* 权限组管理(服务端 `modules/permissions`)。
*
* 权限生效规则:**有权限组时取各组的并集**(因此也能用来收回权限),
* 没有任何组时回落到角色默认模板;管理员固定拥有全集,班级端不受权限组影响。
*
* 除 `me` 之外,本组接口都要求当前账号有 `permission:manage`(否则服务端 403)。
*/
export const permissionApi = {
/** 当前账号的生效权限(任何登录账号都能读自己这一份) */
me: (): Promise<MyPermissionsDto> => api.get(`${API_PATHS.permissions}/me`),
/** 权限目录:全部键 / 中文名 / 分类 / 各键的默认角色 / 管理员不可剥夺的键 */
catalog: (): Promise<PermissionCatalogDto> => api.get(`${API_PATHS.permissions}/catalog`),
listGroups: (): Promise<PermissionGroupDto[]> => api.get(`${API_PATHS.permissions}/groups`),
createGroup: (payload: CreatePermissionGroupRequest): Promise<PermissionGroupDto> =>
api.post(`${API_PATHS.permissions}/groups`, payload),
/** `keys` 传了就**整体替换**该组的权限键(不传则保持原样) */
updateGroup: (id: string, payload: UpdatePermissionGroupRequest): Promise<PermissionGroupDto> =>
api.patch(`${API_PATHS.permissions}/groups/${id}`, payload),
/** 内置权限组不能删除(服务端会 400),调用前页面已经禁用了按钮 */
removeGroup: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.permissions}/groups/${id}`),
/** 可分配权限的成员(管理员与教师;管理员是固定行,不能分配) */
listMembers: (): Promise<PermissionMemberDto[]> => api.get(`${API_PATHS.permissions}/users`),
/** 分配权限组:传空数组 = 回到角色默认模板 */
assignGroups: (userId: string, payload: AssignUserGroupsRequest): Promise<PermissionMemberDto> =>
api.put(`${API_PATHS.permissions}/users/${userId}/groups`, payload),
};
/* ------------------------------------------------------------------ 客户端设备(远控与版本管理) */
/**
* 「客户端设备」页的接口。
*
* 权限(与后端 `clients.module.ts` 逐个对齐):
* - `list` / `remove`:`client:manage`
* - `commands`:`remote:control`(关机 / 重启 / 注销 / 锁屏,机器上还会再弹一次确认框)
* - `pushUpdate`:`client:update-push`
*/
export const clientApi = {
/** 设备列表(自动心跳上报产生的记录);`classId` 留空 = 全部班级 */
devices: (classId?: string): Promise<ClientDeviceDto[]> =>
api.get(`${API_PATHS.clients}/devices`, classId ? { classId } : undefined),
/**
* 下发远控命令。
*
* 同一台机器上未执行的旧命令会被服务端**自动撤销**(否则"先关机、后重启"会排队执行)。
*/
commands: (payload: CreateClientCommandRequest): Promise<ClientPushResult> =>
api.post(`${API_PATHS.clients}/commands`, payload),
/** 统一下发客户端更新:按设备 / 按班级 / 全部,或 `clear: true` 取消下发 */
pushUpdate: (payload: PushClientUpdateRequest): Promise<ClientPushResult> =>
api.post(`${API_PATHS.clients}/updates`, payload),
/** 移除一台设备记录(换机 / 旧机器清理;只删记录,不影响那台机器本身) */
removeDevice: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.clients}/devices/${id}`),
};
/** GitHub 代下与「登记 → 校验 sha256」都可能要几十秒到几分钟,够长才不至于半路超时 */
const CLIENT_RELEASE_FETCH_TIMEOUT_MS = 10 * 60 * 1000;
/**
* 客户端安装包缓存管理(服务端 `modules/update/client-releases.service.ts`)。
*
* 教室机器常常没有外网,所以安装包由**服务端替教室下载**再缓存:
* Web 端不直连 GitHub(CSP 的 connect-src 只放行 self 与 WebSocket,见 AGENTS.md §5 第 52 条)。
*
* 权限:`list` 要 `client:manage`,`fetch` / `register` / `remove` 要 `client:update-push`。
*/
export const clientReleaseApi = {
/** 服务端已缓存的安装包列表 */
list: (): Promise<ClientReleaseDto[]> => api.get(`${API_PATHS.update}/client/releases`),
/**
* 让服务端从 GitHub 下载指定版本并登记。
*
* ⚠️ **必须显式放宽超时**:axios 实例默认 20 秒,而服务端自己允许下载 10 分钟
* (`client-releases.service.ts` 的 `FETCH_TIMEOUT_MS`)。用默认值会在服务端还在下载时
* 就报"请求超时",用户会以为失败而重复点。这里与服务端取同一个上限。
*/
fetch: (version: string): Promise<ClientReleaseDto> =>
api.post(
`${API_PATHS.update}/client/fetch`,
{ version },
{ timeout: CLIENT_RELEASE_FETCH_TIMEOUT_MS },
),
/** 登记一份已经手工放进 `data/client-releases/<版本>/` 的安装包 */
register: (payload: { version: string; fileName?: string; notes?: string }): Promise<ClientReleaseDto> =>
api.post(`${API_PATHS.update}/client/releases`, payload),
/** 删除缓存(元数据 + 磁盘文件) */
remove: (version: string): Promise<{ version: string }> =>
api.delete(`${API_PATHS.update}/client/releases/${encodeURIComponent(version)}`),
};
/* ------------------------------------------------------------------ 大模型助手 */
/**
* 大模型助手的接口前缀(取自共享契约 `API_PATHS.ai`,与服务端 `modules/ai` 的挂载点同源)。
*/
const AI_BASE = API_PATHS.ai;
/**
* 大模型助手(服务端 `modules/ai`)。
*
* 权限:`settings` / `audit` 要 `ai:manage`(仅管理员);`interpret` 要 `ai:use`(教师也有)。
*
* ⚠️ **助手只解析、不落库**:`interpret` 只是把一句中文换成结构化参数,
* 真正写数据仍然由前端在老师确认后调既有的发布接口(作业 / 通知 / 叫人)。
*/
export const aiApi = {
/**
* 读取配置。
*
* 只有 `ai:manage` 能读(教师读会 403,而响应拦截器会弹一个错误提示),
* 所以助手页**不要**无条件调它——教师侧靠"探测式":直接尝试解析,
* 服务端回了 `AI_NOT_CONFIGURED` 再切空态。
*/
settings: (): Promise<AiSettingsDto> => api.get(`${AI_BASE}/settings`),
/**
* 保存配置。
*
* `apiKey` 的三种语义(服务端约定):传字符串 = 换新、传 `null` = 清除、**不传 = 保持原样**。
* 因此"留空表示不修改"时不要往 body 里塞 `apiKey: ''`,那会被当成一个空密钥。
*/
updateSettings: (payload: UpdateAiSettingsRequest): Promise<AiSettingsDto> =>
api.put(`${AI_BASE}/settings`, payload),
/** 把一句话解析成待确认的操作;`text` 上限 500 字(超出由服务端 400) */
interpret: (payload: { text: string; classId?: string }): Promise<AiInterpretResult> =>
api.post(`${AI_BASE}/interpret`, payload),
/** 使用记录(谁、什么时候、说了什么、解析成什么、结果如何),倒序 */
audit: (limit = 50): Promise<AiAuditDto[]> => api.get(`${AI_BASE}/audit`, { limit }),
};
/* ------------------------------------------------------------------ 系统设置与诊断 */
/**
* 系统设置与诊断(服务端 `modules/system`),全部要 `system:manage`(仅管理员)。
*/
const SYSTEM_BASE = API_PATHS.system;
export const systemApi = {
settings: (): Promise<SystemSettingsDto> => api.get(`${SYSTEM_BASE}/settings`),
/** 目前只有 `developerMode` 一项;保存后服务端返回完整设置 */
updateSettings: (payload: Partial<SystemSettingsDto>): Promise<SystemSettingsDto> =>
api.put(`${SYSTEM_BASE}/settings`, payload),
/** 只读诊断信息(版本 / Node / 平台 / 运行时长 / 数据库 / 目录 / 模块数) */
diagnostics: (): Promise<SystemDiagnosticsDto> => api.get(`${SYSTEM_BASE}/diagnostics`),
};
+15
View File
@@ -7,11 +7,15 @@ import {
Connection,
CopyDocument,
Document,
Expand,
Fold,
FolderAdd,
FolderOpened,
Guide,
Lock,
MagicStick,
Menu,
Monitor,
Moon,
Notebook,
Odometer,
@@ -20,6 +24,7 @@ import {
Right,
School,
Search,
Setting,
Sunny,
Sort,
Switch,
@@ -29,6 +34,7 @@ import {
User,
UserFilled,
WarningFilled,
ZoomIn,
} from '@element-plus/icons-vue';
/**
@@ -54,11 +60,17 @@ const APP_ICONS = {
Connection,
CopyDocument,
Document,
/* 侧栏折叠/展开(顶栏汉堡按钮),与桌面客户端用同一对图标 */
Expand,
Fold,
FolderAdd,
FolderOpened,
Guide,
Lock,
/* 大模型助手入口 */
MagicStick,
Menu,
Monitor,
Moon,
Notebook,
Odometer,
@@ -67,6 +79,7 @@ const APP_ICONS = {
Right,
School,
Search,
Setting,
Sunny,
Sort,
Switch,
@@ -76,6 +89,8 @@ const APP_ICONS = {
User,
UserFilled,
WarningFilled,
/* 关怀模式(放大字号与控件) */
ZoomIn,
} as const;
/** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */
+411 -15
View File
@@ -2,12 +2,12 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { STORAGE_KEYS } from '@classhelper/shared';
import { STORAGE_KEYS, type PermissionKey, type UserRole } from '@classhelper/shared';
import { authApi } from '@/api';
import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { useUiStore } from '@/stores/ui';
import { useUiStore, CARE_SCALE_DEFAULT, CARE_SCALE_MAX, CARE_SCALE_MIN } from '@/stores/ui';
import { useResponsive } from '@/composables/useResponsive';
import { useThemeReveal } from '@/composables/motion';
import { APP_TITLE } from '@/config';
@@ -32,6 +32,49 @@ function handleThemeToggle(event: MouseEvent): void {
revealThemeChange(event, () => ui.toggleTheme());
}
/* ---------------------------------------------------------------- 侧栏折叠 */
/**
* 顶栏左侧的汉堡按钮(仅桌面端;≤768px 侧栏已经收进抽屉,用的是另一个 `nav-toggle`)。
*
* 它**只改一个布尔值**:栏宽、文字的淡出、图标位置、行高、滚动条全都交给
* `styles/index.css` 里 `.ch-nav.is-collapsed …` 那一段(那里有完整推导)——
* 模板里没有任何 `v-if` 增删菜单项,折叠才是连续的收拢动画而不是"重排一下"。
*
* 缓动刻意与桌面客户端同一套参数(`--nav-dur` / `--nav-ease`,AGENTS.md §5 第 49 条):
* 这里不要换成 EASE_OUT 之类的"起步快"曲线 —— 两端观感要一致,且自动化会量到中间态。
*/
const collapseLabel = computed(() => (ui.sidebarCollapsed ? '展开菜单' : '收起菜单'));
/* ---------------------------------------------------------------- 关怀模式 */
/**
* 关怀模式的入口放在顶栏的 `el-popover` 里,**不放进用户下拉菜单**:
* 下拉菜单点一下(甚至点里面的滑块)就会收起,滑块根本拖不动。
* 气泡里的开关与滑块都不是"点一下就走"的控件,放在气泡里正好。
*/
/** 放大倍数按百分比显示(1.2 → 120%),比裸小数好读 */
const careScalePercent = computed(() => `${Math.round(ui.careScale * 100)}%`);
/** el-switch 的 payload 是 `boolean | string | number` 的联合(active-value 可自定义),这里收口 */
function handleCareModeChange(value: boolean | string | number): void {
ui.setCareMode(value === true);
}
/** el-slider 单值模式发 `number`、范围模式发 `number[]`,这里只接单值 */
function handleCareScaleChange(value: number | number[]): void {
ui.setCareScale(Array.isArray(value) ? (value[0] ?? CARE_SCALE_DEFAULT) : value);
}
/* -------------------------------------------------------------- AI 助手入口 */
/** 顶栏的 AI 助手快捷入口:与侧栏菜单项同一道**生效权限**门控(默认管理员与教师都有) */
const canUseAssistant = computed(() => auth.can('ai:use'));
function openAssistant(): void {
void router.push('/assistant');
}
/**
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
* - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
@@ -41,8 +84,20 @@ function handleThemeToggle(event: MouseEvent): void {
* - 作业 / 通知:所有教师
* - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮)
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
* - 权限组管理 / 客户端设备:`roles` 只是粗筛(管理员 + 教师),真正的门控是**生效权限**
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
* (见 `visibleMenuItems`)
*/
const menuItems = [
interface MenuItem {
path: string;
title: string;
icon: string;
roles: UserRole[];
/** 可选的**生效权限**门控:有值时还要 `auth.can()` 通过才显示(角色判不出来这一层) */
permission?: PermissionKey;
}
const menuItems: MenuItem[] = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
{ path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] },
{ path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] },
@@ -51,13 +106,57 @@ const menuItems = [
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] },
{
path: '/assistant',
title: 'AI 助手',
icon: 'MagicStick',
// `ai:use` 默认给 ADMIN + TEACHER(教师用一句话发布作业/通知/叫人),
// 但仍走**生效权限**细筛:管理员把 ai:use 从某个权限组收回后这里就该消失。
roles: ['ADMIN', 'TEACHER'],
permission: 'ai:use',
},
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
{
path: '/permissions',
title: '权限组',
icon: 'Lock',
roles: ['ADMIN', 'TEACHER'],
permission: 'permission:manage',
},
{
path: '/clients',
title: '客户端设备',
icon: 'Monitor',
// 与「权限组」同样按**生效权限**门控:教师默认没有 client:manage(见 shared/permissions.ts
// 的默认模板只给 ADMIN),所以这里不能只写 roles —— 被权限组授予后教师也该看见。
roles: ['ADMIN', 'TEACHER'],
permission: 'client:manage',
},
{
path: '/ai-settings',
title: '大模型设置',
icon: 'Setting',
// 与「权限组」「客户端设备」同一道门控:只有 ai:manage(默认仅管理员)才看得见,
// 页面里的配置读写也要求它(教师访问会 403)。
roles: ['ADMIN', 'TEACHER'],
permission: 'ai:manage',
},
{ path: '/database', title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
];
/** 当前账号可见的菜单(班级/学生管理仅管理员可见) */
/**
* 当前账号可见的菜单。
*
* 两道筛子:`roles` 是粗筛(角色级,与后端 `requireRole` 对齐),`permission` 是细筛
* (生效权限级)——「权限组」必须走后者:教师被权限组授予 `permission:manage` 后
* 角色还是 TEACHER,靠 `roles` 判断不出来。管理员在 `can()` 里短路为 true,
* 所以这里不会因为权限还没拉回来而少显示一项。
*/
const visibleMenuItems = computed(() =>
menuItems.filter((item) => (auth.role ? item.roles.includes(auth.role) : false)),
menuItems.filter((item) => {
if (!auth.role || !item.roles.includes(auth.role)) return false;
return !item.permission || auth.can(item.permission);
}),
);
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
@@ -96,25 +195,53 @@ function asideElement(): HTMLElement | null {
}
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
/** 指示器的裁剪框 = 菜单**视口**(滚动容器自身),滚动时激活项滚出去就跟着被裁掉 */
const indicatorClip = ref({ top: 0, left: 0, width: 0, height: 0 });
/** 菜单滚动容器:桌面侧栏里的 .el-menu(小屏是抽屉里的另一个 el-menu,没有指示器) */
function menuScroller(): HTMLElement | null {
return asideElement()?.querySelector<HTMLElement>('.layout-menu') ?? null;
}
function syncIndicator(): void {
const aside = asideElement();
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!aside || !active) {
const scroller = menuScroller();
const active = scroller?.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!aside || !scroller || !active) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const asideRect = aside.getBoundingClientRect();
const box = scroller.getBoundingClientRect();
/*
* 以菜单视口为基准量,而不是以整个侧栏为基准。
*
* 菜单列是可滚动的(见 .layout-menu 的 overflow):量侧栏的话,激活项被滚出视口后
* 指示器会"贴"到品牌区/页脚上。裁剪框跟着菜单视口,指示器就只在菜单里可见。
*/
indicatorClip.value = {
top: box.top - asideRect.top,
left: box.left - asideRect.left,
width: box.width,
height: box.height,
};
const rect = active.getBoundingClientRect();
indicator.value = {
x: rect.left - asideRect.left,
y: rect.top - asideRect.top,
x: rect.left - box.left,
y: rect.top - box.top,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0,
};
}
const indicatorClipStyle = computed(() => ({
top: `${indicatorClip.value.top}px`,
left: `${indicatorClip.value.left}px`,
width: `${indicatorClip.value.width}px`,
height: `${indicatorClip.value.height}px`,
}));
const indicatorStyle = computed(() => ({
width: `${indicator.value.width}px`,
height: `${indicator.value.height}px`,
@@ -124,6 +251,11 @@ const indicatorStyle = computed(() => ({
let asideObserver: ResizeObserver | null = null;
let indicatorFrame = 0;
let scrollerEl: HTMLElement | null = null;
let scrollSettleTimer = 0;
/** 正在跟随滚动:这期间关掉过渡,否则每帧都重新起一段 397ms 的弹簧,看起来"慢吞吞地追" */
const indicatorFollowing = ref(false);
const SCROLL_SETTLE_MS = 140;
/**
* 把测量推迟到下一帧再跑。
@@ -141,13 +273,39 @@ function scheduleIndicatorSync(): void {
});
}
/**
* 滚动菜单时**同步**量一次(不走 rAF 版):滚动本身已经在合成器上跑,
* 再隔一帧读数会看到指示器明显落后于菜单。
*/
function handleMenuScroll(): void {
syncIndicator();
indicatorFollowing.value = true;
window.clearTimeout(scrollSettleTimer);
scrollSettleTimer = window.setTimeout(() => {
indicatorFollowing.value = false;
}, SCROLL_SETTLE_MS);
}
/** 绑定/解绑菜单滚动监听(小屏没有侧栏,切回来时要重新绑) */
function bindMenuScroller(): void {
const next = menuScroller();
if (next === scrollerEl) return;
scrollerEl?.removeEventListener('scroll', handleMenuScroll);
scrollerEl = next;
scrollerEl?.addEventListener('scroll', handleMenuScroll, { passive: true });
}
/**
* 首次测量放在 onMounted(而不是 nextTick):子组件先于父组件挂载完成,
* 此刻 `.el-menu-item.is-active` 已在文档里,量完再让浏览器首帧绘制 ——
* 于是指示器**第一次出现就是正确位置**,不会从左上角滑过去。
*/
onMounted(() => {
// 生效权限(`GET /permissions/me`):菜单里的「权限组」要等它回来才显示。
// 失败不阻塞布局(store 内已静默吞掉),管理员在 can() 里短路、不受这一拍影响。
void auth.ensurePermissions();
syncIndicator();
bindMenuScroller();
const aside = asideElement();
if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync);
@@ -158,6 +316,9 @@ onMounted(() => {
onUnmounted(() => {
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
indicatorFrame = 0;
window.clearTimeout(scrollSettleTimer);
scrollerEl?.removeEventListener('scroll', handleMenuScroll);
scrollerEl = null;
asideObserver?.disconnect();
asideObserver = null;
});
@@ -182,6 +343,11 @@ watch(
watch(isMobile, (mobile) => {
if (!mobile) drawerVisible.value = false;
// 侧栏是按 v-if 挂载/卸载的:从抽屉切回常驻侧栏后,新的 .el-menu 需要重新绑滚动监听
void nextTick(() => {
syncIndicator();
bindMenuScroller();
});
});
/**
@@ -351,12 +517,13 @@ const tourSteps = computed(() => [
<template>
<el-container class="layout">
<!-- 桌面/平板:常驻深色侧边栏 -->
<!-- 桌面/平板:常驻深色侧边栏(折叠成 64px 图标导轨,见样式里的 .ch-nav.is-collapsed) -->
<el-aside
v-if="!isMobile"
ref="asideRef"
width="210px"
class="layout-aside ch-sidebar"
:width="ui.sidebarCollapsed ? 'var(--ch-aside-collapsed-width)' : 'var(--ch-aside-width)'"
class="layout-aside ch-sidebar ch-nav"
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
data-tour="side-nav"
>
<div class="brand">
@@ -364,10 +531,22 @@ const tourSteps = computed(() => [
<span class="brand-text">ClassHelper</span>
</div>
<!-- 激活项的滑动指示器:单个药丸在菜单项之间平移,位置由 syncIndicator() 量出来。
外层裁剪框贴着**菜单视口**(菜单列可滚动,激活项滚出去时指示器要一起被裁掉),
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
<div class="nav-indicator-clip" :style="indicatorClipStyle" aria-hidden="true">
<div
class="nav-indicator"
:class="{ 'is-following': indicatorFollowing }"
:style="indicatorStyle"
/>
</div>
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
<el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path">
<el-menu-item
v-for="item in visibleMenuItems"
:key="item.path"
:index="item.path"
:title="ui.sidebarCollapsed ? item.title : undefined"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
@@ -389,6 +568,26 @@ const tourSteps = computed(() => [
>
<el-icon :size="20"><Menu /></el-icon>
</button>
<!--
桌面:同一个位置的汉堡按钮负责把侧栏收成图标导轨。
图标方向跟着**当前状态**走(展开时给「收起」、折叠时给「展开」),
文案同步进 aria-label 与 title,鼠标悬停时能看出这一下会做什么。
-->
<button
v-else
type="button"
class="nav-toggle"
:aria-label="collapseLabel"
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
:title="collapseLabel"
data-test="nav-collapse-toggle"
@click="ui.toggleSidebar()"
>
<el-icon :size="20">
<Expand v-if="ui.sidebarCollapsed" />
<Fold v-else />
</el-icon>
</button>
<span class="header-title">{{ isMobile ? currentTitle : APP_TITLE }}</span>
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light" data-tour="conn-tag">
<span class="header-conn">{{ connectionText }}</span>
@@ -409,6 +608,76 @@ const tourSteps = computed(() => [
</div>
<div class="header-right">
<!--
AI 助手快捷入口:与侧栏「AI 助手」菜单项同一道门控。
关怀模式打开时挂 .care-emphasis(字号写在 styles/index.css 的关怀模式块里,
这里不写死数值)—— 这就是需求里"关怀模式下助手入口更大更醒目"那条。
-->
<el-tooltip
v-if="canUseAssistant"
content="AI 助手:一句话发布作业 / 通知 / 叫人"
placement="bottom"
>
<el-button
class="assistant-entry"
:class="{ 'care-emphasis': ui.careMode }"
text
circle
aria-label="打开 AI 助手"
data-test="assistant-entry"
@click="openAssistant"
>
<el-icon><MagicStick /></el-icon>
</el-button>
</el-tooltip>
<!--
关怀模式。入口刻意放在 el-popover 而不是用户下拉菜单里:
下拉菜单点一下(甚至点里面的滑块)就会收起,滑块根本拖不动(见脚本里的注释)。
-->
<el-popover placement="bottom-end" :width="264" trigger="click" popper-class="care-popover">
<template #reference>
<button
type="button"
class="nav-toggle care-toggle"
:class="{ 'is-on': ui.careMode }"
:aria-pressed="ui.careMode ? 'true' : 'false'"
aria-label="关怀模式"
title="关怀模式"
data-test="care-toggle"
>
<el-icon :size="18"><ZoomIn /></el-icon>
</button>
</template>
<div class="care-panel">
<div class="care-panel-row">
<span class="care-panel-title">关怀模式</span>
<el-switch
:model-value="ui.careMode"
aria-label="关怀模式"
@update:model-value="handleCareModeChange"
/>
</div>
<p class="care-panel-desc">放大正文字号与控件,方便不习惯默认字号的老师。</p>
<div class="care-panel-row">
<span class="care-panel-label">放大倍数</span>
<span class="care-panel-value">{{ careScalePercent }}</span>
</div>
<el-slider
:model-value="ui.careScale"
:min="CARE_SCALE_MIN"
:max="CARE_SCALE_MAX"
:step="0.05"
:disabled="!ui.careMode"
aria-label="放大倍数"
@update:model-value="handleCareScaleChange"
/>
<p class="care-panel-desc">
100% 是原始大小,最大 150%。关掉关怀模式后字号立即恢复,倍数会记住。
</p>
</div>
</el-popover>
<!--
主题切换。与桌面客户端同一套(圆形揭示由 useThemeReveal 提供)。
注意 `.header-right` 必须是 flex 容器:Element Plus 给 `.el-button` 的是
@@ -559,6 +828,12 @@ const tourSteps = computed(() => [
flex-direction: column;
/* 导航指示器(.nav-indicator)的定位父级 */
position: relative;
/*
* 折叠只过渡**栏宽**:宽度是唯一在变的东西,其余(文字淡出、图标位置、行高)
* 都在 styles/index.css 的 .ch-nav.is-collapsed 段里各自声明过渡。
* 时长/缓动用 --nav-dur / --nav-ease —— 与桌面客户端同一套参数,改一处两端同步。
*/
transition: width var(--nav-dur) var(--nav-ease);
}
.brand {
@@ -590,11 +865,37 @@ const tourSteps = computed(() => [
border-right: none;
background: transparent;
flex: 1;
/*
* 菜单列**自己滚动**(这是"小屏状态下菜单无法上下滑动"的修法):
* - `min-height: 0` 是关键:flex 项默认 `min-height: auto`,永远不小于内容高度,
* 于是 overflow 永不生效、超出的菜单项被直接裁掉;
* - 小屏抽屉里 `.el-drawer__body` 是 `overflow: hidden`,滚动只能由这一列承担,
* 否则抽屉一关一开就只剩上半截菜单能点。
* 滚动条做细一点、半透明:深色侧栏上一条系统浅色滚动条太扎眼。
*/
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
--el-menu-text-color: #cbd5e1;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #ffffff;
}
.layout-menu::-webkit-scrollbar {
width: 6px;
}
.layout-menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
}
.layout-menu::-webkit-scrollbar-track {
background: transparent;
}
/*
* 激活项的底色改由导航指示器承担(见 .nav-indicator)。
*
@@ -614,6 +915,17 @@ const tourSteps = computed(() => [
color: #ffffff;
}
/*
* 导航指示器的裁剪框:贴着菜单视口(.layout-menu 的 border box)。
* 菜单列可以滚动,激活项滚出视口后指示器必须跟着被裁掉,否则它会画到品牌区/页脚上。
*/
.nav-indicator-clip {
position: absolute;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
/*
* 导航指示器:一个跟着激活项平移的药丸。宽高与位移由 syncIndicator() 量出来后内联下发,
* 这里只负责外观与过渡。
@@ -625,7 +937,6 @@ const tourSteps = computed(() => [
position: absolute;
top: 0;
left: 0;
z-index: 0;
border-radius: var(--ch-radius-sm);
background: #409eff;
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
@@ -638,6 +949,11 @@ const tourSteps = computed(() => [
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
/* 滚动跟随期间关掉过渡:每帧重起一段 397ms 弹簧会变成"慢吞吞地追" */
.nav-indicator.is-following {
transition: none;
}
.layout-header {
/* 顶栏跟随圆角设计:底部圆角 + 轻投影,与内容区一体化 */
border-radius: 0 0 var(--ch-radius-lg) var(--ch-radius-lg);
@@ -704,6 +1020,37 @@ const tourSteps = computed(() => [
color: var(--ch-text);
}
/*
* 关怀模式按钮:关掉时与主题按钮同款(安静的文字色),开启时用主色 + 淡底标出"正在生效"。
* 纯图标按钮必须靠这点颜色区分状态,否则用户看不出自己刚点开过什么。
*/
.care-toggle {
color: var(--ch-text-muted);
}
.care-toggle:hover {
color: var(--ch-text);
}
.care-toggle.is-on {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
/* 悬停时同样要保持"已开启"的底色(.nav-toggle:hover 的特异度更低,不会覆盖到这里) */
.care-toggle.is-on:hover {
background: var(--el-color-primary-light-8);
}
/* AI 助手快捷入口:与主题按钮同款文字色;关怀模式下由全局的 .care-emphasis 放大字号 */
.assistant-entry {
color: var(--ch-text-muted);
}
.assistant-entry:hover {
color: var(--ch-text);
}
.user-chip {
display: flex;
align-items: center;
@@ -759,4 +1106,53 @@ const tourSteps = computed(() => [
padding: 0;
overflow: hidden;
}
/*
* 关怀模式气泡的排版。
*
* 放在**全局块**里是必需的:el-popover 的内容被 teleport 到 body 上,
* 组件级 scoped 样式(带 data-v 属性选择器)一个都选不中它 —— 与上面 .ch-sidebar 同一个理由。
* 字号一律引用 --el-font-size-* 且圆角/投影走 --ch-* 令牌:关怀模式打开时这一块自己也会跟着放大,
* 不需要在这里再写一份 calc。
*/
.care-popover {
border-radius: var(--ch-radius-md);
box-shadow: var(--ch-shadow-md);
}
.care-panel {
display: flex;
flex-direction: column;
gap: 6px;
}
.care-panel-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.care-panel-title {
font-size: var(--el-font-size-base);
font-weight: 600;
}
.care-panel-label {
color: var(--ch-text-secondary);
font-size: var(--el-font-size-small);
}
.care-panel-value {
color: var(--el-color-primary);
font-size: var(--el-font-size-small);
font-variant-numeric: tabular-nums;
}
.care-panel-desc {
margin: 0;
color: var(--ch-text-muted);
font-size: var(--el-font-size-extra-small);
line-height: 1.5;
}
</style>
+45 -2
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import { STORAGE_KEYS } from '@classhelper/shared';
import { STORAGE_KEYS, type PermissionKey } from '@classhelper/shared';
import { useAuthStore } from '@/stores/auth';
const routes: RouteRecordRaw[] = [
{
@@ -75,6 +76,14 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/GradesView.vue'),
meta: { title: '成绩录入', icon: 'Trophy' },
},
{
path: 'assistant',
name: 'assistant',
component: () => import('@/views/AssistantView.vue'),
// 按**生效权限**门控(教师默认就有 ai:use,见 shared/permissions.ts 的默认模板):
// 管理员把 ai:use 从某个权限组收回后,这一页同时从菜单与路由上消失
meta: { title: 'AI 助手', icon: 'MagicStick', permission: 'ai:use' },
},
{
path: 'integrations',
name: 'integrations',
@@ -82,6 +91,13 @@ const routes: RouteRecordRaw[] = [
// 与后端 requireRole('ADMIN','TEACHER') 对齐:这一页含"班级端在线状态",教师要看自己的班
meta: { title: 'ClassHelper 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
},
{
path: 'ai-settings',
name: 'ai-settings',
component: () => import('@/views/AiSettingsView.vue'),
// 与后端 requirePermission('ai:manage') 对齐:这一页能改密钥与开关,默认仅管理员
meta: { title: '大模型设置', icon: 'Setting', permission: 'ai:manage' },
},
{
path: 'database',
name: 'database',
@@ -89,6 +105,24 @@ const routes: RouteRecordRaw[] = [
// 与后端 requireRole('ADMIN') 对齐:改动数据库是最高危操作,仅管理员
meta: { title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
},
{
path: 'permissions',
name: 'permissions',
component: () => import('@/views/PermissionsView.vue'),
// 这一页**按生效权限**门控而不是按角色:教师被某个权限组授予 permission:manage 后同样可用,
// 与后端 requirePermission('permission:manage') 对齐(详见下方守卫)
meta: { title: '权限组管理', icon: 'Lock', permission: 'permission:manage' },
},
{
path: 'clients',
name: 'clients',
component: () => import('@/views/ClientsView.vue'),
// 这一页同样**按生效权限**门控:远控关机、统一下发更新都是高危操作,
// 后端三个接口分别要 client:manage / client:update-push / remote:control,
// 但教师默认三个都没有(见 shared/permissions.ts 的默认模板),所以这里用
// 最基础的 client:manage 当入口门禁,页面内的按钮再各自按权限显示/禁用。
meta: { title: '客户端设备', icon: 'Monitor', permission: 'client:manage' },
},
],
},
{
@@ -116,7 +150,7 @@ function storedRole(): string | null {
}
}
router.beforeEach((to) => {
router.beforeEach(async (to) => {
const token = localStorage.getItem(STORAGE_KEYS.token);
if (to.meta.public) {
if (to.name === 'login' && token) return { name: 'dashboard' };
@@ -134,6 +168,15 @@ router.beforeEach((to) => {
return { name: 'dashboard' };
}
}
// 按**生效权限**门控的页面(权限组管理):角色判不出来 —— 教师被权限组授予
// permission:manage 后同样该放行,所以这里异步取一次生效权限。
// `ensurePermissions()` 在 store 内是幂等的(本次会话只真拉一次),不会每次跳转都打接口。
const permission = to.meta.permission;
if (typeof permission === 'string') {
const auth = useAuthStore();
await auth.ensurePermissions();
if (!auth.can(permission as PermissionKey)) return { name: 'dashboard' };
}
return true;
});
+67 -2
View File
@@ -1,7 +1,15 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { ROLE_LABELS, STORAGE_KEYS, type LoginRequest, type LoginResponse, type SessionUser } from '@classhelper/shared';
import { authApi } from '@/api';
import {
ROLE_LABELS,
STORAGE_KEYS,
hasPermission,
type LoginRequest,
type LoginResponse,
type PermissionKey,
type SessionUser,
} from '@classhelper/shared';
import { authApi, permissionApi } from '@/api';
/**
* 会话主体。
@@ -25,6 +33,18 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<SessionUser | null>(readStoredUser());
const loading = ref(false);
/**
* 当前账号的**生效权限**(`GET /api/permissions/me`)。
*
* 只用来决定"入口显不显示",**不是**另一套权限规则(AGENTS.md §5 第 3 条)——
* 真正的拦截始终在服务端。页面刷新后这里是空的,由 `AdminLayout` 挂载时调
* {@link ensurePermissions} 补上。
*/
const permissions = ref<PermissionKey[]>([]);
/** 生效权限是否来自权限组(false = 没有任何组,走角色默认模板) */
const permissionsFromGroups = ref(false);
const permissionsLoaded = ref(false);
const isAuthenticated = computed(() => Boolean(token.value));
const role = computed(() => user.value?.role ?? null);
const isStaff = computed(() => role.value === 'ADMIN' || role.value === 'TEACHER');
@@ -32,6 +52,44 @@ export const useAuthStore = defineStore('auth', () => {
const displayName = computed(() => user.value?.name ?? '未登录');
const roleLabel = computed(() => (role.value ? ROLE_LABELS[role.value] : '未登录'));
/**
* 是否有某个生效权限。
*
* 管理员**短路为 true**:服务端 `loadEffectivePermissions` 对 ADMIN 直接给全集,
* 而权限是异步拉的 —— 不短路的话,管理员刚进页面时侧栏会先缺几项再补上(可见闪烁),
* 且 `/permissions/me` 偶发失败会把管理员挡在本来就有的功能之外。
*/
function can(key: PermissionKey): boolean {
if (isAdmin.value) return true;
return hasPermission(permissions.value, key);
}
/**
* 拉取生效权限(幂等,`force` 用于权限改完之后刷新)。
*
* 失败**不抛出**:拿不到权限时 `can()` 保持 false(即"少显示几个入口"),
* 不要因此卡住路由或让整页渲染不出来;错误提示由 http.ts 的响应拦截器负责。
*/
async function ensurePermissions(force = false): Promise<void> {
if (!token.value) return;
if (permissionsLoaded.value && !force) return;
try {
const mine = await permissionApi.me();
permissions.value = mine.permissions;
permissionsFromGroups.value = mine.fromGroups;
permissionsLoaded.value = true;
} catch {
// 见上方注释:静默失败,保持"未加载"(下次调用还会重试)
}
}
/** 清掉权限缓存(换账号 / 退出登录时必须调用,否则会沿用上一个账号的权限) */
function resetPermissions(): void {
permissions.value = [];
permissionsFromGroups.value = false;
permissionsLoaded.value = false;
}
function persist(): void {
if (token.value) localStorage.setItem(STORAGE_KEYS.token, token.value);
else localStorage.removeItem(STORAGE_KEYS.token);
@@ -40,6 +98,7 @@ export const useAuthStore = defineStore('auth', () => {
}
function setSession(payload: LoginResponse): void {
resetPermissions();
token.value = payload.token;
user.value = { ...payload.user };
persist();
@@ -73,6 +132,7 @@ export const useAuthStore = defineStore('auth', () => {
function clear(): void {
token.value = null;
user.value = null;
resetPermissions();
persist();
}
@@ -80,12 +140,17 @@ export const useAuthStore = defineStore('auth', () => {
token,
user,
loading,
permissions,
permissionsFromGroups,
permissionsLoaded,
isAuthenticated,
isStaff,
isAdmin,
role,
displayName,
roleLabel,
can,
ensurePermissions,
login,
logout,
fetchProfile,
+122 -4
View File
@@ -2,7 +2,7 @@ import { ref } from 'vue';
import { defineStore } from 'pinia';
/**
* 界面偏好(当前只有主题)。
* 界面偏好(主题 / 侧栏折叠 / 关怀模式)。
*
* 机制与桌面客户端(`desktop-client/src/renderer/stores/ui.ts`)保持一套:
* 深色主题 = 在 `<html>` 上挂 `.dark` 类,由 `styles/index.css` 的 `html.dark` 块
@@ -11,6 +11,9 @@ import { defineStore } from 'pinia';
* **没有选过时跟随系统**,且系统切换时会实时跟上;一旦用户手动点过切换按钮,
* 就以用户的选择为准,不再被系统改回去(这是主题开关的通行语义:
* 系统的偏好是默认值,不是命令)。
*
* 三份偏好都存 localStorage;折叠与关怀模式**必须在 `app.mount()` 之前恢复**,
* 否则首帧会先按默认值画一遍再跳变(深色主题"先白后黑"是同一个道理)。
*/
export type AdminTheme = 'light' | 'dark';
@@ -18,6 +21,24 @@ export type AdminTheme = 'light' | 'dark';
const THEME_KEY = 'classhelper.admin.theme';
const DARK_QUERY = '(prefers-color-scheme: dark)';
/** 侧栏折叠(图标导轨) */
const SIDEBAR_KEY = 'classhelper.admin.sidebarCollapsed';
/** 关怀模式开关 */
const CARE_KEY = 'classhelper.admin.careMode';
/** 关怀模式的放大倍数 */
const CARE_SCALE_KEY = 'classhelper.admin.careScale';
/** 关怀模式放大倍数的可调范围(界面上的滑块与这里必须一致) */
export const CARE_SCALE_MIN = 1;
export const CARE_SCALE_MAX = 1.5;
/** 打开关怀模式时的默认倍数:14px → 16.8px,够大又不会撑破表格 */
export const CARE_SCALE_DEFAULT = 1.2;
function clampCareScale(value: number): number {
if (!Number.isFinite(value)) return CARE_SCALE_DEFAULT;
return Math.min(CARE_SCALE_MAX, Math.max(CARE_SCALE_MIN, Math.round(value * 100) / 100));
}
function systemPrefersDark(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia(DARK_QUERY).matches;
@@ -49,15 +70,61 @@ function readStored(): AdminTheme | null {
}
}
/** 读一个布尔偏好(localStorage 不可用时一律按"没选过"处理) */
function readFlag(key: string): boolean {
try {
return localStorage.getItem(key) === '1';
} catch {
return false;
}
}
function writeFlag(key: string, value: boolean): void {
try {
localStorage.setItem(key, value ? '1' : '0');
} catch {
// 隐私模式下写不进去:本次会话内仍然生效
}
}
function readScale(): number {
try {
const raw = localStorage.getItem(CARE_SCALE_KEY);
return raw === null ? CARE_SCALE_DEFAULT : clampCareScale(Number(raw));
} catch {
return CARE_SCALE_DEFAULT;
}
}
/**
* 把关怀模式落到 DOM:一个类名 + 一个倍数。
*
* 类名(`html.care-mode`)是 `styles/index.css` 里**唯一**的放大块开关;
* 倍数写进 `--ch-font-scale`,那块里的所有 `calc(基准 * var(--ch-font-scale))` 都读它。
* 关掉时倍数复位成 1(而不是删掉变量)——变量留着,`calc()` 才不会解析失败。
*/
function applyCareMode(enabled: boolean, scale: number): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.toggle('care-mode', enabled);
root.style.setProperty('--ch-font-scale', String(enabled ? clampCareScale(scale) : 1));
}
export const useUiStore = defineStore('ui', () => {
const theme = ref<AdminTheme>('light');
/** 侧栏折叠成图标导轨(桌面端;≤768px 走抽屉,不看这个值) */
const sidebarCollapsed = ref(false);
/** 关怀模式:放大正文字号与控件 */
const careMode = ref(false);
/** 关怀模式的放大倍数(1.0~1.5) */
const careScale = ref(CARE_SCALE_DEFAULT);
/** 用户是否显式选过。没选过时系统偏好说了算,且会实时跟随 */
let explicit = false;
/**
* 在 `app.mount()` **之前**调用:主题类要在首帧之前挂好,
* 否则深色用户会看到"先白后黑"闪一下(与客户端同一条要求)。
* 在 `app.mount()` **之前**调用:主题类、折叠状态、关怀模式都要在首帧之前落好,
* 否则深色用户会看到"先白后黑"、折叠用户会看到侧栏先展开再收回(与客户端同一条要求)。
*/
function init(): void {
const stored = readStored();
@@ -65,6 +132,14 @@ export const useUiStore = defineStore('ui', () => {
theme.value = stored ?? (systemPrefersDark() ? 'dark' : 'light');
applyTheme(theme.value);
// 折叠状态只需要恢复 ref:`el-aside` 的宽度是绑在 ref 上的,首帧就是折好的宽度
sidebarCollapsed.value = readFlag(SIDEBAR_KEY);
// 关怀模式要落到 DOM(html 的类名 + 倍数变量),否则还会按默认字号画一帧
careScale.value = readScale();
careMode.value = readFlag(CARE_KEY);
applyCareMode(careMode.value, careScale.value);
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const query = window.matchMedia(DARK_QUERY);
const onChange = (): void => {
@@ -92,5 +167,48 @@ export const useUiStore = defineStore('ui', () => {
setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
return { theme, init, setTheme, toggleTheme };
/** 折叠/展开侧栏(顶栏汉堡按钮),写回 localStorage */
function setSidebarCollapsed(collapsed: boolean): void {
sidebarCollapsed.value = collapsed;
writeFlag(SIDEBAR_KEY, collapsed);
}
function toggleSidebar(): void {
setSidebarCollapsed(!sidebarCollapsed.value);
}
/** 关怀模式开关(顶栏「关怀模式」气泡里的滑块与开关共用) */
function setCareMode(enabled: boolean): void {
careMode.value = enabled;
writeFlag(CARE_KEY, enabled);
applyCareMode(enabled, careScale.value);
}
/**
* 调整放大倍数。倍数在**关掉关怀模式时也记下来** ——
* 下次打开还要用上次调好的那档,否则每次都要重新拖一遍。
*/
function setCareScale(scale: number): void {
careScale.value = clampCareScale(scale);
try {
localStorage.setItem(CARE_SCALE_KEY, String(careScale.value));
} catch {
// 同上:写不进去只影响下次启动
}
applyCareMode(careMode.value, careScale.value);
}
return {
theme,
sidebarCollapsed,
careMode,
careScale,
init,
setTheme,
toggleTheme,
setSidebarCollapsed,
toggleSidebar,
setCareMode,
setCareScale,
};
});
+222
View File
@@ -13,6 +13,29 @@
/* 1Panel 风格:固定顶栏 + 深色侧栏 */
--ch-header-height: 56px;
--ch-aside-width: 210px;
/* 折叠态侧栏宽(图标导轨)。与展开态并排放在令牌里 —— 几何只在这个文件里定义一次 */
--ch-aside-collapsed-width: 64px;
/*
* 侧栏收放动画。组件(AdminLayout 的 .layout-aside/.brand)与全局的折叠导轨规则
* (下面的 .ch-nav.is-collapsed …)共用这一份参数 —— 改时长/缓动只改这里,两边不会漂。
*
* ⚠️ 缓动**刻意保留 Material 的 cubic-bezier(.4, 0, .2, 1)**,没有换成全站统一的
* beUI EASE_OUT:EASE_OUT 起步极快,栏宽掉到 80px 时动画才走了约 35%,
* 此刻内边距还在动,自动化量到的图标中心会偏 2px 以上而判红(桌面客户端实测踩过)。
* 想换曲线就得连"折叠几何"的断言一起重新商量。
*/
--nav-dur: 260ms;
--nav-ease: cubic-bezier(0.4, 0, 0.2, 1);
/* 图标与文字的间距(折叠时收到 0) */
--nav-gap: 8px;
/*
* 关怀模式的放大倍数:1 = 默认,界面上可调 1.0~1.5。
* 只有**一个**来源 —— `html.care-mode { … }` 那一块用它做 calc 的乘数,
* stores/ui.ts 在 mount 之前把它写成 <html> 的内联变量。
*/
--ch-font-scale: 1;
/*
* 语义色 —— 组件里**一律引用这些,不要写死颜色**(深色主题靠 html.dark 覆盖这里)。
@@ -69,6 +92,193 @@ html.dark {
--el-box-shadow-light: var(--ch-shadow-sm);
}
/* ============================================================================
* 关怀模式(需求 14):放大正文字号与常用控件,方便不习惯默认字号的老师
*
* 三条纪律:
* 1. **只放大字号与控件高度**。几何令牌(--ch-radius-* / --ch-shadow-* /
* --ch-aside-width / --nav-*)一律不动 —— 放大圆角会让卡片与弹窗看起来"变形",
* 放大侧栏宽会让折叠动画的几何全部重算;
* 2. 倍数只有一个来源 `--ch-font-scale`(:root 里定义,stores/ui.ts 在 mount 之前
* 写入 <html> 的内联变量)。想"再大一点"就调这个倍数,不要在组件里另写一份 calc;
* 3. Element Plus 的字号与控件高度全部由它自己的变量拼出来
* (`--el-font-size-*` / `--el-component-size*`),覆盖变量等于一次性放大全站组件;
* 项目自己写死的那几个 px 字号(页面标题等)在下面一并给 calc。
*
* 这一块**只依赖 --ch-font-scale,不依赖主题** —— 深浅色模式下都是同一套几何与字号。
* ========================================================================== */
html.care-mode {
/* 文字:正文基准 + 各档字号(Element Plus 的组件字号全部由这几个变量派生) */
--el-font-size-extra-large: calc(20px * var(--ch-font-scale));
--el-font-size-large: calc(18px * var(--ch-font-scale));
--el-font-size-medium: calc(16px * var(--ch-font-scale));
--el-font-size-base: calc(14px * var(--ch-font-scale));
--el-font-size-small: calc(13px * var(--ch-font-scale));
--el-font-size-extra-small: calc(12px * var(--ch-font-scale));
/* 控件高度:按钮 / 输入框 / 下拉 / 日期选择器 / 分页…(大字配矮控件会挤在一起) */
--el-component-size-large: calc(40px * var(--ch-font-scale));
--el-component-size: calc(32px * var(--ch-font-scale));
--el-component-size-small: calc(24px * var(--ch-font-scale));
/* 正文字号:body 没写 font-size,浏览器默认 16px —— 这里跟着倍数走 */
font-size: calc(16px * var(--ch-font-scale));
}
/* 项目自己写死的字号(不引用 --el-* 的那几个),跟着倍数放大 */
html.care-mode .page-title {
font-size: calc(18px * var(--ch-font-scale));
}
html.care-mode .page-subtitle,
html.care-mode .stat-label {
font-size: calc(13px * var(--ch-font-scale));
}
html.care-mode .stat-value {
font-size: calc(26px * var(--ch-font-scale));
}
html.care-mode .stat-hint,
html.care-mode .timetable-meta {
font-size: calc(12px * var(--ch-font-scale));
}
/*
* 助手入口(顶栏按钮 + 侧栏菜单项)在关怀模式下更醒目:
* 文案放大 1.3 倍并加粗 —— 关怀模式的用户正是"看小字吃力"的那批人,
* 助手这个新入口不该比别处更难找。
*/
html.care-mode .care-emphasis {
font-size: calc(var(--el-font-size-base) * 1.3);
font-weight: 700;
}
/* ============================================================================
* 侧栏导航:折叠态是"图标导轨"(AGENTS.md §5 第 49 条)
*
* 折叠**不用** el-menu 的 `:collapse` 属性:那个 prop 会把每个分组的子项卸载进浮层
* (一次结构切换)并让整条菜单重排一遍 —— 既做不出连续形变,也是"点汉堡卡一下"的根因。
* 现在折叠只做四件事:栏宽(.layout-aside 的 width)、文字(min-width:0 让 flex 压得动 +
* overflow:hidden 裁切 + opacity 淡出)、图标位置(padding 过渡)、行高(导轨要放得下)。
* **全程没有结构变化、没有 v-if 增删**。
*
* 几何(64px 栏、菜单项左右各 8px margin):
* 展开 菜单项 padding-left 20px(Element Plus 自带)→ 图标中心 8 + 20 + 12 = 40px
* 折叠 菜单项 padding 左右各 12px → 图标中心 8 + 12 + 12 = 32px = 64/2,正好在栏中线上
* ========================================================================== */
/* 图标与文字的间距改由文字的 margin-left 负责:Element Plus 给图标挂了 margin-right: 5px,
那会让图标在 24px 的槽里偏左 2.5px(折叠后肉眼可见地不居中)。 */
.ch-nav .el-menu-item > [class^='el-icon'] {
margin-right: 0;
}
.ch-nav .el-menu-item {
transition:
padding-left var(--nav-dur) var(--nav-ease),
padding-right var(--nav-dur) var(--nav-ease),
height var(--nav-dur) var(--nav-ease),
line-height var(--nav-dur) var(--nav-ease),
background-color var(--el-transition-duration),
color var(--el-transition-duration);
}
/*
* 文字:**不给它单独设动画终点**,宽度靠 flex 被压缩 —— `min-width: 0` 让这个 flex 子项
* 能被压到 0(默认的 `min-width: auto` 会撑住内容宽度),多出来的字由 `overflow: hidden` 裁掉。
* 于是文字宽度天然跟着栏宽连续变化,不需要写什么魔法 max-width;再叠一层 opacity 淡出,
* 避免中间帧留下"半截字"(`display: none` 没法过渡,用不了)。
*/
.ch-nav .el-menu-item > span {
min-width: 0;
margin-left: var(--nav-gap);
overflow: hidden;
white-space: nowrap;
transition:
margin-left var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/* 折叠态的判据是"看得见"而不是"有没有被布局":文字仍在布局里,只是被裁掉且全透明 */
.ch-nav.is-collapsed .el-menu-item > span {
margin-left: 0;
opacity: 0;
pointer-events: none;
}
/*
* 折叠后的内边距与行高。
*
* 这两条选择器的形状是**照抄 Element Plus 的**,不是冗余 —— 它按下标层级用不同的选择器算缩进
* (一级项 20px、分组标题 20px、分组内子项 40px),特异度分别是 0-4-0 / 0-4-0 / 0-5-0:
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu__title`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu .el-menu-item`
* 写成 `.ch-nav.is-collapsed .el-sub-menu__title` 这种(0-3-0)一条都压不住 —— 桌面客户端踩过两次
* (第一版漏了分组标题、第二版漏了一级项,都偏 8.0px)。下面两条(0-5-0 / 0-6-0)把三种形状全覆盖,
* 且不用 `!important`。Web 管理端目前没有 el-sub-menu,这里仍然写全:以后加设置分组时不用再踩一次。
*
* 行高 36px + 上下各 1px 外边距 = 每项 38px(展开态是 Element Plus 默认的 56px)。
* **只在折叠态生效**:导轨要"一眼看全" —— Web 端目前 14 个菜单项,
* 14 × 38 = 532px,在 1366×768 笔记本上那个约 540px 的菜单视口里刚好放得下
* (1366×768 扣掉浏览器框、顶栏 56px、品牌区 56px)。展开态不动 —— 那是原有的观感。
* 这里的 `margin` 必须一起写:`.layout-menu :deep(.el-menu-item)` 的 `margin: 2px 8px`
* 是 AdminLayout 的 scoped 规则(0-3-0),本段是 0-5-0,靠特异度压住它。
*/
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-menu-item,
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu__title {
height: 36px;
line-height: 36px;
margin: 1px 8px;
padding-left: 12px;
padding-right: 12px;
}
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu .el-menu-item {
height: 36px;
line-height: 36px;
margin: 1px 8px;
padding-left: 12px;
padding-right: 12px;
}
/*
* 导轨里**不出现滚动条**:图标要一眼看全(用户要求"不要用滚轮、删掉滚动条")。
* 只隐藏滚动条本身,`overflow-y: auto` 保留 —— 窗口被拉到极矮时还能滚,不至于把图标直接裁掉。
* 展开态**不隐藏**:菜单确实会超高,滚动条是"下面还有内容"的唯一提示,不能拿掉。
*/
.ch-nav.is-collapsed .layout-menu {
scrollbar-width: none;
}
.ch-nav.is-collapsed .layout-menu::-webkit-scrollbar {
width: 0;
height: 0;
}
/*
* 品牌文字同样靠"能被压扁 + 裁切 + 淡出"收掉,不写任何魔法 max-width:
* `min-width: 0` 是必需的(flex 子项默认 `min-width: auto`,不归零就会撑住内容宽度)。
*/
.ch-nav .brand-text {
min-width: 0;
overflow: hidden;
white-space: nowrap;
transition: opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/* 品牌区在折叠态收成图标一枚:收内边距 + 收掉 gap + 文字淡出(**不是** v-if 删节点) */
.ch-nav.is-collapsed .brand {
gap: 0;
padding-left: 20px;
padding-right: 20px;
}
.ch-nav.is-collapsed .brand-text {
opacity: 0;
}
/* ---------------------------------------------------------------- 圆角设计:
统一"大圆角 + 轻阴影 + 无硬边框"的观感:
@@ -937,3 +1147,15 @@ body {
::view-transition-new(root) {
z-index: 2;
}
/* ------------------------------------------------- 多行确认框(ElMessageBox)
*
* 远控指令与更新推送的二次确认里有换行(逐台设备列表、"什么时候才装"的说明),
* 而 ElMessageBox 的正文默认按 HTML 折叠空白 —— 不保留换行的话那几句会挤成一整段,
* 逐台列表就读不出来了。页面通过 `customClass: 'ch-confirm-multiline'` 挂上这个类,
* 弹窗节点在 body 上(不在组件的 scoped 样式作用域里),所以规则写在这里。
*/
.ch-confirm-multiline .el-message-box__message p {
white-space: pre-line;
line-height: 1.7;
}
@@ -0,0 +1,452 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
formatDate,
type AiAuditDto,
type AiSettingsDto,
type SystemDiagnosticsDto,
type UpdateAiSettingsRequest,
} from '@classhelper/shared';
import { aiApi, systemApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useResponsive } from '@/composables/useResponsive';
/**
* 大模型设置(`ai:manage`)。
*
* 三个 Tab 的理由:
* 1. 「模型配置」—— 开关与接入参数。**密钥单向**:`GET /ai/settings` 永不回传密钥,
* 只回 `hasApiKey` + `keyHint`(尾号),所以输入框永远是空的,"留空 = 不修改"是刻意的语义
* (见 `api/index.ts` 的 `aiApi.updateSettings` 与 `modules/ai/ai.service.ts` 的 put 分支)。
* 2. 「使用记录」—— 解析审计(谁、什么原话、判断成什么动作)。
* 本页只读,不做"发布回执":审计行是解析那一刻写的(`status: 'pending'`),
* 真正发布走的是既有发布端点,那不是助手链路的事。
* 3. 「系统」(仅 `system:manage`)—— 开发者模式 + 只读诊断。
* 放在这里而不是单独开一个路由,是因为它权限更高、平时不需要看,
* Tabs 天然按权限隐藏(没有 system:manage 的人连标签都看不到),比多一个菜单项更省事。
*/
const auth = useAuthStore();
const { isMobile } = useResponsive();
/** 开发者模式/诊断属于 `system:manage`,与页面本身的 `ai:manage` 不是一回事 */
const canManageSystem = computed(() => auth.can('system:manage'));
type TabName = 'model' | 'audit' | 'system';
const activeTab = ref<TabName>('model');
/* ------------------------------------------------------------ 模型配置 */
type Provider = AiSettingsDto['provider'];
const loading = ref(true);
const saving = ref(false);
const clearingKey = ref(false);
const form = reactive({
enabled: false,
provider: 'openai' as Provider,
baseUrl: '',
model: '',
systemPromptExtra: '',
});
/** 已保存的密钥信息(只用于显示,永不回填到输入框) */
const hasApiKey = ref(false);
const keyHint = ref('');
/** 本次要写入的新密钥;留空 = 保持原密钥不变 */
const apiKeyInput = ref('');
const providerHint = computed(() =>
form.provider === 'mock'
? '离线示例解析:不联网,只按关键词猜测,供演示与验收。'
: 'OpenAI 兼容接口:请求 {服务地址}/chat/completions,适用于 OpenAI、通义、DeepSeek 等兼容服务。',
);
function applySettings(settings: AiSettingsDto): void {
form.enabled = settings.enabled;
form.provider = settings.provider;
form.baseUrl = settings.baseUrl;
form.model = settings.model;
form.systemPromptExtra = settings.systemPromptExtra;
hasApiKey.value = settings.hasApiKey;
keyHint.value = settings.keyHint;
apiKeyInput.value = '';
}
async function loadSettings(): Promise<void> {
loading.value = true;
try {
applySettings(await aiApi.settings());
} catch {
// 响应拦截器已经弹过服务端的中文提示,这里只保证页面不是一片空白
} finally {
loading.value = false;
}
}
async function save(): Promise<void> {
saving.value = true;
try {
const payload: UpdateAiSettingsRequest = {
enabled: form.enabled,
provider: form.provider,
baseUrl: form.baseUrl.trim(),
model: form.model.trim(),
systemPromptExtra: form.systemPromptExtra,
};
// 只有真的填了新密钥才带上 apiKey:不带 = 保持原值,null = 清除
const typedKey = apiKeyInput.value.trim();
if (typedKey) payload.apiKey = typedKey;
applySettings(await aiApi.updateSettings(payload));
ElMessage.success('设置已保存');
} catch {
// 同上:错误提示由拦截器统一给出
} finally {
saving.value = false;
}
}
async function clearApiKey(): Promise<void> {
try {
await ElMessageBox.confirm('清除后助手无法调用真实大模型;确认清除已保存的密钥吗?', '清除密钥', {
type: 'warning',
confirmButtonText: '清除',
cancelButtonText: '取消',
});
} catch {
return;
}
clearingKey.value = true;
try {
// apiKey: null 是"清除"的约定(空串在服务端同样按清除处理)
applySettings(await aiApi.updateSettings({ apiKey: null }));
ElMessage.success('密钥已清除');
} catch {
// 拦截器已提示
} finally {
clearingKey.value = false;
}
}
/* ------------------------------------------------------------ 使用记录 */
const audit = ref<AiAuditDto[]>([]);
const auditLoading = ref(false);
const INTENT_LABELS: Record<string, string> = {
create_homework: '布置作业',
create_notification: '发布通知',
call_student: '叫同学来',
unknown: '没听懂',
};
const STATUS_LABELS: Record<string, string> = {
pending: '已解析',
confirmed: '已发布',
cancelled: '已放弃',
failed: '发布失败',
};
function intentLabel(intent: string): string {
return INTENT_LABELS[intent] ?? intent;
}
function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status;
}
async function loadAudit(): Promise<void> {
auditLoading.value = true;
try {
audit.value = await aiApi.audit(50);
} catch {
// 拦截器已提示
} finally {
auditLoading.value = false;
}
}
/* ------------------------------------------------------------ 系统/开发者 */
const developerMode = ref(false);
const systemSaving = ref(false);
const diagnostics = ref<SystemDiagnosticsDto | null>(null);
const diagnosticsLoading = ref(false);
async function loadSystemSettings(): Promise<void> {
try {
developerMode.value = (await systemApi.settings()).developerMode;
} catch {
// 拦截器已提示
}
}
async function handleDeveloperModeChange(value: boolean | string | number): Promise<void> {
const next = value === true;
const previous = developerMode.value;
developerMode.value = next;
systemSaving.value = true;
try {
await systemApi.updateSettings({ developerMode: next });
ElMessage.success(next ? '开发者模式已开启' : '开发者模式已关闭');
} catch {
developerMode.value = previous;
} finally {
systemSaving.value = false;
}
}
async function loadDiagnostics(): Promise<void> {
diagnosticsLoading.value = true;
try {
diagnostics.value = await systemApi.diagnostics();
} catch {
// 拦截器已提示
} finally {
diagnosticsLoading.value = false;
}
}
/** 运行时长(秒 → 「3 天 4 小时 5 分」),纯展示,不做精度承诺 */
function formatUptime(seconds: number | undefined): string {
if (seconds === undefined) return '—';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days} 天`);
if (hours > 0) parts.push(`${hours} 小时`);
parts.push(`${minutes} 分`);
return parts.join(' ');
}
/* ------------------------------------------------------------ 生命周期 */
onMounted(async () => {
await loadSettings();
await loadAudit();
if (canManageSystem.value) {
await loadSystemSettings();
await loadDiagnostics();
}
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">大模型设置</h2>
<p class="page-subtitle">配置「AI 助手」用哪种解析方式;助手只解析、不写库,发布始终由老师确认</p>
</div>
</div>
<el-tabs v-model="activeTab" class="settings-tabs">
<el-tab-pane label="模型配置" name="model">
<el-card v-loading="loading" shadow="never">
<el-form :model="form" :label-width="isMobile ? undefined : '132px'" :label-position="isMobile ? 'top' : 'right'">
<el-form-item label="启用助手">
<el-switch v-model="form.enabled" data-test="ai-enabled" />
<span class="field-hint">关闭后教师在「AI 助手」页会看到"还没启用"的空态</span>
</el-form-item>
<el-form-item label="解析方式">
<el-radio-group v-model="form.provider">
<el-radio value="openai">OpenAI 兼容接口</el-radio>
<el-radio value="mock">离线示例解析</el-radio>
</el-radio-group>
<p class="field-hint">{{ providerHint }}</p>
</el-form-item>
<template v-if="form.provider === 'openai'">
<el-form-item label="服务地址">
<el-input v-model="form.baseUrl" placeholder="https://api.openai.com/v1" class="field-input" />
</el-form-item>
<el-form-item label="模型名">
<el-input v-model="form.model" placeholder="gpt-4o-mini(留空即用默认)" class="field-input" />
</el-form-item>
<el-form-item label="API 密钥">
<div class="key-row">
<el-input
v-model="apiKeyInput"
type="password"
show-password
class="field-input"
:placeholder="hasApiKey ? `已配置(尾号 ${keyHint}),留空表示不修改` : '粘贴密钥,保存后不再显示'"
/>
<el-button v-if="hasApiKey" :loading="clearingKey" @click="clearApiKey">清除密钥</el-button>
</div>
<p class="field-hint">密钥只保存在服务端;接口永不回传明文,所以这里不会回填。</p>
</el-form-item>
</template>
<el-form-item label="补充提示词">
<el-input
v-model="form.systemPromptExtra"
type="textarea"
:rows="3"
class="field-input"
placeholder="例如:本校的班级叫「高一二班」这样的名字;把「卷子」理解为作业"
/>
<p class="field-hint">追加在系统提示词末尾,用来补充本校的称呼习惯。</p>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" data-test="ai-save" @click="save">保存</el-button>
<el-button :disabled="saving" @click="loadSettings">重新载入</el-button>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
<el-tab-pane label="使用记录" name="audit">
<el-card shadow="never">
<div class="table-toolbar">
<span class="field-hint">最近 50 条解析记录(只记录解析这一步,发布结果不在这张表里)</span>
<el-button :loading="auditLoading" @click="loadAudit">刷新</el-button>
</div>
<el-table v-loading="auditLoading" :data="audit" class="table-card" empty-text="还没有解析记录">
<el-table-column label="时间" width="150">
<template #default="{ row }">
<span class="cell-muted">{{ formatDate((row as AiAuditDto).createdAt, true) }}</span>
</template>
</el-table-column>
<el-table-column label="操作人" width="120">
<template #default="{ row }">{{ (row as AiAuditDto).userName || '—' }}</template>
</el-table-column>
<el-table-column label="原话" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ (row as AiAuditDto).prompt }}</template>
</el-table-column>
<el-table-column label="动作" width="110">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ intentLabel((row as AiAuditDto).intent) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="结果" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<el-tag size="small" type="info" effect="plain">
{{ statusLabel((row as AiAuditDto).status) }}
</el-tag>
<span class="cell-text">{{ (row as AiAuditDto).message }}</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane v-if="canManageSystem" label="系统" name="system">
<el-card shadow="never" class="system-card">
<div class="switch-row">
<div>
<p class="switch-title">开发者模式</p>
<p class="field-hint">
打开后只把开关记在服务端配置里(用于排查问题时确认环境),不改变老师与学生的日常使用。
</p>
</div>
<el-switch
:model-value="developerMode"
:loading="systemSaving"
@update:model-value="handleDeveloperModeChange"
/>
</div>
</el-card>
<el-card v-loading="diagnosticsLoading" shadow="never" class="system-card">
<template #header>
<div class="table-toolbar">
<span>服务端诊断</span>
<el-button :loading="diagnosticsLoading" @click="loadDiagnostics">刷新</el-button>
</div>
</template>
<el-descriptions :column="isMobile ? 1 : 2" size="small" border>
<el-descriptions-item label="版本">{{ diagnostics?.version ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="Node">{{ diagnostics?.nodeVersion ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="平台">{{ diagnostics?.platform ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="已运行">{{ formatUptime(diagnostics?.uptimeSeconds) }}</el-descriptions-item>
<el-descriptions-item label="数据库">{{ diagnostics?.databaseProvider ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="在线客户端">
{{ diagnostics?.onlineClientCount ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="已发布客户端版本">
{{ diagnostics?.clientReleaseCount ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="已加载模块">{{ diagnostics?.moduleCount ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="服务端目录" :span="isMobile ? 1 : 2">
<span class="cell-path">{{ diagnostics?.serverRoot ?? '—' }}</span>
</el-descriptions-item>
<el-descriptions-item label="数据目录" :span="isMobile ? 1 : 2">
<span class="cell-path">{{ diagnostics?.dataDir ?? '—' }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<style scoped>
.settings-tabs {
margin-top: -4px;
}
.field-input {
max-width: 420px;
}
.field-hint {
margin: 4px 0 0;
color: var(--ch-text-muted);
font-size: var(--el-font-size-extra-small);
line-height: 1.6;
}
.key-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.cell-muted {
color: var(--ch-text-secondary);
}
.cell-text {
margin-left: 8px;
color: var(--ch-text-secondary);
}
.cell-path {
word-break: break-all;
font-family: var(--el-font-family-mono, monospace);
font-size: var(--el-font-size-extra-small);
}
.system-card + .system-card {
margin-top: 16px;
}
.switch-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.switch-title {
margin: 0;
font-weight: 600;
}
</style>
@@ -0,0 +1,644 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
dayKeyLocal,
type AiInterpretResult,
type ApiErrorResponse,
type ClassDto,
type CourseDto,
type NotificationPriority,
type StudentDto,
} from '@classhelper/shared';
import { aiApi, callApi, classApi, courseApi, homeworkApi, notificationApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useResponsive } from '@/composables/useResponsive';
/**
* AI 助手:**一句话 → 待确认的草稿 → 老师确认后才落库**。
*
* 三条边界(与服务端 `modules/ai/ai.service.ts` 的设计一一对应):
* 1. 助手**只解析、不写业务表**:`POST /ai/interpret` 返回的是结构化参数与中文预览,
* 真正发布走的仍是既有端点(`POST /homeworks` / `/notifications` / `/calls`)——
* 于是科目权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用,不会另开一条旁路。
* 2. **参数可改**:模型的输出不可信(服务端自己也会用同一份 zod 再校验一遍),
* 所以解析结果一律填进表单让老师过目、修改,而不是直接提交。
* 3. **教师不读配置**:`GET /ai/settings` 要 `ai:manage`,教师读会 403 且响应拦截器会弹一条错误提示。
* 所以只有管理者才去读配置判断"是否启用",教师侧走"探测式"——
* 直接解析,服务端回 `AI_NOT_CONFIGURED` 时再切到空态(见 interpret())。
*/
const auth = useAuthStore();
const router = useRouter();
const { isMobile } = useResponsive();
const canManage = computed(() => auth.can('ai:manage'));
/* -------------------------------------------------------------- 可用性 */
/** checking = 管理者正在读配置;ready = 可以用;disabled = 还没启用(显示空态) */
type AssistantState = 'checking' | 'ready' | 'disabled';
const state = ref<AssistantState>('checking');
const disabledReason = ref('助手还没启用,请管理员在「大模型设置」里配置');
/* -------------------------------------------------------------- 输入 */
const text = ref('');
const interpreting = ref(false);
const result = ref<AiInterpretResult | null>(null);
/** 发布失败的中文原因(常驻在结果卡片里,不只靠一条会消失的 toast) */
const publishError = ref('');
const publishing = ref(false);
/* -------------------------------------------------------------- 表单 */
const classes = ref<ClassDto[]>([]);
const courseOptions = ref<CourseDto[]>([]);
const students = ref<StudentDto[]>([]);
/** 叫人时按姓名匹配的结果说明(没匹配到要让老师自己选,不能默默留空) */
const studentHint = ref('');
const formRef = ref<FormInstance>();
const form = reactive({
classId: '',
courseId: '',
title: '',
content: '',
assignDate: dayKeyLocal(new Date()),
priority: 'NORMAL' as NotificationPriority,
studentId: '',
message: '',
});
const rules = computed<FormRules>(() => {
const action = result.value?.action;
const base: FormRules = {
classId: [{ required: true, message: '请选择班级', trigger: 'change' }],
};
if (action === 'create_homework' || action === 'create_notification') {
base.title = [{ required: true, message: '请填写标题', trigger: 'blur' }];
base.content = [{ required: true, message: '请填写内容', trigger: 'blur' }];
}
if (action === 'call_student') {
base.studentId = [{ required: true, message: '请选择要叫的同学', trigger: 'change' }];
}
return base;
});
const actionLabel = computed(() => {
switch (result.value?.action) {
case 'create_homework':
return '布置作业';
case 'create_notification':
return '发布通知';
case 'call_student':
return '叫同学来';
default:
return '没听懂';
}
});
const confidencePercent = computed(() => `${Math.round((result.value?.confidence ?? 0) * 100)}%`);
const confidenceType = computed(() => ((result.value?.confidence ?? 0) < 0.5 ? 'warning' : 'success'));
/* -------------------------------------------------------------- 工具 */
function str(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function isPriority(value: unknown): value is NotificationPriority {
return typeof value === 'string' && (NOTIFICATION_PRIORITIES as readonly string[]).includes(value);
}
/**
* 统一的错误读取。
*
* `api/http.ts` 的响应拦截器**已经弹过一次**服务端的中文提示(这就是不在这里重复 toast 的原因),
* 这里只是把状态码与业务码取出来,好做分支判断(403 科目越权 / 409 上课时段紧急通知)。
*/
function readServerError(error: unknown): { status: number; code: string; message: string } {
if (axios.isAxiosError<ApiErrorResponse>(error)) {
return {
status: error.response?.status ?? 0,
code: error.response?.data?.code ?? '',
message: error.response?.data?.message ?? error.message,
};
}
return { status: 0, code: '', message: error instanceof Error ? error.message : '请求失败' };
}
/* -------------------------------------------------------------- 加载 */
async function loadClasses(): Promise<void> {
try {
classes.value = await classApi.list();
} catch {
// 拦截器已提示;拿不到班级时后面的表单会缺选项,但不至于整页报错
classes.value = [];
}
}
async function refreshCourses(): Promise<void> {
courseOptions.value = form.classId ? await courseApi.list(form.classId).catch(() => []) : [];
}
async function refreshStudents(): Promise<void> {
students.value = form.classId ? await classApi.students(form.classId).catch(() => []) : [];
}
/** 换班级:科目与名单都得跟着换,否则会把 A 班的科目发到 B 班 */
async function onClassChange(): Promise<void> {
form.courseId = '';
form.studentId = '';
studentHint.value = '';
await refreshCourses();
if (result.value?.action === 'call_student') await refreshStudents();
}
/**
* 叫人的学生匹配:助手的 `call_student` 通常只给 `studentName`(预览也按姓名查过),
* 而 `POST /calls` 要的是 `studentId`。同名或没匹配到都**不猜**,交给老师在下拉里选。
*/
async function resolveStudent(params: Record<string, unknown>): Promise<void> {
await refreshStudents();
const byId = str(params.studentId);
if (byId && students.value.some((item) => item.id === byId)) {
form.studentId = byId;
studentHint.value = '';
return;
}
const name = str(params.studentName);
if (!name) {
studentHint.value = '助手没识别出学生姓名,请手动选择。';
return;
}
const matched = students.value.filter((item) => item.name === name);
if (matched.length === 1) {
form.studentId = matched[0]?.id ?? '';
studentHint.value = '';
return;
}
studentHint.value =
matched.length > 1
? `本班有 ${matched.length} 位「${name}」,请手动选择是哪一位。`
: `本班没找到「${name}」,请手动选择。`;
}
/* -------------------------------------------------------------- 解析 */
async function interpret(): Promise<void> {
const value = text.value.trim();
if (!value) {
ElMessage.warning('先说说要做什么,例如「给高一二班布置作业,让他们把卷子写完」');
return;
}
interpreting.value = true;
publishError.value = '';
try {
const data = await aiApi.interpret({ text: value, classId: form.classId || undefined });
result.value = data;
await fillForm(data);
} catch (error) {
const info = readServerError(error);
// 未启用 / 没配服务地址与密钥:服务端用同一个业务码区分,这里统一切到空态
if (info.code === 'AI_NOT_CONFIGURED') {
state.value = 'disabled';
disabledReason.value = info.message;
result.value = null;
}
} finally {
interpreting.value = false;
}
}
/** 把解析出的参数填进表单(解析结果只是**草稿**,老师可以逐项改) */
async function fillForm(data: AiInterpretResult): Promise<void> {
const params = data.params ?? {};
form.classId = str(params.classId) || form.classId || classes.value[0]?.id || '';
form.courseId = str(params.courseId);
form.title = str(params.title);
form.content = str(params.content);
form.assignDate = str(params.assignDate) || dayKeyLocal(new Date());
form.priority = isPriority(params.priority) ? params.priority : 'NORMAL';
form.studentId = '';
form.message = str(params.message);
studentHint.value = '';
await refreshCourses();
if (data.action === 'call_student') await resolveStudent(params);
// 表单是 v-if 出来的:等它挂载完再清掉上一次残留的校验红字
await nextTick();
formRef.value?.clearValidate();
}
/* -------------------------------------------------------------- 发布 */
async function confirmPublish(): Promise<void> {
const current = result.value;
if (!current || current.action === 'unknown') return;
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
publishing.value = true;
publishError.value = '';
try {
await publishOnce(current.action, false);
finishSuccess(current.action);
} catch (error) {
await handlePublishError(error, current.action);
} finally {
publishing.value = false;
}
}
/** 真正落库:复用既有的发布端点(权限断言 / 上课保护 / ClassIsland 推送都在服务端) */
async function publishOnce(action: AiInterpretResult['action'], confirmDuringClass: boolean): Promise<void> {
if (action === 'create_homework') {
await homeworkApi.create({
classId: form.classId,
courseId: form.courseId || null,
title: form.title.trim(),
content: form.content,
assignDate: form.assignDate || null,
});
return;
}
if (action === 'create_notification') {
await notificationApi.create({
classId: form.classId,
title: form.title.trim(),
content: form.content,
priority: form.priority,
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
});
return;
}
await callApi.create({
classId: form.classId,
studentId: form.studentId,
message: form.message.trim() || undefined,
});
}
function finishSuccess(action: AiInterpretResult['action']): void {
ElMessage.success(
action === 'create_homework'
? '作业已发布,已推送给该班学生'
: action === 'create_notification'
? '通知已发布,已推送给该班学生'
: '已通知该同学到老师这里',
);
// 发布成功后清空结果:同一句话不会被误发第二遍
result.value = null;
text.value = '';
publishError.value = '';
studentHint.value = '';
}
/**
* 失败分支。
*
* 服务端的 403(如「不是你任教的科目」)与其它 4xx 的中文提示**已经由响应拦截器弹过**,
* 这里只做两件拦截器做不了的事:
* 1. 把原因常驻在结果卡片里(toast 会消失,而老师需要边看参数边改);
* 2. 上课时段的紧急通知(409 `URGENT_DURING_CLASS`)再问一次 —— 服务端要求二次确认才放行。
*/
async function handlePublishError(error: unknown, action: AiInterpretResult['action']): Promise<void> {
const info = readServerError(error);
if (info.status === 409 && info.code === 'URGENT_DURING_CLASS' && action === 'create_notification') {
try {
await ElMessageBox.confirm(
'正在上课,紧急通知需二次确认。确认立即把这条通知发到班级吗?',
'上课时段发布紧急通知',
{ type: 'warning', confirmButtonText: '继续发布', cancelButtonText: '取消' },
);
} catch {
publishError.value = '已取消:正在上课,紧急通知需要二次确认';
return;
}
try {
await publishOnce(action, true);
finishSuccess(action);
} catch (retryError) {
publishError.value = readServerError(retryError).message;
}
return;
}
publishError.value = info.message;
}
/* -------------------------------------------------------------- 生命周期 */
onMounted(async () => {
await loadClasses();
if (canManage.value) {
// 管理者:读一次配置就能给出准确空态(教师不能读,会 403)
try {
const settings = await aiApi.settings();
if (!settings.enabled) {
state.value = 'disabled';
disabledReason.value = '助手还没启用,请管理员在「大模型设置」里配置';
} else if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
state.value = 'disabled';
disabledReason.value = '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写';
} else {
state.value = 'ready';
}
} catch {
// 读配置失败不该把老师挡在门外:按"可以用"处理,真正的判据交给解析时的报错
state.value = 'ready';
}
return;
}
// 教师(或权限还没拉回来时):探测式 —— 直接允许输入,解析报 AI_NOT_CONFIGURED 再切空态
state.value = 'ready';
});
function goSettings(): void {
void router.push('/ai-settings');
}
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">AI 助手</h2>
<p class="page-subtitle">
用一句话说清要做什么,助手解析成可修改的草稿;<b>确认发布后</b>才会真正发到班级
</p>
</div>
</div>
<!-- 读配置中:先占位,避免"先看到输入框、再被空态替换"的闪动 -->
<el-card v-if="state === 'checking'" v-loading="true" shadow="never" class="ask-card" />
<!-- 未启用:只说明原因,不给输入框(说了也发不出去) -->
<el-card v-else-if="state === 'disabled'" shadow="never">
<el-empty :description="disabledReason">
<el-button v-if="canManage" type="primary" @click="goSettings">去「大模型设置」</el-button>
</el-empty>
</el-card>
<template v-else>
<el-card shadow="never" class="ask-card">
<div class="ask-row">
<el-input
v-model="text"
type="textarea"
:rows="2"
maxlength="500"
show-word-limit
resize="none"
placeholder="给高一二班布置作业,让他们把卷子写完"
@keydown.enter.exact.prevent="interpret"
/>
<el-button type="primary" :loading="interpreting" data-test="ai-interpret" @click="interpret">
解析
</el-button>
</div>
<p class="ask-hint">回车解析,Shift + 回车换行|可以说「布置作业 / 发通知 / 叫某位同学来办公室」</p>
</el-card>
<el-card v-if="result" shadow="never" class="result-card">
<template #header>
<div class="result-header">
<span class="result-title">{{ actionLabel }}</span>
<el-tag :type="confidenceType" size="small" effect="light">
可信度 {{ confidencePercent }}
</el-tag>
</div>
</template>
<!-- 没听懂:只给一句提示,不摆一堆用不上的表单 -->
<el-empty v-if="result.action === 'unknown'" description="没听懂,请换一种说法" />
<template v-else>
<el-alert
v-if="result.confidence < 0.5"
class="result-alert"
type="warning"
show-icon
:closable="false"
title="助手把握不大,请仔细核对下面的参数再发布"
/>
<el-alert
v-if="publishError"
class="result-alert"
type="error"
show-icon
:closable="false"
:title="publishError"
/>
<p class="result-summary">{{ result.preview.summary }}</p>
<div class="result-meta">
<span>班级:{{ result.preview.className || '(未指定)' }}</span>
<span v-if="result.action === 'create_homework'">
科目:{{ result.preview.courseName || '(不指定科目,仅班主任/管理员可发)' }}
</span>
<span v-if="result.action === 'call_student'">
学生:{{ result.preview.studentName || '(未识别)' }}
</span>
</div>
<ul v-if="result.preview.warnings.length" class="result-warnings">
<li v-for="(warning, index) in result.preview.warnings" :key="index">{{ warning }}</li>
</ul>
<el-form
ref="formRef"
:model="form"
:rules="rules"
:label-width="isMobile ? undefined : '82px'"
:label-position="isMobile ? 'top' : 'right'"
class="result-form"
>
<el-form-item label="班级" prop="classId">
<el-select
v-model="form.classId"
placeholder="选择班级"
class="result-field"
@change="onClassChange"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<template v-if="result.action === 'create_homework'">
<el-form-item label="科目">
<el-select v-model="form.courseId" clearable placeholder="不指定科目" class="result-field">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" class="result-field" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="日期">
<el-date-picker
v-model="form.assignDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="作业归属日期"
class="result-field"
/>
</el-form-item>
</template>
<template v-else-if="result.action === 'create_notification'">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" class="result-field" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input v-model="form.content" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="form.priority" class="result-field">
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
</el-form-item>
</template>
<template v-else>
<el-form-item label="学生" prop="studentId">
<el-select
v-model="form.studentId"
filterable
placeholder="选择要叫的同学"
class="result-field"
>
<el-option
v-for="item in students"
:key="item.id"
:label="`${item.name}(${item.studentNo})`"
:value="item.id"
/>
</el-select>
<p v-if="studentHint" class="result-hint">{{ studentHint }}</p>
</el-form-item>
<el-form-item label="留言">
<el-input v-model="form.message" type="textarea" :rows="2" />
</el-form-item>
</template>
</el-form>
<div class="result-actions">
<el-button :disabled="publishing" @click="result = null">放弃</el-button>
<el-button
type="primary"
:loading="publishing"
data-test="ai-confirm"
@click="confirmPublish"
>
确认发布
</el-button>
</div>
</template>
</el-card>
</template>
</div>
</template>
<style scoped>
.ask-card {
margin-bottom: 16px;
}
.ask-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.ask-row :deep(.el-textarea) {
flex: 1;
}
.ask-row .el-button {
/* 与两行输入框的顶部对齐(按钮不该被拉成和输入框一样高) */
flex: 0 0 auto;
height: var(--el-component-size);
}
.ask-hint {
margin: 8px 0 0;
color: var(--ch-text-muted);
font-size: var(--el-font-size-extra-small);
}
.result-header {
display: flex;
align-items: center;
gap: 8px;
}
.result-title {
font-weight: 600;
}
.result-alert {
margin-bottom: 12px;
}
.result-summary {
margin: 0 0 8px;
font-size: var(--el-font-size-medium);
font-weight: 600;
}
.result-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
color: var(--ch-text-secondary);
font-size: var(--el-font-size-small);
}
.result-warnings {
margin: 8px 0 0;
padding-left: 18px;
color: var(--el-color-warning);
font-size: var(--el-font-size-small);
}
.result-form {
margin-top: 16px;
}
/* 表单控件不铺满整行:长输入框在宽屏上读起来很累,也容易看错改错 */
.result-field {
max-width: 320px;
}
.result-hint {
margin: 4px 0 0;
color: var(--el-color-warning);
font-size: var(--el-font-size-extra-small);
line-height: 1.5;
}
.result-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more