feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`requireRole` 退成默认模板。 权限组 - 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission - 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺; CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板 - 新增「权限组」页:组增删改、成员分配、生效权限预览 - 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖 客户端设备(ClassHelper 桌面端) - 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留) - 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒 - 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、 60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次 - 新增「客户端设备」页:版本一览、一键推送更新、下发远控 客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制) - POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size - 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装 大模型助手:只解析、不落库 - POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人), 真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用 - 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信) - API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出 - provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥) 系统与运维 - 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥) - 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable, **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项 - 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌 客户端 - 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次), 未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例) - 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推 发布内容批量 - POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果 - 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课 (自动建课会把创建者写成任课老师,等于越权改任教关系) - 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。 未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。 服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 / AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
This commit is contained in:
1 parent
e51879667a
commit
817af02357
131 files changed
+12995
-538
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/shared",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { GradeLevelType, NotificationPriority, StudentGender, StudentStatus, UserRole } from './types.js';
|
||||
import type {
|
||||
ClientCommandKind,
|
||||
GradeLevelType,
|
||||
NotificationPriority,
|
||||
StudentGender,
|
||||
StudentStatus,
|
||||
UserRole,
|
||||
} from './types.js';
|
||||
|
||||
/** API 前缀 */
|
||||
export const API_PREFIX = '/api';
|
||||
@@ -33,6 +40,14 @@ export const API_PATHS = {
|
||||
database: '/database',
|
||||
/** 更新检查:Web 端经服务端转发查 GitHub 上的最新 Release(浏览器受 CSP 限制不能直连) */
|
||||
update: '/update',
|
||||
/** 权限组:权限目录、组的增删改、成员分配、当前账号生效权限 */
|
||||
permissions: '/permissions',
|
||||
/** ClassHelper 客户端设备:心跳、设备清单、统一下发更新与远控指令 */
|
||||
clients: '/clients',
|
||||
/** 大模型助手:设置(仅管理员)、一句话解析、使用记录 */
|
||||
ai: '/ai',
|
||||
/** 系统设置与诊断:开发者模式、服务端开机自启、运行诊断(仅管理员) */
|
||||
system: '/system',
|
||||
dashboard: '/dashboard/summary',
|
||||
} as const;
|
||||
|
||||
@@ -654,3 +669,43 @@ export const UPDATE_CHECK_CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
/** 单次检查的超时:教室机 / 机房的网络常常很慢,但也不能把界面卡住 */
|
||||
export const UPDATE_CHECK_TIMEOUT_MS = 8_000;
|
||||
|
||||
/* -------------------------------------------- 客户端设备(ClassHelper 桌面端) */
|
||||
|
||||
/**
|
||||
* 桌面客户端向服务端上报心跳的间隔(30 秒,与班级端在线状态刷新一致)。
|
||||
*
|
||||
* 为什么**不用 Socket.IO 事件**下发更新与远控:教室机的连接经常断(代理、休眠、重启),
|
||||
* 而「这台机器什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上,
|
||||
* 所以统一用「DB 记状态 + 客户端定时拉取」的范式(与 `IntegrationDevice.requestScheduleReport` 同一套路),
|
||||
* 不引入新的长连接握手。
|
||||
*/
|
||||
export const CLIENT_HEARTBEAT_INTERVAL_MS = 30_000;
|
||||
|
||||
/** 「在线」判定窗口:容忍连续丢两次心跳(30 秒 × 3) */
|
||||
export const CLIENT_ONLINE_WINDOW_MS = 90_000;
|
||||
|
||||
/** 远控命令种类:只有这四种,全部用系统命令实现 */
|
||||
export const CLIENT_COMMAND_KINDS: readonly ClientCommandKind[] = ['shutdown', 'restart', 'logoff', 'lock'];
|
||||
|
||||
export const CLIENT_COMMAND_LABELS: Record<ClientCommandKind, string> = {
|
||||
shutdown: '关机',
|
||||
restart: '重启',
|
||||
logoff: '注销',
|
||||
lock: '锁屏',
|
||||
};
|
||||
|
||||
/** 命令有效期:超过这个时间还没被拉走就作废(避免机器离线几天后一开机就被昨天的命令关掉) */
|
||||
export const CLIENT_COMMAND_EXPIRE_MS = 10 * 60 * 1000;
|
||||
|
||||
/** 客户端回执里 result 的长度上限(一句中文足够) */
|
||||
export const CLIENT_COMMAND_RESULT_MAX = 200;
|
||||
|
||||
/**
|
||||
* 自动安装更新前的「空闲」判据:主窗口已隐藏满这么久才装。
|
||||
* 上课时段(渲染进程上报的课表状态)与窗口可见都会把安装往后推。
|
||||
*/
|
||||
export const CLIENT_UPDATE_IDLE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** 服务端缓存客户端安装包的目录(相对 `<serverRoot>/../data`,见 update 模块) */
|
||||
export const CLIENT_RELEASE_DIR = 'client-releases';
|
||||
@@ -145,6 +145,249 @@ export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind
|
||||
return canManageGrades(classRole);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 权限键目录(权限组的唯一事实来源) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 权限键:三端共用的**唯一权限标识**。
|
||||
*
|
||||
* - 服务端:路由级用 `requirePermission(key)`,班级维度的细门仍走下面那些
|
||||
* `canXxx`(科目归属这类**数据完整性**规则故意不可被权限组覆盖);
|
||||
* - Web 管理端:按钮/菜单显隐、权限组管理页的勾选项;
|
||||
* - 桌面客户端:功能入口显隐。
|
||||
*
|
||||
* 键名用 `领域:动作` 的形式,新增功能要**先在这里加键**,再去两端用。
|
||||
* **不要**在页面或服务里硬编码角色判断来代替它(AGENTS.md §5 第 3 条)。
|
||||
*/
|
||||
export const PERMISSION_KEYS = [
|
||||
// 班级与人员
|
||||
'class:read',
|
||||
'class:manage',
|
||||
'roster:manage',
|
||||
'teacher:manage',
|
||||
'schedule:read',
|
||||
'schedule:write',
|
||||
// 内容发布
|
||||
'homework:read',
|
||||
'homework:write',
|
||||
'notification:read',
|
||||
'notification:publish',
|
||||
'notification:delete',
|
||||
'call:create',
|
||||
// 成绩与导入
|
||||
'grade:read',
|
||||
'grade:write',
|
||||
'grade:import',
|
||||
'student:grade-query',
|
||||
'import:students',
|
||||
'import:teachers',
|
||||
'import:schedule',
|
||||
'import:class-teachers',
|
||||
// 设备与联动
|
||||
'device:manage',
|
||||
'classisland:notify',
|
||||
'client:read',
|
||||
'client:manage',
|
||||
'client:update-push',
|
||||
'remote:control',
|
||||
// 学期与归档
|
||||
'term:read',
|
||||
'term:write',
|
||||
'archive:manage',
|
||||
'dashboard:read',
|
||||
// 系统与安全
|
||||
'database:manage',
|
||||
'update:check',
|
||||
'update:force',
|
||||
'ai:use',
|
||||
'ai:manage',
|
||||
'permission:manage',
|
||||
'system:manage',
|
||||
'log:read',
|
||||
'developer:mode',
|
||||
] as const;
|
||||
|
||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
|
||||
/** 权限键的中文名(权限组管理页与文档展示,别再各写一份) */
|
||||
export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
||||
'class:read': '查看班级与班级列表',
|
||||
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)',
|
||||
'roster:manage': '学生名单管理(增删 / 调班 / 导入)',
|
||||
'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)',
|
||||
'schedule:read': '查看课表',
|
||||
'schedule:write': '课表与课程编辑',
|
||||
'homework:read': '查看作业',
|
||||
'homework:write': '发布 / 修改 / 删除作业(仍按任教科目录入)',
|
||||
'notification:read': '查看通知',
|
||||
'notification:publish': '发布通知',
|
||||
'notification:delete': '删除通知',
|
||||
'call:create': '叫人',
|
||||
'grade:read': '查看成绩与成绩汇总',
|
||||
'grade:write': '录入 / 修改成绩(仍按任教科目录入)',
|
||||
'grade:import': '导入成绩',
|
||||
'student:grade-query': '按学号查询学生成绩明细',
|
||||
'import:students': '导入学生名单',
|
||||
'import:teachers': '导入教师名单',
|
||||
'import:schedule': '导入课表时间配置',
|
||||
'import:class-teachers': '导入班级任课关系',
|
||||
'device:manage': '联动设备管理(ClassIsland 设备令牌 / 课表上报)',
|
||||
'classisland:notify': '向 ClassIsland 下发提醒',
|
||||
'client:read': '查看 ClassHelper 客户端在线状态与版本',
|
||||
'client:manage': '管理客户端设备(改名 / 停用 / 删除)',
|
||||
'client:update-push': '向客户端统一下发更新',
|
||||
'remote:control': '远控客户端电脑(关机 / 重启 / 注销 / 锁屏)',
|
||||
'term:read': '查看学期周次',
|
||||
'term:write': '修改学期周次与开学日期',
|
||||
'archive:manage': '毕业归档',
|
||||
'dashboard:read': '查看统计看板',
|
||||
'database:manage': '数据库快照 / 导出 / 切库',
|
||||
'update:check': '检查服务端更新',
|
||||
'update:force': '强制检查更新(绕过缓存)',
|
||||
'ai:use': '使用大模型指令(一句话发布)',
|
||||
'ai:manage': '大模型设置(开关 / 服务地址 / 密钥 / 模型)',
|
||||
'permission:manage': '权限组与用户权限管理',
|
||||
'system:manage': '全局系统设置(含开发者模式)',
|
||||
'log:read': '查看服务端日志',
|
||||
'developer:mode': '开发者模式',
|
||||
};
|
||||
|
||||
/** 权限组管理页的分组展示(只影响排序与标题,不改变语义) */
|
||||
export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly PermissionKey[] }[] = [
|
||||
{
|
||||
title: '班级与人员',
|
||||
keys: ['class:read', 'class:manage', 'roster:manage', 'teacher:manage', 'schedule:read', 'schedule:write'],
|
||||
},
|
||||
{
|
||||
title: '内容发布',
|
||||
keys: [
|
||||
'homework:read',
|
||||
'homework:write',
|
||||
'notification:read',
|
||||
'notification:publish',
|
||||
'notification:delete',
|
||||
'call:create',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '成绩与导入',
|
||||
keys: [
|
||||
'grade:read',
|
||||
'grade:write',
|
||||
'grade:import',
|
||||
'student:grade-query',
|
||||
'import:students',
|
||||
'import:teachers',
|
||||
'import:schedule',
|
||||
'import:class-teachers',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '设备与联动',
|
||||
keys: ['device:manage', 'classisland:notify', 'client:read', 'client:manage', 'client:update-push', 'remote:control'],
|
||||
},
|
||||
{ title: '学期与统计', keys: ['term:read', 'term:write', 'archive:manage', 'dashboard:read'] },
|
||||
{
|
||||
title: '系统与安全',
|
||||
keys: [
|
||||
'database:manage',
|
||||
'update:check',
|
||||
'update:force',
|
||||
'ai:use',
|
||||
'ai:manage',
|
||||
'permission:manage',
|
||||
'system:manage',
|
||||
'log:read',
|
||||
'developer:mode',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 每个权限键的**默认角色**(镜像改造前各路由的 `requireRole(...)`,逐条对齐)。
|
||||
*
|
||||
* 这是"没有分过任何权限组"时的回落模板:行为与权限组功能上线前完全一致。
|
||||
* 一旦管理员给某个账号分了组,生效集合就**只由组的并集决定**(可以比默认更少)。
|
||||
*
|
||||
* 注意 `CLASS_DEVICE` 不出现在任何默认里:班级端的能力由班级维度断言
|
||||
* (`canManageSubjectContent(..., { allowClassDevice })` 等)决定,不受权限组影响。
|
||||
*/
|
||||
export const PERMISSION_DEFAULT_ROLES: Record<PermissionKey, readonly UserRole[]> = {
|
||||
'class:read': ['ADMIN', 'TEACHER'],
|
||||
'class:manage': ['ADMIN'],
|
||||
'roster:manage': ['ADMIN', 'TEACHER'],
|
||||
'teacher:manage': ['ADMIN'],
|
||||
'schedule:read': ['ADMIN', 'TEACHER'],
|
||||
'schedule:write': ['ADMIN', 'TEACHER'],
|
||||
'homework:read': ['ADMIN', 'TEACHER'],
|
||||
'homework:write': ['ADMIN', 'TEACHER'],
|
||||
'notification:read': ['ADMIN', 'TEACHER'],
|
||||
'notification:publish': ['ADMIN', 'TEACHER'],
|
||||
'notification:delete': ['ADMIN', 'TEACHER'],
|
||||
'call:create': ['ADMIN', 'TEACHER'],
|
||||
'grade:read': ['ADMIN', 'TEACHER'],
|
||||
'grade:write': ['ADMIN', 'TEACHER'],
|
||||
'grade:import': ['ADMIN', 'TEACHER'],
|
||||
'student:grade-query': ['ADMIN', 'TEACHER'],
|
||||
'import:students': ['ADMIN', 'TEACHER'],
|
||||
'import:teachers': ['ADMIN'],
|
||||
'import:schedule': ['ADMIN', 'TEACHER'],
|
||||
'import:class-teachers': ['ADMIN'],
|
||||
'device:manage': ['ADMIN', 'TEACHER'],
|
||||
'classisland:notify': ['ADMIN', 'TEACHER'],
|
||||
'client:read': ['ADMIN', 'TEACHER'],
|
||||
'client:manage': ['ADMIN'],
|
||||
'client:update-push': ['ADMIN'],
|
||||
'remote:control': ['ADMIN'],
|
||||
'term:read': ['ADMIN', 'TEACHER'],
|
||||
'term:write': ['ADMIN'],
|
||||
'archive:manage': ['ADMIN'],
|
||||
'dashboard:read': ['ADMIN', 'TEACHER'],
|
||||
'database:manage': ['ADMIN'],
|
||||
'update:check': ['ADMIN', 'TEACHER'],
|
||||
'update:force': ['ADMIN'],
|
||||
'ai:use': ['ADMIN', 'TEACHER'],
|
||||
'ai:manage': ['ADMIN'],
|
||||
'permission:manage': ['ADMIN'],
|
||||
'system:manage': ['ADMIN'],
|
||||
'log:read': ['ADMIN'],
|
||||
'developer:mode': ['ADMIN'],
|
||||
};
|
||||
|
||||
/** 全部权限键(管理员的生效集合;也用在校验 zod 的枚举上) */
|
||||
export const ALL_PERMISSION_KEYS: readonly PermissionKey[] = PERMISSION_KEYS;
|
||||
|
||||
/**
|
||||
* 角色默认权限清单(由 {@link PERMISSION_DEFAULT_ROLES} 派生)。
|
||||
*
|
||||
* `ADMIN` 是全集(服务端还会强制补上 `permission:manage` / `system:manage`,防止把自己锁死)。
|
||||
*/
|
||||
export const ROLE_DEFAULT_PERMISSIONS: Record<UserRole, readonly PermissionKey[]> = {
|
||||
ADMIN: PERMISSION_KEYS,
|
||||
TEACHER: PERMISSION_KEYS.filter((key) => PERMISSION_DEFAULT_ROLES[key].includes('TEACHER')),
|
||||
CLASS_DEVICE: [],
|
||||
};
|
||||
|
||||
/** 取某个角色的默认权限集合(无分组时的回落模板) */
|
||||
export function defaultPermissionsForRole(role: UserRole): PermissionKey[] {
|
||||
return [...ROLE_DEFAULT_PERMISSIONS[role]];
|
||||
}
|
||||
|
||||
/** 判断权限集合里是否有某个键(`Set` 或数组都接受) */
|
||||
export function hasPermission(permissions: ReadonlySet<string> | readonly string[], key: PermissionKey): boolean {
|
||||
const list: readonly string[] = Array.isArray(permissions) ? permissions : [...permissions];
|
||||
return list.includes(key);
|
||||
}
|
||||
|
||||
/** 判断是否含任意一个键(服务端 requirePermission 是"全部满足",这里留给页面做"任一"判断) */
|
||||
export function hasAnyPermission(
|
||||
permissions: ReadonlySet<string> | readonly string[],
|
||||
keys: readonly PermissionKey[],
|
||||
): boolean {
|
||||
return keys.some((key) => hasPermission(permissions, key));
|
||||
}
|
||||
|
||||
/** 人类可读的权限矩阵(README / 设置页展示,单一事实来源) */
|
||||
export const PERMISSION_MATRIX: readonly {
|
||||
action: string;
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
* 后端返回值与前端调用方都必须依赖它,避免契约漂移。
|
||||
*/
|
||||
|
||||
// 只导入类型(PermissionKey 由 permissions.ts 的 PERMISSION_KEYS 推导):
|
||||
// 两个文件之间是**类型层面**的相互引用,编译后都会被抹掉,运行时不存在循环依赖。
|
||||
import type { PermissionKey } from './permissions.js';
|
||||
|
||||
/**
|
||||
* 账号角色:管理员 / 教师 / ClassHelper 班级端。
|
||||
*
|
||||
@@ -733,6 +737,13 @@ export interface NotificationDto {
|
||||
priority: NotificationPriority;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
/**
|
||||
* 通知所属日期(YYYY-MM-DD,**不带时区**)。
|
||||
*
|
||||
* 与 createdAt 的区别:createdAt 是"什么时候发的",assignDate 是"这天的通知"。
|
||||
* Web 端与客户端的按天归档、日期筛选都用它(晚间发布不会被 UTC 切片算到第二天)。
|
||||
*/
|
||||
assignDate: string;
|
||||
creator?: ClassTeacherBrief | null;
|
||||
/** 当前用户是否已读 */
|
||||
read?: boolean;
|
||||
@@ -777,6 +788,99 @@ export interface NotificationQueryParams {
|
||||
priority?: NotificationPriority;
|
||||
unreadOnly?: boolean;
|
||||
keyword?: string;
|
||||
/** 只看某一天(YYYY-MM-DD),与 from/to 二选一 */
|
||||
date?: string;
|
||||
/** 日期区间起点(YYYY-MM-DD,含当天) */
|
||||
from?: string;
|
||||
/** 日期区间终点(YYYY-MM-DD,含当天) */
|
||||
to?: string;
|
||||
/** 只看某位发布人的通知 */
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
/** 某一天有通知(用于按天归档的日期列表) */
|
||||
export interface NotificationDaySummary {
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
/** 当天通知条数 */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** GET /api/notifications/days 的返回 */
|
||||
export interface NotificationDaysDto {
|
||||
days: NotificationDaySummary[];
|
||||
}
|
||||
|
||||
/** 通知发布人汇总(按发布人归档时的筛选项) */
|
||||
export interface NotificationCreatorSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
username: string;
|
||||
/** 该发布人在当前筛选范围内的通知条数 */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** GET /api/notifications/creators 的返回 */
|
||||
export interface NotificationCreatorsDto {
|
||||
creators: NotificationCreatorSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发布的单班结果。
|
||||
*
|
||||
* 批量发布**逐班判定**:某个班没有权限、或正在上课而通知是紧急的,都不会让整批失败 ——
|
||||
* 失败的那个班带着错误码与中文原因回到这里,成功的班照常落库并实时推送。
|
||||
*/
|
||||
export interface BulkPublishClassResult {
|
||||
classId: string;
|
||||
ok: boolean;
|
||||
/** 成功时:新建的实体 id(通知 / 作业) */
|
||||
id?: string;
|
||||
/** 失败时:错误码(FORBIDDEN / URGENT_DURING_CLASS / CLASS_NOT_FOUND …) */
|
||||
code?: string;
|
||||
/** 失败时:可直接展示给用户的中文原因 */
|
||||
message?: string;
|
||||
/** 失败时的附加信息(如 URGENT_DURING_CLASS 的当前课程与下一次课) */
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 批量发布结果汇总 */
|
||||
export interface BulkPublishResult {
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
results: BulkPublishClassResult[];
|
||||
}
|
||||
|
||||
/** POST /api/notifications/bulk:一次给多个班级发同一条通知 */
|
||||
export interface BulkCreateNotificationRequest {
|
||||
/** 目标班级 id 列表(1~50 个) */
|
||||
classIds: string[];
|
||||
title: string;
|
||||
content: string;
|
||||
priority?: NotificationPriority;
|
||||
/** 有班级正在上课且优先级为紧急时必须置 true(前端二次确认后重试) */
|
||||
confirmDuringClass?: boolean;
|
||||
}
|
||||
|
||||
/** POST /api/homeworks/bulk:一次给多个班级布置同一份作业 */
|
||||
export interface BulkCreateHomeworkRequest {
|
||||
/** 目标班级 id 列表(1~50 个) */
|
||||
classIds: string[];
|
||||
courseId?: string | null;
|
||||
/**
|
||||
* 按**科目名称**指定科目(批量场景用这个,而不是 courseId)。
|
||||
*
|
||||
* 课程是"班级内"的:同一门「语文」在每个班是不同的 Course 记录,跨班不可能共用一个 courseId。
|
||||
* 传名称后由服务端在每个目标班里查同名课程;某个班没有这门课时**只让那个班失败**
|
||||
* (结果里带「该班级没有…这门课程」),**不会自动建课**。与 courseId 同时传时以本字段为准。
|
||||
*/
|
||||
subjectName?: string | null;
|
||||
title: string;
|
||||
content: string;
|
||||
/** 作业所属日期(YYYY-MM-DD);不传按服务器当天处理 */
|
||||
assignDate?: string | null;
|
||||
attachmentUrl?: string | null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 成绩 */
|
||||
@@ -979,8 +1083,15 @@ export interface IslandState {
|
||||
inClass: boolean;
|
||||
/** 当前课的结束时间(HH:mm),用于"下课后自动弹出" */
|
||||
currentPeriodEnd: string | null;
|
||||
/** 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 */
|
||||
reason: 'new' | 'after-class' | 'urgent' | 'call' | null;
|
||||
/**
|
||||
* 本轮展示原因:新消息 / 下课后补发 / 紧急插播 / 叫人 / 收回中。
|
||||
*
|
||||
* `closing` 是"用户点了知道了 / 标为已读"之后的**收回动画**阶段:状态在同一帧就已经落地
|
||||
* (`active` 清空、`queued` 清空),主进程只是把形态切回胶囊让渲染进程把卡片收回去,
|
||||
* 待 `CLOSE_ANIMATION_MS` 之后再淡出隐藏。它不是跨进程握手,渲染进程也不需要任何回执
|
||||
* (见 AGENTS.md §5 第 34 条)。
|
||||
*/
|
||||
reason: 'new' | 'after-class' | 'urgent' | 'call' | 'closing' | null;
|
||||
/**
|
||||
* 多条通知时是否已点过"展开更多"(展开后显示全部放得下的通知)。
|
||||
* 由主进程持有:卡片高度与窗口包围盒都由它决定,渲染进程只负责照此渲染。
|
||||
@@ -1601,3 +1712,326 @@ export interface UpdateInfo {
|
||||
assets: UpdateAssetDto[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 权限组 */
|
||||
|
||||
/**
|
||||
* 一个权限组:名字 + 描述 + 一组权限键(`PermissionGroupItem`)。
|
||||
*
|
||||
* 生效规则见服务端 `lib/permissions.ts` 的 `loadEffectivePermissions`:
|
||||
* 账号**有分组**时取所属组的键并集(因此能比角色默认更少 ⇒ 可以用来收权);
|
||||
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 requireRole)。
|
||||
*/
|
||||
export interface PermissionGroupDto {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
keys: PermissionKey[];
|
||||
memberCount: number;
|
||||
/** 内置组(种子创建)不可删除,只允许改成员与键 */
|
||||
builtin: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 权限页里的一个成员(管理员/教师账号) */
|
||||
export interface PermissionMemberDto {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
/** 所属权限组;空数组 = 走角色默认模板 */
|
||||
groupIds: string[];
|
||||
/** 生效权限数量(ADMIN 恒为全集;班级端账号恒为 0,见权限页的说明) */
|
||||
permissionCount: number;
|
||||
/** 该账号固定拥有全部权限,不接受分配(管理员) */
|
||||
fixed: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */
|
||||
export interface PermissionCatalogDto {
|
||||
keys: PermissionKey[];
|
||||
labels: Record<PermissionKey, string>;
|
||||
categories: { title: string; keys: PermissionKey[] }[];
|
||||
/** 每个键"默认给哪些角色"(权限页的提示文案用) */
|
||||
defaultRoles: Record<PermissionKey, UserRole[]>;
|
||||
/** 每个角色的默认模板("没有分组时会是什么") */
|
||||
roleDefaults: Record<UserRole, PermissionKey[]>;
|
||||
/** 管理员不可被剥夺的键(前端置灰、服务端也会合并回去) */
|
||||
unremovable: PermissionKey[];
|
||||
}
|
||||
|
||||
export interface CreatePermissionGroupRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
keys?: PermissionKey[];
|
||||
}
|
||||
|
||||
export interface UpdatePermissionGroupRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
keys?: PermissionKey[];
|
||||
}
|
||||
|
||||
/** `PUT /api/permissions/users/:id/groups`:整体替换某账号的权限组 */
|
||||
export interface AssignUserGroupsRequest {
|
||||
groupIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /api/permissions/me`:当前账号的生效权限。
|
||||
*
|
||||
* 两端用它决定"入口显不显示",而**不是**再写一套权限规则(AGENTS.md §5 第 3 条):
|
||||
* 真正的拦截始终在服务端。
|
||||
*/
|
||||
export interface MyPermissionsDto {
|
||||
role: UserRole;
|
||||
permissions: PermissionKey[];
|
||||
/** true = 权限来自权限组;false = 走角色默认模板(未分配任何组) */
|
||||
fromGroups: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------- 客户端设备 / 远控 / 客户端更新 */
|
||||
|
||||
/** 远控命令种类(与 shared 的常量 `CLIENT_COMMAND_KINDS` 一一对应) */
|
||||
export type ClientCommandKind = 'shutdown' | 'restart' | 'logoff' | 'lock';
|
||||
|
||||
/** 一条下发给教室客户端电脑的命令 */
|
||||
export interface ClientCommandDto {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
kind: ClientCommandKind;
|
||||
/** 给人看的说明:动作描述 + 管理员填的备注 */
|
||||
payload: string;
|
||||
createdAt: string;
|
||||
/** 过期时间:客户端拉到时已过期就丢弃并回执「已过期」(避免机器离线几天后突然关机) */
|
||||
expiresAt: string | null;
|
||||
ackedAt: string | null;
|
||||
/** 执行结果说明(未回执为空串) */
|
||||
result: string;
|
||||
}
|
||||
|
||||
/** Web 端「客户端设备」列表里的一台机器 */
|
||||
export interface ClientDeviceDto {
|
||||
id: string;
|
||||
classId: string | null;
|
||||
className: string | null;
|
||||
deviceKey: string;
|
||||
name: string;
|
||||
/** 本机版本(客户端主进程 `app.getVersion()`) */
|
||||
clientVersion: string;
|
||||
platform: string;
|
||||
/** 是否接受服务端统一下发的自动更新 */
|
||||
autoUpdate: boolean;
|
||||
/** 服务端指定的目标版本;`''` = 不指定 */
|
||||
updateTargetVersion: string;
|
||||
/** 已下载待安装的版本;`''` = 无 */
|
||||
pendingVersion: string;
|
||||
lastHeartbeatAt: string | null;
|
||||
lastIp: string;
|
||||
/** 客户端上报的状态说明(如「上课中」「窗口已隐藏」) */
|
||||
stateNote: string;
|
||||
/** 由最后一次心跳与 `CLIENT_ONLINE_WINDOW_MS` 判定 */
|
||||
online: boolean;
|
||||
/** 该设备当前待执行(未回执、未撤销、未过期)的命令;没有则 null */
|
||||
pendingCommand: ClientCommandDto | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
|
||||
export interface ClientHeartbeatRequest {
|
||||
deviceKey: string;
|
||||
name?: string;
|
||||
clientVersion?: string;
|
||||
platform?: string;
|
||||
pendingVersion?: string | null;
|
||||
stateNote?: string;
|
||||
}
|
||||
|
||||
/** 服务端为某个版本缓存好的安装包信息(教室没有外网时也走服务端下载) */
|
||||
export interface ClientUpdateInfoDto {
|
||||
version: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
notes: string;
|
||||
/** 下载路径(**含 `/api` 前缀**;客户端用「服务器地址 + 它」直接下载) */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** `POST /api/clients/heartbeat` 的返回:更新下发与远控命令都在这里带回 */
|
||||
export interface ClientHeartbeatResponse {
|
||||
deviceId: string;
|
||||
serverTime: string;
|
||||
/** 服务端要求升到的版本;null = 不要求 */
|
||||
updateTargetVersion: string | null;
|
||||
/** 目标版本的安装包信息;null = 服务端还没有这份安装包(客户端只能提示手动更新) */
|
||||
update: ClientUpdateInfoDto | null;
|
||||
/** 待执行命令(最早的一条);null = 无 */
|
||||
command: ClientCommandDto | null;
|
||||
}
|
||||
|
||||
/** `POST /api/clients/commands/:id/ack`:客户端执行后的回执 */
|
||||
export interface ClientCommandAckRequest {
|
||||
/** 既是身份也是护栏:班级端令牌全班共用,靠它确认回执来自命令指向的那台机器 */
|
||||
deviceKey: string;
|
||||
ok: boolean;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
/** 一台设备上的操作结果(远控命令 / 更新推送共用) */
|
||||
export interface ClientTargetResult {
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 批量下发结果(远控命令 / 更新推送共用) */
|
||||
export interface ClientPushResult {
|
||||
total: number;
|
||||
succeeded: number;
|
||||
failed: number;
|
||||
results: ClientTargetResult[];
|
||||
}
|
||||
|
||||
/** `POST /api/clients/commands`:给选中的电脑下发远控命令 */
|
||||
export interface CreateClientCommandRequest {
|
||||
deviceIds: string[];
|
||||
kind: ClientCommandKind;
|
||||
/** 备注(会显示在被控机器上的二次确认弹窗里) */
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** `POST /api/clients/updates`:推送更新(按设备、按班级或全部) */
|
||||
export interface PushClientUpdateRequest {
|
||||
deviceIds?: string[];
|
||||
classIds?: string[];
|
||||
all?: boolean;
|
||||
/** 目标版本;省略则用服务端缓存的最新版本 */
|
||||
version?: string | null;
|
||||
/** true = 取消下发(回到"不要求更新"的状态) */
|
||||
clear?: boolean;
|
||||
}
|
||||
|
||||
/** `GET /api/update/client/releases`:服务端已缓存的客户端安装包 */
|
||||
export interface ClientReleaseDto {
|
||||
version: string;
|
||||
fileName: string;
|
||||
size: number;
|
||||
sha256: string;
|
||||
notes: string;
|
||||
/** 文件是否真的在磁盘上(登记的元数据可能因为手工清理而失效) */
|
||||
fileReady: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- 全局设置 / 服务端系统状态 */
|
||||
|
||||
/** `GET/PUT /api/system/settings`:全局设置(目前只有开发者模式,仅 ADMIN 可改) */
|
||||
export interface SystemSettingsDto {
|
||||
/** 开发者模式:错误响应带堆栈、日志降到 debug 级 */
|
||||
developerMode: boolean;
|
||||
}
|
||||
|
||||
/** `GET/PUT /api/system/autostart`:服务端开机自启状态与切换 */
|
||||
export interface AutostartStatusDto {
|
||||
/** 当前形态是否支持切换(开发机/免安装/容器里不支持 —— 避免把开发机的自启项写坏) */
|
||||
supported: boolean;
|
||||
enabled: boolean;
|
||||
/** run-key=Windows 注册表 Run 项 / scheduled-task / systemd / unsupported */
|
||||
mechanism: 'run-key' | 'scheduled-task' | 'systemd' | 'unsupported';
|
||||
/** 给人看的说明:命令、路径,或"为什么不支持" */
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** `GET /api/system/diagnostics`:开发者模式下的运行诊断(**不含任何密钥**) */
|
||||
export interface SystemDiagnosticsDto {
|
||||
version: string;
|
||||
nodeVersion: string;
|
||||
platform: string;
|
||||
uptimeSeconds: number;
|
||||
databaseProvider: string;
|
||||
serverRoot: string;
|
||||
dataDir: string;
|
||||
clientReleaseCount: number;
|
||||
onlineClientCount: number;
|
||||
moduleCount: number;
|
||||
developerMode: boolean;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 大模型助手 */
|
||||
|
||||
/** 大模型服务端实现:openai=真接口 / mock=离线确定性结果(自动化验收用,不联网) */
|
||||
export type AiProvider = 'openai' | 'mock';
|
||||
|
||||
/** `GET/PUT /api/ai/settings`:大模型设置(**API Key 只进不出**) */
|
||||
export interface AiSettingsDto {
|
||||
enabled: boolean;
|
||||
provider: AiProvider;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
/** 是否已配置密钥(界面据此显示"已配置/未配置",永不回传密钥本身) */
|
||||
hasApiKey: boolean;
|
||||
/** 密钥尾 4 位(便于确认换对了,空串表示未配置) */
|
||||
keyHint: string;
|
||||
/** 追加到系统提示词的学校自定义要求 */
|
||||
systemPromptExtra: string;
|
||||
}
|
||||
|
||||
export interface UpdateAiSettingsRequest {
|
||||
enabled?: boolean;
|
||||
provider?: AiProvider;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
/** 传字符串=换成新密钥;传 null=清除;不传=保持不变 */
|
||||
apiKey?: string | null;
|
||||
systemPromptExtra?: string;
|
||||
}
|
||||
|
||||
/** 助手能执行的动作(都是既有发布入口的封装,绝不直接写库) */
|
||||
export type AiActionKind = 'create_homework' | 'create_notification' | 'call_student' | 'unknown';
|
||||
|
||||
/** `POST /api/ai/interpret`:只解析、不落库;确认后由前端调既有端点 */
|
||||
export interface AiInterpretRequest {
|
||||
/** 老师原话,例如「给高一二班布置作业,把卷子写完」 */
|
||||
text: string;
|
||||
/** 有上下文班级时一并给出(助手据此缩小候选) */
|
||||
classId?: string;
|
||||
}
|
||||
|
||||
/** 助手解析出的一次动作(params 用与手动入口**同一份 zod 校验**后再返回) */
|
||||
export interface AiInterpretResult {
|
||||
action: AiActionKind;
|
||||
/** 可直接提交给既有端点的参数(已通过 zod 校验) */
|
||||
params: Record<string, unknown>;
|
||||
/** 给人看的预览(班级名 / 科目名 / 学生名等已解析成中文) */
|
||||
preview: {
|
||||
summary: string;
|
||||
className?: string | null;
|
||||
courseName?: string | null;
|
||||
studentName?: string | null;
|
||||
warnings: string[];
|
||||
};
|
||||
/** 模型自评置信度 0~1(低于 0.5 时界面提示"请仔细核对") */
|
||||
confidence: number;
|
||||
/** 一律为 true:助手的结果必须由老师确认后才落库 */
|
||||
needConfirm: boolean;
|
||||
/** 模型原始回复(仅在开发者模式下回传,便于排查格式问题) */
|
||||
rawReply?: string | null;
|
||||
}
|
||||
|
||||
/** `GET /api/ai/audit`:助手使用审计(谁在什么时候让助手做了什么) */
|
||||
export interface AiAuditDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
prompt: string;
|
||||
intent: string;
|
||||
status: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -524,3 +524,24 @@ export function isNewerVersion(
|
||||
if (!normalizeVersion(latest)) return false;
|
||||
return compareVersions(latest, current) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端安装包的**文件名约定**。
|
||||
*
|
||||
* 与 GitHub Release 上上传的 ASCII 名、以及官网下载页的离线兜底直链
|
||||
* **必须一致**(AGENTS.md §4.4:改名要三处一起改)。
|
||||
*/
|
||||
export function clientReleaseFileName(version: string): string {
|
||||
return `ClassHelper-${normalizeVersion(version)}-x64-client-setup.exe`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端从 GitHub 拉取客户端安装包的直链。
|
||||
*
|
||||
* 服务端替教室机器"代下"再缓存(教室常常没有外网)—— Web 端**不能**直连 GitHub
|
||||
* (CSP 的 connect-src 只有 self / ws / CORS_ORIGIN,见 AGENTS.md §5 第 52 条)。
|
||||
*/
|
||||
export function clientReleaseDownloadUrl(version: string): string {
|
||||
const tag = `v${normalizeVersion(version)}`;
|
||||
return `https://github.com/LoveChengke/ClassHelper/releases/download/${tag}/${clientReleaseFileName(version)}`;
|
||||
}
|
||||
Reference in new issue
Block a user