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/web-admin",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||
"type": "module",
|
||||
|
||||
@@ -58,7 +58,8 @@ async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
export const api = {
|
||||
get: <T>(url: string, params?: Record<string, unknown>): Promise<T> =>
|
||||
request<T>({ method: 'GET', url, params }),
|
||||
post: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'POST', url, data }),
|
||||
post: <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> =>
|
||||
request<T>({ method: 'POST', url, data, ...config }),
|
||||
patch: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PATCH', url, data }),
|
||||
put: <T>(url: string, data?: unknown): Promise<T> => request<T>({ method: 'PUT', url, data }),
|
||||
delete: <T>(url: string): Promise<T> => request<T>({ method: 'DELETE', url }),
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import {
|
||||
API_PATHS,
|
||||
type AiAuditDto,
|
||||
type AiInterpretResult,
|
||||
type AiSettingsDto,
|
||||
type ApiErrorResponse,
|
||||
type ArchivedClassContentDto,
|
||||
type ArchivedYearDetailDto,
|
||||
type ArchivedYearDto,
|
||||
type AssignUserGroupsRequest,
|
||||
type ClassDetailDto,
|
||||
type ClassDto,
|
||||
type AutoTermWeeksRequest,
|
||||
@@ -10,15 +15,25 @@ import {
|
||||
type ClassPlanImportResultDto,
|
||||
type ClassPlanPreviewDto,
|
||||
type ClassStatusDto,
|
||||
type ClientDeviceDto,
|
||||
type ClientPushResult,
|
||||
type ClientReleaseDto,
|
||||
type CourseDto,
|
||||
type CreateArchiveRequest,
|
||||
type CreateClassRequest,
|
||||
type CreateCallRequest,
|
||||
type CreateClassRequest,
|
||||
type CreateClientCommandRequest,
|
||||
type CreateCourseRequest,
|
||||
type CreateGradeRequest,
|
||||
type CreateHomeworkRequest,
|
||||
type BulkCreateHomeworkRequest,
|
||||
type BulkCreateNotificationRequest,
|
||||
type BulkPublishResult,
|
||||
type NotificationCreatorsDto,
|
||||
type NotificationDaysDto,
|
||||
type CreateIntegrationDeviceRequest,
|
||||
type CreateNotificationRequest,
|
||||
type CreatePermissionGroupRequest,
|
||||
type CreateScheduleRequest,
|
||||
type CreateStudentRequest,
|
||||
type DashboardSummary,
|
||||
@@ -38,8 +53,13 @@ import {
|
||||
type IntegrationDeviceTokenDto,
|
||||
type LoginRequest,
|
||||
type LoginResponse,
|
||||
type MyPermissionsDto,
|
||||
type NotificationDto,
|
||||
type PaginatedResult,
|
||||
type PermissionCatalogDto,
|
||||
type PermissionGroupDto,
|
||||
type PermissionMemberDto,
|
||||
type PushClientUpdateRequest,
|
||||
type ScheduleDto,
|
||||
type ScheduleWeekView,
|
||||
type SendClassIslandNotificationRequest,
|
||||
@@ -50,16 +70,20 @@ import {
|
||||
type StudentStatus,
|
||||
type StudentTransferDto,
|
||||
type SubjectTeacherDto,
|
||||
type SystemDiagnosticsDto,
|
||||
type SystemSettingsDto,
|
||||
type TableImportPreview,
|
||||
type TableImportResult,
|
||||
type TermWeeksDto,
|
||||
type TimeLayoutDto,
|
||||
type TimeLayoutImportResult,
|
||||
type TimeLayoutParsePreview,
|
||||
type UpdateAiSettingsRequest,
|
||||
type UpdateClassAccountRequest,
|
||||
type UpdateClassRequest,
|
||||
type UpdateInfo,
|
||||
type UpdateIntegrationDeviceRequest,
|
||||
type UpdatePermissionGroupRequest,
|
||||
type UpdateStudentRequest,
|
||||
type UpdateTermWeeksRequest,
|
||||
type UserDto,
|
||||
@@ -131,6 +155,22 @@ export const courseApi = {
|
||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.courses}/${id}`),
|
||||
};
|
||||
|
||||
/**
|
||||
* 从「同名课程已存在」的失败里取出那门已有课程的 id。
|
||||
*
|
||||
* 服务端对 `POST /courses` 做了 (班级 + 科目名) 去重(返回 409 `COURSE_EXISTS` +
|
||||
* `details.courseId`),因为课程创建会把创建者写成任课老师 —— 若放行同名第二门课,
|
||||
* 谁都能借"新建科目"把自己变成别人的科目的老师,绕过科目维度的写权限。
|
||||
* 前端拿到这个 id 就**复用既有课程**:权限判定随即回到"你是不是这门课的老师",
|
||||
* 该 403 的地方就 403(而不是悄悄多出一门归属自己的同名课)。
|
||||
*/
|
||||
export function courseIdFromExistsError(error: unknown): string | null {
|
||||
const data = (error as { response?: { data?: ApiErrorResponse } })?.response?.data;
|
||||
if (!data || data.code !== 'COURSE_EXISTS') return null;
|
||||
const details = data.details as { courseId?: unknown } | null | undefined;
|
||||
return typeof details?.courseId === 'string' ? details.courseId : null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 课表 */
|
||||
|
||||
export const scheduleApi = {
|
||||
@@ -163,6 +203,9 @@ export const homeworkApi = {
|
||||
api.get(`${API_PATHS.homeworks}/days`, params),
|
||||
detail: (id: string): Promise<HomeworkDto> => api.get(`${API_PATHS.homeworks}/${id}`),
|
||||
create: (payload: CreateHomeworkRequest): Promise<HomeworkDto> => api.post(API_PATHS.homeworks, payload),
|
||||
/** 一次给多个班级布置同一份作业(服务端逐班判定并返回逐班结果) */
|
||||
bulkCreate: (payload: BulkCreateHomeworkRequest): Promise<BulkPublishResult> =>
|
||||
api.post(`${API_PATHS.homeworks}/bulk`, payload),
|
||||
update: (id: string, payload: Partial<CreateHomeworkRequest>): Promise<HomeworkDto> =>
|
||||
api.patch(`${API_PATHS.homeworks}/${id}`, payload),
|
||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.homeworks}/${id}`),
|
||||
@@ -181,9 +224,35 @@ export const notificationApi = {
|
||||
priority?: string;
|
||||
unreadOnly?: boolean;
|
||||
keyword?: string;
|
||||
/** 只看某一天(YYYY-MM-DD,按通知所属日期 assignDate) */
|
||||
date?: string;
|
||||
/** 日期区间(含边界) */
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** 只看某位发布人 */
|
||||
createdBy?: string;
|
||||
}): Promise<NotificationDto[]> => api.get(API_PATHS.notifications, params),
|
||||
/** 按天归档:有通知的日期 + 当天条数 */
|
||||
days: (params: {
|
||||
classId?: string;
|
||||
priority?: string;
|
||||
createdBy?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}): Promise<NotificationDaysDto> => api.get(`${API_PATHS.notifications}/days`, params),
|
||||
/** 按发布人归档:发布人 + 各人的通知条数 */
|
||||
creators: (params: {
|
||||
classId?: string;
|
||||
priority?: string;
|
||||
date?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}): Promise<NotificationCreatorsDto> => api.get(`${API_PATHS.notifications}/creators`, params),
|
||||
create: (payload: CreateNotificationRequest): Promise<NotificationDto> =>
|
||||
api.post(API_PATHS.notifications, payload),
|
||||
/** 一次给多个班级发同一条通知(服务端逐班判定并返回逐班结果) */
|
||||
bulkCreate: (payload: BulkCreateNotificationRequest): Promise<BulkPublishResult> =>
|
||||
api.post(`${API_PATHS.notifications}/bulk`, payload),
|
||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.notifications}/${id}`),
|
||||
unreadCount: (classId?: string): Promise<{ count: number }> =>
|
||||
api.get(`${API_PATHS.notifications}/unread-count`, classId ? { classId } : undefined),
|
||||
@@ -504,3 +573,151 @@ export const updateApi = {
|
||||
check: (force = false): Promise<UpdateInfo> =>
|
||||
api.get(`${API_PATHS.update}/check${force ? '?force=1' : ''}`),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 权限组与用户权限 */
|
||||
|
||||
/**
|
||||
* 权限组管理(服务端 `modules/permissions`)。
|
||||
*
|
||||
* 权限生效规则:**有权限组时取各组的并集**(因此也能用来收回权限),
|
||||
* 没有任何组时回落到角色默认模板;管理员固定拥有全集,班级端不受权限组影响。
|
||||
*
|
||||
* 除 `me` 之外,本组接口都要求当前账号有 `permission:manage`(否则服务端 403)。
|
||||
*/
|
||||
export const permissionApi = {
|
||||
/** 当前账号的生效权限(任何登录账号都能读自己这一份) */
|
||||
me: (): Promise<MyPermissionsDto> => api.get(`${API_PATHS.permissions}/me`),
|
||||
/** 权限目录:全部键 / 中文名 / 分类 / 各键的默认角色 / 管理员不可剥夺的键 */
|
||||
catalog: (): Promise<PermissionCatalogDto> => api.get(`${API_PATHS.permissions}/catalog`),
|
||||
listGroups: (): Promise<PermissionGroupDto[]> => api.get(`${API_PATHS.permissions}/groups`),
|
||||
createGroup: (payload: CreatePermissionGroupRequest): Promise<PermissionGroupDto> =>
|
||||
api.post(`${API_PATHS.permissions}/groups`, payload),
|
||||
/** `keys` 传了就**整体替换**该组的权限键(不传则保持原样) */
|
||||
updateGroup: (id: string, payload: UpdatePermissionGroupRequest): Promise<PermissionGroupDto> =>
|
||||
api.patch(`${API_PATHS.permissions}/groups/${id}`, payload),
|
||||
/** 内置权限组不能删除(服务端会 400),调用前页面已经禁用了按钮 */
|
||||
removeGroup: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.permissions}/groups/${id}`),
|
||||
/** 可分配权限的成员(管理员与教师;管理员是固定行,不能分配) */
|
||||
listMembers: (): Promise<PermissionMemberDto[]> => api.get(`${API_PATHS.permissions}/users`),
|
||||
/** 分配权限组:传空数组 = 回到角色默认模板 */
|
||||
assignGroups: (userId: string, payload: AssignUserGroupsRequest): Promise<PermissionMemberDto> =>
|
||||
api.put(`${API_PATHS.permissions}/users/${userId}/groups`, payload),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 客户端设备(远控与版本管理) */
|
||||
|
||||
/**
|
||||
* 「客户端设备」页的接口。
|
||||
*
|
||||
* 权限(与后端 `clients.module.ts` 逐个对齐):
|
||||
* - `list` / `remove`:`client:manage`
|
||||
* - `commands`:`remote:control`(关机 / 重启 / 注销 / 锁屏,机器上还会再弹一次确认框)
|
||||
* - `pushUpdate`:`client:update-push`
|
||||
*/
|
||||
export const clientApi = {
|
||||
/** 设备列表(自动心跳上报产生的记录);`classId` 留空 = 全部班级 */
|
||||
devices: (classId?: string): Promise<ClientDeviceDto[]> =>
|
||||
api.get(`${API_PATHS.clients}/devices`, classId ? { classId } : undefined),
|
||||
/**
|
||||
* 下发远控命令。
|
||||
*
|
||||
* 同一台机器上未执行的旧命令会被服务端**自动撤销**(否则"先关机、后重启"会排队执行)。
|
||||
*/
|
||||
commands: (payload: CreateClientCommandRequest): Promise<ClientPushResult> =>
|
||||
api.post(`${API_PATHS.clients}/commands`, payload),
|
||||
/** 统一下发客户端更新:按设备 / 按班级 / 全部,或 `clear: true` 取消下发 */
|
||||
pushUpdate: (payload: PushClientUpdateRequest): Promise<ClientPushResult> =>
|
||||
api.post(`${API_PATHS.clients}/updates`, payload),
|
||||
/** 移除一台设备记录(换机 / 旧机器清理;只删记录,不影响那台机器本身) */
|
||||
removeDevice: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.clients}/devices/${id}`),
|
||||
};
|
||||
|
||||
/** GitHub 代下与「登记 → 校验 sha256」都可能要几十秒到几分钟,够长才不至于半路超时 */
|
||||
const CLIENT_RELEASE_FETCH_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 客户端安装包缓存管理(服务端 `modules/update/client-releases.service.ts`)。
|
||||
*
|
||||
* 教室机器常常没有外网,所以安装包由**服务端替教室下载**再缓存:
|
||||
* Web 端不直连 GitHub(CSP 的 connect-src 只放行 self 与 WebSocket,见 AGENTS.md §5 第 52 条)。
|
||||
*
|
||||
* 权限:`list` 要 `client:manage`,`fetch` / `register` / `remove` 要 `client:update-push`。
|
||||
*/
|
||||
export const clientReleaseApi = {
|
||||
/** 服务端已缓存的安装包列表 */
|
||||
list: (): Promise<ClientReleaseDto[]> => api.get(`${API_PATHS.update}/client/releases`),
|
||||
/**
|
||||
* 让服务端从 GitHub 下载指定版本并登记。
|
||||
*
|
||||
* ⚠️ **必须显式放宽超时**:axios 实例默认 20 秒,而服务端自己允许下载 10 分钟
|
||||
* (`client-releases.service.ts` 的 `FETCH_TIMEOUT_MS`)。用默认值会在服务端还在下载时
|
||||
* 就报"请求超时",用户会以为失败而重复点。这里与服务端取同一个上限。
|
||||
*/
|
||||
fetch: (version: string): Promise<ClientReleaseDto> =>
|
||||
api.post(
|
||||
`${API_PATHS.update}/client/fetch`,
|
||||
{ version },
|
||||
{ timeout: CLIENT_RELEASE_FETCH_TIMEOUT_MS },
|
||||
),
|
||||
/** 登记一份已经手工放进 `data/client-releases/<版本>/` 的安装包 */
|
||||
register: (payload: { version: string; fileName?: string; notes?: string }): Promise<ClientReleaseDto> =>
|
||||
api.post(`${API_PATHS.update}/client/releases`, payload),
|
||||
/** 删除缓存(元数据 + 磁盘文件) */
|
||||
remove: (version: string): Promise<{ version: string }> =>
|
||||
api.delete(`${API_PATHS.update}/client/releases/${encodeURIComponent(version)}`),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 大模型助手 */
|
||||
|
||||
/**
|
||||
* 大模型助手的接口前缀(取自共享契约 `API_PATHS.ai`,与服务端 `modules/ai` 的挂载点同源)。
|
||||
*/
|
||||
const AI_BASE = API_PATHS.ai;
|
||||
|
||||
/**
|
||||
* 大模型助手(服务端 `modules/ai`)。
|
||||
*
|
||||
* 权限:`settings` / `audit` 要 `ai:manage`(仅管理员);`interpret` 要 `ai:use`(教师也有)。
|
||||
*
|
||||
* ⚠️ **助手只解析、不落库**:`interpret` 只是把一句中文换成结构化参数,
|
||||
* 真正写数据仍然由前端在老师确认后调既有的发布接口(作业 / 通知 / 叫人)。
|
||||
*/
|
||||
export const aiApi = {
|
||||
/**
|
||||
* 读取配置。
|
||||
*
|
||||
* 只有 `ai:manage` 能读(教师读会 403,而响应拦截器会弹一个错误提示),
|
||||
* 所以助手页**不要**无条件调它——教师侧靠"探测式":直接尝试解析,
|
||||
* 服务端回了 `AI_NOT_CONFIGURED` 再切空态。
|
||||
*/
|
||||
settings: (): Promise<AiSettingsDto> => api.get(`${AI_BASE}/settings`),
|
||||
/**
|
||||
* 保存配置。
|
||||
*
|
||||
* `apiKey` 的三种语义(服务端约定):传字符串 = 换新、传 `null` = 清除、**不传 = 保持原样**。
|
||||
* 因此"留空表示不修改"时不要往 body 里塞 `apiKey: ''`,那会被当成一个空密钥。
|
||||
*/
|
||||
updateSettings: (payload: UpdateAiSettingsRequest): Promise<AiSettingsDto> =>
|
||||
api.put(`${AI_BASE}/settings`, payload),
|
||||
/** 把一句话解析成待确认的操作;`text` 上限 500 字(超出由服务端 400) */
|
||||
interpret: (payload: { text: string; classId?: string }): Promise<AiInterpretResult> =>
|
||||
api.post(`${AI_BASE}/interpret`, payload),
|
||||
/** 使用记录(谁、什么时候、说了什么、解析成什么、结果如何),倒序 */
|
||||
audit: (limit = 50): Promise<AiAuditDto[]> => api.get(`${AI_BASE}/audit`, { limit }),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 系统设置与诊断 */
|
||||
|
||||
/**
|
||||
* 系统设置与诊断(服务端 `modules/system`),全部要 `system:manage`(仅管理员)。
|
||||
*/
|
||||
const SYSTEM_BASE = API_PATHS.system;
|
||||
|
||||
export const systemApi = {
|
||||
settings: (): Promise<SystemSettingsDto> => api.get(`${SYSTEM_BASE}/settings`),
|
||||
/** 目前只有 `developerMode` 一项;保存后服务端返回完整设置 */
|
||||
updateSettings: (payload: Partial<SystemSettingsDto>): Promise<SystemSettingsDto> =>
|
||||
api.put(`${SYSTEM_BASE}/settings`, payload),
|
||||
/** 只读诊断信息(版本 / Node / 平台 / 运行时长 / 数据库 / 目录 / 模块数) */
|
||||
diagnostics: (): Promise<SystemDiagnosticsDto> => api.get(`${SYSTEM_BASE}/diagnostics`),
|
||||
};
|
||||
@@ -7,11 +7,15 @@ import {
|
||||
Connection,
|
||||
CopyDocument,
|
||||
Document,
|
||||
Expand,
|
||||
Fold,
|
||||
FolderAdd,
|
||||
FolderOpened,
|
||||
Guide,
|
||||
Lock,
|
||||
MagicStick,
|
||||
Menu,
|
||||
Monitor,
|
||||
Moon,
|
||||
Notebook,
|
||||
Odometer,
|
||||
@@ -20,6 +24,7 @@ import {
|
||||
Right,
|
||||
School,
|
||||
Search,
|
||||
Setting,
|
||||
Sunny,
|
||||
Sort,
|
||||
Switch,
|
||||
@@ -29,6 +34,7 @@ import {
|
||||
User,
|
||||
UserFilled,
|
||||
WarningFilled,
|
||||
ZoomIn,
|
||||
} from '@element-plus/icons-vue';
|
||||
|
||||
/**
|
||||
@@ -54,11 +60,17 @@ const APP_ICONS = {
|
||||
Connection,
|
||||
CopyDocument,
|
||||
Document,
|
||||
/* 侧栏折叠/展开(顶栏汉堡按钮),与桌面客户端用同一对图标 */
|
||||
Expand,
|
||||
Fold,
|
||||
FolderAdd,
|
||||
FolderOpened,
|
||||
Guide,
|
||||
Lock,
|
||||
/* 大模型助手入口 */
|
||||
MagicStick,
|
||||
Menu,
|
||||
Monitor,
|
||||
Moon,
|
||||
Notebook,
|
||||
Odometer,
|
||||
@@ -67,6 +79,7 @@ const APP_ICONS = {
|
||||
Right,
|
||||
School,
|
||||
Search,
|
||||
Setting,
|
||||
Sunny,
|
||||
Sort,
|
||||
Switch,
|
||||
@@ -76,6 +89,8 @@ const APP_ICONS = {
|
||||
User,
|
||||
UserFilled,
|
||||
WarningFilled,
|
||||
/* 关怀模式(放大字号与控件) */
|
||||
ZoomIn,
|
||||
} as const;
|
||||
|
||||
/** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { STORAGE_KEYS } from '@classhelper/shared';
|
||||
import { STORAGE_KEYS, type PermissionKey, type UserRole } from '@classhelper/shared';
|
||||
import { authApi } from '@/api';
|
||||
import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { useUiStore, CARE_SCALE_DEFAULT, CARE_SCALE_MAX, CARE_SCALE_MIN } from '@/stores/ui';
|
||||
import { useResponsive } from '@/composables/useResponsive';
|
||||
import { useThemeReveal } from '@/composables/motion';
|
||||
import { APP_TITLE } from '@/config';
|
||||
@@ -32,6 +32,49 @@ function handleThemeToggle(event: MouseEvent): void {
|
||||
revealThemeChange(event, () => ui.toggleTheme());
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 侧栏折叠 */
|
||||
|
||||
/**
|
||||
* 顶栏左侧的汉堡按钮(仅桌面端;≤768px 侧栏已经收进抽屉,用的是另一个 `nav-toggle`)。
|
||||
*
|
||||
* 它**只改一个布尔值**:栏宽、文字的淡出、图标位置、行高、滚动条全都交给
|
||||
* `styles/index.css` 里 `.ch-nav.is-collapsed …` 那一段(那里有完整推导)——
|
||||
* 模板里没有任何 `v-if` 增删菜单项,折叠才是连续的收拢动画而不是"重排一下"。
|
||||
*
|
||||
* 缓动刻意与桌面客户端同一套参数(`--nav-dur` / `--nav-ease`,AGENTS.md §5 第 49 条):
|
||||
* 这里不要换成 EASE_OUT 之类的"起步快"曲线 —— 两端观感要一致,且自动化会量到中间态。
|
||||
*/
|
||||
const collapseLabel = computed(() => (ui.sidebarCollapsed ? '展开菜单' : '收起菜单'));
|
||||
|
||||
/* ---------------------------------------------------------------- 关怀模式 */
|
||||
|
||||
/**
|
||||
* 关怀模式的入口放在顶栏的 `el-popover` 里,**不放进用户下拉菜单**:
|
||||
* 下拉菜单点一下(甚至点里面的滑块)就会收起,滑块根本拖不动。
|
||||
* 气泡里的开关与滑块都不是"点一下就走"的控件,放在气泡里正好。
|
||||
*/
|
||||
/** 放大倍数按百分比显示(1.2 → 120%),比裸小数好读 */
|
||||
const careScalePercent = computed(() => `${Math.round(ui.careScale * 100)}%`);
|
||||
|
||||
/** el-switch 的 payload 是 `boolean | string | number` 的联合(active-value 可自定义),这里收口 */
|
||||
function handleCareModeChange(value: boolean | string | number): void {
|
||||
ui.setCareMode(value === true);
|
||||
}
|
||||
|
||||
/** el-slider 单值模式发 `number`、范围模式发 `number[]`,这里只接单值 */
|
||||
function handleCareScaleChange(value: number | number[]): void {
|
||||
ui.setCareScale(Array.isArray(value) ? (value[0] ?? CARE_SCALE_DEFAULT) : value);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- AI 助手入口 */
|
||||
|
||||
/** 顶栏的 AI 助手快捷入口:与侧栏菜单项同一道**生效权限**门控(默认管理员与教师都有) */
|
||||
const canUseAssistant = computed(() => auth.can('ai:use'));
|
||||
|
||||
function openAssistant(): void {
|
||||
void router.push('/assistant');
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
|
||||
* - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
|
||||
@@ -41,8 +84,20 @@ function handleThemeToggle(event: MouseEvent): void {
|
||||
* - 作业 / 通知:所有教师
|
||||
* - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮)
|
||||
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
|
||||
* - 权限组管理 / 客户端设备:`roles` 只是粗筛(管理员 + 教师),真正的门控是**生效权限**
|
||||
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
|
||||
* (见 `visibleMenuItems`)
|
||||
*/
|
||||
const menuItems = [
|
||||
interface MenuItem {
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
roles: UserRole[];
|
||||
/** 可选的**生效权限**门控:有值时还要 `auth.can()` 通过才显示(角色判不出来这一层) */
|
||||
permission?: PermissionKey;
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] },
|
||||
{ path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] },
|
||||
@@ -51,13 +106,57 @@ const menuItems = [
|
||||
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] },
|
||||
{
|
||||
path: '/assistant',
|
||||
title: 'AI 助手',
|
||||
icon: 'MagicStick',
|
||||
// `ai:use` 默认给 ADMIN + TEACHER(教师用一句话发布作业/通知/叫人),
|
||||
// 但仍走**生效权限**细筛:管理员把 ai:use 从某个权限组收回后这里就该消失。
|
||||
roles: ['ADMIN', 'TEACHER'],
|
||||
permission: 'ai:use',
|
||||
},
|
||||
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
{
|
||||
path: '/permissions',
|
||||
title: '权限组',
|
||||
icon: 'Lock',
|
||||
roles: ['ADMIN', 'TEACHER'],
|
||||
permission: 'permission:manage',
|
||||
},
|
||||
{
|
||||
path: '/clients',
|
||||
title: '客户端设备',
|
||||
icon: 'Monitor',
|
||||
// 与「权限组」同样按**生效权限**门控:教师默认没有 client:manage(见 shared/permissions.ts
|
||||
// 的默认模板只给 ADMIN),所以这里不能只写 roles —— 被权限组授予后教师也该看见。
|
||||
roles: ['ADMIN', 'TEACHER'],
|
||||
permission: 'client:manage',
|
||||
},
|
||||
{
|
||||
path: '/ai-settings',
|
||||
title: '大模型设置',
|
||||
icon: 'Setting',
|
||||
// 与「权限组」「客户端设备」同一道门控:只有 ai:manage(默认仅管理员)才看得见,
|
||||
// 页面里的配置读写也要求它(教师访问会 403)。
|
||||
roles: ['ADMIN', 'TEACHER'],
|
||||
permission: 'ai:manage',
|
||||
},
|
||||
{ path: '/database', title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
|
||||
];
|
||||
|
||||
/** 当前账号可见的菜单(班级/学生管理仅管理员可见) */
|
||||
/**
|
||||
* 当前账号可见的菜单。
|
||||
*
|
||||
* 两道筛子:`roles` 是粗筛(角色级,与后端 `requireRole` 对齐),`permission` 是细筛
|
||||
* (生效权限级)——「权限组」必须走后者:教师被权限组授予 `permission:manage` 后
|
||||
* 角色还是 TEACHER,靠 `roles` 判断不出来。管理员在 `can()` 里短路为 true,
|
||||
* 所以这里不会因为权限还没拉回来而少显示一项。
|
||||
*/
|
||||
const visibleMenuItems = computed(() =>
|
||||
menuItems.filter((item) => (auth.role ? item.roles.includes(auth.role) : false)),
|
||||
menuItems.filter((item) => {
|
||||
if (!auth.role || !item.roles.includes(auth.role)) return false;
|
||||
return !item.permission || auth.can(item.permission);
|
||||
}),
|
||||
);
|
||||
|
||||
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
|
||||
@@ -96,25 +195,53 @@ function asideElement(): HTMLElement | null {
|
||||
}
|
||||
|
||||
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
|
||||
/** 指示器的裁剪框 = 菜单**视口**(滚动容器自身),滚动时激活项滚出去就跟着被裁掉 */
|
||||
const indicatorClip = ref({ top: 0, left: 0, width: 0, height: 0 });
|
||||
|
||||
/** 菜单滚动容器:桌面侧栏里的 .el-menu(小屏是抽屉里的另一个 el-menu,没有指示器) */
|
||||
function menuScroller(): HTMLElement | null {
|
||||
return asideElement()?.querySelector<HTMLElement>('.layout-menu') ?? null;
|
||||
}
|
||||
|
||||
function syncIndicator(): void {
|
||||
const aside = asideElement();
|
||||
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
|
||||
if (!aside || !active) {
|
||||
const scroller = menuScroller();
|
||||
const active = scroller?.querySelector<HTMLElement>('.el-menu-item.is-active');
|
||||
if (!aside || !scroller || !active) {
|
||||
indicator.value = { ...indicator.value, visible: false };
|
||||
return;
|
||||
}
|
||||
const asideRect = aside.getBoundingClientRect();
|
||||
const box = scroller.getBoundingClientRect();
|
||||
/*
|
||||
* 以菜单视口为基准量,而不是以整个侧栏为基准。
|
||||
*
|
||||
* 菜单列是可滚动的(见 .layout-menu 的 overflow):量侧栏的话,激活项被滚出视口后
|
||||
* 指示器会"贴"到品牌区/页脚上。裁剪框跟着菜单视口,指示器就只在菜单里可见。
|
||||
*/
|
||||
indicatorClip.value = {
|
||||
top: box.top - asideRect.top,
|
||||
left: box.left - asideRect.left,
|
||||
width: box.width,
|
||||
height: box.height,
|
||||
};
|
||||
const rect = active.getBoundingClientRect();
|
||||
indicator.value = {
|
||||
x: rect.left - asideRect.left,
|
||||
y: rect.top - asideRect.top,
|
||||
x: rect.left - box.left,
|
||||
y: rect.top - box.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
};
|
||||
}
|
||||
|
||||
const indicatorClipStyle = computed(() => ({
|
||||
top: `${indicatorClip.value.top}px`,
|
||||
left: `${indicatorClip.value.left}px`,
|
||||
width: `${indicatorClip.value.width}px`,
|
||||
height: `${indicatorClip.value.height}px`,
|
||||
}));
|
||||
|
||||
const indicatorStyle = computed(() => ({
|
||||
width: `${indicator.value.width}px`,
|
||||
height: `${indicator.value.height}px`,
|
||||
@@ -124,6 +251,11 @@ const indicatorStyle = computed(() => ({
|
||||
|
||||
let asideObserver: ResizeObserver | null = null;
|
||||
let indicatorFrame = 0;
|
||||
let scrollerEl: HTMLElement | null = null;
|
||||
let scrollSettleTimer = 0;
|
||||
/** 正在跟随滚动:这期间关掉过渡,否则每帧都重新起一段 397ms 的弹簧,看起来"慢吞吞地追" */
|
||||
const indicatorFollowing = ref(false);
|
||||
const SCROLL_SETTLE_MS = 140;
|
||||
|
||||
/**
|
||||
* 把测量推迟到下一帧再跑。
|
||||
@@ -141,13 +273,39 @@ function scheduleIndicatorSync(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动菜单时**同步**量一次(不走 rAF 版):滚动本身已经在合成器上跑,
|
||||
* 再隔一帧读数会看到指示器明显落后于菜单。
|
||||
*/
|
||||
function handleMenuScroll(): void {
|
||||
syncIndicator();
|
||||
indicatorFollowing.value = true;
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
indicatorFollowing.value = false;
|
||||
}, SCROLL_SETTLE_MS);
|
||||
}
|
||||
|
||||
/** 绑定/解绑菜单滚动监听(小屏没有侧栏,切回来时要重新绑) */
|
||||
function bindMenuScroller(): void {
|
||||
const next = menuScroller();
|
||||
if (next === scrollerEl) return;
|
||||
scrollerEl?.removeEventListener('scroll', handleMenuScroll);
|
||||
scrollerEl = next;
|
||||
scrollerEl?.addEventListener('scroll', handleMenuScroll, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次测量放在 onMounted(而不是 nextTick):子组件先于父组件挂载完成,
|
||||
* 此刻 `.el-menu-item.is-active` 已在文档里,量完再让浏览器首帧绘制 ——
|
||||
* 于是指示器**第一次出现就是正确位置**,不会从左上角滑过去。
|
||||
*/
|
||||
onMounted(() => {
|
||||
// 生效权限(`GET /permissions/me`):菜单里的「权限组」要等它回来才显示。
|
||||
// 失败不阻塞布局(store 内已静默吞掉),管理员在 can() 里短路、不受这一拍影响。
|
||||
void auth.ensurePermissions();
|
||||
syncIndicator();
|
||||
bindMenuScroller();
|
||||
const aside = asideElement();
|
||||
if (aside && typeof ResizeObserver !== 'undefined') {
|
||||
asideObserver = new ResizeObserver(scheduleIndicatorSync);
|
||||
@@ -158,6 +316,9 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
|
||||
indicatorFrame = 0;
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
scrollerEl?.removeEventListener('scroll', handleMenuScroll);
|
||||
scrollerEl = null;
|
||||
asideObserver?.disconnect();
|
||||
asideObserver = null;
|
||||
});
|
||||
@@ -182,6 +343,11 @@ watch(
|
||||
|
||||
watch(isMobile, (mobile) => {
|
||||
if (!mobile) drawerVisible.value = false;
|
||||
// 侧栏是按 v-if 挂载/卸载的:从抽屉切回常驻侧栏后,新的 .el-menu 需要重新绑滚动监听
|
||||
void nextTick(() => {
|
||||
syncIndicator();
|
||||
bindMenuScroller();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -351,12 +517,13 @@ const tourSteps = computed(() => [
|
||||
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<!-- 桌面/平板:常驻深色侧边栏 -->
|
||||
<!-- 桌面/平板:常驻深色侧边栏(折叠成 64px 图标导轨,见样式里的 .ch-nav.is-collapsed) -->
|
||||
<el-aside
|
||||
v-if="!isMobile"
|
||||
ref="asideRef"
|
||||
width="210px"
|
||||
class="layout-aside ch-sidebar"
|
||||
:width="ui.sidebarCollapsed ? 'var(--ch-aside-collapsed-width)' : 'var(--ch-aside-width)'"
|
||||
class="layout-aside ch-sidebar ch-nav"
|
||||
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
|
||||
data-tour="side-nav"
|
||||
>
|
||||
<div class="brand">
|
||||
@@ -364,10 +531,22 @@ const tourSteps = computed(() => [
|
||||
<span class="brand-text">ClassHelper</span>
|
||||
</div>
|
||||
<!-- 激活项的滑动指示器:单个药丸在菜单项之间平移,位置由 syncIndicator() 量出来。
|
||||
外层裁剪框贴着**菜单视口**(菜单列可滚动,激活项滚出去时指示器要一起被裁掉),
|
||||
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
|
||||
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
|
||||
<div class="nav-indicator-clip" :style="indicatorClipStyle" aria-hidden="true">
|
||||
<div
|
||||
class="nav-indicator"
|
||||
:class="{ 'is-following': indicatorFollowing }"
|
||||
:style="indicatorStyle"
|
||||
/>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
|
||||
<el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path">
|
||||
<el-menu-item
|
||||
v-for="item in visibleMenuItems"
|
||||
:key="item.path"
|
||||
:index="item.path"
|
||||
:title="ui.sidebarCollapsed ? item.title : undefined"
|
||||
>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
@@ -389,6 +568,26 @@ const tourSteps = computed(() => [
|
||||
>
|
||||
<el-icon :size="20"><Menu /></el-icon>
|
||||
</button>
|
||||
<!--
|
||||
桌面:同一个位置的汉堡按钮负责把侧栏收成图标导轨。
|
||||
图标方向跟着**当前状态**走(展开时给「收起」、折叠时给「展开」),
|
||||
文案同步进 aria-label 与 title,鼠标悬停时能看出这一下会做什么。
|
||||
-->
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="nav-toggle"
|
||||
:aria-label="collapseLabel"
|
||||
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
|
||||
:title="collapseLabel"
|
||||
data-test="nav-collapse-toggle"
|
||||
@click="ui.toggleSidebar()"
|
||||
>
|
||||
<el-icon :size="20">
|
||||
<Expand v-if="ui.sidebarCollapsed" />
|
||||
<Fold v-else />
|
||||
</el-icon>
|
||||
</button>
|
||||
<span class="header-title">{{ isMobile ? currentTitle : APP_TITLE }}</span>
|
||||
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light" data-tour="conn-tag">
|
||||
<span class="header-conn">{{ connectionText }}</span>
|
||||
@@ -409,6 +608,76 @@ const tourSteps = computed(() => [
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!--
|
||||
AI 助手快捷入口:与侧栏「AI 助手」菜单项同一道门控。
|
||||
关怀模式打开时挂 .care-emphasis(字号写在 styles/index.css 的关怀模式块里,
|
||||
这里不写死数值)—— 这就是需求里"关怀模式下助手入口更大更醒目"那条。
|
||||
-->
|
||||
<el-tooltip
|
||||
v-if="canUseAssistant"
|
||||
content="AI 助手:一句话发布作业 / 通知 / 叫人"
|
||||
placement="bottom"
|
||||
>
|
||||
<el-button
|
||||
class="assistant-entry"
|
||||
:class="{ 'care-emphasis': ui.careMode }"
|
||||
text
|
||||
circle
|
||||
aria-label="打开 AI 助手"
|
||||
data-test="assistant-entry"
|
||||
@click="openAssistant"
|
||||
>
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<!--
|
||||
关怀模式。入口刻意放在 el-popover 而不是用户下拉菜单里:
|
||||
下拉菜单点一下(甚至点里面的滑块)就会收起,滑块根本拖不动(见脚本里的注释)。
|
||||
-->
|
||||
<el-popover placement="bottom-end" :width="264" trigger="click" popper-class="care-popover">
|
||||
<template #reference>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-toggle care-toggle"
|
||||
:class="{ 'is-on': ui.careMode }"
|
||||
:aria-pressed="ui.careMode ? 'true' : 'false'"
|
||||
aria-label="关怀模式"
|
||||
title="关怀模式"
|
||||
data-test="care-toggle"
|
||||
>
|
||||
<el-icon :size="18"><ZoomIn /></el-icon>
|
||||
</button>
|
||||
</template>
|
||||
<div class="care-panel">
|
||||
<div class="care-panel-row">
|
||||
<span class="care-panel-title">关怀模式</span>
|
||||
<el-switch
|
||||
:model-value="ui.careMode"
|
||||
aria-label="关怀模式"
|
||||
@update:model-value="handleCareModeChange"
|
||||
/>
|
||||
</div>
|
||||
<p class="care-panel-desc">放大正文字号与控件,方便不习惯默认字号的老师。</p>
|
||||
<div class="care-panel-row">
|
||||
<span class="care-panel-label">放大倍数</span>
|
||||
<span class="care-panel-value">{{ careScalePercent }}</span>
|
||||
</div>
|
||||
<el-slider
|
||||
:model-value="ui.careScale"
|
||||
:min="CARE_SCALE_MIN"
|
||||
:max="CARE_SCALE_MAX"
|
||||
:step="0.05"
|
||||
:disabled="!ui.careMode"
|
||||
aria-label="放大倍数"
|
||||
@update:model-value="handleCareScaleChange"
|
||||
/>
|
||||
<p class="care-panel-desc">
|
||||
100% 是原始大小,最大 150%。关掉关怀模式后字号立即恢复,倍数会记住。
|
||||
</p>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
<!--
|
||||
主题切换。与桌面客户端同一套(圆形揭示由 useThemeReveal 提供)。
|
||||
注意 `.header-right` 必须是 flex 容器:Element Plus 给 `.el-button` 的是
|
||||
@@ -559,6 +828,12 @@ const tourSteps = computed(() => [
|
||||
flex-direction: column;
|
||||
/* 导航指示器(.nav-indicator)的定位父级 */
|
||||
position: relative;
|
||||
/*
|
||||
* 折叠只过渡**栏宽**:宽度是唯一在变的东西,其余(文字淡出、图标位置、行高)
|
||||
* 都在 styles/index.css 的 .ch-nav.is-collapsed 段里各自声明过渡。
|
||||
* 时长/缓动用 --nav-dur / --nav-ease —— 与桌面客户端同一套参数,改一处两端同步。
|
||||
*/
|
||||
transition: width var(--nav-dur) var(--nav-ease);
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -590,11 +865,37 @@ const tourSteps = computed(() => [
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
/*
|
||||
* 菜单列**自己滚动**(这是"小屏状态下菜单无法上下滑动"的修法):
|
||||
* - `min-height: 0` 是关键:flex 项默认 `min-height: auto`,永远不小于内容高度,
|
||||
* 于是 overflow 永不生效、超出的菜单项被直接裁掉;
|
||||
* - 小屏抽屉里 `.el-drawer__body` 是 `overflow: hidden`,滚动只能由这一列承担,
|
||||
* 否则抽屉一关一开就只剩上半截菜单能点。
|
||||
* 滚动条做细一点、半透明:深色侧栏上一条系统浅色滚动条太扎眼。
|
||||
*/
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
--el-menu-text-color: #cbd5e1;
|
||||
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
|
||||
--el-menu-active-color: #ffffff;
|
||||
}
|
||||
|
||||
.layout-menu::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.layout-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.layout-menu::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/*
|
||||
* 激活项的底色改由导航指示器承担(见 .nav-indicator)。
|
||||
*
|
||||
@@ -614,6 +915,17 @@ const tourSteps = computed(() => [
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/*
|
||||
* 导航指示器的裁剪框:贴着菜单视口(.layout-menu 的 border box)。
|
||||
* 菜单列可以滚动,激活项滚出视口后指示器必须跟着被裁掉,否则它会画到品牌区/页脚上。
|
||||
*/
|
||||
.nav-indicator-clip {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 导航指示器:一个跟着激活项平移的药丸。宽高与位移由 syncIndicator() 量出来后内联下发,
|
||||
* 这里只负责外观与过渡。
|
||||
@@ -625,7 +937,6 @@ const tourSteps = computed(() => [
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
border-radius: var(--ch-radius-sm);
|
||||
background: #409eff;
|
||||
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
|
||||
@@ -638,6 +949,11 @@ const tourSteps = computed(() => [
|
||||
opacity var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
/* 滚动跟随期间关掉过渡:每帧重起一段 397ms 弹簧会变成"慢吞吞地追" */
|
||||
.nav-indicator.is-following {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
/* 顶栏跟随圆角设计:底部圆角 + 轻投影,与内容区一体化 */
|
||||
border-radius: 0 0 var(--ch-radius-lg) var(--ch-radius-lg);
|
||||
@@ -704,6 +1020,37 @@ const tourSteps = computed(() => [
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
/*
|
||||
* 关怀模式按钮:关掉时与主题按钮同款(安静的文字色),开启时用主色 + 淡底标出"正在生效"。
|
||||
* 纯图标按钮必须靠这点颜色区分状态,否则用户看不出自己刚点开过什么。
|
||||
*/
|
||||
.care-toggle {
|
||||
color: var(--ch-text-muted);
|
||||
}
|
||||
|
||||
.care-toggle:hover {
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
.care-toggle.is-on {
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
/* 悬停时同样要保持"已开启"的底色(.nav-toggle:hover 的特异度更低,不会覆盖到这里) */
|
||||
.care-toggle.is-on:hover {
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
/* AI 助手快捷入口:与主题按钮同款文字色;关怀模式下由全局的 .care-emphasis 放大字号 */
|
||||
.assistant-entry {
|
||||
color: var(--ch-text-muted);
|
||||
}
|
||||
|
||||
.assistant-entry:hover {
|
||||
color: var(--ch-text);
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -759,4 +1106,53 @@ const tourSteps = computed(() => [
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/*
|
||||
* 关怀模式气泡的排版。
|
||||
*
|
||||
* 放在**全局块**里是必需的:el-popover 的内容被 teleport 到 body 上,
|
||||
* 组件级 scoped 样式(带 data-v 属性选择器)一个都选不中它 —— 与上面 .ch-sidebar 同一个理由。
|
||||
* 字号一律引用 --el-font-size-* 且圆角/投影走 --ch-* 令牌:关怀模式打开时这一块自己也会跟着放大,
|
||||
* 不需要在这里再写一份 calc。
|
||||
*/
|
||||
.care-popover {
|
||||
border-radius: var(--ch-radius-md);
|
||||
box-shadow: var(--ch-shadow-md);
|
||||
}
|
||||
|
||||
.care-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.care-panel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.care-panel-title {
|
||||
font-size: var(--el-font-size-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.care-panel-label {
|
||||
color: var(--ch-text-secondary);
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
.care-panel-value {
|
||||
color: var(--el-color-primary);
|
||||
font-size: var(--el-font-size-small);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.care-panel-desc {
|
||||
margin: 0;
|
||||
color: var(--ch-text-muted);
|
||||
font-size: var(--el-font-size-extra-small);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import { STORAGE_KEYS } from '@classhelper/shared';
|
||||
import { STORAGE_KEYS, type PermissionKey } from '@classhelper/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -75,6 +76,14 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/GradesView.vue'),
|
||||
meta: { title: '成绩录入', icon: 'Trophy' },
|
||||
},
|
||||
{
|
||||
path: 'assistant',
|
||||
name: 'assistant',
|
||||
component: () => import('@/views/AssistantView.vue'),
|
||||
// 按**生效权限**门控(教师默认就有 ai:use,见 shared/permissions.ts 的默认模板):
|
||||
// 管理员把 ai:use 从某个权限组收回后,这一页同时从菜单与路由上消失
|
||||
meta: { title: 'AI 助手', icon: 'MagicStick', permission: 'ai:use' },
|
||||
},
|
||||
{
|
||||
path: 'integrations',
|
||||
name: 'integrations',
|
||||
@@ -82,6 +91,13 @@ const routes: RouteRecordRaw[] = [
|
||||
// 与后端 requireRole('ADMIN','TEACHER') 对齐:这一页含"班级端在线状态",教师要看自己的班
|
||||
meta: { title: 'ClassHelper 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
},
|
||||
{
|
||||
path: 'ai-settings',
|
||||
name: 'ai-settings',
|
||||
component: () => import('@/views/AiSettingsView.vue'),
|
||||
// 与后端 requirePermission('ai:manage') 对齐:这一页能改密钥与开关,默认仅管理员
|
||||
meta: { title: '大模型设置', icon: 'Setting', permission: 'ai:manage' },
|
||||
},
|
||||
{
|
||||
path: 'database',
|
||||
name: 'database',
|
||||
@@ -89,6 +105,24 @@ const routes: RouteRecordRaw[] = [
|
||||
// 与后端 requireRole('ADMIN') 对齐:改动数据库是最高危操作,仅管理员
|
||||
meta: { title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
|
||||
},
|
||||
{
|
||||
path: 'permissions',
|
||||
name: 'permissions',
|
||||
component: () => import('@/views/PermissionsView.vue'),
|
||||
// 这一页**按生效权限**门控而不是按角色:教师被某个权限组授予 permission:manage 后同样可用,
|
||||
// 与后端 requirePermission('permission:manage') 对齐(详见下方守卫)
|
||||
meta: { title: '权限组管理', icon: 'Lock', permission: 'permission:manage' },
|
||||
},
|
||||
{
|
||||
path: 'clients',
|
||||
name: 'clients',
|
||||
component: () => import('@/views/ClientsView.vue'),
|
||||
// 这一页同样**按生效权限**门控:远控关机、统一下发更新都是高危操作,
|
||||
// 后端三个接口分别要 client:manage / client:update-push / remote:control,
|
||||
// 但教师默认三个都没有(见 shared/permissions.ts 的默认模板),所以这里用
|
||||
// 最基础的 client:manage 当入口门禁,页面内的按钮再各自按权限显示/禁用。
|
||||
meta: { title: '客户端设备', icon: 'Monitor', permission: 'client:manage' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -116,7 +150,7 @@ function storedRole(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.token);
|
||||
if (to.meta.public) {
|
||||
if (to.name === 'login' && token) return { name: 'dashboard' };
|
||||
@@ -134,6 +168,15 @@ router.beforeEach((to) => {
|
||||
return { name: 'dashboard' };
|
||||
}
|
||||
}
|
||||
// 按**生效权限**门控的页面(权限组管理):角色判不出来 —— 教师被权限组授予
|
||||
// permission:manage 后同样该放行,所以这里异步取一次生效权限。
|
||||
// `ensurePermissions()` 在 store 内是幂等的(本次会话只真拉一次),不会每次跳转都打接口。
|
||||
const permission = to.meta.permission;
|
||||
if (typeof permission === 'string') {
|
||||
const auth = useAuthStore();
|
||||
await auth.ensurePermissions();
|
||||
if (!auth.can(permission as PermissionKey)) return { name: 'dashboard' };
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ROLE_LABELS, STORAGE_KEYS, type LoginRequest, type LoginResponse, type SessionUser } from '@classhelper/shared';
|
||||
import { authApi } from '@/api';
|
||||
import {
|
||||
ROLE_LABELS,
|
||||
STORAGE_KEYS,
|
||||
hasPermission,
|
||||
type LoginRequest,
|
||||
type LoginResponse,
|
||||
type PermissionKey,
|
||||
type SessionUser,
|
||||
} from '@classhelper/shared';
|
||||
import { authApi, permissionApi } from '@/api';
|
||||
|
||||
/**
|
||||
* 会话主体。
|
||||
@@ -25,6 +33,18 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<SessionUser | null>(readStoredUser());
|
||||
const loading = ref(false);
|
||||
|
||||
/**
|
||||
* 当前账号的**生效权限**(`GET /api/permissions/me`)。
|
||||
*
|
||||
* 只用来决定"入口显不显示",**不是**另一套权限规则(AGENTS.md §5 第 3 条)——
|
||||
* 真正的拦截始终在服务端。页面刷新后这里是空的,由 `AdminLayout` 挂载时调
|
||||
* {@link ensurePermissions} 补上。
|
||||
*/
|
||||
const permissions = ref<PermissionKey[]>([]);
|
||||
/** 生效权限是否来自权限组(false = 没有任何组,走角色默认模板) */
|
||||
const permissionsFromGroups = ref(false);
|
||||
const permissionsLoaded = ref(false);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value));
|
||||
const role = computed(() => user.value?.role ?? null);
|
||||
const isStaff = computed(() => role.value === 'ADMIN' || role.value === 'TEACHER');
|
||||
@@ -32,6 +52,44 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const displayName = computed(() => user.value?.name ?? '未登录');
|
||||
const roleLabel = computed(() => (role.value ? ROLE_LABELS[role.value] : '未登录'));
|
||||
|
||||
/**
|
||||
* 是否有某个生效权限。
|
||||
*
|
||||
* 管理员**短路为 true**:服务端 `loadEffectivePermissions` 对 ADMIN 直接给全集,
|
||||
* 而权限是异步拉的 —— 不短路的话,管理员刚进页面时侧栏会先缺几项再补上(可见闪烁),
|
||||
* 且 `/permissions/me` 偶发失败会把管理员挡在本来就有的功能之外。
|
||||
*/
|
||||
function can(key: PermissionKey): boolean {
|
||||
if (isAdmin.value) return true;
|
||||
return hasPermission(permissions.value, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取生效权限(幂等,`force` 用于权限改完之后刷新)。
|
||||
*
|
||||
* 失败**不抛出**:拿不到权限时 `can()` 保持 false(即"少显示几个入口"),
|
||||
* 不要因此卡住路由或让整页渲染不出来;错误提示由 http.ts 的响应拦截器负责。
|
||||
*/
|
||||
async function ensurePermissions(force = false): Promise<void> {
|
||||
if (!token.value) return;
|
||||
if (permissionsLoaded.value && !force) return;
|
||||
try {
|
||||
const mine = await permissionApi.me();
|
||||
permissions.value = mine.permissions;
|
||||
permissionsFromGroups.value = mine.fromGroups;
|
||||
permissionsLoaded.value = true;
|
||||
} catch {
|
||||
// 见上方注释:静默失败,保持"未加载"(下次调用还会重试)
|
||||
}
|
||||
}
|
||||
|
||||
/** 清掉权限缓存(换账号 / 退出登录时必须调用,否则会沿用上一个账号的权限) */
|
||||
function resetPermissions(): void {
|
||||
permissions.value = [];
|
||||
permissionsFromGroups.value = false;
|
||||
permissionsLoaded.value = false;
|
||||
}
|
||||
|
||||
function persist(): void {
|
||||
if (token.value) localStorage.setItem(STORAGE_KEYS.token, token.value);
|
||||
else localStorage.removeItem(STORAGE_KEYS.token);
|
||||
@@ -40,6 +98,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
function setSession(payload: LoginResponse): void {
|
||||
resetPermissions();
|
||||
token.value = payload.token;
|
||||
user.value = { ...payload.user };
|
||||
persist();
|
||||
@@ -73,6 +132,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
function clear(): void {
|
||||
token.value = null;
|
||||
user.value = null;
|
||||
resetPermissions();
|
||||
persist();
|
||||
}
|
||||
|
||||
@@ -80,12 +140,17 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
token,
|
||||
user,
|
||||
loading,
|
||||
permissions,
|
||||
permissionsFromGroups,
|
||||
permissionsLoaded,
|
||||
isAuthenticated,
|
||||
isStaff,
|
||||
isAdmin,
|
||||
role,
|
||||
displayName,
|
||||
roleLabel,
|
||||
can,
|
||||
ensurePermissions,
|
||||
login,
|
||||
logout,
|
||||
fetchProfile,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
/**
|
||||
* 界面偏好(当前只有主题)。
|
||||
* 界面偏好(主题 / 侧栏折叠 / 关怀模式)。
|
||||
*
|
||||
* 机制与桌面客户端(`desktop-client/src/renderer/stores/ui.ts`)保持一套:
|
||||
* 深色主题 = 在 `<html>` 上挂 `.dark` 类,由 `styles/index.css` 的 `html.dark` 块
|
||||
@@ -11,6 +11,9 @@ import { defineStore } from 'pinia';
|
||||
* **没有选过时跟随系统**,且系统切换时会实时跟上;一旦用户手动点过切换按钮,
|
||||
* 就以用户的选择为准,不再被系统改回去(这是主题开关的通行语义:
|
||||
* 系统的偏好是默认值,不是命令)。
|
||||
*
|
||||
* 三份偏好都存 localStorage;折叠与关怀模式**必须在 `app.mount()` 之前恢复**,
|
||||
* 否则首帧会先按默认值画一遍再跳变(深色主题"先白后黑"是同一个道理)。
|
||||
*/
|
||||
|
||||
export type AdminTheme = 'light' | 'dark';
|
||||
@@ -18,6 +21,24 @@ export type AdminTheme = 'light' | 'dark';
|
||||
const THEME_KEY = 'classhelper.admin.theme';
|
||||
const DARK_QUERY = '(prefers-color-scheme: dark)';
|
||||
|
||||
/** 侧栏折叠(图标导轨) */
|
||||
const SIDEBAR_KEY = 'classhelper.admin.sidebarCollapsed';
|
||||
/** 关怀模式开关 */
|
||||
const CARE_KEY = 'classhelper.admin.careMode';
|
||||
/** 关怀模式的放大倍数 */
|
||||
const CARE_SCALE_KEY = 'classhelper.admin.careScale';
|
||||
|
||||
/** 关怀模式放大倍数的可调范围(界面上的滑块与这里必须一致) */
|
||||
export const CARE_SCALE_MIN = 1;
|
||||
export const CARE_SCALE_MAX = 1.5;
|
||||
/** 打开关怀模式时的默认倍数:14px → 16.8px,够大又不会撑破表格 */
|
||||
export const CARE_SCALE_DEFAULT = 1.2;
|
||||
|
||||
function clampCareScale(value: number): number {
|
||||
if (!Number.isFinite(value)) return CARE_SCALE_DEFAULT;
|
||||
return Math.min(CARE_SCALE_MAX, Math.max(CARE_SCALE_MIN, Math.round(value * 100) / 100));
|
||||
}
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||
return window.matchMedia(DARK_QUERY).matches;
|
||||
@@ -49,15 +70,61 @@ function readStored(): AdminTheme | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** 读一个布尔偏好(localStorage 不可用时一律按"没选过"处理) */
|
||||
function readFlag(key: string): boolean {
|
||||
try {
|
||||
return localStorage.getItem(key) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeFlag(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, value ? '1' : '0');
|
||||
} catch {
|
||||
// 隐私模式下写不进去:本次会话内仍然生效
|
||||
}
|
||||
}
|
||||
|
||||
function readScale(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(CARE_SCALE_KEY);
|
||||
return raw === null ? CARE_SCALE_DEFAULT : clampCareScale(Number(raw));
|
||||
} catch {
|
||||
return CARE_SCALE_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把关怀模式落到 DOM:一个类名 + 一个倍数。
|
||||
*
|
||||
* 类名(`html.care-mode`)是 `styles/index.css` 里**唯一**的放大块开关;
|
||||
* 倍数写进 `--ch-font-scale`,那块里的所有 `calc(基准 * var(--ch-font-scale))` 都读它。
|
||||
* 关掉时倍数复位成 1(而不是删掉变量)——变量留着,`calc()` 才不会解析失败。
|
||||
*/
|
||||
function applyCareMode(enabled: boolean, scale: number): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('care-mode', enabled);
|
||||
root.style.setProperty('--ch-font-scale', String(enabled ? clampCareScale(scale) : 1));
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const theme = ref<AdminTheme>('light');
|
||||
/** 侧栏折叠成图标导轨(桌面端;≤768px 走抽屉,不看这个值) */
|
||||
const sidebarCollapsed = ref(false);
|
||||
/** 关怀模式:放大正文字号与控件 */
|
||||
const careMode = ref(false);
|
||||
/** 关怀模式的放大倍数(1.0~1.5) */
|
||||
const careScale = ref(CARE_SCALE_DEFAULT);
|
||||
|
||||
/** 用户是否显式选过。没选过时系统偏好说了算,且会实时跟随 */
|
||||
let explicit = false;
|
||||
|
||||
/**
|
||||
* 在 `app.mount()` **之前**调用:主题类要在首帧之前挂好,
|
||||
* 否则深色用户会看到"先白后黑"闪一下(与客户端同一条要求)。
|
||||
* 在 `app.mount()` **之前**调用:主题类、折叠状态、关怀模式都要在首帧之前落好,
|
||||
* 否则深色用户会看到"先白后黑"、折叠用户会看到侧栏先展开再收回(与客户端同一条要求)。
|
||||
*/
|
||||
function init(): void {
|
||||
const stored = readStored();
|
||||
@@ -65,6 +132,14 @@ export const useUiStore = defineStore('ui', () => {
|
||||
theme.value = stored ?? (systemPrefersDark() ? 'dark' : 'light');
|
||||
applyTheme(theme.value);
|
||||
|
||||
// 折叠状态只需要恢复 ref:`el-aside` 的宽度是绑在 ref 上的,首帧就是折好的宽度
|
||||
sidebarCollapsed.value = readFlag(SIDEBAR_KEY);
|
||||
|
||||
// 关怀模式要落到 DOM(html 的类名 + 倍数变量),否则还会按默认字号画一帧
|
||||
careScale.value = readScale();
|
||||
careMode.value = readFlag(CARE_KEY);
|
||||
applyCareMode(careMode.value, careScale.value);
|
||||
|
||||
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||
const query = window.matchMedia(DARK_QUERY);
|
||||
const onChange = (): void => {
|
||||
@@ -92,5 +167,48 @@ export const useUiStore = defineStore('ui', () => {
|
||||
setTheme(theme.value === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
return { theme, init, setTheme, toggleTheme };
|
||||
/** 折叠/展开侧栏(顶栏汉堡按钮),写回 localStorage */
|
||||
function setSidebarCollapsed(collapsed: boolean): void {
|
||||
sidebarCollapsed.value = collapsed;
|
||||
writeFlag(SIDEBAR_KEY, collapsed);
|
||||
}
|
||||
|
||||
function toggleSidebar(): void {
|
||||
setSidebarCollapsed(!sidebarCollapsed.value);
|
||||
}
|
||||
|
||||
/** 关怀模式开关(顶栏「关怀模式」气泡里的滑块与开关共用) */
|
||||
function setCareMode(enabled: boolean): void {
|
||||
careMode.value = enabled;
|
||||
writeFlag(CARE_KEY, enabled);
|
||||
applyCareMode(enabled, careScale.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整放大倍数。倍数在**关掉关怀模式时也记下来** ——
|
||||
* 下次打开还要用上次调好的那档,否则每次都要重新拖一遍。
|
||||
*/
|
||||
function setCareScale(scale: number): void {
|
||||
careScale.value = clampCareScale(scale);
|
||||
try {
|
||||
localStorage.setItem(CARE_SCALE_KEY, String(careScale.value));
|
||||
} catch {
|
||||
// 同上:写不进去只影响下次启动
|
||||
}
|
||||
applyCareMode(careMode.value, careScale.value);
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
sidebarCollapsed,
|
||||
careMode,
|
||||
careScale,
|
||||
init,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
setSidebarCollapsed,
|
||||
toggleSidebar,
|
||||
setCareMode,
|
||||
setCareScale,
|
||||
};
|
||||
});
|
||||
@@ -13,6 +13,29 @@
|
||||
/* 1Panel 风格:固定顶栏 + 深色侧栏 */
|
||||
--ch-header-height: 56px;
|
||||
--ch-aside-width: 210px;
|
||||
/* 折叠态侧栏宽(图标导轨)。与展开态并排放在令牌里 —— 几何只在这个文件里定义一次 */
|
||||
--ch-aside-collapsed-width: 64px;
|
||||
|
||||
/*
|
||||
* 侧栏收放动画。组件(AdminLayout 的 .layout-aside/.brand)与全局的折叠导轨规则
|
||||
* (下面的 .ch-nav.is-collapsed …)共用这一份参数 —— 改时长/缓动只改这里,两边不会漂。
|
||||
*
|
||||
* ⚠️ 缓动**刻意保留 Material 的 cubic-bezier(.4, 0, .2, 1)**,没有换成全站统一的
|
||||
* beUI EASE_OUT:EASE_OUT 起步极快,栏宽掉到 80px 时动画才走了约 35%,
|
||||
* 此刻内边距还在动,自动化量到的图标中心会偏 2px 以上而判红(桌面客户端实测踩过)。
|
||||
* 想换曲线就得连"折叠几何"的断言一起重新商量。
|
||||
*/
|
||||
--nav-dur: 260ms;
|
||||
--nav-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* 图标与文字的间距(折叠时收到 0) */
|
||||
--nav-gap: 8px;
|
||||
|
||||
/*
|
||||
* 关怀模式的放大倍数:1 = 默认,界面上可调 1.0~1.5。
|
||||
* 只有**一个**来源 —— `html.care-mode { … }` 那一块用它做 calc 的乘数,
|
||||
* stores/ui.ts 在 mount 之前把它写成 <html> 的内联变量。
|
||||
*/
|
||||
--ch-font-scale: 1;
|
||||
|
||||
/*
|
||||
* 语义色 —— 组件里**一律引用这些,不要写死颜色**(深色主题靠 html.dark 覆盖这里)。
|
||||
@@ -69,6 +92,193 @@ html.dark {
|
||||
--el-box-shadow-light: var(--ch-shadow-sm);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 关怀模式(需求 14):放大正文字号与常用控件,方便不习惯默认字号的老师
|
||||
*
|
||||
* 三条纪律:
|
||||
* 1. **只放大字号与控件高度**。几何令牌(--ch-radius-* / --ch-shadow-* /
|
||||
* --ch-aside-width / --nav-*)一律不动 —— 放大圆角会让卡片与弹窗看起来"变形",
|
||||
* 放大侧栏宽会让折叠动画的几何全部重算;
|
||||
* 2. 倍数只有一个来源 `--ch-font-scale`(:root 里定义,stores/ui.ts 在 mount 之前
|
||||
* 写入 <html> 的内联变量)。想"再大一点"就调这个倍数,不要在组件里另写一份 calc;
|
||||
* 3. Element Plus 的字号与控件高度全部由它自己的变量拼出来
|
||||
* (`--el-font-size-*` / `--el-component-size*`),覆盖变量等于一次性放大全站组件;
|
||||
* 项目自己写死的那几个 px 字号(页面标题等)在下面一并给 calc。
|
||||
*
|
||||
* 这一块**只依赖 --ch-font-scale,不依赖主题** —— 深浅色模式下都是同一套几何与字号。
|
||||
* ========================================================================== */
|
||||
html.care-mode {
|
||||
/* 文字:正文基准 + 各档字号(Element Plus 的组件字号全部由这几个变量派生) */
|
||||
--el-font-size-extra-large: calc(20px * var(--ch-font-scale));
|
||||
--el-font-size-large: calc(18px * var(--ch-font-scale));
|
||||
--el-font-size-medium: calc(16px * var(--ch-font-scale));
|
||||
--el-font-size-base: calc(14px * var(--ch-font-scale));
|
||||
--el-font-size-small: calc(13px * var(--ch-font-scale));
|
||||
--el-font-size-extra-small: calc(12px * var(--ch-font-scale));
|
||||
|
||||
/* 控件高度:按钮 / 输入框 / 下拉 / 日期选择器 / 分页…(大字配矮控件会挤在一起) */
|
||||
--el-component-size-large: calc(40px * var(--ch-font-scale));
|
||||
--el-component-size: calc(32px * var(--ch-font-scale));
|
||||
--el-component-size-small: calc(24px * var(--ch-font-scale));
|
||||
|
||||
/* 正文字号:body 没写 font-size,浏览器默认 16px —— 这里跟着倍数走 */
|
||||
font-size: calc(16px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
/* 项目自己写死的字号(不引用 --el-* 的那几个),跟着倍数放大 */
|
||||
html.care-mode .page-title {
|
||||
font-size: calc(18px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
html.care-mode .page-subtitle,
|
||||
html.care-mode .stat-label {
|
||||
font-size: calc(13px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
html.care-mode .stat-value {
|
||||
font-size: calc(26px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
html.care-mode .stat-hint,
|
||||
html.care-mode .timetable-meta {
|
||||
font-size: calc(12px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
/*
|
||||
* 助手入口(顶栏按钮 + 侧栏菜单项)在关怀模式下更醒目:
|
||||
* 文案放大 1.3 倍并加粗 —— 关怀模式的用户正是"看小字吃力"的那批人,
|
||||
* 助手这个新入口不该比别处更难找。
|
||||
*/
|
||||
html.care-mode .care-emphasis {
|
||||
font-size: calc(var(--el-font-size-base) * 1.3);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 侧栏导航:折叠态是"图标导轨"(AGENTS.md §5 第 49 条)
|
||||
*
|
||||
* 折叠**不用** el-menu 的 `:collapse` 属性:那个 prop 会把每个分组的子项卸载进浮层
|
||||
* (一次结构切换)并让整条菜单重排一遍 —— 既做不出连续形变,也是"点汉堡卡一下"的根因。
|
||||
* 现在折叠只做四件事:栏宽(.layout-aside 的 width)、文字(min-width:0 让 flex 压得动 +
|
||||
* overflow:hidden 裁切 + opacity 淡出)、图标位置(padding 过渡)、行高(导轨要放得下)。
|
||||
* **全程没有结构变化、没有 v-if 增删**。
|
||||
*
|
||||
* 几何(64px 栏、菜单项左右各 8px margin):
|
||||
* 展开 菜单项 padding-left 20px(Element Plus 自带)→ 图标中心 8 + 20 + 12 = 40px
|
||||
* 折叠 菜单项 padding 左右各 12px → 图标中心 8 + 12 + 12 = 32px = 64/2,正好在栏中线上
|
||||
* ========================================================================== */
|
||||
|
||||
/* 图标与文字的间距改由文字的 margin-left 负责:Element Plus 给图标挂了 margin-right: 5px,
|
||||
那会让图标在 24px 的槽里偏左 2.5px(折叠后肉眼可见地不居中)。 */
|
||||
.ch-nav .el-menu-item > [class^='el-icon'] {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.ch-nav .el-menu-item {
|
||||
transition:
|
||||
padding-left var(--nav-dur) var(--nav-ease),
|
||||
padding-right var(--nav-dur) var(--nav-ease),
|
||||
height var(--nav-dur) var(--nav-ease),
|
||||
line-height var(--nav-dur) var(--nav-ease),
|
||||
background-color var(--el-transition-duration),
|
||||
color var(--el-transition-duration);
|
||||
}
|
||||
|
||||
/*
|
||||
* 文字:**不给它单独设动画终点**,宽度靠 flex 被压缩 —— `min-width: 0` 让这个 flex 子项
|
||||
* 能被压到 0(默认的 `min-width: auto` 会撑住内容宽度),多出来的字由 `overflow: hidden` 裁掉。
|
||||
* 于是文字宽度天然跟着栏宽连续变化,不需要写什么魔法 max-width;再叠一层 opacity 淡出,
|
||||
* 避免中间帧留下"半截字"(`display: none` 没法过渡,用不了)。
|
||||
*/
|
||||
.ch-nav .el-menu-item > span {
|
||||
min-width: 0;
|
||||
margin-left: var(--nav-gap);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
margin-left var(--nav-dur) var(--nav-ease),
|
||||
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
|
||||
}
|
||||
|
||||
/* 折叠态的判据是"看得见"而不是"有没有被布局":文字仍在布局里,只是被裁掉且全透明 */
|
||||
.ch-nav.is-collapsed .el-menu-item > span {
|
||||
margin-left: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* 折叠后的内边距与行高。
|
||||
*
|
||||
* 这两条选择器的形状是**照抄 Element Plus 的**,不是冗余 —— 它按下标层级用不同的选择器算缩进
|
||||
* (一级项 20px、分组标题 20px、分组内子项 40px),特异度分别是 0-4-0 / 0-4-0 / 0-5-0:
|
||||
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item`
|
||||
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu__title`
|
||||
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu .el-menu-item`
|
||||
* 写成 `.ch-nav.is-collapsed .el-sub-menu__title` 这种(0-3-0)一条都压不住 —— 桌面客户端踩过两次
|
||||
* (第一版漏了分组标题、第二版漏了一级项,都偏 8.0px)。下面两条(0-5-0 / 0-6-0)把三种形状全覆盖,
|
||||
* 且不用 `!important`。Web 管理端目前没有 el-sub-menu,这里仍然写全:以后加设置分组时不用再踩一次。
|
||||
*
|
||||
* 行高 36px + 上下各 1px 外边距 = 每项 38px(展开态是 Element Plus 默认的 56px)。
|
||||
* **只在折叠态生效**:导轨要"一眼看全" —— Web 端目前 14 个菜单项,
|
||||
* 14 × 38 = 532px,在 1366×768 笔记本上那个约 540px 的菜单视口里刚好放得下
|
||||
* (1366×768 扣掉浏览器框、顶栏 56px、品牌区 56px)。展开态不动 —— 那是原有的观感。
|
||||
* 这里的 `margin` 必须一起写:`.layout-menu :deep(.el-menu-item)` 的 `margin: 2px 8px`
|
||||
* 是 AdminLayout 的 scoped 规则(0-3-0),本段是 0-5-0,靠特异度压住它。
|
||||
*/
|
||||
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-menu-item,
|
||||
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu__title {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
margin: 1px 8px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu .el-menu-item {
|
||||
height: 36px;
|
||||
line-height: 36px;
|
||||
margin: 1px 8px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 导轨里**不出现滚动条**:图标要一眼看全(用户要求"不要用滚轮、删掉滚动条")。
|
||||
* 只隐藏滚动条本身,`overflow-y: auto` 保留 —— 窗口被拉到极矮时还能滚,不至于把图标直接裁掉。
|
||||
* 展开态**不隐藏**:菜单确实会超高,滚动条是"下面还有内容"的唯一提示,不能拿掉。
|
||||
*/
|
||||
.ch-nav.is-collapsed .layout-menu {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.ch-nav.is-collapsed .layout-menu::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* 品牌文字同样靠"能被压扁 + 裁切 + 淡出"收掉,不写任何魔法 max-width:
|
||||
* `min-width: 0` 是必需的(flex 子项默认 `min-width: auto`,不归零就会撑住内容宽度)。
|
||||
*/
|
||||
.ch-nav .brand-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
transition: opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
|
||||
}
|
||||
|
||||
/* 品牌区在折叠态收成图标一枚:收内边距 + 收掉 gap + 文字淡出(**不是** v-if 删节点) */
|
||||
.ch-nav.is-collapsed .brand {
|
||||
gap: 0;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.ch-nav.is-collapsed .brand-text {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 圆角设计:
|
||||
|
||||
统一"大圆角 + 轻阴影 + 无硬边框"的观感:
|
||||
@@ -937,3 +1147,15 @@ body {
|
||||
::view-transition-new(root) {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------- 多行确认框(ElMessageBox)
|
||||
*
|
||||
* 远控指令与更新推送的二次确认里有换行(逐台设备列表、"什么时候才装"的说明),
|
||||
* 而 ElMessageBox 的正文默认按 HTML 折叠空白 —— 不保留换行的话那几句会挤成一整段,
|
||||
* 逐台列表就读不出来了。页面通过 `customClass: 'ch-confirm-multiline'` 挂上这个类,
|
||||
* 弹窗节点在 body 上(不在组件的 scoped 样式作用域里),所以规则写在这里。
|
||||
*/
|
||||
.ch-confirm-multiline .el-message-box__message p {
|
||||
white-space: pre-line;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
formatDate,
|
||||
type AiAuditDto,
|
||||
type AiSettingsDto,
|
||||
type SystemDiagnosticsDto,
|
||||
type UpdateAiSettingsRequest,
|
||||
} from '@classhelper/shared';
|
||||
import { aiApi, systemApi } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useResponsive } from '@/composables/useResponsive';
|
||||
|
||||
/**
|
||||
* 大模型设置(`ai:manage`)。
|
||||
*
|
||||
* 三个 Tab 的理由:
|
||||
* 1. 「模型配置」—— 开关与接入参数。**密钥单向**:`GET /ai/settings` 永不回传密钥,
|
||||
* 只回 `hasApiKey` + `keyHint`(尾号),所以输入框永远是空的,"留空 = 不修改"是刻意的语义
|
||||
* (见 `api/index.ts` 的 `aiApi.updateSettings` 与 `modules/ai/ai.service.ts` 的 put 分支)。
|
||||
* 2. 「使用记录」—— 解析审计(谁、什么原话、判断成什么动作)。
|
||||
* 本页只读,不做"发布回执":审计行是解析那一刻写的(`status: 'pending'`),
|
||||
* 真正发布走的是既有发布端点,那不是助手链路的事。
|
||||
* 3. 「系统」(仅 `system:manage`)—— 开发者模式 + 只读诊断。
|
||||
* 放在这里而不是单独开一个路由,是因为它权限更高、平时不需要看,
|
||||
* Tabs 天然按权限隐藏(没有 system:manage 的人连标签都看不到),比多一个菜单项更省事。
|
||||
*/
|
||||
|
||||
const auth = useAuthStore();
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
/** 开发者模式/诊断属于 `system:manage`,与页面本身的 `ai:manage` 不是一回事 */
|
||||
const canManageSystem = computed(() => auth.can('system:manage'));
|
||||
|
||||
type TabName = 'model' | 'audit' | 'system';
|
||||
const activeTab = ref<TabName>('model');
|
||||
|
||||
/* ------------------------------------------------------------ 模型配置 */
|
||||
|
||||
type Provider = AiSettingsDto['provider'];
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
const clearingKey = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
provider: 'openai' as Provider,
|
||||
baseUrl: '',
|
||||
model: '',
|
||||
systemPromptExtra: '',
|
||||
});
|
||||
|
||||
/** 已保存的密钥信息(只用于显示,永不回填到输入框) */
|
||||
const hasApiKey = ref(false);
|
||||
const keyHint = ref('');
|
||||
/** 本次要写入的新密钥;留空 = 保持原密钥不变 */
|
||||
const apiKeyInput = ref('');
|
||||
|
||||
const providerHint = computed(() =>
|
||||
form.provider === 'mock'
|
||||
? '离线示例解析:不联网,只按关键词猜测,供演示与验收。'
|
||||
: 'OpenAI 兼容接口:请求 {服务地址}/chat/completions,适用于 OpenAI、通义、DeepSeek 等兼容服务。',
|
||||
);
|
||||
|
||||
function applySettings(settings: AiSettingsDto): void {
|
||||
form.enabled = settings.enabled;
|
||||
form.provider = settings.provider;
|
||||
form.baseUrl = settings.baseUrl;
|
||||
form.model = settings.model;
|
||||
form.systemPromptExtra = settings.systemPromptExtra;
|
||||
hasApiKey.value = settings.hasApiKey;
|
||||
keyHint.value = settings.keyHint;
|
||||
apiKeyInput.value = '';
|
||||
}
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
applySettings(await aiApi.settings());
|
||||
} catch {
|
||||
// 响应拦截器已经弹过服务端的中文提示,这里只保证页面不是一片空白
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: UpdateAiSettingsRequest = {
|
||||
enabled: form.enabled,
|
||||
provider: form.provider,
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
model: form.model.trim(),
|
||||
systemPromptExtra: form.systemPromptExtra,
|
||||
};
|
||||
// 只有真的填了新密钥才带上 apiKey:不带 = 保持原值,null = 清除
|
||||
const typedKey = apiKeyInput.value.trim();
|
||||
if (typedKey) payload.apiKey = typedKey;
|
||||
applySettings(await aiApi.updateSettings(payload));
|
||||
ElMessage.success('设置已保存');
|
||||
} catch {
|
||||
// 同上:错误提示由拦截器统一给出
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearApiKey(): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm('清除后助手无法调用真实大模型;确认清除已保存的密钥吗?', '清除密钥', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '清除',
|
||||
cancelButtonText: '取消',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
clearingKey.value = true;
|
||||
try {
|
||||
// apiKey: null 是"清除"的约定(空串在服务端同样按清除处理)
|
||||
applySettings(await aiApi.updateSettings({ apiKey: null }));
|
||||
ElMessage.success('密钥已清除');
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
clearingKey.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 使用记录 */
|
||||
|
||||
const audit = ref<AiAuditDto[]>([]);
|
||||
const auditLoading = ref(false);
|
||||
|
||||
const INTENT_LABELS: Record<string, string> = {
|
||||
create_homework: '布置作业',
|
||||
create_notification: '发布通知',
|
||||
call_student: '叫同学来',
|
||||
unknown: '没听懂',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '已解析',
|
||||
confirmed: '已发布',
|
||||
cancelled: '已放弃',
|
||||
failed: '发布失败',
|
||||
};
|
||||
|
||||
function intentLabel(intent: string): string {
|
||||
return INTENT_LABELS[intent] ?? intent;
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
return STATUS_LABELS[status] ?? status;
|
||||
}
|
||||
|
||||
async function loadAudit(): Promise<void> {
|
||||
auditLoading.value = true;
|
||||
try {
|
||||
audit.value = await aiApi.audit(50);
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
auditLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 系统/开发者 */
|
||||
|
||||
const developerMode = ref(false);
|
||||
const systemSaving = ref(false);
|
||||
const diagnostics = ref<SystemDiagnosticsDto | null>(null);
|
||||
const diagnosticsLoading = ref(false);
|
||||
|
||||
async function loadSystemSettings(): Promise<void> {
|
||||
try {
|
||||
developerMode.value = (await systemApi.settings()).developerMode;
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeveloperModeChange(value: boolean | string | number): Promise<void> {
|
||||
const next = value === true;
|
||||
const previous = developerMode.value;
|
||||
developerMode.value = next;
|
||||
systemSaving.value = true;
|
||||
try {
|
||||
await systemApi.updateSettings({ developerMode: next });
|
||||
ElMessage.success(next ? '开发者模式已开启' : '开发者模式已关闭');
|
||||
} catch {
|
||||
developerMode.value = previous;
|
||||
} finally {
|
||||
systemSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiagnostics(): Promise<void> {
|
||||
diagnosticsLoading.value = true;
|
||||
try {
|
||||
diagnostics.value = await systemApi.diagnostics();
|
||||
} catch {
|
||||
// 拦截器已提示
|
||||
} finally {
|
||||
diagnosticsLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 运行时长(秒 → 「3 天 4 小时 5 分」),纯展示,不做精度承诺 */
|
||||
function formatUptime(seconds: number | undefined): string {
|
||||
if (seconds === undefined) return '—';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days} 天`);
|
||||
if (hours > 0) parts.push(`${hours} 小时`);
|
||||
parts.push(`${minutes} 分`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 生命周期 */
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSettings();
|
||||
await loadAudit();
|
||||
if (canManageSystem.value) {
|
||||
await loadSystemSettings();
|
||||
await loadDiagnostics();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">大模型设置</h2>
|
||||
<p class="page-subtitle">配置「AI 助手」用哪种解析方式;助手只解析、不写库,发布始终由老师确认</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="settings-tabs">
|
||||
<el-tab-pane label="模型配置" name="model">
|
||||
<el-card v-loading="loading" shadow="never">
|
||||
<el-form :model="form" :label-width="isMobile ? undefined : '132px'" :label-position="isMobile ? 'top' : 'right'">
|
||||
<el-form-item label="启用助手">
|
||||
<el-switch v-model="form.enabled" data-test="ai-enabled" />
|
||||
<span class="field-hint">关闭后教师在「AI 助手」页会看到"还没启用"的空态</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="解析方式">
|
||||
<el-radio-group v-model="form.provider">
|
||||
<el-radio value="openai">OpenAI 兼容接口</el-radio>
|
||||
<el-radio value="mock">离线示例解析</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="field-hint">{{ providerHint }}</p>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.provider === 'openai'">
|
||||
<el-form-item label="服务地址">
|
||||
<el-input v-model="form.baseUrl" placeholder="https://api.openai.com/v1" class="field-input" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模型名">
|
||||
<el-input v-model="form.model" placeholder="gpt-4o-mini(留空即用默认)" class="field-input" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="API 密钥">
|
||||
<div class="key-row">
|
||||
<el-input
|
||||
v-model="apiKeyInput"
|
||||
type="password"
|
||||
show-password
|
||||
class="field-input"
|
||||
:placeholder="hasApiKey ? `已配置(尾号 ${keyHint}),留空表示不修改` : '粘贴密钥,保存后不再显示'"
|
||||
/>
|
||||
<el-button v-if="hasApiKey" :loading="clearingKey" @click="clearApiKey">清除密钥</el-button>
|
||||
</div>
|
||||
<p class="field-hint">密钥只保存在服务端;接口永不回传明文,所以这里不会回填。</p>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item label="补充提示词">
|
||||
<el-input
|
||||
v-model="form.systemPromptExtra"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
class="field-input"
|
||||
placeholder="例如:本校的班级叫「高一二班」这样的名字;把「卷子」理解为作业"
|
||||
/>
|
||||
<p class="field-hint">追加在系统提示词末尾,用来补充本校的称呼习惯。</p>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" data-test="ai-save" @click="save">保存</el-button>
|
||||
<el-button :disabled="saving" @click="loadSettings">重新载入</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="使用记录" name="audit">
|
||||
<el-card shadow="never">
|
||||
<div class="table-toolbar">
|
||||
<span class="field-hint">最近 50 条解析记录(只记录解析这一步,发布结果不在这张表里)</span>
|
||||
<el-button :loading="auditLoading" @click="loadAudit">刷新</el-button>
|
||||
</div>
|
||||
<el-table v-loading="auditLoading" :data="audit" class="table-card" empty-text="还没有解析记录">
|
||||
<el-table-column label="时间" width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-muted">{{ formatDate((row as AiAuditDto).createdAt, true) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" width="120">
|
||||
<template #default="{ row }">{{ (row as AiAuditDto).userName || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原话" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ (row as AiAuditDto).prompt }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="动作" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ intentLabel((row as AiAuditDto).intent) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结果" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" type="info" effect="plain">
|
||||
{{ statusLabel((row as AiAuditDto).status) }}
|
||||
</el-tag>
|
||||
<span class="cell-text">{{ (row as AiAuditDto).message }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane v-if="canManageSystem" label="系统" name="system">
|
||||
<el-card shadow="never" class="system-card">
|
||||
<div class="switch-row">
|
||||
<div>
|
||||
<p class="switch-title">开发者模式</p>
|
||||
<p class="field-hint">
|
||||
打开后只把开关记在服务端配置里(用于排查问题时确认环境),不改变老师与学生的日常使用。
|
||||
</p>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="developerMode"
|
||||
:loading="systemSaving"
|
||||
@update:model-value="handleDeveloperModeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="diagnosticsLoading" shadow="never" class="system-card">
|
||||
<template #header>
|
||||
<div class="table-toolbar">
|
||||
<span>服务端诊断</span>
|
||||
<el-button :loading="diagnosticsLoading" @click="loadDiagnostics">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="isMobile ? 1 : 2" size="small" border>
|
||||
<el-descriptions-item label="版本">{{ diagnostics?.version ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Node">{{ diagnostics?.nodeVersion ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">{{ diagnostics?.platform ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已运行">{{ formatUptime(diagnostics?.uptimeSeconds) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库">{{ diagnostics?.databaseProvider ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="在线客户端">
|
||||
{{ diagnostics?.onlineClientCount ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已发布客户端版本">
|
||||
{{ diagnostics?.clientReleaseCount ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已加载模块">{{ diagnostics?.moduleCount ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务端目录" :span="isMobile ? 1 : 2">
|
||||
<span class="cell-path">{{ diagnostics?.serverRoot ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据目录" :span="isMobile ? 1 : 2">
|
||||
<span class="cell-path">{{ diagnostics?.dataDir ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-tabs {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin: 4px 0 0;
|
||||
color: var(--ch-text-muted);
|
||||
font-size: var(--el-font-size-extra-small);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.key-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cell-muted {
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.cell-text {
|
||||
margin-left: 8px;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.cell-path {
|
||||
word-break: break-all;
|
||||
font-family: var(--el-font-family-mono, monospace);
|
||||
font-size: var(--el-font-size-extra-small);
|
||||
}
|
||||
|
||||
.system-card + .system-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.switch-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,644 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
NOTIFICATION_PRIORITIES,
|
||||
PRIORITY_LABELS,
|
||||
dayKeyLocal,
|
||||
type AiInterpretResult,
|
||||
type ApiErrorResponse,
|
||||
type ClassDto,
|
||||
type CourseDto,
|
||||
type NotificationPriority,
|
||||
type StudentDto,
|
||||
} from '@classhelper/shared';
|
||||
import { aiApi, callApi, classApi, courseApi, homeworkApi, notificationApi } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useResponsive } from '@/composables/useResponsive';
|
||||
|
||||
/**
|
||||
* AI 助手:**一句话 → 待确认的草稿 → 老师确认后才落库**。
|
||||
*
|
||||
* 三条边界(与服务端 `modules/ai/ai.service.ts` 的设计一一对应):
|
||||
* 1. 助手**只解析、不写业务表**:`POST /ai/interpret` 返回的是结构化参数与中文预览,
|
||||
* 真正发布走的仍是既有端点(`POST /homeworks` / `/notifications` / `/calls`)——
|
||||
* 于是科目权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用,不会另开一条旁路。
|
||||
* 2. **参数可改**:模型的输出不可信(服务端自己也会用同一份 zod 再校验一遍),
|
||||
* 所以解析结果一律填进表单让老师过目、修改,而不是直接提交。
|
||||
* 3. **教师不读配置**:`GET /ai/settings` 要 `ai:manage`,教师读会 403 且响应拦截器会弹一条错误提示。
|
||||
* 所以只有管理者才去读配置判断"是否启用",教师侧走"探测式"——
|
||||
* 直接解析,服务端回 `AI_NOT_CONFIGURED` 时再切到空态(见 interpret())。
|
||||
*/
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
const canManage = computed(() => auth.can('ai:manage'));
|
||||
|
||||
/* -------------------------------------------------------------- 可用性 */
|
||||
|
||||
/** checking = 管理者正在读配置;ready = 可以用;disabled = 还没启用(显示空态) */
|
||||
type AssistantState = 'checking' | 'ready' | 'disabled';
|
||||
const state = ref<AssistantState>('checking');
|
||||
const disabledReason = ref('助手还没启用,请管理员在「大模型设置」里配置');
|
||||
|
||||
/* -------------------------------------------------------------- 输入 */
|
||||
|
||||
const text = ref('');
|
||||
const interpreting = ref(false);
|
||||
const result = ref<AiInterpretResult | null>(null);
|
||||
/** 发布失败的中文原因(常驻在结果卡片里,不只靠一条会消失的 toast) */
|
||||
const publishError = ref('');
|
||||
const publishing = ref(false);
|
||||
|
||||
/* -------------------------------------------------------------- 表单 */
|
||||
|
||||
const classes = ref<ClassDto[]>([]);
|
||||
const courseOptions = ref<CourseDto[]>([]);
|
||||
const students = ref<StudentDto[]>([]);
|
||||
/** 叫人时按姓名匹配的结果说明(没匹配到要让老师自己选,不能默默留空) */
|
||||
const studentHint = ref('');
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({
|
||||
classId: '',
|
||||
courseId: '',
|
||||
title: '',
|
||||
content: '',
|
||||
assignDate: dayKeyLocal(new Date()),
|
||||
priority: 'NORMAL' as NotificationPriority,
|
||||
studentId: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const rules = computed<FormRules>(() => {
|
||||
const action = result.value?.action;
|
||||
const base: FormRules = {
|
||||
classId: [{ required: true, message: '请选择班级', trigger: 'change' }],
|
||||
};
|
||||
if (action === 'create_homework' || action === 'create_notification') {
|
||||
base.title = [{ required: true, message: '请填写标题', trigger: 'blur' }];
|
||||
base.content = [{ required: true, message: '请填写内容', trigger: 'blur' }];
|
||||
}
|
||||
if (action === 'call_student') {
|
||||
base.studentId = [{ required: true, message: '请选择要叫的同学', trigger: 'change' }];
|
||||
}
|
||||
return base;
|
||||
});
|
||||
|
||||
const actionLabel = computed(() => {
|
||||
switch (result.value?.action) {
|
||||
case 'create_homework':
|
||||
return '布置作业';
|
||||
case 'create_notification':
|
||||
return '发布通知';
|
||||
case 'call_student':
|
||||
return '叫同学来';
|
||||
default:
|
||||
return '没听懂';
|
||||
}
|
||||
});
|
||||
|
||||
const confidencePercent = computed(() => `${Math.round((result.value?.confidence ?? 0) * 100)}%`);
|
||||
const confidenceType = computed(() => ((result.value?.confidence ?? 0) < 0.5 ? 'warning' : 'success'));
|
||||
|
||||
/* -------------------------------------------------------------- 工具 */
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function isPriority(value: unknown): value is NotificationPriority {
|
||||
return typeof value === 'string' && (NOTIFICATION_PRIORITIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的错误读取。
|
||||
*
|
||||
* `api/http.ts` 的响应拦截器**已经弹过一次**服务端的中文提示(这就是不在这里重复 toast 的原因),
|
||||
* 这里只是把状态码与业务码取出来,好做分支判断(403 科目越权 / 409 上课时段紧急通知)。
|
||||
*/
|
||||
function readServerError(error: unknown): { status: number; code: string; message: string } {
|
||||
if (axios.isAxiosError<ApiErrorResponse>(error)) {
|
||||
return {
|
||||
status: error.response?.status ?? 0,
|
||||
code: error.response?.data?.code ?? '',
|
||||
message: error.response?.data?.message ?? error.message,
|
||||
};
|
||||
}
|
||||
return { status: 0, code: '', message: error instanceof Error ? error.message : '请求失败' };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 加载 */
|
||||
|
||||
async function loadClasses(): Promise<void> {
|
||||
try {
|
||||
classes.value = await classApi.list();
|
||||
} catch {
|
||||
// 拦截器已提示;拿不到班级时后面的表单会缺选项,但不至于整页报错
|
||||
classes.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCourses(): Promise<void> {
|
||||
courseOptions.value = form.classId ? await courseApi.list(form.classId).catch(() => []) : [];
|
||||
}
|
||||
|
||||
async function refreshStudents(): Promise<void> {
|
||||
students.value = form.classId ? await classApi.students(form.classId).catch(() => []) : [];
|
||||
}
|
||||
|
||||
/** 换班级:科目与名单都得跟着换,否则会把 A 班的科目发到 B 班 */
|
||||
async function onClassChange(): Promise<void> {
|
||||
form.courseId = '';
|
||||
form.studentId = '';
|
||||
studentHint.value = '';
|
||||
await refreshCourses();
|
||||
if (result.value?.action === 'call_student') await refreshStudents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 叫人的学生匹配:助手的 `call_student` 通常只给 `studentName`(预览也按姓名查过),
|
||||
* 而 `POST /calls` 要的是 `studentId`。同名或没匹配到都**不猜**,交给老师在下拉里选。
|
||||
*/
|
||||
async function resolveStudent(params: Record<string, unknown>): Promise<void> {
|
||||
await refreshStudents();
|
||||
const byId = str(params.studentId);
|
||||
if (byId && students.value.some((item) => item.id === byId)) {
|
||||
form.studentId = byId;
|
||||
studentHint.value = '';
|
||||
return;
|
||||
}
|
||||
const name = str(params.studentName);
|
||||
if (!name) {
|
||||
studentHint.value = '助手没识别出学生姓名,请手动选择。';
|
||||
return;
|
||||
}
|
||||
const matched = students.value.filter((item) => item.name === name);
|
||||
if (matched.length === 1) {
|
||||
form.studentId = matched[0]?.id ?? '';
|
||||
studentHint.value = '';
|
||||
return;
|
||||
}
|
||||
studentHint.value =
|
||||
matched.length > 1
|
||||
? `本班有 ${matched.length} 位「${name}」,请手动选择是哪一位。`
|
||||
: `本班没找到「${name}」,请手动选择。`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 解析 */
|
||||
|
||||
async function interpret(): Promise<void> {
|
||||
const value = text.value.trim();
|
||||
if (!value) {
|
||||
ElMessage.warning('先说说要做什么,例如「给高一二班布置作业,让他们把卷子写完」');
|
||||
return;
|
||||
}
|
||||
|
||||
interpreting.value = true;
|
||||
publishError.value = '';
|
||||
try {
|
||||
const data = await aiApi.interpret({ text: value, classId: form.classId || undefined });
|
||||
result.value = data;
|
||||
await fillForm(data);
|
||||
} catch (error) {
|
||||
const info = readServerError(error);
|
||||
// 未启用 / 没配服务地址与密钥:服务端用同一个业务码区分,这里统一切到空态
|
||||
if (info.code === 'AI_NOT_CONFIGURED') {
|
||||
state.value = 'disabled';
|
||||
disabledReason.value = info.message;
|
||||
result.value = null;
|
||||
}
|
||||
} finally {
|
||||
interpreting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把解析出的参数填进表单(解析结果只是**草稿**,老师可以逐项改) */
|
||||
async function fillForm(data: AiInterpretResult): Promise<void> {
|
||||
const params = data.params ?? {};
|
||||
form.classId = str(params.classId) || form.classId || classes.value[0]?.id || '';
|
||||
form.courseId = str(params.courseId);
|
||||
form.title = str(params.title);
|
||||
form.content = str(params.content);
|
||||
form.assignDate = str(params.assignDate) || dayKeyLocal(new Date());
|
||||
form.priority = isPriority(params.priority) ? params.priority : 'NORMAL';
|
||||
form.studentId = '';
|
||||
form.message = str(params.message);
|
||||
studentHint.value = '';
|
||||
|
||||
await refreshCourses();
|
||||
if (data.action === 'call_student') await resolveStudent(params);
|
||||
|
||||
// 表单是 v-if 出来的:等它挂载完再清掉上一次残留的校验红字
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 发布 */
|
||||
|
||||
async function confirmPublish(): Promise<void> {
|
||||
const current = result.value;
|
||||
if (!current || current.action === 'unknown') return;
|
||||
|
||||
const valid = await formRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
publishing.value = true;
|
||||
publishError.value = '';
|
||||
try {
|
||||
await publishOnce(current.action, false);
|
||||
finishSuccess(current.action);
|
||||
} catch (error) {
|
||||
await handlePublishError(error, current.action);
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 真正落库:复用既有的发布端点(权限断言 / 上课保护 / ClassIsland 推送都在服务端) */
|
||||
async function publishOnce(action: AiInterpretResult['action'], confirmDuringClass: boolean): Promise<void> {
|
||||
if (action === 'create_homework') {
|
||||
await homeworkApi.create({
|
||||
classId: form.classId,
|
||||
courseId: form.courseId || null,
|
||||
title: form.title.trim(),
|
||||
content: form.content,
|
||||
assignDate: form.assignDate || null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (action === 'create_notification') {
|
||||
await notificationApi.create({
|
||||
classId: form.classId,
|
||||
title: form.title.trim(),
|
||||
content: form.content,
|
||||
priority: form.priority,
|
||||
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await callApi.create({
|
||||
classId: form.classId,
|
||||
studentId: form.studentId,
|
||||
message: form.message.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function finishSuccess(action: AiInterpretResult['action']): void {
|
||||
ElMessage.success(
|
||||
action === 'create_homework'
|
||||
? '作业已发布,已推送给该班学生'
|
||||
: action === 'create_notification'
|
||||
? '通知已发布,已推送给该班学生'
|
||||
: '已通知该同学到老师这里',
|
||||
);
|
||||
// 发布成功后清空结果:同一句话不会被误发第二遍
|
||||
result.value = null;
|
||||
text.value = '';
|
||||
publishError.value = '';
|
||||
studentHint.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败分支。
|
||||
*
|
||||
* 服务端的 403(如「不是你任教的科目」)与其它 4xx 的中文提示**已经由响应拦截器弹过**,
|
||||
* 这里只做两件拦截器做不了的事:
|
||||
* 1. 把原因常驻在结果卡片里(toast 会消失,而老师需要边看参数边改);
|
||||
* 2. 上课时段的紧急通知(409 `URGENT_DURING_CLASS`)再问一次 —— 服务端要求二次确认才放行。
|
||||
*/
|
||||
async function handlePublishError(error: unknown, action: AiInterpretResult['action']): Promise<void> {
|
||||
const info = readServerError(error);
|
||||
|
||||
if (info.status === 409 && info.code === 'URGENT_DURING_CLASS' && action === 'create_notification') {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'正在上课,紧急通知需二次确认。确认立即把这条通知发到班级吗?',
|
||||
'上课时段发布紧急通知',
|
||||
{ type: 'warning', confirmButtonText: '继续发布', cancelButtonText: '取消' },
|
||||
);
|
||||
} catch {
|
||||
publishError.value = '已取消:正在上课,紧急通知需要二次确认';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await publishOnce(action, true);
|
||||
finishSuccess(action);
|
||||
} catch (retryError) {
|
||||
publishError.value = readServerError(retryError).message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
publishError.value = info.message;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 生命周期 */
|
||||
|
||||
onMounted(async () => {
|
||||
await loadClasses();
|
||||
|
||||
if (canManage.value) {
|
||||
// 管理者:读一次配置就能给出准确空态(教师不能读,会 403)
|
||||
try {
|
||||
const settings = await aiApi.settings();
|
||||
if (!settings.enabled) {
|
||||
state.value = 'disabled';
|
||||
disabledReason.value = '助手还没启用,请管理员在「大模型设置」里配置';
|
||||
} else if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
|
||||
state.value = 'disabled';
|
||||
disabledReason.value = '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写';
|
||||
} else {
|
||||
state.value = 'ready';
|
||||
}
|
||||
} catch {
|
||||
// 读配置失败不该把老师挡在门外:按"可以用"处理,真正的判据交给解析时的报错
|
||||
state.value = 'ready';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 教师(或权限还没拉回来时):探测式 —— 直接允许输入,解析报 AI_NOT_CONFIGURED 再切空态
|
||||
state.value = 'ready';
|
||||
});
|
||||
|
||||
function goSettings(): void {
|
||||
void router.push('/ai-settings');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">AI 助手</h2>
|
||||
<p class="page-subtitle">
|
||||
用一句话说清要做什么,助手解析成可修改的草稿;<b>确认发布后</b>才会真正发到班级
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 读配置中:先占位,避免"先看到输入框、再被空态替换"的闪动 -->
|
||||
<el-card v-if="state === 'checking'" v-loading="true" shadow="never" class="ask-card" />
|
||||
|
||||
<!-- 未启用:只说明原因,不给输入框(说了也发不出去) -->
|
||||
<el-card v-else-if="state === 'disabled'" shadow="never">
|
||||
<el-empty :description="disabledReason">
|
||||
<el-button v-if="canManage" type="primary" @click="goSettings">去「大模型设置」</el-button>
|
||||
</el-empty>
|
||||
</el-card>
|
||||
|
||||
<template v-else>
|
||||
<el-card shadow="never" class="ask-card">
|
||||
<div class="ask-row">
|
||||
<el-input
|
||||
v-model="text"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
resize="none"
|
||||
placeholder="给高一二班布置作业,让他们把卷子写完"
|
||||
@keydown.enter.exact.prevent="interpret"
|
||||
/>
|
||||
<el-button type="primary" :loading="interpreting" data-test="ai-interpret" @click="interpret">
|
||||
解析
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="ask-hint">回车解析,Shift + 回车换行|可以说「布置作业 / 发通知 / 叫某位同学来办公室」</p>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="result" shadow="never" class="result-card">
|
||||
<template #header>
|
||||
<div class="result-header">
|
||||
<span class="result-title">{{ actionLabel }}</span>
|
||||
<el-tag :type="confidenceType" size="small" effect="light">
|
||||
可信度 {{ confidencePercent }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 没听懂:只给一句提示,不摆一堆用不上的表单 -->
|
||||
<el-empty v-if="result.action === 'unknown'" description="没听懂,请换一种说法" />
|
||||
|
||||
<template v-else>
|
||||
<el-alert
|
||||
v-if="result.confidence < 0.5"
|
||||
class="result-alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="助手把握不大,请仔细核对下面的参数再发布"
|
||||
/>
|
||||
<el-alert
|
||||
v-if="publishError"
|
||||
class="result-alert"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
:title="publishError"
|
||||
/>
|
||||
|
||||
<p class="result-summary">{{ result.preview.summary }}</p>
|
||||
<div class="result-meta">
|
||||
<span>班级:{{ result.preview.className || '(未指定)' }}</span>
|
||||
<span v-if="result.action === 'create_homework'">
|
||||
科目:{{ result.preview.courseName || '(不指定科目,仅班主任/管理员可发)' }}
|
||||
</span>
|
||||
<span v-if="result.action === 'call_student'">
|
||||
学生:{{ result.preview.studentName || '(未识别)' }}
|
||||
</span>
|
||||
</div>
|
||||
<ul v-if="result.preview.warnings.length" class="result-warnings">
|
||||
<li v-for="(warning, index) in result.preview.warnings" :key="index">{{ warning }}</li>
|
||||
</ul>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:label-width="isMobile ? undefined : '82px'"
|
||||
:label-position="isMobile ? 'top' : 'right'"
|
||||
class="result-form"
|
||||
>
|
||||
<el-form-item label="班级" prop="classId">
|
||||
<el-select
|
||||
v-model="form.classId"
|
||||
placeholder="选择班级"
|
||||
class="result-field"
|
||||
@change="onClassChange"
|
||||
>
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="result.action === 'create_homework'">
|
||||
<el-form-item label="科目">
|
||||
<el-select v-model="form.courseId" clearable placeholder="不指定科目" class="result-field">
|
||||
<el-option v-for="item in courseOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" class="result-field" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input v-model="form.content" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="日期">
|
||||
<el-date-picker
|
||||
v-model="form.assignDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="作业归属日期"
|
||||
class="result-field"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="result.action === 'create_notification'">
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" class="result-field" />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input v-model="form.content" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-select v-model="form.priority" class="result-field">
|
||||
<el-option
|
||||
v-for="item in NOTIFICATION_PRIORITIES"
|
||||
:key="item"
|
||||
:label="PRIORITY_LABELS[item]"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-form-item label="学生" prop="studentId">
|
||||
<el-select
|
||||
v-model="form.studentId"
|
||||
filterable
|
||||
placeholder="选择要叫的同学"
|
||||
class="result-field"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in students"
|
||||
:key="item.id"
|
||||
:label="`${item.name}(${item.studentNo})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<p v-if="studentHint" class="result-hint">{{ studentHint }}</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="留言">
|
||||
<el-input v-model="form.message" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<div class="result-actions">
|
||||
<el-button :disabled="publishing" @click="result = null">放弃</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="publishing"
|
||||
data-test="ai-confirm"
|
||||
@click="confirmPublish"
|
||||
>
|
||||
确认发布
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ask-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ask-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ask-row :deep(.el-textarea) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ask-row .el-button {
|
||||
/* 与两行输入框的顶部对齐(按钮不该被拉成和输入框一样高) */
|
||||
flex: 0 0 auto;
|
||||
height: var(--el-component-size);
|
||||
}
|
||||
|
||||
.ask-hint {
|
||||
margin: 8px 0 0;
|
||||
color: var(--ch-text-muted);
|
||||
font-size: var(--el-font-size-extra-small);
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
margin: 0 0 8px;
|
||||
font-size: var(--el-font-size-medium);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
color: var(--ch-text-secondary);
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
.result-warnings {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
color: var(--el-color-warning);
|
||||
font-size: var(--el-font-size-small);
|
||||
}
|
||||
|
||||
.result-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 表单控件不铺满整行:长输入框在宽屏上读起来很累,也容易看错改错 */
|
||||
.result-field {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.result-hint {
|
||||
margin: 4px 0 0;
|
||||
color: var(--el-color-warning);
|
||||
font-size: var(--el-font-size-extra-small);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,7 +19,7 @@ import {
|
||||
type StudentDto,
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, gradeApi } from '@/api';
|
||||
import { classApi, courseApi, courseIdFromExistsError, gradeApi } from '@/api';
|
||||
import ScoreBarChart from '@/components/ScoreBarChart.vue';
|
||||
import ScoreLineChart from '@/components/ScoreLineChart.vue';
|
||||
import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||
@@ -100,7 +100,6 @@ async function onClassChange(): Promise<void> {
|
||||
courses.value = await courseApi.list(filter.classId);
|
||||
students.value = await classApi.students(filter.classId);
|
||||
await loadGrades();
|
||||
await loadTeacherSuggestions();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 图表
|
||||
@@ -132,9 +131,40 @@ async function resolveCourseId(value: string): Promise<string | null> {
|
||||
const name = value.slice(SUBJECT_PREFIX.length);
|
||||
const existing = courses.value.find((item) => item.name === name);
|
||||
if (existing) return existing.id;
|
||||
const created = await courseApi.create({ classId: filter.classId, name });
|
||||
courses.value = [...courses.value, created];
|
||||
return created.id;
|
||||
try {
|
||||
const created = await courseApi.create({ classId: filter.classId, name });
|
||||
courses.value = [...courses.value, created];
|
||||
return created.id;
|
||||
} catch (error) {
|
||||
// 该班已有同名课程(本地列表过期):复用那一门(服务端 409 COURSE_EXISTS + details.courseId);
|
||||
// 复用之后成绩的科目写权限照常判定,不会"因为选了新建就变成自己的科目"
|
||||
const reusedId = courseIdFromExistsError(error);
|
||||
if (reusedId) return reusedId;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 录入下拉里可选的课程:**只列自己任教的科目**(管理员不受限)。
|
||||
*
|
||||
* 与后端 `assertCanManageSubjectContent` 同源:班主任也不自动拥有本班所有科目的成绩录入权,
|
||||
* 所以下拉里不给"别人的科目",避免老师选完提交才吃 403。
|
||||
*/
|
||||
const ownCourses = computed(() =>
|
||||
auth.isAdmin ? courses.value : courses.value.filter((item) => item.teacherId === auth.user?.id),
|
||||
);
|
||||
|
||||
/** 能不能在该班新建科目(与课表管理同权限:管理员或本班班主任) */
|
||||
const canCreateCourse = computed(() => {
|
||||
if (auth.isAdmin) return true;
|
||||
return classes.value.find((item) => item.id === filter.classId)?.teacherId === auth.user?.id;
|
||||
});
|
||||
|
||||
/** 录入弹窗的默认科目:优先沿用页面筛选,但筛的是别人的科目时退回"我的科目"第一条 */
|
||||
function defaultFormCourseId(): string {
|
||||
const list = ownCourses.value;
|
||||
if (filter.courseId && list.some((item) => item.id === filter.courseId)) return filter.courseId;
|
||||
return list[0]?.id ?? '';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 单条录入 */
|
||||
@@ -156,7 +186,7 @@ function openSingle(): void {
|
||||
return;
|
||||
}
|
||||
singleForm.studentId = students.value[0]?.id ?? '';
|
||||
singleForm.courseId = filter.courseId || courses.value[0]?.id || '';
|
||||
singleForm.courseId = defaultFormCourseId();
|
||||
singleForm.examName = filter.examName || '期中考试';
|
||||
singleForm.score = 90;
|
||||
singleForm.totalScore = 100;
|
||||
@@ -202,7 +232,7 @@ function openBulk(): void {
|
||||
ElMessage.warning('该班级暂无学生');
|
||||
return;
|
||||
}
|
||||
bulkForm.courseId = filter.courseId || courses.value[0]?.id || '';
|
||||
bulkForm.courseId = defaultFormCourseId();
|
||||
bulkForm.examName = filter.examName || '期中考试';
|
||||
bulkForm.totalScore = 100;
|
||||
bulkForm.levelType = 'percent';
|
||||
@@ -269,7 +299,7 @@ function openLevelBatch(): void {
|
||||
ElMessage.warning('请先选择班级');
|
||||
return;
|
||||
}
|
||||
levelBatchForm.courseId = filter.courseId || courses.value[0]?.id || '';
|
||||
levelBatchForm.courseId = defaultFormCourseId();
|
||||
levelBatchForm.examName = filter.examName || examOptions.value[0] || '';
|
||||
levelBatchForm.levelType = 'letter';
|
||||
levelBatchVisible.value = true;
|
||||
@@ -355,11 +385,6 @@ async function removeGrade(row: GradeDto): Promise<void> {
|
||||
|
||||
/* ------------------------------------------------------------ 教师视角建议:按考试分组统计 */
|
||||
|
||||
const teacherCourseSuggestions = ref<CourseDto[]>([]);
|
||||
async function loadTeacherSuggestions(): Promise<void> {
|
||||
teacherCourseSuggestions.value = courses.value;
|
||||
}
|
||||
|
||||
function onGradeEvent(): void {
|
||||
void loadGrades();
|
||||
}
|
||||
@@ -523,8 +548,8 @@ onUnmounted(() => {
|
||||
</el-form-item>
|
||||
<el-form-item label="科目">
|
||||
<el-select v-model="singleForm.courseId" clearable filterable style="width: 100%">
|
||||
<el-option v-for="item in courses" :key="item.id" :label="`${item.name}(${item.teacher?.name ?? ''})`" :value="item.id" />
|
||||
<el-option-group v-if="subjectOptions.length" label="统一科目(自动建课)">
|
||||
<el-option v-for="item in ownCourses" :key="item.id" :label="`${item.name}(${item.teacher?.name ?? ''})`" :value="item.id" />
|
||||
<el-option-group v-if="canCreateCourse && subjectOptions.length" label="统一科目(自动建课)">
|
||||
<el-option
|
||||
v-for="name in subjectOptions"
|
||||
:key="name"
|
||||
@@ -579,12 +604,12 @@ onUnmounted(() => {
|
||||
<el-form-item label="科目">
|
||||
<el-select v-model="bulkForm.courseId" clearable filterable style="width: 150px">
|
||||
<el-option
|
||||
v-for="item in teacherCourseSuggestions"
|
||||
v-for="item in ownCourses"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
<el-option-group v-if="subjectOptions.length" label="统一科目(自动建课)">
|
||||
<el-option-group v-if="canCreateCourse && subjectOptions.length" label="统一科目(自动建课)">
|
||||
<el-option
|
||||
v-for="name in subjectOptions"
|
||||
:key="name"
|
||||
@@ -645,8 +670,8 @@ onUnmounted(() => {
|
||||
<el-form :model="levelBatchForm" label-width="90px">
|
||||
<el-form-item label="科目">
|
||||
<el-select v-model="levelBatchForm.courseId" clearable filterable style="width: 100%">
|
||||
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option-group v-if="subjectOptions.length" label="统一科目(自动建课)">
|
||||
<el-option v-for="item in ownCourses" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option-group v-if="canCreateCourse && subjectOptions.length" label="统一科目(自动建课)">
|
||||
<el-option
|
||||
v-for="name in subjectOptions"
|
||||
:key="name"
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
type HomeworkDto,
|
||||
type HomeworkSubmissionsDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, homeworkApi } from '@/api';
|
||||
import { classApi, courseApi, courseIdFromExistsError, homeworkApi } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
import { useResponsive } from '@/composables/useResponsive';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
@@ -218,6 +220,23 @@ const newSubjectOptions = computed(() => {
|
||||
return SUBJECT_CATALOG.filter((name) => !existing.has(name));
|
||||
});
|
||||
|
||||
/**
|
||||
* 发布弹窗里可选的课程:**只列自己任教的科目**(管理员不受限)。
|
||||
*
|
||||
* 展示侧的收口与服务端 `assertCanManageSubjectContent` 同源:班主任也不自动拥有
|
||||
* 本班所有科目的作业权,所以下拉里不给"别人的科目"这个选项 —— 否则老师选了、
|
||||
* 提交、再吃一个 403,只会以为是系统坏了。留空(不指定科目)仍然允许班主任用。
|
||||
*/
|
||||
const ownFormCourses = computed(() =>
|
||||
auth.isAdmin ? formCourses.value : formCourses.value.filter((item) => item.teacherId === auth.user?.id),
|
||||
);
|
||||
|
||||
/** 该班里能不能新建科目:与课表管理同权限(管理员或本班班主任) */
|
||||
const canCreateCourse = computed(() => {
|
||||
if (auth.isAdmin) return true;
|
||||
return classes.value.find((item) => item.id === form.classId)?.teacherId === auth.user?.id;
|
||||
});
|
||||
|
||||
async function loadFormCourses(): Promise<void> {
|
||||
if (!form.classId) {
|
||||
formCourses.value = [];
|
||||
@@ -292,6 +311,14 @@ async function resolveCourseId(): Promise<string | null> {
|
||||
formCourses.value = [...formCourses.value, created];
|
||||
return created.id;
|
||||
} catch (error) {
|
||||
// 该班已有同名课程(本地列表过期或别人刚建):复用那一门 ——
|
||||
// 服务端对同名课程返回 409 COURSE_EXISTS + details.courseId(不许再建第二门同名的,
|
||||
// 否则创建者会凭空成为该科任课老师,绕过科目维度的写权限)。
|
||||
const reusedId = courseIdFromExistsError(error);
|
||||
if (reusedId) {
|
||||
await loadFormCourses();
|
||||
return reusedId;
|
||||
}
|
||||
// 新建课程需要"管理员或本班班主任"权限(与课表管理一致),科任老师会被 403 拦截
|
||||
ElMessage.error(
|
||||
`新建科目「${name}」失败:${
|
||||
@@ -334,6 +361,89 @@ async function submitForm(): Promise<void> {
|
||||
await loadHomeworks();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 批量布置(多班同发) */
|
||||
|
||||
/**
|
||||
* 批量布置:一次给多个班级布置同一份作业。
|
||||
*
|
||||
* 科目按**名称**提交(`subjectName`),不是一个 courseId —— 课程是"班级内"的:
|
||||
* 同一门「语文」在每个班是不同的 Course 记录,没法共用一个 id。服务端在每个目标班里查同名课程,
|
||||
* 某个班没有这门课时只让那个班失败(结果里带原因),不会自动建课、也不会拖垮整批。
|
||||
*/
|
||||
const bulkVisible = ref(false);
|
||||
const bulkSending = ref(false);
|
||||
const bulkForm = reactive({
|
||||
classIds: [] as string[],
|
||||
subjectName: '',
|
||||
title: '',
|
||||
content: '',
|
||||
attachmentUrl: '',
|
||||
assignDate: dayKeyLocal(new Date()),
|
||||
});
|
||||
|
||||
function openBulk(): void {
|
||||
bulkForm.classIds = filter.classId ? [filter.classId] : [];
|
||||
bulkForm.subjectName = '';
|
||||
bulkForm.title = '';
|
||||
bulkForm.content = '';
|
||||
bulkForm.attachmentUrl = '';
|
||||
// 默认跟当前「按天查看」的日期一致
|
||||
bulkForm.assignDate = filter.date || dayKeyLocal(new Date());
|
||||
bulkVisible.value = true;
|
||||
}
|
||||
|
||||
function classNameOf(classId: string): string {
|
||||
return classes.value.find((item) => item.id === classId)?.name ?? classId;
|
||||
}
|
||||
|
||||
async function submitBulk(): Promise<void> {
|
||||
if (bulkForm.classIds.length === 0) {
|
||||
ElMessage.warning('请至少选择一个目标班级');
|
||||
return;
|
||||
}
|
||||
if (!bulkForm.title.trim()) {
|
||||
ElMessage.warning('请输入作业标题');
|
||||
return;
|
||||
}
|
||||
if (!bulkForm.content.trim()) {
|
||||
ElMessage.warning('请输入作业内容');
|
||||
return;
|
||||
}
|
||||
|
||||
bulkSending.value = true;
|
||||
try {
|
||||
const result = await homeworkApi.bulkCreate({
|
||||
classIds: bulkForm.classIds,
|
||||
subjectName: bulkForm.subjectName || null,
|
||||
title: bulkForm.title.trim(),
|
||||
content: bulkForm.content,
|
||||
assignDate: bulkForm.assignDate || null,
|
||||
attachmentUrl: bulkForm.attachmentUrl.trim() || null,
|
||||
});
|
||||
|
||||
const failures = result.results.filter((item) => !item.ok);
|
||||
if (failures.length === 0) {
|
||||
ElMessage.success(`作业已发布到 ${result.succeeded} 个班级,实时推送已发出`);
|
||||
} else {
|
||||
const detail = failures
|
||||
.map((item) => `${classNameOf(item.classId)}(${item.message ?? '失败'})`)
|
||||
.join(';');
|
||||
ElMessage({
|
||||
type: result.succeeded > 0 ? 'warning' : 'error',
|
||||
message: `成功 ${result.succeeded} 个班,失败 ${failures.length} 个班|${detail}`,
|
||||
duration: 8000,
|
||||
showClose: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 全部失败时把弹窗留着,方便老师改科目/改班级后直接重试
|
||||
bulkVisible.value = result.succeeded === 0;
|
||||
if (result.succeeded > 0) await loadHomeworks();
|
||||
} finally {
|
||||
bulkSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeHomework(row: HomeworkDto): Promise<void> {
|
||||
await ElMessageBox.confirm(`删除作业「${row.title}」?`, '确认', { type: 'warning' });
|
||||
await homeworkApi.remove(row.id);
|
||||
@@ -414,6 +524,7 @@ onUnmounted(() => {
|
||||
@clear="loadHomeworks"
|
||||
/>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布作业</el-button>
|
||||
<el-button @click="openBulk">批量布置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -460,10 +571,18 @@ onUnmounted(() => {
|
||||
:loading="formCoursesLoading"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option-group v-if="formCourses.length" label="已有课程">
|
||||
<el-option v-for="item in formCourses" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option-group v-if="ownFormCourses.length" label="我的科目">
|
||||
<el-option
|
||||
v-for="item in ownFormCourses"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-option-group>
|
||||
<el-option-group v-if="!formCoursesLoading && newSubjectOptions.length" label="其他科目(新建)">
|
||||
<el-option-group
|
||||
v-if="canCreateCourse && !formCoursesLoading && newSubjectOptions.length"
|
||||
label="其他科目(新建)"
|
||||
>
|
||||
<el-option
|
||||
v-for="name in newSubjectOptions"
|
||||
:key="`new-${name}`"
|
||||
@@ -476,7 +595,9 @@ onUnmounted(() => {
|
||||
{{
|
||||
formCoursesLoading
|
||||
? '正在加载该班课程…'
|
||||
: '该班还没有的科目在「其他科目(新建)」里,选中后会自动建课再发布(需班主任/管理员)'
|
||||
: canCreateCourse
|
||||
? '只列你任教的科目;该班还没有的科目在「其他科目(新建)」里,选中后自动建课再发布'
|
||||
: '只列你任教的科目;需要别的科目请先由班主任/管理员在课表管理里建好并分配任课老师'
|
||||
}}
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -506,6 +627,69 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量布置:多班同发同一份作业;科目按名称匹配,某个班没有这门课只让那个班失败 -->
|
||||
<el-dialog v-model="bulkVisible" title="批量布置作业" width="560px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="目标班级" required>
|
||||
<el-select
|
||||
v-model="bulkForm.classIds"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="可多选;一次布置到所有选中班级"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属科目">
|
||||
<el-select
|
||||
v-model="bulkForm.subjectName"
|
||||
placeholder="不指定科目(仅班主任/管理员可用)"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="name in SUBJECT_CATALOG" :key="name" :label="name" :value="name" />
|
||||
</el-select>
|
||||
<div class="form-hint">
|
||||
按科目名称匹配:服务端在每个班里找同名课程,某个班没有这门课时只有那个班失败(不会自动建课)
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属日期">
|
||||
<el-date-picker
|
||||
v-model="bulkForm.assignDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:cell-class-name="dayCellClass"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="bulkForm.title" maxlength="120" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" required>
|
||||
<el-input
|
||||
v-model="bulkForm.content"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="5000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="附件链接">
|
||||
<el-input v-model="bulkForm.attachmentUrl" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="bulkVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="bulkSending" @click="submitBulk">
|
||||
发布到 {{ bulkForm.classIds.length }} 个班级
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '作业详情'">
|
||||
<template v-if="current">
|
||||
<el-descriptions :column="detailColumns" border size="small">
|
||||
|
||||
@@ -81,6 +81,41 @@ async function submit(): Promise<void> {
|
||||
border-radius: 14px;
|
||||
padding: 30px 28px 22px;
|
||||
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
|
||||
/*
|
||||
* 登录卡片**永远按浅色渲染**。
|
||||
*
|
||||
* 页面底色是刻意固定的深色渐变(AGENTS.md §5 第 46 条),卡片按设计就是"深底白卡"。
|
||||
* 但卡片的内部颜色原本会随 `html.dark` 变:Element Plus 的深色 css-vars 与 `--ch-*`
|
||||
* 是挂在 `<html>` 上的,照常继承进白卡片 —— 于是深色主题下出现"白卡片 + 深色输入框 +
|
||||
* 浅灰字"的黑白混搭(用户反馈的「登录页出现黑夜白天模式混合显示」)。
|
||||
* 这里把卡片范围内的语义色与关键 --el-* 变量钉回浅色值;原生控件也一并声明为 light。
|
||||
*/
|
||||
color-scheme: light;
|
||||
--ch-surface: #ffffff;
|
||||
--ch-surface-alt: #f7f9fc;
|
||||
--ch-surface-sunken: #f2f3f5;
|
||||
--ch-surface-hover: #e6e8eb;
|
||||
--ch-border: #e6e8ef;
|
||||
--ch-text: #303133;
|
||||
--ch-text-secondary: #606266;
|
||||
--ch-text-muted: #909399;
|
||||
--ch-text-placeholder: #c0c4cc;
|
||||
--el-bg-color: #ffffff;
|
||||
--el-bg-color-overlay: #ffffff;
|
||||
--el-bg-color-page: #f2f4f8;
|
||||
--el-text-color-primary: #303133;
|
||||
--el-text-color-regular: #606266;
|
||||
--el-text-color-secondary: #909399;
|
||||
--el-text-color-placeholder: #c0c4cc;
|
||||
--el-text-color-disabled: #a8abb2;
|
||||
--el-fill-color-blank: #ffffff;
|
||||
--el-fill-color-light: #f2f3f5;
|
||||
--el-fill-color-lighter: #f5f7fa;
|
||||
--el-fill-color: #f2f3f5;
|
||||
--el-border-color: #e6e8ef;
|
||||
--el-border-color-light: #e6e8ef;
|
||||
--el-border-color-lighter: #e6e8ef;
|
||||
--el-color-primary: #409eff;
|
||||
/*
|
||||
* 入场用 PANEL 弹簧(beUI 的 center-morph-modal 观感:从略小、略高处落下来)。
|
||||
* 登录页是整个应用里最"一次性"、最值得给一点分量的一屏 ——
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
truncate,
|
||||
type ClassDto,
|
||||
type ClassPeriod,
|
||||
type NotificationCreatorSummary,
|
||||
type NotificationDaySummary,
|
||||
type NotificationDto,
|
||||
type NotificationPriority,
|
||||
type StudentDto,
|
||||
@@ -27,7 +29,38 @@ const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const classes = ref<ClassDto[]>([]);
|
||||
const notifications = ref<NotificationDto[]>([]);
|
||||
const filter = reactive({ classId: '', priority: '', keyword: '' });
|
||||
const filter = reactive({ classId: '', priority: '', keyword: '', date: '', createdBy: '' });
|
||||
|
||||
/**
|
||||
* 按天归档 / 按发布人归档的候选列表(需求:通知按天归档,也可按发布人、类型看)。
|
||||
*
|
||||
* 两者跟着"班级 + 优先级"走:班级一换,日期栏与发布人栏就应该只剩那个班里真实存在的
|
||||
* 日期与人 —— 否则选项里全是别的班级的数据,选中后得到空列表,看着像坏了。
|
||||
* 发布人栏还额外跟着当前选中的日期收敛("这一天都有谁发过通知")。
|
||||
*/
|
||||
const days = ref<NotificationDaySummary[]>([]);
|
||||
const creators = ref<NotificationCreatorSummary[]>([]);
|
||||
|
||||
async function loadArchive(): Promise<void> {
|
||||
const base = {
|
||||
...(filter.classId ? { classId: filter.classId } : {}),
|
||||
...(filter.priority ? { priority: filter.priority } : {}),
|
||||
};
|
||||
const [dayList, creatorList] = await Promise.all([
|
||||
notificationApi.days(base).catch(() => ({ days: [] as NotificationDaySummary[] })),
|
||||
notificationApi
|
||||
.creators({ ...base, ...(filter.date ? { date: filter.date } : {}) })
|
||||
.catch(() => ({ creators: [] as NotificationCreatorSummary[] })),
|
||||
]);
|
||||
days.value = dayList.days;
|
||||
creators.value = creatorList.creators;
|
||||
}
|
||||
|
||||
/** 筛选变化后统一刷新:先更新归档候选,再拉当前筛选下的列表 */
|
||||
async function refreshList(): Promise<void> {
|
||||
await loadArchive();
|
||||
await loadNotifications();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否"单班视图":选中了具体班级时列表只服务一个班。
|
||||
@@ -65,10 +98,18 @@ async function loadClassStudentCounts(): Promise<void> {
|
||||
async function loadNotifications(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: { classId?: string; priority?: string; keyword?: string } = {};
|
||||
const params: {
|
||||
classId?: string;
|
||||
priority?: string;
|
||||
keyword?: string;
|
||||
date?: string;
|
||||
createdBy?: string;
|
||||
} = {};
|
||||
if (filter.classId) params.classId = filter.classId;
|
||||
if (filter.priority) params.priority = filter.priority;
|
||||
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
|
||||
if (filter.date) params.date = filter.date;
|
||||
if (filter.createdBy) params.createdBy = filter.createdBy;
|
||||
notifications.value = await notificationApi.list(params);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -236,48 +277,34 @@ interface PublishFailure {
|
||||
}
|
||||
|
||||
/**
|
||||
* 真正提交:对每个班级各调一次 `POST /api/notifications`(Promise.allSettled),
|
||||
* confirmDuringClass 为 true 表示教师已在上课警告中确认。
|
||||
* 真正提交:调用服务端的**批量发布**端点(`POST /api/notifications/bulk`)一次提交,
|
||||
* 服务端逐班做权限断言与上课保护,并把每个班的结果回给我们。
|
||||
*
|
||||
* 为什么不再由前端对每个班各发一次请求:N 个班就是 N 次往返,权限/上课保护/ClassIsland 推送
|
||||
* 分散在 N 个响应里,前端还得自己合并;服务端批量端点把这些收敛成一份逐班结果(见 AGENTS.md §5)。
|
||||
* confirmDuringClass 为 true 表示教师已在上课警告里确认。
|
||||
*/
|
||||
async function doPublish(classIds: string[], confirmDuringClass: boolean): Promise<void> {
|
||||
if (classIds.length === 0) return;
|
||||
|
||||
const payload = {
|
||||
const result = await notificationApi.bulkCreate({
|
||||
classIds,
|
||||
title: form.title.trim(),
|
||||
content: form.content,
|
||||
priority: form.priority,
|
||||
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
|
||||
};
|
||||
|
||||
const settled = await Promise.allSettled(
|
||||
classIds.map((classId) => notificationApi.create({ ...payload, classId })),
|
||||
);
|
||||
|
||||
let successCount = 0;
|
||||
const failures: PublishFailure[] = [];
|
||||
|
||||
settled.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount += 1;
|
||||
return;
|
||||
}
|
||||
const response = (
|
||||
result.reason as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: { code?: string; message?: string; details?: { current?: ClassPeriod | null } };
|
||||
};
|
||||
}
|
||||
).response;
|
||||
failures.push({
|
||||
classId: classIds[index] ?? '',
|
||||
reason:
|
||||
response?.data?.message ?? (result.reason instanceof Error ? result.reason.message : '发布失败'),
|
||||
urgentConflict: response?.status === 409 && response?.data?.code === 'URGENT_DURING_CLASS',
|
||||
period: response?.data?.details?.current ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
const successCount = result.succeeded;
|
||||
const failures: PublishFailure[] = result.results
|
||||
.filter((item) => !item.ok)
|
||||
.map((item) => ({
|
||||
classId: item.classId,
|
||||
reason: item.message ?? '发布失败',
|
||||
urgentConflict: item.code === 'URGENT_DURING_CLASS',
|
||||
period: ((item.details ?? {}) as { current?: ClassPeriod | null }).current ?? null,
|
||||
}));
|
||||
|
||||
// 兜底:并发场景下服务端仍可能拦截(例如刚好上课铃响),对这批班级重新走一次二次确认
|
||||
const conflicted = failures.filter((item) => item.urgentConflict);
|
||||
const pendingConfirm = !confirmDuringClass && conflicted.length > 0;
|
||||
@@ -296,7 +323,7 @@ async function doPublish(classIds: string[], confirmDuringClass: boolean): Promi
|
||||
// 注意这里是 pendingConfirm 而不是 !pendingConfirm —— 写成取反会让弹窗在**发布成功后仍然开着**,
|
||||
// 老师以为没发出去再点一次「立即发布」,同一批班级就重复收到通知(还会重复广播与推 ClassIsland)。
|
||||
formVisible.value = pendingConfirm;
|
||||
await loadNotifications();
|
||||
await refreshList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +390,7 @@ function rowClassName({ row }: { row: NotificationDto }): string {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadClasses();
|
||||
await loadNotifications();
|
||||
await refreshList();
|
||||
realtime.on(SOCKET_EVENTS.notificationNew, onNotificationEvent);
|
||||
// 列表渲染完再滚动,否则表格行还没生成
|
||||
if (highlightedId.value) {
|
||||
@@ -390,7 +417,7 @@ onUnmounted(() => {
|
||||
placeholder="选择班级"
|
||||
clearable
|
||||
style="width: 170px"
|
||||
@change="loadNotifications"
|
||||
@change="refreshList"
|
||||
>
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
@@ -399,7 +426,7 @@ onUnmounted(() => {
|
||||
placeholder="全部优先级"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
@change="loadNotifications"
|
||||
@change="refreshList"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in NOTIFICATION_PRIORITIES"
|
||||
@@ -408,6 +435,36 @@ onUnmounted(() => {
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
<!-- 按天归档:只列当前班级/优先级下真实存在的日期与条数 -->
|
||||
<el-select
|
||||
v-model="filter.date"
|
||||
placeholder="按天归档"
|
||||
clearable
|
||||
style="width: 170px"
|
||||
@change="refreshList"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in days"
|
||||
:key="item.date"
|
||||
:label="`${item.date}(${item.count})`"
|
||||
:value="item.date"
|
||||
/>
|
||||
</el-select>
|
||||
<!-- 按发布人归档:跟着班级/优先级/日期收敛 -->
|
||||
<el-select
|
||||
v-model="filter.createdBy"
|
||||
placeholder="全部发布人"
|
||||
clearable
|
||||
style="width: 160px"
|
||||
@change="loadNotifications"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in creators"
|
||||
:key="item.id"
|
||||
:label="`${item.name}(${item.count})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="filter.keyword"
|
||||
placeholder="搜索标题/内容"
|
||||
@@ -452,6 +509,12 @@ onUnmounted(() => {
|
||||
<div class="text-muted">{{ relativeTime(row.createdAt) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- 所属日期:按天归档用的口径(本地日期),晚间发布不会被算到第二天 -->
|
||||
<el-table-column label="所属日期" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.assignDate }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="isSingleClass ? '已读情况' : '已读率'" width="150">
|
||||
<template #default="{ row }">
|
||||
<!--
|
||||
@@ -489,7 +552,7 @@ onUnmounted(() => {
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="可多选;发布时逐班提交"
|
||||
placeholder="可多选;一次发布到所有选中班级"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 权限组管理(对应需求「允许 admin 设置各用户权限,或创建权限组」)。
|
||||
*
|
||||
* 两个 Tab:
|
||||
* 1. **权限组**:新建 / 编辑 / 删除权限组(一组权限键),内置组只允许改、禁止删;
|
||||
* 2. **成员**:把管理员 / 教师账号分配到权限组(不选任何组 = 回到角色默认模板)。
|
||||
*
|
||||
* 权限规则本身**不在这里实现**(AGENTS.md §5 第 3 条):本页只是把服务端
|
||||
* `GET /permissions/catalog` 给的目录、以及 `lib/permissions.ts` 的生效规则
|
||||
* 如实展示出来,真正的判定始终在服务端 —— 页面拿到的 `fromGroups` / `permissionCount`
|
||||
* 都是服务端算好的结果。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
ROLE_LABELS,
|
||||
type PermissionCatalogDto,
|
||||
type PermissionGroupDto,
|
||||
type PermissionKey,
|
||||
type PermissionMemberDto,
|
||||
type UserRole,
|
||||
} from '@classhelper/shared';
|
||||
import { permissionApi } from '@/api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useResponsive } from '@/composables/useResponsive';
|
||||
|
||||
const { isMobile } = useResponsive();
|
||||
const auth = useAuthStore();
|
||||
|
||||
/** 当前 Tab:权限组 / 成员 */
|
||||
const activeTab = ref<'groups' | 'members'>('groups');
|
||||
|
||||
const loading = ref(false);
|
||||
/** 加载失败的中文提示(非空 = 页面顶部显示错误态 + 重试按钮) */
|
||||
const loadError = ref('');
|
||||
const groups = ref<PermissionGroupDto[]>([]);
|
||||
const members = ref<PermissionMemberDto[]>([]);
|
||||
const catalog = ref<PermissionCatalogDto | null>(null);
|
||||
|
||||
/** 当前账号的生效权限数(还没拉回来时用 null 表示"未知",不显示 0) */
|
||||
const myPermissionCount = computed(() => (auth.permissionsLoaded ? auth.permissions.length : null));
|
||||
|
||||
/** 权限键总数(弹窗标题里的分母) */
|
||||
const catalogKeyCount = computed(() => catalog.value?.keys.length ?? 0);
|
||||
|
||||
/** 组 id → 组名(成员表把 groupIds 翻成名字) */
|
||||
const groupNameMap = computed<Record<string, string>>(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const group of groups.value) map[group.id] = group.name;
|
||||
return map;
|
||||
});
|
||||
|
||||
function roleLabel(role: UserRole): string {
|
||||
return ROLE_LABELS[role];
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误里取服务端的中文提示(取不到就退回 axios/浏览器的 message)。
|
||||
*
|
||||
* 注意 `http.ts` 的响应拦截器**已经**为带响应的错误弹过一次 `ElMessage.error`,
|
||||
* 所以这里只负责"拿文案",不再自己弹一遍 —— 见 {@link toastError}。
|
||||
*/
|
||||
function errorMessage(error: unknown): string {
|
||||
const response = (error as { response?: { data?: { message?: string } } } | null)?.response;
|
||||
const serverMessage = response?.data?.message;
|
||||
if (serverMessage) return serverMessage;
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return '请求失败';
|
||||
}
|
||||
|
||||
/**
|
||||
* 补一次失败提示。
|
||||
*
|
||||
* 只给"压根没拿到响应"的那一类(断网、超时、被 CSP 拦掉)补提示:带响应的错误
|
||||
* 拦截器已经弹过,这里再弹一次就会在屏幕上叠两条一模一样的 toast。有响应的错误
|
||||
* 落到页面内的错误态(`loadError`)。
|
||||
*/
|
||||
function toastError(error: unknown): void {
|
||||
if (!(error as { response?: unknown } | null)?.response) ElMessage.error(errorMessage(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉全部数据:权限组 + 成员 + 权限目录(目录只在首次拉,之后复用)。
|
||||
*
|
||||
* 一并刷新 `auth.ensurePermissions(true)`:顶栏那句"权限来自权限组 / 角色默认模板"
|
||||
* 必须是最新的(服务端每次请求实时算权限,改了立刻生效)。
|
||||
*/
|
||||
async function loadAll(): Promise<void> {
|
||||
loading.value = true;
|
||||
loadError.value = '';
|
||||
try {
|
||||
const [groupList, memberList, catalogData] = await Promise.all([
|
||||
permissionApi.listGroups(),
|
||||
permissionApi.listMembers(),
|
||||
catalog.value ? Promise.resolve(catalog.value) : permissionApi.catalog(),
|
||||
]);
|
||||
groups.value = groupList;
|
||||
members.value = memberList;
|
||||
catalog.value = catalogData;
|
||||
await auth.ensurePermissions(true);
|
||||
} catch (error) {
|
||||
loadError.value = errorMessage(error);
|
||||
toastError(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadAll();
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------- 权限组:新建 / 编辑 */
|
||||
|
||||
/** 新建与编辑共用的弹窗状态(`id` 为空串 = 新建) */
|
||||
const groupDialog = reactive({
|
||||
visible: false,
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
keys: [] as PermissionKey[],
|
||||
saving: false,
|
||||
});
|
||||
|
||||
const isEditingGroup = computed(() => Boolean(groupDialog.id));
|
||||
const groupDialogTitle = computed(() => (isEditingGroup.value ? '编辑权限组' : '新建权限组'));
|
||||
const groupKeysLabel = computed(() => `权限键(已选 ${groupDialog.keys.length} / ${catalogKeyCount.value})`);
|
||||
|
||||
function openCreateGroup(): void {
|
||||
groupDialog.id = '';
|
||||
groupDialog.name = '';
|
||||
groupDialog.description = '';
|
||||
groupDialog.keys = [];
|
||||
groupDialog.visible = true;
|
||||
}
|
||||
|
||||
function openEditGroup(group: PermissionGroupDto): void {
|
||||
groupDialog.id = group.id;
|
||||
groupDialog.name = group.name;
|
||||
groupDialog.description = group.description;
|
||||
// 复制一份:取消/关闭时不能污染表格里的数据
|
||||
groupDialog.keys = [...group.keys];
|
||||
groupDialog.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该键"默认给哪些角色"(来自目录的 `defaultRoles`,与服务端
|
||||
* `lib/permissions.ts` 的角色默认模板同源)。空数组 = 谁都不默认给。
|
||||
*/
|
||||
function defaultRoleHint(key: PermissionKey): string {
|
||||
const roles = catalog.value?.defaultRoles[key] ?? [];
|
||||
if (roles.length === 0) return '默认不给任何角色';
|
||||
return `默认给:${roles.map((role) => ROLE_LABELS[role]).join('、')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为"管理员不可被剥夺"的键(目录里的 `unremovable`)。
|
||||
*
|
||||
* **故意不禁用勾选框**:这两个键(`permission:manage` / `system:manage`)的真实语义是
|
||||
* "管理员账号永远保留",而不是"权限组里不能勾" —— 把 `permission:manage` 授给一个权限组
|
||||
* 恰恰是本功能的正当用法(让某位老师帮忙管权限),禁掉等于让这条链路没法配。
|
||||
* 这里只打一个「高权限」标记提醒配置的人。
|
||||
*/
|
||||
function isHighPrivilege(key: PermissionKey): boolean {
|
||||
return catalog.value?.unremovable.includes(key) ?? false;
|
||||
}
|
||||
|
||||
/** 分类勾选状态:全选 / 全不选 / 选了一部分(决定"全选-清空"按钮的文案) */
|
||||
function categoryState(keys: PermissionKey[]): 'all' | 'none' | 'part' {
|
||||
const selected = keys.filter((key) => groupDialog.keys.includes(key)).length;
|
||||
if (selected === 0) return 'none';
|
||||
return selected === keys.length ? 'all' : 'part';
|
||||
}
|
||||
|
||||
/** 整个分类一起勾 / 一起取消(40 个键一个个点太累) */
|
||||
function toggleCategory(keys: PermissionKey[]): void {
|
||||
if (categoryState(keys) === 'all') {
|
||||
groupDialog.keys = groupDialog.keys.filter((key) => !keys.includes(key));
|
||||
return;
|
||||
}
|
||||
const merged = new Set<PermissionKey>(groupDialog.keys);
|
||||
for (const key of keys) merged.add(key);
|
||||
groupDialog.keys = Array.from(merged);
|
||||
}
|
||||
|
||||
async function submitGroup(): Promise<void> {
|
||||
const name = groupDialog.name.trim();
|
||||
if (!name) {
|
||||
ElMessage.warning('请填写权限组名称');
|
||||
return;
|
||||
}
|
||||
// 先在前端拦一次重名(服务端也会 409),错误提示能更早出现
|
||||
if (groups.value.some((group) => group.id !== groupDialog.id && group.name === name)) {
|
||||
ElMessage.warning(`已存在名为「${name}」的权限组`);
|
||||
return;
|
||||
}
|
||||
groupDialog.saving = true;
|
||||
try {
|
||||
const payload = {
|
||||
name,
|
||||
description: groupDialog.description.trim(),
|
||||
// 传了就**整体替换**(服务端契约),所以这里始终带上完整勾选结果
|
||||
keys: [...groupDialog.keys],
|
||||
};
|
||||
if (isEditingGroup.value) {
|
||||
await permissionApi.updateGroup(groupDialog.id, payload);
|
||||
ElMessage.success('权限组已更新,组内成员立即生效');
|
||||
} else {
|
||||
await permissionApi.createGroup(payload);
|
||||
ElMessage.success('权限组已创建');
|
||||
}
|
||||
groupDialog.visible = false;
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
toastError(error);
|
||||
} finally {
|
||||
groupDialog.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeGroup(group: PermissionGroupDto): Promise<void> {
|
||||
// 按钮已经 disabled,这里只是兜底(服务端也会 400)
|
||||
if (group.builtin) return;
|
||||
const memberHint =
|
||||
group.memberCount > 0
|
||||
? `组内 ${group.memberCount} 名成员会回到角色默认权限(不会变成无权限)。`
|
||||
: '该组目前没有成员。';
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除权限组「${group.name}」?${memberHint}`, '确认删除', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
});
|
||||
} catch {
|
||||
return; // 用户取消
|
||||
}
|
||||
try {
|
||||
await permissionApi.removeGroup(group.id);
|
||||
ElMessage.success('已删除');
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
toastError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- 成员:分配权限组 */
|
||||
|
||||
const assignDialog = reactive({
|
||||
visible: false,
|
||||
member: null as PermissionMemberDto | null,
|
||||
groupIds: [] as string[],
|
||||
saving: false,
|
||||
});
|
||||
|
||||
function openAssign(member: PermissionMemberDto): void {
|
||||
if (member.fixed) return; // 管理员固定拥有全部权限(服务端同样 400)
|
||||
assignDialog.member = member;
|
||||
assignDialog.groupIds = [...member.groupIds];
|
||||
assignDialog.visible = true;
|
||||
}
|
||||
|
||||
/** 该成员角色"不选任何组"时会拿到多少项权限(目录里的 `roleDefaults`) */
|
||||
const roleDefaultCount = computed(() => {
|
||||
const role = assignDialog.member?.role;
|
||||
if (!role || !catalog.value) return 0;
|
||||
return catalog.value.roleDefaults[role].length;
|
||||
});
|
||||
|
||||
async function submitAssign(): Promise<void> {
|
||||
const member = assignDialog.member;
|
||||
if (!member) return;
|
||||
assignDialog.saving = true;
|
||||
try {
|
||||
// 空数组 = 回到角色默认模板(服务端契约)
|
||||
await permissionApi.assignGroups(member.id, { groupIds: [...assignDialog.groupIds] });
|
||||
ElMessage.success('已更新该成员的权限组,立即生效');
|
||||
assignDialog.visible = false;
|
||||
await loadAll();
|
||||
} catch (error) {
|
||||
toastError(error);
|
||||
} finally {
|
||||
assignDialog.saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">权限组管理</h2>
|
||||
<p class="page-subtitle">
|
||||
创建权限组并把账号分配进去,用来给老师额外开通某项能力(也可以反过来收回权限)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
生效规则说明:这一句是页面的"图例",两端的生效逻辑都按它来
|
||||
(服务端 lib/permissions.ts 的 loadEffectivePermissions)。
|
||||
-->
|
||||
<el-alert class="perm-banner" type="info" :closable="false" show-icon>
|
||||
<template #title>
|
||||
权限生效规则:有权限组时取各组的并集;没有任何组时使用角色默认模板。
|
||||
</template>
|
||||
<template #default>
|
||||
当前账号:{{ auth.roleLabel }}
|
||||
<template v-if="myPermissionCount !== null"> · 生效权限 {{ myPermissionCount }} 项</template>
|
||||
· {{ auth.permissionsFromGroups ? '权限来自权限组' : '未分配权限组(走角色默认模板)' }}
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<!-- 错误态:拦截器已经弹过一次 toast,这里给一个能重试的落点 -->
|
||||
<el-alert v-if="loadError" class="perm-banner" type="error" :closable="false" show-icon>
|
||||
<template #title>权限数据加载失败:{{ loadError }}</template>
|
||||
<template #default>
|
||||
<el-button link type="primary" @click="loadAll">重试</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<!-- ------------------------------------------------ Tab 1:权限组 -->
|
||||
<el-tab-pane label="权限组" name="groups">
|
||||
<div class="toolbar tab-toolbar">
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreateGroup">新建权限组</el-button>
|
||||
<span class="text-muted">内置权限组由系统预置,可以改名与调整权限,但不能删除。</span>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="groups" empty-text="暂无权限组">
|
||||
<el-table-column prop="name" label="名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="描述" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.description">{{ row.description }}</span>
|
||||
<span v-else class="text-muted">(无描述)</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="权限数" width="90">
|
||||
<template #default="{ row }">{{ row.keys.length }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成员数" width="90">
|
||||
<template #default="{ row }">{{ row.memberCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain" :type="row.builtin ? 'info' : 'primary'">
|
||||
{{ row.builtin ? '内置' : '自定义' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEditGroup(row)">编辑</el-button>
|
||||
<!-- 内置组禁止删除:按钮 disabled + tooltip 说明(disabled 元素不发光标事件,外面套一层 span) -->
|
||||
<el-tooltip :disabled="!row.builtin" content="内置权限组不能删除" placement="top">
|
||||
<span>
|
||||
<el-button link type="danger" :disabled="row.builtin" @click="removeGroup(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ------------------------------------------------ Tab 2:成员 -->
|
||||
<el-tab-pane label="成员" name="members">
|
||||
<div class="toolbar tab-toolbar">
|
||||
<span class="text-muted">
|
||||
只列出管理员与教师账号;管理员固定拥有全部权限,不能被分配权限组。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="members" empty-text="暂无成员">
|
||||
<el-table-column prop="name" label="姓名" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="username" label="用户名" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="角色" width="130">
|
||||
<template #default="{ row }">{{ roleLabel(row.role) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="所属权限组" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.groupIds.length">
|
||||
<el-tag
|
||||
v-for="groupId in row.groupIds"
|
||||
:key="groupId"
|
||||
class="perm-tag"
|
||||
size="small"
|
||||
effect="plain"
|
||||
>
|
||||
{{ groupNameMap[groupId] ?? '(权限组已删除)' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">角色默认模板</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="生效权限数" width="110">
|
||||
<template #default="{ row }">{{ row.permissionCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :disabled="!row.fixed" content="管理员固定拥有全部权限" placement="top">
|
||||
<span>
|
||||
<el-button link type="primary" :disabled="row.fixed" @click="openAssign(row)">
|
||||
分配权限组
|
||||
</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- ------------------------------------------------ 新建 / 编辑权限组 -->
|
||||
<el-dialog
|
||||
v-model="groupDialog.visible"
|
||||
:title="groupDialogTitle"
|
||||
:width="isMobile ? '92%' : '720px'"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input
|
||||
v-model="groupDialog.name"
|
||||
maxlength="30"
|
||||
show-word-limit
|
||||
placeholder="例如:教务协助、机房值班"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input
|
||||
v-model="groupDialog.description"
|
||||
maxlength="100"
|
||||
placeholder="这个权限组是做什么的(可留空)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item :label="groupKeysLabel">
|
||||
<div class="perm-picker">
|
||||
<div v-for="category in catalog?.categories ?? []" :key="category.title" class="perm-category">
|
||||
<div class="perm-category-head">
|
||||
<span class="perm-category-title">{{ category.title }}</span>
|
||||
<el-button link type="primary" size="small" @click="toggleCategory(category.keys)">
|
||||
{{ categoryState(category.keys) === 'all' ? '清空' : '全选' }}
|
||||
</el-button>
|
||||
</div>
|
||||
<!-- 每个分类一个 checkbox-group,但共用同一个 keys 数组 -->
|
||||
<el-checkbox-group v-model="groupDialog.keys" class="perm-keys">
|
||||
<div v-for="key in category.keys" :key="key" class="perm-key">
|
||||
<el-checkbox :value="key">
|
||||
{{ catalog?.labels[key] ?? key }}
|
||||
<el-tag
|
||||
v-if="isHighPrivilege(key)"
|
||||
class="perm-flag"
|
||||
size="small"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
>
|
||||
高权限
|
||||
</el-tag>
|
||||
</el-checkbox>
|
||||
<span class="perm-hint">{{ defaultRoleHint(key) }}</span>
|
||||
</div>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</div>
|
||||
<p class="perm-note text-muted">
|
||||
保存时会整体替换该组的权限键(取消勾选 = 收回这项权限);不填任何键则该组成员没有任何权限。
|
||||
</p>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="groupDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="groupDialog.saving" @click="submitGroup">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ------------------------------------------------ 给成员分配权限组 -->
|
||||
<el-dialog
|
||||
v-model="assignDialog.visible"
|
||||
title="分配权限组"
|
||||
:width="isMobile ? '92%' : '520px'"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<template v-if="assignDialog.member">
|
||||
<p class="assign-member">
|
||||
{{ assignDialog.member.name }}({{ assignDialog.member.username }} ·
|
||||
{{ roleLabel(assignDialog.member.role) }})
|
||||
</p>
|
||||
<el-select
|
||||
v-model="assignDialog.groupIds"
|
||||
class="assign-select"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
placeholder="不选任何组 = 使用角色默认模板"
|
||||
>
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id">
|
||||
<span>{{ group.name }}</span>
|
||||
<span class="text-muted">({{ group.keys.length }} 项权限)</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<p class="assign-hint text-muted">
|
||||
不选任何组 = 使用角色默认模板({{ roleLabel(assignDialog.member.role) }} 默认
|
||||
{{ roleDefaultCount }} 项权限);选了组就取各组权限的并集。
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="assignDialog.saving" @click="submitAssign">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 顶部两条横幅(生效规则 / 错误态):圆角与底都用设计令牌,不写死数值 */
|
||||
.perm-banner {
|
||||
margin-bottom: 12px;
|
||||
border-radius: var(--ch-radius-sm);
|
||||
}
|
||||
|
||||
.tab-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 权限键勾选区:两列铺开,自己内部滚动,避免弹窗比屏幕还高 */
|
||||
.perm-picker {
|
||||
width: 100%;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 12px 12px;
|
||||
border: 1px solid var(--ch-border);
|
||||
border-radius: var(--ch-radius-md);
|
||||
background: var(--ch-surface-alt);
|
||||
}
|
||||
|
||||
.perm-category + .perm-category {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.perm-category-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 0 2px;
|
||||
border-bottom: 1px solid var(--ch-border);
|
||||
}
|
||||
|
||||
.perm-category-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.perm-keys {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 2px 16px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.perm-key {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.perm-hint {
|
||||
color: var(--ch-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.perm-flag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.perm-note {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.perm-tag {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.assign-member {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.assign-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.assign-hint {
|
||||
margin: 10px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 小屏(≤768px):勾选区改一列,说明文字与表格卡片同宽 */
|
||||
@media (max-width: 768px) {
|
||||
.perm-keys {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type ScheduleWeekView,
|
||||
type WeekParity,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
|
||||
import { classApi, courseApi, courseIdFromExistsError, dashboardApi, scheduleApi } from '@/api';
|
||||
import ClassPlanImportDialog from '@/components/ClassPlanImportDialog.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
@@ -122,9 +122,17 @@ async function resolveCourseId(): Promise<string | null> {
|
||||
const name = value.slice(SUBJECT_PREFIX.length);
|
||||
const existing = courses.value.find((item) => item.name === name);
|
||||
if (existing) return existing.id;
|
||||
const created = await courseApi.create({ classId: query.classId, name });
|
||||
courses.value = [...courses.value, created];
|
||||
return created.id;
|
||||
try {
|
||||
const created = await courseApi.create({ classId: query.classId, name });
|
||||
courses.value = [...courses.value, created];
|
||||
return created.id;
|
||||
} catch (error) {
|
||||
// 该班已有同名课程(本地列表过期):复用那一门,绝不另建第二门同名的课
|
||||
// —— 课程创建会把创建者写成任课老师,重复建课正是"把自己变成别人科目的老师"的入口
|
||||
const reusedId = courseIdFromExistsError(error);
|
||||
if (!reusedId) throw error;
|
||||
return reusedId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user