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