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

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

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

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

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

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

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

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

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

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

No files matched your search

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