diff --git a/AGENTS.md b/AGENTS.md
index f4dd407..b6fa448 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -118,10 +118,13 @@ website/ 产品官网(**纯静态、无构建步骤**,
-x64-server-setup.exe),它们是离线兜底链接的唯一依据。
packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts
packages/server/
- prisma/schema.prisma 数据模型(16 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
- prisma/migrations/ 16 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
+ prisma/schema.prisma 数据模型(24 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
+ prisma/migrations/ 17 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query /
- add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks)
+ add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks;
+ 最后 1 个是 2026-10-10 的 add_permission_groups_and_clients:
+ 权限组 3 表 + AppSetting + 客户端设备/命令/安装包 + AiAudit +
+ Notification.assignDate + User.careMode)
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项)
@@ -134,10 +137,12 @@ packages/server/
password schemas session term version(读 package.json) web-static
src/middleware/ auth validate error security
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
- src/modules/ 17 个功能模块 + registry.ts + module.types.ts
- (含 archives 毕业归档、term 学期周次)
-packages/web-admin/src/ api stores router layouts views(14) components(5) composables styles config.ts
- (含 ArchivesView 毕业归档、TermWeeksView 学期周次)
+ src/modules/ 21 个功能模块 + registry.ts + module.types.ts
+ (含 archives 毕业归档、term 学期周次、permissions 权限组、
+ clients 客户端心跳与远控、system 开发者模式/开机自启、ai 大模型助手)
+packages/web-admin/src/ api stores router layouts views(18) components(5) composables styles config.ts
+ (含 ArchivesView 毕业归档、TermWeeksView 学期周次、PermissionsView 权限组、
+ ClientsView 客户端设备、AssistantView/aiSettingsView 大模型助手)
packages/desktop-client/
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
@@ -163,8 +168,8 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
| 项 | 状态 |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Node | **v24.19.0**(`D:\nodejs\node.exe`;要求 ≥ 20.19,Prisma 7 硬性要求) |
-| pnpm | 11.8.0(`packageManager` 锁定 11.8.0),宿主就是上面这个 node |
+| Node | **v22.15.0**(`D:\Node.js\node.exe`;根 `engines` 要求 ≥ 20.19,Prisma 7 的 engine 约束是 `^20.19 \|\| ^22.12 \|\| >=24.0`,v22.15 都满足) |
+| pnpm | 11.8.0(`packageManager` 锁定 11.8.0)。本机可用的本体在 `C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs`;PATH 里那个 `pnpm.ps1` 是 DSH 桌面端的转发壳,**别绕它**(见下) |
| npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
@@ -180,6 +185,43 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
+#### AI 会话里跑 pnpm:别撞上 DSH 的转发壳(2026-10-11 实测)
+
+在 DSH 的 shell 工具里直接写 `pnpm xxx` 会拿到
+`pnpm.ps1 : Node.js runtime not found. Please run DeepSeek Harness Desktop to install it first.`,
+而同一台机器上人在普通终端里跑得好好的。原因与绕过办法:
+
+- PATH 里排在前面的是 **DSH 桌面端的转发壳** `C:\Users\hck16\AppData\Local\deepseek-harness\bin\pnpm.ps1`
+ (`.cmd` 同理)。它有两道会挡住的判据:① 认 `$env:DSH_NODE` / PATH 上的 `node`,
+ **但要求 `major ≥ 24` 或 `22.19+`**,本机的 `v22.15.0` 卡在门外;
+ ② 它自己的兜底 `%APPDATA%\dsh-tauri\runtime\node.exe` 与 bundled pnpm
+ `%APPDATA%\dsh-tauri\dependencies\pnpm\bin\pnpm.cjs` **本机都不存在**。
+- **正确解法是喂给这个壳两个环境变量**(它自己就认这两个覆盖开关,见 shim 第 10、52 行),
+ 把 `node` 与 `pnpm` 都指到本机真实的那份,然后就**照常写 `pnpm xxx`**:
+
+ ```powershell
+ $env:DSH_NODE = 'D:\Node.js\node.exe'
+ $env:DSH_PNPM_BIN = 'C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
+ pnpm typecheck # 与人在终端里跑完全等价
+ pnpm build:shared
+ ```
+
+ - `DSH_NODE` 必须是**绝对路径**:壳拿到它就不再走"发现 PATH 上的 node 再卡版本"那段。
+ - `DSH_PNPM_BIN` 是**本体 .cjs 的路径**(不是目录,也不是 `pnpm.ps1`);壳会 `& $node $pnpmBin @args`
+ 直接执行它。它同时替掉那条会失败的 `pnpmBin` 存在性检查。
+ - **`pnpm` 的嵌套调用**(`typecheck` = `build:shared && pnpm -r --no-bail typecheck`)靠这两个变量就够了:
+ 子进程继承同一份环境,于是又落进同一个壳、又被同一对覆盖接住。
+ - **这三条都是踩过的坑**(同一个会话里依次试错,前两条各失败一次):
+ - 不要用 `DSH_PNPM`,那是"用户 pnpm 的可执行路径":壳转发给它时**仍然先过 ② 的版本门槛**,
+ 而且**残留的 `DSH_PNPM` 会污染后面所有调用**——它与 `DSH_PNPM_BIN` 同时存在时壳会转发进
+ corepack 的 `pnpm.ps1`,那个壳又把它重置回不存在的 bundled 路径,于是原本能跑的
+ `typecheck` 报出 `[pnpm] pnpm not found`。要么别设,要么先 `Remove-Item Env:DSH_PNPM`。
+ - 也不要用 `node
diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts
index 510a5d5..8087374 100644
--- a/packages/desktop-client/src/renderer/cache/self-test.ts
+++ b/packages/desktop-client/src/renderer/cache/self-test.ts
@@ -1048,6 +1048,15 @@ export async function layoutChromeSelfTest(): Promise {
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
+ //
+ // ⚠️ 量之前必须等页脚真的收成 0 高:宽度到 64px 与页脚收拢各有一条 260ms 过渡,
+ // 而 `--nav-ease`(Material 曲线)在 74% 处就越过"宽度 ≤ 80px"这个等待条件,
+ // 此时页脚还剩十几像素 —— 直接量 `.menu` 会读出"溢出 16px"的**假红**
+ // (实测出现过一次:同一份产物上一次全绿、下一次红)。这里等的是**稳态几何**,
+ // 与这条断言本来的意图一致(CSS 里那句"量到的是稳态")。
+ const railFooter = document.querySelector('.aside-footer');
+ await waitUntil(() => (railFooter?.offsetHeight ?? 0) <= 1, 2000);
+
const settingsItem = Array.from(
document.querySelectorAll('.aside .el-sub-menu .el-menu-item'),
).find((element) =>
diff --git a/packages/desktop-client/src/renderer/island/bridge.ts b/packages/desktop-client/src/renderer/island/bridge.ts
index 16ed5f1..eec5850 100644
--- a/packages/desktop-client/src/renderer/island/bridge.ts
+++ b/packages/desktop-client/src/renderer/island/bridge.ts
@@ -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 {
+ // 冒烟保护:`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 在挂载时订阅一次。
diff --git a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue b/packages/desktop-client/src/renderer/layouts/ClientLayout.vue
index 1b485ab..477428d 100644
--- a/packages/desktop-client/src/renderer/layouts/ClientLayout.vue
+++ b/packages/desktop-client/src/renderer/layouts/ClientLayout.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>({
+ top: '0px',
+ left: '0px',
+ width: '0px',
+ height: '0px',
+});
+
function syncIndicator(): void {
const aside = document.querySelector('.aside.ch-nav');
- const active = aside?.querySelector('.el-menu-item.is-active');
- if (!aside || !active) {
+ const scroller = aside?.querySelector('.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('.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('.aside.ch-nav');
+ if (aside) {
+ // 菜单自己的滚动容器:翻菜单时底片必须跟着走(用户反馈的「上下滑动时项目选择块不移动」)
+ menuScroller = aside.querySelector('.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(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(() => {
ClassHelper
-
+ 外层 .nav-indicator-clip 与 .menu 的视口重合(overflow:hidden),
+ 把"跟着菜单滚出视口"的底片裁掉,不让它盖到品牌区/页脚上。
+ 两层都 pointer-events:none —— 底片盖在菜单项上,否则会把点击吃掉。 -->
+
{
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: '';
diff --git a/packages/desktop-client/src/renderer/router/index.ts b/packages/desktop-client/src/renderer/router/index.ts
index 6f1cd51..4c961f2 100644
--- a/packages/desktop-client/src/renderer/router/index.ts
+++ b/packages/desktop-client/src/renderer/router/index.ts
@@ -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;
});
diff --git a/packages/desktop-client/src/renderer/stores/app.ts b/packages/desktop-client/src/renderer/stores/app.ts
index 0ad4d7e..c723b9b 100644
--- a/packages/desktop-client/src/renderer/stores/app.ts
+++ b/packages/desktop-client/src/renderer/stores/app.ts
@@ -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;
}
}
diff --git a/packages/desktop-client/src/renderer/stores/ui.ts b/packages/desktop-client/src/renderer/stores/ui.ts
index 18c175c..54481a6 100644
--- a/packages/desktop-client/src/renderer/stores/ui.ts
+++ b/packages/desktop-client/src/renderer/stores/ui.ts
@@ -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。
* 深色主题 = 在 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
+ *
+ * 关怀模式 = 在 上挂 .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 {
@@ -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 {
+ 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 {
+ 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;
+export type UiConfig = Pick;
diff --git a/packages/desktop-client/src/renderer/styles/index.css b/packages/desktop-client/src/renderer/styles/index.css
index 75dfc27..5b1b7cc 100644
--- a/packages/desktop-client/src/renderer/styles/index.css
+++ b/packages/desktop-client/src/renderer/styles/index.css
@@ -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` 给 `` 挂/摘 `.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));
+}
diff --git a/packages/desktop-client/src/renderer/views/NotificationView.vue b/packages/desktop-client/src/renderer/views/NotificationView.vue
index 8ab326a..55066e2 100644
--- a/packages/desktop-client/src/renderer/views/NotificationView.vue
+++ b/packages/desktop-client/src/renderer/views/NotificationView.vue
@@ -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();
+ 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();
+ 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();
+ 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 {
current.value = item;
detailVisible.value = true;
@@ -76,6 +158,7 @@ onUnmounted(() => {
通知
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
+ · 筛选后 {{ filteredItems.length }} 条
离线缓存
@@ -92,37 +175,88 @@ onUnmounted(() => {
-
-
- -
+
+
+
+
+
-
-
-
-
- {{ PRIORITY_LABELS[item.priority as NotificationPriority] }}
-
- {{ item.title }}
-
-
{{ item.content }}
+
+
+
+
+
+
+ 清除筛选
+
+
+
+
+
+
+
-
-
-
+
+ -
+
+
+
+
+ {{ PRIORITY_LABELS[item.priority as NotificationPriority] }}
+
+ {{ item.title }}
+
+
{{ item.content }}
+
+
+
+
+
+
@@ -147,6 +281,30 @@ onUnmounted(() => {
diff --git a/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue b/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue
index ac88f65..0a64b10 100644
--- a/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue
+++ b/packages/desktop-client/src/renderer/views/settings/GeneralSettingsView.vue
@@ -1,12 +1,14 @@
@@ -238,6 +426,77 @@ onMounted(async () => {
恢复默认
+
+
+ 启动与更新
+
+
+
开机自启
+
登录 Windows 后自动启动 ClassHelper;教室机器建议打开。
+
+
+
+
+
+
自动更新
+
+ 安装只在
+ 不上课且窗口已隐藏 5 分钟后
+ 进行,下载随时可能发生。
+
+
服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。
+
+
+
+
+ 打开日志目录
+ 日志文件 main.log,排查问题时可发给管理员
+
+
+
+
+ 显示与调试
+
+
+
关怀模式
+
+ 放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。
+
+
+ 「开启后同时放大 AI 助手按钮」这一条只在 Web 管理端生效 —— 本客户端目前没有助手入口。
+
+
+
+
+
+
+
开发者模式
+
显示调试页面与详细日志,仅排查问题时打开。
+
+ debug 级日志已开启;侧栏「设置 → 开发者」可查看版本、设备上报、更新检查、日志目录与 DevTools。
+
+
+
+
+
@@ -265,6 +524,35 @@ onMounted(async () => {
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
+
+
+ 服务端联动状态
+
+ 本机每 30 秒向服务端上报一次状态(是否在上课、当前版本、已下载的更新),
+ 服务端据此下发统一更新与远控命令。本机标识尾号用于在服务端设备列表里核对这台电脑。
+
+
+
+
+ {{ agent?.online ? '已连接(近期上报成功)' : '未连接(等待上报)' }}
+
+
+
+ {{ appVersion }}
+
+
+ {{ agent?.deviceKeyTail || '—' }}
+
+ {{ lastHeartbeatText }}
+ {{ updateTargetText }}
+ {{ pendingUpdateText }}
+ {{ lastErrorText }}
+
+
+ 立即上报
+ 刷新状态
+
+
@@ -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;
+}
diff --git a/packages/desktop-client/src/types/desktop.d.ts b/packages/desktop-client/src/types/desktop.d.ts
index 468d144..d12def9 100644
--- a/packages/desktop-client/src/types/desktop.d.ts
+++ b/packages/desktop-client/src/types/desktop.d.ts
@@ -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;
+ /** 设置开机自启:写配置 + 同步系统登录项,返回生效后的值 */
+ setAutoLaunch(enabled: boolean): Promise;
+ /** 打开日志目录(排查问题用;日志文件是 main.log) */
+ openLogDir(): Promise;
+ /**
+ * 打开主窗口的开发者工具(detached 独立窗口)。
+ *
+ * 只在**非冒烟模式**下允许:冒烟验证会给窗口注入大量合成事件与截图,
+ * 多一个 DevTools 窗口会抢焦点、改变窗口尺寸,把那些断言打乱。
+ * 返回是否真的打开了(冒烟模式下返回 false)。
+ */
+ openDevTools(): Promise;
+ /** 读服务端联动状态(设置页「服务端联动状态」区块) */
+ getClientAgentStatus(): Promise;
+ /** 立即向服务端上报一次,返回上报后的最新状态 */
+ beatClientNow(): Promise;
}
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
diff --git a/packages/server/package.json b/packages/server/package.json
index c3a9916..5c47288 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -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",
diff --git a/packages/server/prisma/migrations/20261010120000_add_permission_groups_and_clients/migration.sql b/packages/server/prisma/migrations/20261010120000_add_permission_groups_and_clients/migration.sql
new file mode 100644
index 0000000..252b6c2
--- /dev/null
+++ b/packages/server/prisma/migrations/20261010120000_add_permission_groups_and_clients/migration.sql
@@ -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");
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index b09de24..e10e8f8 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -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])
+}
diff --git a/packages/server/prisma/seed.ts b/packages/server/prisma/seed.ts
index d3b13bb..a8931b9 100644
--- a/packages/server/prisma/seed.ts
+++ b/packages/server/prisma/seed.ts
@@ -70,6 +70,17 @@ function createRandom(seed: number): () => number {
async function resetDatabase(): Promise {
// 顺序敏感:先删子表,再删主表
+ // 权限组 / 客户端设备 / 远端指令 / 安装包缓存 / 大模型审计(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 {
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 级是高二。
diff --git a/packages/server/scripts/apply-column-migrations.cjs b/packages/server/scripts/apply-column-migrations.cjs
index dfdf409..7786344 100644
--- a/packages/server/scripts/apply-column-migrations.cjs
+++ b/packages/server/scripts/apply-column-migrations.cjs
@@ -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,按天归档用)',
+ },
];
/**
diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs
index c399759..9e58da7 100644
--- a/packages/server/scripts/verify-e2e.mjs
+++ b/packages/server/scripts/verify-e2e.mjs
@@ -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 ?? '-'
+ }`,
);
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index 9c86928..67b54a8 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -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 {
await prepareDatabase();
+// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts)
+await refreshDeveloperMode();
+
const app = createApp();
const httpServer = createServer(app);
const io = initRealtime(httpServer);
diff --git a/packages/server/src/lib/bulk.ts b/packages/server/src/lib/bulk.ts
new file mode 100644
index 0000000..7be9272
--- /dev/null
+++ b/packages/server/src/lib/bulk.ts
@@ -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 }),
+ };
+ }
+
+ 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)];
+}
diff --git a/packages/server/src/lib/http.ts b/packages/server/src/lib/http.ts
index d1c1871..84767a1 100644
--- a/packages/server/src/lib/http.ts
+++ b/packages/server/src/lib/http.ts
@@ -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'
diff --git a/packages/server/src/lib/mappers.ts b/packages/server/src/lib/mappers.ts
index 66d001f..282b753 100644
--- a/packages/server/src/lib/mappers.ts
+++ b/packages/server/src/lib/mappers.ts
@@ -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),
diff --git a/packages/server/src/lib/permissions.ts b/packages/server/src/lib/permissions.ts
new file mode 100644
index 0000000..2c141af
--- /dev/null
+++ b/packages/server/src/lib/permissions.ts
@@ -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> {
+ const permissions = new Set();
+
+ 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(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];
+}
diff --git a/packages/server/src/lib/secrets.ts b/packages/server/src/lib/secrets.ts
new file mode 100644
index 0000000..d1fc4f9
--- /dev/null
+++ b/packages/server/src/lib/secrets.ts
@@ -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 '';
+ }
+}
diff --git a/packages/server/src/lib/settings.ts b/packages/server/src/lib/settings.ts
new file mode 100644
index 0000000..29f8842
--- /dev/null
+++ b/packages/server/src/lib/settings.ts
@@ -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 {
+ const row = await prisma.appSetting.findUnique({ where: { key }, select: { value: true } });
+ return row?.value ?? '';
+}
+
+/** 批量读(避免逐键一条 SQL) */
+export async function getSettings(keys: readonly SettingKey[]): Promise> {
+ const rows = await prisma.appSetting.findMany({
+ where: { key: { in: [...keys] } },
+ select: { key: true, value: true },
+ });
+ const map: Record = {};
+ 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 {
+ await prisma.appSetting.upsert({
+ where: { key },
+ create: { key, value },
+ update: { value },
+ });
+}
+
+export async function getBooleanSetting(key: SettingKey, fallback = false): Promise {
+ const raw = await getSetting(key);
+ if (raw === '') return fallback;
+ return raw === 'true' || raw === '1';
+}
+
+export async function setBooleanSetting(key: SettingKey, value: boolean): Promise {
+ 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 {
+ developerModeCache = await getBooleanSetting(SETTING_KEYS.developerMode, false);
+}
diff --git a/packages/server/src/lib/snapshot.ts b/packages/server/src/lib/snapshot.ts
index e8361db..f848bf1 100644
--- a/packages/server/src/lib/snapshot.ts
+++ b/packages/server/src/lib/snapshot.ts
@@ -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];
diff --git a/packages/server/src/middleware/auth.ts b/packages/server/src/middleware/auth.ts
index 7139ebb..6fc0a7b 100644
--- a/packages/server/src/middleware/auth.ts
+++ b/packages/server/src/middleware/auth.ts
@@ -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();
+ 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();
+ 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 {
+ if (!req.auth?.user) throw ApiError.unauthorized();
+ return req.auth.permissions ?? new Set();
+}
+
+/** 当前主体是否有某个权限键(页面/服务里做条件分支用,找不到也当无权限) */
+export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
+ return (req.auth?.permissions ?? new Set()).has(key);
+}
diff --git a/packages/server/src/middleware/error.ts b/packages/server/src/middleware/error.ts
index 9d99c55..aedbaf1 100644
--- a/packages/server/src/middleware/error.ts
+++ b/packages/server/src/middleware/error.ts
@@ -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)
+ : { details: error.details }),
+ stack: error.stack,
+ }
+ : error.details;
+ sendError(res, error.status, error.message, error.code, withStack);
return;
}
diff --git a/packages/server/src/modules/ai/ai.module.ts b/packages/server/src/modules/ai/ai.module.ts
new file mode 100644
index 0000000..8ad6878
--- /dev/null
+++ b/packages/server/src/modules/ai/ai.module.ts
@@ -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(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(req);
+ sendOk(res, await aiService.interpret(user, input), '助手已解析,请确认后发布');
+ },
+);
+
+router.get('/audit', requirePermission('ai:manage'), validate({ query: aiAuditQuerySchema }), async (req, res) => {
+ const query = validatedQuery(req);
+ sendOk(res, await aiService.listAudit(query.limit ?? 50), '获取助手使用记录成功');
+});
+
+export const aiModule = defineModule({
+ name: 'ai',
+ basePath: API_PATHS.ai,
+ router,
+});
diff --git a/packages/server/src/modules/ai/ai.schemas.ts b/packages/server/src/modules/ai/ai.schemas.ts
new file mode 100644
index 0000000..f54f74f
--- /dev/null
+++ b/packages/server/src/modules/ai/ai.schemas.ts
@@ -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;
+export type AiInterpretInput = z.infer;
+export type AiAuditQuery = z.infer;
diff --git a/packages/server/src/modules/ai/ai.service.ts b/packages/server/src/modules/ai/ai.service.ts
new file mode 100644
index 0000000..2bfe74d
--- /dev/null
+++ b/packages/server/src/modules/ai/ai.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ // 优先用句子里**真正提到的**班级(「给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> {
+ 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,
+): Promise {
+ 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 {
+ 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 = {};
+ 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;
+ } 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;
+ } 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;
+ }
+ } 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 {
+ 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(),
+ }));
+}
diff --git a/packages/server/src/modules/archives/archives.module.ts b/packages/server/src/modules/archives/archives.module.ts
index b9b18f2..1d9b2b4 100644
--- a/packages/server/src/modules/archives/archives.module.ts
+++ b/packages/server/src/modules/archives/archives.module.ts
@@ -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',
diff --git a/packages/server/src/modules/calls/calls.module.ts b/packages/server/src/modules/calls/calls.module.ts
index 4120cf3..6366373 100644
--- a/packages/server/src/modules/calls/calls.module.ts
+++ b/packages/server/src/modules/calls/calls.module.ts
@@ -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), '已通知该学生');
});
diff --git a/packages/server/src/modules/classes/classes.module.ts b/packages/server/src/modules/classes/classes.module.ts
index 340e2db..8b2e5c0 100644
--- a/packages/server/src/modules/classes/classes.module.ts
+++ b/packages/server/src/modules/classes/classes.module.ts
@@ -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);
diff --git a/packages/server/src/modules/clients/clients.module.ts b/packages/server/src/modules/clients/clients.module.ts
new file mode 100644
index 0000000..ed84ad1
--- /dev/null
+++ b/packages/server/src/modules/clients/clients.module.ts
@@ -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(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(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(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(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(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,
+});
diff --git a/packages/server/src/modules/clients/clients.schemas.ts b/packages/server/src/modules/clients/clients.schemas.ts
new file mode 100644
index 0000000..0bea4b3
--- /dev/null
+++ b/packages/server/src/modules/clients/clients.schemas.ts
@@ -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;
+export type ClientCommandAckInput = z.infer;
+export type CreateClientCommandInput = z.infer;
+export type ListClientDevicesQuery = z.infer;
+export type PushClientUpdateInput = z.infer;
diff --git a/packages/server/src/modules/clients/clients.service.ts b/packages/server/src/modules/clients/clients.service.ts
new file mode 100644
index 0000000..2704921
--- /dev/null
+++ b/packages/server/src/modules/clients/clients.service.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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 {
+ 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();
+ 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
-
+