feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)
学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。 学生不是账号 - 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段 - 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支 - 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher - 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失 任课关系唯一来源 = Course(班级 + 科目 + 教师) - 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号 - 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员 - 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关) 成绩与名单 - 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级 - 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制 - 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属 - 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业) - 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」 ClassHelper 班级端在线状态 - Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳 - lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开 学期周次 - 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置 - 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常) - 改周次后随课表下发给教室的 ClassHelper 班级端 课表方向反转 - 主方向改为「服务端 → 教室」自动下发;教室不再自动回传 - 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报 改名 - 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、 安装目录、systemd 描述、插件清单与设置页、官网、文档站、README BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe), 安装目录随之改变,不支持覆盖安装,需先卸载旧版。 服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢), 升级前请先备份。
This commit is contained in:
1 parent
17f55864bf
commit
9d5ac7adf9
172 files changed
+8761
-1896
No files matched your search
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#409eff" />
|
||||
<meta name="description" content="班级小助手管理端:班级、课表、作业、通知与成绩管理" />
|
||||
<meta name="description" content="ClassHelper管理端:班级、课表、作业、通知与成绩管理" />
|
||||
<!-- PWA:支持在 Chrome/Edge 中「安装为应用」 -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
@@ -12,8 +12,8 @@
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="班级小助手" />
|
||||
<title>班级小助手 · 管理端</title>
|
||||
<meta name="apple-mobile-web-app-title" content="ClassHelper" />
|
||||
<title>ClassHelper · 管理端</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@classhelper/web-admin",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"description": "班级小助手 Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import {
|
||||
API_PATHS,
|
||||
type ArchivedClassContentDto,
|
||||
type ArchivedYearDetailDto,
|
||||
type ArchivedYearDto,
|
||||
type ClassDetailDto,
|
||||
type ClassDto,
|
||||
type AutoTermWeeksRequest,
|
||||
type ClassHelperStatusListDto,
|
||||
type ClassPlanImportResultDto,
|
||||
type ClassPlanPreviewDto,
|
||||
type ClassStatusDto,
|
||||
type CourseDto,
|
||||
type CreateArchiveRequest,
|
||||
type CreateClassRequest,
|
||||
type CreateCallRequest,
|
||||
type CreateCourseRequest,
|
||||
@@ -14,6 +20,7 @@ import {
|
||||
type CreateIntegrationDeviceRequest,
|
||||
type CreateNotificationRequest,
|
||||
type CreateScheduleRequest,
|
||||
type CreateStudentRequest,
|
||||
type DashboardSummary,
|
||||
type DatabaseBackupMetaDto,
|
||||
type DatabaseBackupScheduleDto,
|
||||
@@ -21,7 +28,9 @@ import {
|
||||
type DatabaseStatusDto,
|
||||
type DatabaseSwitchJobDto,
|
||||
type GradeDto,
|
||||
type GradeLevelType,
|
||||
type GradeStats,
|
||||
type HolidaySuggestionDto,
|
||||
type HomeworkDaysDto,
|
||||
type HomeworkDto,
|
||||
type HomeworkSubmissionsDto,
|
||||
@@ -30,13 +39,20 @@ import {
|
||||
type LoginRequest,
|
||||
type LoginResponse,
|
||||
type NotificationDto,
|
||||
type PaginatedResult,
|
||||
type ScheduleDto,
|
||||
type ScheduleWeekView,
|
||||
type SendClassIslandNotificationRequest,
|
||||
type SendClassIslandNotificationResult,
|
||||
type SessionUser,
|
||||
type StudentDto,
|
||||
type StudentGradeDetailDto,
|
||||
type StudentStatus,
|
||||
type StudentTransferDto,
|
||||
type SubjectTeacherDto,
|
||||
type TableImportPreview,
|
||||
type TableImportResult,
|
||||
type TermWeeksDto,
|
||||
type TimeLayoutDto,
|
||||
type TimeLayoutImportResult,
|
||||
type TimeLayoutParsePreview,
|
||||
@@ -44,6 +60,8 @@ import {
|
||||
type UpdateClassRequest,
|
||||
type UpdateInfo,
|
||||
type UpdateIntegrationDeviceRequest,
|
||||
type UpdateStudentRequest,
|
||||
type UpdateTermWeeksRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
import { api } from './http';
|
||||
@@ -53,7 +71,8 @@ import { api } from './http';
|
||||
export const authApi = {
|
||||
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
|
||||
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
|
||||
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
|
||||
/** 当前会话主体:教师/管理员是账号,ClassHelper 班级端是班级 */
|
||||
me: (): Promise<SessionUser> => api.get(API_PATHS.auth.me),
|
||||
changePassword: (payload: {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
@@ -72,20 +91,33 @@ export const classApi = {
|
||||
students: (id: string): Promise<StudentDto[]> => api.get(`${API_PATHS.classes}/${id}/students`),
|
||||
addStudent: (
|
||||
id: string,
|
||||
payload: { username: string; name: string; password?: string },
|
||||
payload: { studentNo: string; name: string; gender?: string; guardianPhone?: string },
|
||||
): Promise<StudentDto> => api.post(`${API_PATHS.classes}/${id}/students`, payload),
|
||||
removeStudent: (id: string, userId: string): Promise<unknown> =>
|
||||
api.delete(`${API_PATHS.classes}/${id}/students/${userId}`),
|
||||
assignTeacher: (id: string, teacherId: string): Promise<unknown> =>
|
||||
api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }),
|
||||
/** 设置 / 更改班主任:仅管理员 */
|
||||
removeStudent: (id: string, studentId: string): Promise<unknown> =>
|
||||
api.delete(`${API_PATHS.classes}/${id}/students/${studentId}`),
|
||||
/** 设置 / 更改班主任(每班 1 人):仅管理员 */
|
||||
assignHeadTeacher: (id: string, teacherId: string): Promise<ClassDto> =>
|
||||
api.patch(`${API_PATHS.classes}/${id}/head-teacher`, { teacherId }),
|
||||
removeTeacher: (id: string, teacherId: string): Promise<unknown> =>
|
||||
api.delete(`${API_PATHS.classes}/${id}/teachers/${teacherId}`),
|
||||
/**
|
||||
* 设置各科任课老师(班级 + 科目 + 教师):仅管理员。
|
||||
* 这是「科任老师」这条关系的唯一写入口。
|
||||
*/
|
||||
assignSubjectTeachers: (
|
||||
id: string,
|
||||
assignments: Array<{ courseId?: string | null; subjectName: string; teacherId?: string | null }>,
|
||||
): Promise<SubjectTeacherDto[]> =>
|
||||
api.put(`${API_PATHS.classes}/${id}/subject-teachers`, { assignments }),
|
||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||
updateAccount: (id: string, payload: UpdateClassAccountRequest): Promise<ClassDto> =>
|
||||
api.patch(`${API_PATHS.classes}/${id}/class-account`, payload),
|
||||
/** 班级端按学号查成绩的开关 */
|
||||
getGradeQuery: (id: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||||
api.get(`${API_PATHS.classes}/${id}/student-grade-query`),
|
||||
setGradeQuery: (id: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||||
api.patch(`${API_PATHS.classes}/${id}/student-grade-query`, { enabled }),
|
||||
/** 学年升级:只改年级,不归档、数据沿用 */
|
||||
promote: (classIds: string[], grade: string): Promise<{ promoted: number }> =>
|
||||
api.post(`${API_PATHS.classes}/promote`, { classIds, grade }),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 课程 */
|
||||
@@ -134,11 +166,11 @@ export const homeworkApi = {
|
||||
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}`),
|
||||
/** 提交名单 / 未交名单(教师与班级设备可用) */
|
||||
/** 提交名单 / 未交名单(教师与 ClassHelper 班级端可用) */
|
||||
submissions: (id: string): Promise<HomeworkSubmissionsDto> =>
|
||||
api.get(`${API_PATHS.homeworks}/${id}/submissions`),
|
||||
saveSubmissions: (id: string, notSubmittedUserIds: string[]): Promise<HomeworkSubmissionsDto> =>
|
||||
api.patch(`${API_PATHS.homeworks}/${id}/submissions`, { notSubmittedUserIds }),
|
||||
saveSubmissions: (id: string, notSubmittedStudentIds: string[]): Promise<HomeworkSubmissionsDto> =>
|
||||
api.patch(`${API_PATHS.homeworks}/${id}/submissions`, { notSubmittedStudentIds }),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 通知 */
|
||||
@@ -160,7 +192,7 @@ export const notificationApi = {
|
||||
/* ------------------------------------------------------------------ 叫人 */
|
||||
|
||||
export const callApi = {
|
||||
/** 点名让学生来找老师(学生端灵动岛会立即弹出"请 XXX 同学找 XXX 老师") */
|
||||
/** 点名让学生来找老师(ClassHelper 班级端灵动岛会立即弹出"请 XXX 同学找 XXX 老师") */
|
||||
create: (payload: CreateCallRequest): Promise<NotificationDto> => api.post(API_PATHS.calls, payload),
|
||||
};
|
||||
|
||||
@@ -170,7 +202,8 @@ export const gradeApi = {
|
||||
list: (params: {
|
||||
classId?: string;
|
||||
courseId?: string;
|
||||
userId?: string;
|
||||
/** 学生 id(学生是名单记录,不是账号) */
|
||||
studentId?: string;
|
||||
examName?: string;
|
||||
}): Promise<GradeDto[]> => api.get(API_PATHS.grades, params),
|
||||
stats: (params: { classId?: string; courseId?: string; examName?: string }): Promise<GradeStats> =>
|
||||
@@ -181,27 +214,55 @@ export const gradeApi = {
|
||||
courseId?: string | null;
|
||||
examName: string;
|
||||
totalScore?: number;
|
||||
levelType?: GradeLevelType;
|
||||
publishedAt?: string | null;
|
||||
items: Array<{ userId: string; score: number }>;
|
||||
items: Array<{ studentId: string; score: number; level?: string }>;
|
||||
}): Promise<{ count: number; items: GradeDto[] }> => api.post(`${API_PATHS.grades}/bulk`, payload),
|
||||
update: (id: string, payload: Partial<CreateGradeRequest>): Promise<GradeDto> =>
|
||||
api.patch(`${API_PATHS.grades}/${id}`, payload),
|
||||
/** 批量改等级:逐条指定;或整批重算(classId + examName + levelType) */
|
||||
updateLevels: (
|
||||
payload:
|
||||
| { items: Array<{ id: string; level: string }> }
|
||||
| { classId: string; examName: string; courseId?: string | null; levelType: GradeLevelType },
|
||||
): Promise<{ updated: number }> => api.patch(`${API_PATHS.grades}/levels`, payload),
|
||||
/** 按**学号**查某个学生的成绩明细(教师端"按学号查成绩") */
|
||||
studentDetail: (studentNo: string, classId?: string): Promise<StudentGradeDetailDto> =>
|
||||
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`, classId ? { classId } : undefined),
|
||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.grades}/${id}`),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 学生 */
|
||||
|
||||
// 学生是"名单"不是"账号":没有密码相关接口(登录统一走班级码 + 班级密码)
|
||||
// 学生是「名单记录」不是「账号」:没有密码相关接口,登录统一走班级码 + 班级密码。
|
||||
// 学号(studentNo)是学生的唯一标识与查询键。
|
||||
export const studentApi = {
|
||||
list: (params?: { classId?: string; keyword?: string }): Promise<StudentDto[]> =>
|
||||
api.get(API_PATHS.students, params),
|
||||
create: (payload: { username: string; name: string; classId?: string | null }): Promise<StudentDto> =>
|
||||
api.post(API_PATHS.students, payload),
|
||||
update: (
|
||||
id: string,
|
||||
payload: { username?: string; name?: string; classId?: string | null },
|
||||
): Promise<StudentDto> => api.patch(`${API_PATHS.students}/${id}`, payload),
|
||||
list: (params?: {
|
||||
classId?: string;
|
||||
keyword?: string;
|
||||
status?: StudentStatus;
|
||||
includeArchived?: boolean;
|
||||
}): Promise<StudentDto[]> => api.get(API_PATHS.students, params),
|
||||
create: (payload: CreateStudentRequest): Promise<StudentDto> => api.post(API_PATHS.students, payload),
|
||||
update: (id: string, payload: UpdateStudentRequest): Promise<StudentDto> =>
|
||||
api.patch(`${API_PATHS.students}/${id}`, payload),
|
||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.students}/${id}`),
|
||||
/** 调班:单个与批量同一接口(studentIds 长度 1 即单个);学号不变、历史可查 */
|
||||
transfer: (studentIds: string[], toClassId: string | null, note?: string): Promise<{ moved: number }> =>
|
||||
api.post(`${API_PATHS.students}/transfer`, { studentIds, toClassId, note }),
|
||||
/** 转出(学籍离开本校,不是调班) */
|
||||
transferOut: (studentIds: string[], note?: string): Promise<{ transferred: number }> =>
|
||||
api.post(`${API_PATHS.students}/transfer-out`, { studentIds, note }),
|
||||
/** 某个学生的调班 / 转出历史 */
|
||||
transfersOf: (id: string): Promise<PaginatedResult<StudentTransferDto>> =>
|
||||
api.get(`${API_PATHS.students}/${id}/transfers`),
|
||||
/** 调班历史(可按学生或班级过滤) */
|
||||
transfers: (params?: {
|
||||
studentId?: string;
|
||||
classId?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<PaginatedResult<StudentTransferDto>> => api.get(`${API_PATHS.students}/transfers`, params),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 仪表盘 */
|
||||
@@ -218,51 +279,100 @@ export const dashboardApi = {
|
||||
|
||||
/* ------------------------------------------------------------------ 教师管理(仅管理员) */
|
||||
|
||||
/** 教师的 `username` 就是**工号**(也是登录名),界面上统一叫「工号」 */
|
||||
export const teacherApi = {
|
||||
list: (keyword?: string): Promise<UserDto[]> =>
|
||||
api.get(API_PATHS.teachers, keyword ? { keyword } : undefined),
|
||||
create: (payload: {
|
||||
username: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
password?: string;
|
||||
role?: 'TEACHER' | 'ADMIN';
|
||||
}): Promise<UserDto> => api.post(API_PATHS.teachers, payload),
|
||||
update: (
|
||||
id: string,
|
||||
payload: { username?: string; name?: string; role?: 'TEACHER' | 'ADMIN' },
|
||||
payload: { username?: string; name?: string; phone?: string; role?: 'TEACHER' | 'ADMIN' },
|
||||
): Promise<UserDto> => api.patch(`${API_PATHS.teachers}/${id}`, payload),
|
||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.teachers}/${id}`),
|
||||
resetPassword: (id: string, newPassword?: string): Promise<unknown> =>
|
||||
api.post(`${API_PATHS.teachers}/${id}/reset-password`, newPassword ? { newPassword } : {}),
|
||||
/** 班级详情中的 teachers 字段即为已分配的协作教师 */
|
||||
fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 毕业归档(仅管理员) */
|
||||
|
||||
/**
|
||||
* 归档是**打标记 + 只读**,不删数据:毕业班级的作业与通知原样留在库里,
|
||||
* 通过 `classContent()` 只读查看。「未毕业而升级的班级不归档」走 `classApi.promote`。
|
||||
*/
|
||||
export const archiveApi = {
|
||||
list: (): Promise<ArchivedYearDto[]> => api.get(API_PATHS.archives),
|
||||
summary: (): Promise<{
|
||||
archiveCount: number;
|
||||
classCount: number;
|
||||
graduateCount: number;
|
||||
transferredCount: number;
|
||||
suggestedEnrollmentYears: number[];
|
||||
}> => api.get(`${API_PATHS.archives}/summary`),
|
||||
detail: (id: string): Promise<ArchivedYearDetailDto> => api.get(`${API_PATHS.archives}/${id}`),
|
||||
/** 归档班级的作业与通知(只读) */
|
||||
classContent: (id: string, classId: string, limit = 200): Promise<ArchivedClassContentDto> =>
|
||||
api.get(`${API_PATHS.archives}/${id}/classes/${classId}/content`, { limit }),
|
||||
create: (payload: CreateArchiveRequest): Promise<ArchivedYearDetailDto> =>
|
||||
api.post(API_PATHS.archives, payload),
|
||||
/** 撤销归档(误操作的退路,不删任何数据) */
|
||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.archives}/${id}`),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 学期周次(仅管理员可写) */
|
||||
|
||||
/**
|
||||
* 学期周次:逐周指定实际的起止日期。
|
||||
*
|
||||
* 课表、作业、成绩都按教学周组织,而"开学日期 + 每周七天"的线性推算遇到调休、
|
||||
* 周末补课、错峰开学就会整体错位 —— 所以支持逐周配置。
|
||||
* `classId` 留空 = 全校默认;保存后变更会随课表下发给教室的 ClassHelper 班级端。
|
||||
*/
|
||||
export const termApi = {
|
||||
get: (classId?: string): Promise<TermWeeksDto> =>
|
||||
api.get(API_PATHS.term, classId ? { classId } : undefined),
|
||||
save: (payload: UpdateTermWeeksRequest): Promise<TermWeeksDto> => api.put(API_PATHS.term, payload),
|
||||
/** 按学期开始日期一键生成逐周区间(配置的起点) */
|
||||
auto: (payload: AutoTermWeeksRequest): Promise<TermWeeksDto> => api.post(`${API_PATHS.term}/auto`, payload),
|
||||
/** 联网获取法定节假日安排(只给建议;机房没外网时 ok=false 属正常) */
|
||||
holidays: (year?: number): Promise<HolidaySuggestionDto> =>
|
||||
api.get(`${API_PATHS.term}/holidays`, year ? { year } : undefined),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 导入(模板 / 表格 / 课表时间配置 / ClassIsland 课程表) */
|
||||
|
||||
type TableKind = 'grades' | 'students' | 'teachers' | 'classTeachers';
|
||||
|
||||
export const importApi = {
|
||||
/** 模板下载地址(CSV 走 JSON,XLSX 走二进制) */
|
||||
template: (
|
||||
kind: 'grades' | 'students' | 'teachers',
|
||||
kind: TableKind,
|
||||
format: 'csv' | 'xlsx' = 'csv',
|
||||
): Promise<{ kind: string; format: string; fileName: string; content: string }> =>
|
||||
api.get(`${API_PATHS.imports}/template`, { kind, format }),
|
||||
|
||||
/** 上传表格并预览(解析 + 必填列校验 + 建议映射,不写库) */
|
||||
previewTable: (payload: {
|
||||
kind: 'grades' | 'students' | 'teachers';
|
||||
kind: TableKind;
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
}): Promise<TableImportPreview> => api.post(`${API_PATHS.imports}/table/preview`, payload),
|
||||
|
||||
/** 确认字段映射与写入模式后执行导入(教师名单与班级无关,classId 可省略) */
|
||||
commitTable: (payload: {
|
||||
kind: 'grades' | 'students' | 'teachers';
|
||||
kind: TableKind;
|
||||
classId?: string;
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
mapping: Record<string, string>;
|
||||
mode: 'append' | 'upsert';
|
||||
/** 班级任课老师导入:是否用「工号」匹配已有教师账号 */
|
||||
useTeacherNo?: boolean;
|
||||
}): Promise<TableImportResult> => api.post(`${API_PATHS.imports}/table/commit`, payload),
|
||||
|
||||
/** ClassIsland 时间配置解析预览(不写库) */
|
||||
@@ -326,9 +436,23 @@ export const integrationApi = {
|
||||
api.post(`${API_PATHS.integrations}/devices/${id}/token`, {}),
|
||||
removeDevice: (id: string): Promise<{ id: string }> =>
|
||||
api.delete(`${API_PATHS.integrations}/devices/${id}`),
|
||||
/**
|
||||
* 「**从教室机器获取一次课表**」:置一个待办,插件在下一次心跳把当前课表推上来。
|
||||
*
|
||||
* 自动回传(`syncScheduleToServer`)现在默认关闭 —— 它会在老师手排课之后
|
||||
* 被教室的旧课表悄悄覆盖。要取教室的课表就点这个按钮(前端会先弹警告)。
|
||||
*/
|
||||
requestSchedule: (id: string): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> =>
|
||||
api.post(`${API_PATHS.integrations}/devices/${id}/request-schedule`, {}),
|
||||
/** 把一条提醒下发到该班的 ClassIsland 设备(ClassIsland 上全屏弹出) */
|
||||
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
|
||||
api.post(`${API_PATHS.integrations}/classisland/notify`, payload),
|
||||
/**
|
||||
* **ClassHelper 班级端在线状态**:管理员看全部班级,教师看自己班主任/任课的班级。
|
||||
* 每条含班级、设备名/设备号、在线离线、最后在线时间、最后心跳时间。
|
||||
*/
|
||||
classHelperStatus: (): Promise<ClassHelperStatusListDto> =>
|
||||
api.get(`${API_PATHS.integrations}/classisland/status`),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 数据库管理(仅管理员) */
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { API_BASE_URL } from '@/config';
|
||||
import { importApi } from '@/api';
|
||||
|
||||
type Kind = 'grades' | 'students' | 'teachers';
|
||||
type Kind = 'grades' | 'students' | 'teachers' | 'classTeachers';
|
||||
|
||||
interface FieldDef {
|
||||
key: string;
|
||||
@@ -29,27 +29,52 @@ interface FieldDef {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
/** 与后端 IMPORT_FIELDS 保持一致(仅用于展示标签与必填提示) */
|
||||
/**
|
||||
* 与后端 `IMPORT_FIELDS` 保持一致(仅用于展示标签与必填提示)。
|
||||
*
|
||||
* 学生相关一律是**学号**(原「用户名」列已改名,但导入时仍兼容老模板的表头)。
|
||||
*/
|
||||
const FIELDS: Record<Kind, FieldDef[]> = {
|
||||
grades: [
|
||||
{ key: 'username', label: '学生用户名', required: false },
|
||||
{ key: 'studentNo', label: '学生学号', required: false },
|
||||
{ key: 'name', label: '学生姓名', required: false },
|
||||
{ key: 'examName', label: '考试名称', required: true },
|
||||
{ key: 'score', label: '分数', required: true },
|
||||
{ key: 'totalScore', label: '总分', required: false },
|
||||
{ key: 'courseName', label: '课程', required: false },
|
||||
{ key: 'courseName', label: '科目', required: false },
|
||||
{ key: 'level', label: '等级', required: false },
|
||||
],
|
||||
students: [
|
||||
{ key: 'username', label: '用户名', required: true },
|
||||
{ key: 'studentNo', label: '学号', required: true },
|
||||
{ key: 'name', label: '姓名', required: true },
|
||||
{ key: 'password', label: '初始密码', required: false },
|
||||
{ key: 'className', label: '班级', required: false },
|
||||
{ key: 'gender', label: '性别', required: false },
|
||||
{ key: 'guardianPhone', label: '家长手机号', required: false },
|
||||
],
|
||||
teachers: [
|
||||
{ key: 'username', label: '用户名', required: true },
|
||||
{ key: 'username', label: '工号', required: true },
|
||||
{ key: 'name', label: '姓名', required: true },
|
||||
{ key: 'phone', label: '手机号', required: false },
|
||||
{ key: 'password', label: '初始密码', required: false },
|
||||
{ key: 'role', label: '角色', required: false },
|
||||
],
|
||||
classTeachers: [
|
||||
{ key: 'className', label: '班级', required: true },
|
||||
{ key: 'subjectName', label: '科目', required: false },
|
||||
{ key: 'teacherNo', label: '教师工号', required: true },
|
||||
{ key: 'teacherName', label: '教师姓名', required: false },
|
||||
{ key: 'role', label: '角色', required: true },
|
||||
],
|
||||
};
|
||||
|
||||
/** 各类导入的一句话说明(弹窗顶部提示) */
|
||||
const KIND_HINTS: Record<Kind, string> = {
|
||||
grades: '按**学号**匹配本班学生;同一学生 + 科目 + 考试重复导入会覆盖旧分数。',
|
||||
students: '按**学号**去重;只生成班级名单,**不创建任何账号**(学生没有密码、不能登录)。班级列可留空,用上一步选的班级。',
|
||||
teachers: '按**工号**去重;工号就是登录名。',
|
||||
classTeachers:
|
||||
'导入「班级 + 科目 + 教师」的任课关系。角色填「班主任」或「科任」;' +
|
||||
'同一位老师既是班主任又教某一科时,写两条记录即可 —— 系统只保留一个账号,权限自动合并。',
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -78,14 +103,21 @@ const mapping = ref<Record<string, string>>({});
|
||||
const mode = ref<'append' | 'upsert'>('upsert');
|
||||
const result = ref<TableImportResult | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
/** 班级任课老师导入:是否用「工号」匹配已有教师账号(默认开) */
|
||||
const useTeacherNo = ref(true);
|
||||
|
||||
const fields = computed(() => FIELDS[props.kind]);
|
||||
const title = computed(() => {
|
||||
if (props.kind === 'grades') return '导入成绩表格';
|
||||
return props.kind === 'teachers' ? '导入教师名单' : '导入学生名单';
|
||||
if (props.kind === 'teachers') return '导入教师名单';
|
||||
if (props.kind === 'classTeachers') return '导入班级任课老师';
|
||||
return '导入学生名单';
|
||||
});
|
||||
/** 教师名单不需要班级(教师不属于任何班级) */
|
||||
const needsClass = computed(() => props.kind !== 'teachers');
|
||||
const hint = computed(() => KIND_HINTS[props.kind]);
|
||||
/** 教师名单、班级任课老师都不需要先在弹窗里选班级(表格里有班级列 / 与班级无关) */
|
||||
const needsClass = computed(() => props.kind !== 'teachers' && props.kind !== 'classTeachers');
|
||||
/** 「使用工号匹配」开关只对班级任课老师导入有意义 */
|
||||
const usesTeacherNo = computed(() => props.kind === 'classTeachers');
|
||||
|
||||
watch(visible, (value) => {
|
||||
if (value) reset();
|
||||
@@ -235,6 +267,7 @@ async function submit(): Promise<void> {
|
||||
contentBase64: contentBase64.value,
|
||||
mapping: mapping.value,
|
||||
mode: mode.value,
|
||||
...(usesTeacherNo.value ? { useTeacherNo: useTeacherNo.value } : {}),
|
||||
});
|
||||
result.value = data;
|
||||
ElMessage.success(
|
||||
@@ -249,13 +282,10 @@ async function submit(): Promise<void> {
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="title" width="760px" class="import-dialog" top="6vh">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="支持 .xlsx / .xls / .csv;先下载模板填写可以避免列名不符"
|
||||
class="mb-12"
|
||||
/>
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-12">
|
||||
<template #title>{{ hint }}</template>
|
||||
<template #default>支持 .xlsx / .xls / .csv;先下载模板填写可以避免列名不符。</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="import-toolbar">
|
||||
<el-button size="small" @click="downloadTemplate('xlsx')">下载模板(Excel)</el-button>
|
||||
@@ -334,7 +364,22 @@ async function submit(): Promise<void> {
|
||||
<el-radio value="append">已存在则跳过</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="mode-hint">
|
||||
{{ kind === 'grades' ? '成绩重复判定:同一学生 + 考试 + 课程' : '名单重复判定:同一用户名' }}
|
||||
{{
|
||||
kind === 'grades'
|
||||
? '成绩重复判定:同一学生 + 科目 + 考试'
|
||||
: kind === 'teachers'
|
||||
? '教师重复判定:同一工号'
|
||||
: kind === 'classTeachers'
|
||||
? '同一个班同一科目已有任课老师时会直接改掉(不重复建记录)'
|
||||
: '学生重复判定:同一学号'
|
||||
}}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="usesTeacherNo" label="教师匹配">
|
||||
<el-switch v-model="useTeacherNo" />
|
||||
<div class="mode-hint">
|
||||
打开时优先按<b>工号</b>匹配已有教师账号(匹配不到会按工号新建);
|
||||
关闭时只按姓名匹配,匹配不到就报错。同一份表格里同一位老师出现多次只会有一个账号。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -17,4 +17,4 @@ export const API_BASE_URL =
|
||||
export const SOCKET_URL =
|
||||
import.meta.env.VITE_SOCKET_URL ?? (servedOverHttp ? window.location.origin : DEFAULT_SERVER_URL);
|
||||
|
||||
export const APP_TITLE = '班级小助手 · 管理端';
|
||||
export const APP_TITLE = 'ClassHelper · 管理端';
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
Connection,
|
||||
CopyDocument,
|
||||
Document,
|
||||
FolderAdd,
|
||||
FolderOpened,
|
||||
Guide,
|
||||
Lock,
|
||||
Menu,
|
||||
@@ -15,9 +17,12 @@ import {
|
||||
Odometer,
|
||||
Plus,
|
||||
Refresh,
|
||||
Right,
|
||||
School,
|
||||
Search,
|
||||
Sunny,
|
||||
Sort,
|
||||
Switch,
|
||||
SwitchButton,
|
||||
Trophy,
|
||||
Upload,
|
||||
@@ -49,6 +54,8 @@ const APP_ICONS = {
|
||||
Connection,
|
||||
CopyDocument,
|
||||
Document,
|
||||
FolderAdd,
|
||||
FolderOpened,
|
||||
Guide,
|
||||
Lock,
|
||||
Menu,
|
||||
@@ -57,9 +64,12 @@ const APP_ICONS = {
|
||||
Odometer,
|
||||
Plus,
|
||||
Refresh,
|
||||
Right,
|
||||
School,
|
||||
Search,
|
||||
Sunny,
|
||||
Sort,
|
||||
Switch,
|
||||
SwitchButton,
|
||||
Trophy,
|
||||
Upload,
|
||||
|
||||
@@ -312,7 +312,7 @@ function tourTarget(name: string): () => HTMLElement | null {
|
||||
const tourSteps = computed(() => [
|
||||
{
|
||||
target: null,
|
||||
title: '欢迎使用班级小助手',
|
||||
title: '欢迎使用 ClassHelper',
|
||||
description:
|
||||
'教师与管理员在这里发布课表、作业、通知与成绩,学生通过桌面客户端实时接收。花一分钟认识一下界面。',
|
||||
},
|
||||
@@ -334,7 +334,7 @@ const tourSteps = computed(() => [
|
||||
{
|
||||
target: tourTarget('conn-tag'),
|
||||
title: '实时通道',
|
||||
description: '绿色表示与后端保持实时连接,发布的内容会立刻推送到对应班级的学生端与灵动岛。',
|
||||
description: '绿色表示与后端保持实时连接,发布的内容会立刻推送到对应班级的 ClassHelper 班级端与灵动岛。',
|
||||
},
|
||||
{
|
||||
target: tourTarget('user-chip'),
|
||||
@@ -361,7 +361,7 @@ const tourSteps = computed(() => [
|
||||
>
|
||||
<div class="brand">
|
||||
<img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
|
||||
<span class="brand-text">班级小助手</span>
|
||||
<span class="brand-text">ClassHelper</span>
|
||||
</div>
|
||||
<!-- 激活项的滑动指示器:单个药丸在菜单项之间平移,位置由 syncIndicator() 量出来。
|
||||
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
|
||||
@@ -495,7 +495,7 @@ const tourSteps = computed(() => [
|
||||
<div class="ch-sidebar ch-sidebar-drawer">
|
||||
<div class="brand">
|
||||
<img class="brand-logo" src="/logo.png" alt="" width="24" height="24" />
|
||||
<span class="brand-text">班级小助手</span>
|
||||
<span class="brand-text">ClassHelper</span>
|
||||
</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">
|
||||
|
||||
@@ -37,6 +37,20 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/TeachersView.vue'),
|
||||
meta: { title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] },
|
||||
},
|
||||
{
|
||||
path: 'term-weeks',
|
||||
name: 'term-weeks',
|
||||
component: () => import('@/views/TermWeeksView.vue'),
|
||||
// 改周次会让全校/某班的课表口径整体变化,与「班级管理」同一档权限
|
||||
meta: { title: '学期周次', icon: 'Calendar', roles: ['ADMIN'] },
|
||||
},
|
||||
{
|
||||
path: 'archives',
|
||||
name: 'archives',
|
||||
component: () => import('@/views/ArchivesView.vue'),
|
||||
// 毕业归档会改动整届班级与学生的状态,与「班级管理」同一档权限
|
||||
meta: { title: '毕业归档', icon: 'FolderOpened', roles: ['ADMIN'] },
|
||||
},
|
||||
{
|
||||
path: 'schedules',
|
||||
name: 'schedules',
|
||||
@@ -65,8 +79,8 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'integrations',
|
||||
name: 'integrations',
|
||||
component: () => import('@/views/IntegrationsView.vue'),
|
||||
// 与后端 requireRole('ADMIN','TEACHER') 对齐:学生角色不该看到这一页
|
||||
meta: { title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
// 与后端 requireRole('ADMIN','TEACHER') 对齐:这一页含"班级端在线状态",教师要看自己的班
|
||||
meta: { title: 'ClassHelper 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
},
|
||||
{
|
||||
path: 'database',
|
||||
@@ -125,7 +139,7 @@ router.beforeEach((to) => {
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = typeof to.meta.title === 'string' ? to.meta.title : '';
|
||||
document.title = title ? `${title} · 班级小助手` : '班级小助手 · 管理端';
|
||||
document.title = title ? `${title} · ClassHelper` : 'ClassHelper · 管理端';
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,13 +1,20 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { STORAGE_KEYS, type LoginRequest, type LoginResponse, type StudentDto } from '@classhelper/shared';
|
||||
import { ROLE_LABELS, STORAGE_KEYS, type LoginRequest, type LoginResponse, type SessionUser } from '@classhelper/shared';
|
||||
import { authApi } from '@/api';
|
||||
|
||||
function readStoredUser(): StudentDto | null {
|
||||
/**
|
||||
* 会话主体。
|
||||
*
|
||||
* 学生**不是账号**(只是 `Student` 名单记录,不能登录),所以 Web 管理端里
|
||||
* 只可能是 `ADMIN` / `TEACHER`;`CLASS_DEVICE` 是教室机器上的 ClassHelper 班级端,
|
||||
* 它走桌面客户端,不会出现在这里 —— 类型仍然带上它,是为了与后端契约一致。
|
||||
*/
|
||||
function readStoredUser(): SessionUser | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.user);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as StudentDto;
|
||||
return JSON.parse(raw) as SessionUser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -15,18 +22,15 @@ function readStoredUser(): StudentDto | null {
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(STORAGE_KEYS.token));
|
||||
const user = ref<StudentDto | null>(readStoredUser());
|
||||
const user = ref<SessionUser | null>(readStoredUser());
|
||||
const loading = ref(false);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value));
|
||||
const role = computed(() => user.value?.role ?? null);
|
||||
const isStaff = computed(() => role.value === 'ADMIN' || role.value === 'TEACHER');
|
||||
const isAdmin = computed(() => role.value === 'ADMIN');
|
||||
const displayName = computed(() => user.value?.name ?? '未登录');
|
||||
const roleLabel = computed(() => {
|
||||
if (role.value === 'ADMIN') return '管理员';
|
||||
if (role.value === 'TEACHER') return '教师';
|
||||
return '学生';
|
||||
});
|
||||
const roleLabel = computed(() => (role.value ? ROLE_LABELS[role.value] : '未登录'));
|
||||
|
||||
function persist(): void {
|
||||
if (token.value) localStorage.setItem(STORAGE_KEYS.token, token.value);
|
||||
@@ -37,7 +41,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
function setSession(payload: LoginResponse): void {
|
||||
token.value = payload.token;
|
||||
user.value = { ...payload.user } as StudentDto;
|
||||
user.value = { ...payload.user };
|
||||
persist();
|
||||
}
|
||||
|
||||
@@ -78,6 +82,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isStaff,
|
||||
isAdmin,
|
||||
role,
|
||||
displayName,
|
||||
roleLabel,
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 毕业归档(仅管理员)。
|
||||
*
|
||||
* 归档是**打标记 + 只读**,不删任何数据:
|
||||
* - 毕业班级与学生挂到 `ArchivedYear` 上,常规的班级/学生列表据此过滤;
|
||||
* - 毕业班级发过的**作业与通知原样留在库里**,在「届别详情 → 查看班级内容」里只读浏览;
|
||||
* - **未毕业而升级的班级不归档** —— 升级只在「班级管理 → 班级设置」里改年级,数据继续沿用。
|
||||
*
|
||||
* 撤销归档是误操作的退路:把班级与学生还原成在读,同样不删数据。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
STUDENT_STATUS_LABELS,
|
||||
STUDENT_STATUS_TAG_TYPES,
|
||||
formatDate,
|
||||
type ArchivedClassContentDto,
|
||||
type ArchivedClassDto,
|
||||
type ArchivedYearDetailDto,
|
||||
type ArchivedYearDto,
|
||||
type StudentStatus,
|
||||
} from '@classhelper/shared';
|
||||
import { archiveApi } from '@/api';
|
||||
|
||||
const loading = ref(false);
|
||||
const years = ref<ArchivedYearDto[]>([]);
|
||||
const summary = ref<{
|
||||
archiveCount: number;
|
||||
classCount: number;
|
||||
graduateCount: number;
|
||||
transferredCount: number;
|
||||
suggestedEnrollmentYears: number[];
|
||||
} | null>(null);
|
||||
|
||||
async function loadYears(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [list, overview] = await Promise.all([archiveApi.list(), archiveApi.summary()]);
|
||||
years.value = list;
|
||||
summary.value = overview;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 届别详情 */
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detail = ref<ArchivedYearDetailDto | null>(null);
|
||||
const activeTab = ref('classes');
|
||||
|
||||
async function openDetail(row: ArchivedYearDto): Promise<void> {
|
||||
detailVisible.value = true;
|
||||
detailLoading.value = true;
|
||||
activeTab.value = 'classes';
|
||||
try {
|
||||
detail.value = await archiveApi.detail(row.id);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 归档班级的作业与通知(只读) */
|
||||
|
||||
const contentVisible = ref(false);
|
||||
const contentLoading = ref(false);
|
||||
const content = ref<ArchivedClassContentDto | null>(null);
|
||||
|
||||
async function openContent(row: ArchivedClassDto): Promise<void> {
|
||||
if (!detail.value) return;
|
||||
contentVisible.value = true;
|
||||
contentLoading.value = true;
|
||||
content.value = null;
|
||||
try {
|
||||
content.value = await archiveApi.classContent(detail.value.id, row.id);
|
||||
} finally {
|
||||
contentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 执行归档 */
|
||||
|
||||
const createVisible = ref(false);
|
||||
const creating = ref(false);
|
||||
const createForm = reactive({ enrollmentYear: new Date().getFullYear() - 3, note: '' });
|
||||
|
||||
/** 已经归档过的届别(下拉里标出来,避免重复提交) */
|
||||
const archivedYears = computed(() => new Set(years.value.map((item) => item.enrollmentYear)));
|
||||
|
||||
function openCreate(): void {
|
||||
const suggested = summary.value?.suggestedEnrollmentYears ?? [];
|
||||
createForm.enrollmentYear = suggested.find((year) => !archivedYears.value.has(year)) ?? suggested[0] ?? createForm.enrollmentYear;
|
||||
createForm.note = '';
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate(): Promise<void> {
|
||||
creating.value = true;
|
||||
try {
|
||||
const result = await archiveApi.create({
|
||||
enrollmentYear: createForm.enrollmentYear,
|
||||
...(createForm.note.trim() ? { note: createForm.note.trim() } : {}),
|
||||
});
|
||||
ElMessage.success(
|
||||
`${result.name} 已归档:${result.classCount} 个班级、${result.studentCount} 名毕业生、${result.transferredCount} 名转出`,
|
||||
);
|
||||
createVisible.value = false;
|
||||
await loadYears();
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 撤销归档:班级与学生回到在读,不删任何数据 */
|
||||
async function undo(row: ArchivedYearDto): Promise<void> {
|
||||
await ElMessageBox.confirm(
|
||||
`撤销「${row.name}」的毕业归档?\n` +
|
||||
'该届班级会回到在读列表、毕业学生会恢复为在读;作业、通知与成绩一条都不会被删除。',
|
||||
'撤销归档',
|
||||
{ type: 'warning', confirmButtonText: '确认撤销' },
|
||||
);
|
||||
await archiveApi.remove(row.id);
|
||||
ElMessage.success('已撤销归档');
|
||||
detailVisible.value = false;
|
||||
await loadYears();
|
||||
}
|
||||
|
||||
onMounted(loadYears);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">毕业归档</h2>
|
||||
<p class="page-subtitle">
|
||||
按年度归档毕业的班级与学生、当年转出的学生,以及毕业班级发过的作业与通知。
|
||||
<b>归档不删数据</b>;未毕业而升级的班级不归档,数据继续沿用
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-button :icon="'Refresh'" :loading="loading" @click="loadYears">刷新</el-button>
|
||||
<el-button type="primary" :icon="'FolderAdd'" @click="openCreate">归档届别</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="12" style="margin-bottom: 12px">
|
||||
<el-col :xs="12" :md="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">已归档届别</div>
|
||||
<div class="stat-value">{{ summary?.archiveCount ?? 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :md="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">归档班级</div>
|
||||
<div class="stat-value">{{ summary?.classCount ?? 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :md="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">已毕业学生</div>
|
||||
<div class="stat-value">{{ summary?.graduateCount ?? 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :xs="12" :md="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">已转出学生</div>
|
||||
<div class="stat-value">{{ summary?.transferredCount ?? 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="years" empty-text="还没有归档记录">
|
||||
<el-table-column prop="name" label="届别" width="120" />
|
||||
<el-table-column label="入学 / 毕业" width="160">
|
||||
<template #default="{ row }">{{ row.enrollmentYear }} → {{ row.graduationYear }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="班级" width="80">
|
||||
<template #default="{ row }">{{ row.classCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="毕业生" width="90">
|
||||
<template #default="{ row }">{{ row.studentCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="转出学生" width="100">
|
||||
<template #default="{ row }">{{ row.transferredCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="作业" width="80">
|
||||
<template #default="{ row }">{{ row.homeworkCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通知" width="80">
|
||||
<template #default="{ row }">{{ row.notificationCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归档时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.archivedAt, true) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operatorName" label="操作人" width="110" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||
<el-button link type="danger" @click="undo(row)">撤销归档</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 届别详情:班级 / 毕业生 / 转出的学生 -->
|
||||
<el-drawer v-model="detailVisible" size="68%" :title="detail ? `${detail.name} · 归档详情` : '归档详情'">
|
||||
<div v-loading="detailLoading">
|
||||
<el-alert v-if="detail?.note" type="info" :closable="false" style="margin-bottom: 12px" :title="detail.note" />
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="班级" name="classes">
|
||||
<el-table :data="detail?.classes ?? []" size="small" class="mt-12" empty-text="该届没有班级">
|
||||
<el-table-column prop="name" label="班级" width="130" />
|
||||
<el-table-column prop="grade" label="毕业时年级" width="110" />
|
||||
<el-table-column label="班主任" width="110">
|
||||
<template #default="{ row }">{{ row.headTeacherName ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="学生" width="80">
|
||||
<template #default="{ row }">{{ row.studentCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="作业" width="80">
|
||||
<template #default="{ row }">{{ row.homeworkCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="通知" width="80">
|
||||
<template #default="{ row }">{{ row.notificationCount }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openContent(row)">看作业与通知</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="`毕业生(${detail?.graduates.length ?? 0})`" name="graduates">
|
||||
<el-table :data="detail?.graduates ?? []" size="small" class="mt-12" empty-text="没有毕业生">
|
||||
<el-table-column prop="studentNo" label="学号" width="140" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column label="毕业前班级" min-width="140">
|
||||
<template #default="{ row }">{{ row.className ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="STUDENT_STATUS_TAG_TYPES[row.status as StudentStatus]">
|
||||
{{ STUDENT_STATUS_LABELS[row.status as StudentStatus] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="`转出学生(${detail?.transferred.length ?? 0})`" name="transferred">
|
||||
<el-table :data="detail?.transferred ?? []" size="small" class="mt-12" empty-text="该届没有转出的学生">
|
||||
<el-table-column prop="studentNo" label="学号" width="140" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column label="转出前班级" min-width="140">
|
||||
<template #default="{ row }">{{ row.className ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="转出时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.transferredAt, true) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="transferNote" label="去向 / 备注" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 归档班级的作业与通知(只读) -->
|
||||
<el-drawer v-model="contentVisible" size="60%" :title="content ? `${content.className} · 作业与通知` : '作业与通知'">
|
||||
<div v-loading="contentLoading">
|
||||
<el-tabs>
|
||||
<el-tab-pane :label="`作业(${content?.homeworks.length ?? 0})`">
|
||||
<el-table :data="content?.homeworks ?? []" size="small" class="mt-12" empty-text="没有作业记录">
|
||||
<el-table-column prop="assignDate" label="日期" width="120" />
|
||||
<el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="科目" width="100">
|
||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布人" width="110">
|
||||
<template #default="{ row }">{{ row.creator?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`通知(${content?.notifications.length ?? 0})`">
|
||||
<el-table :data="content?.notifications ?? []" size="small" class="mt-12" empty-text="没有通知记录">
|
||||
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="content" label="内容" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="发布时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 执行归档 -->
|
||||
<el-dialog v-model="createVisible" title="归档一个届别" width="520px">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 12px">
|
||||
<template #title>归档不删数据</template>
|
||||
<template #default>
|
||||
该届的班级会从常规列表里隐去、在读学生标记为「已毕业」;
|
||||
作业、通知、成绩全部原样保留,可在归档详情里查看。填错届别可以用「撤销归档」还原。
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="届别(入学年)">
|
||||
<el-select v-model="createForm.enrollmentYear" filterable allow-create style="width: 100%">
|
||||
<el-option
|
||||
v-for="year in summary?.suggestedEnrollmentYears ?? []"
|
||||
:key="year"
|
||||
:label="`${year} 级${archivedYears.has(year) ? '(已归档)' : ''}`"
|
||||
:value="year"
|
||||
:disabled="archivedYears.has(year)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="createForm.note" maxlength="200" placeholder="例如:2026 年 6 月毕业(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="submitCreate">确认归档</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
background: var(--ch-surface);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: var(--ch-radius-lg);
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--ch-text-secondary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,11 +4,17 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
MAX_TERM_WEEK,
|
||||
SUBJECT_CATALOG,
|
||||
currentAcademicYearStart,
|
||||
formatClassName,
|
||||
STUDENT_STATUS_LABELS,
|
||||
formatDate,
|
||||
type ClassDetailDto,
|
||||
type ClassDto,
|
||||
type CourseDto,
|
||||
type StudentDto,
|
||||
type StudentStatus,
|
||||
type SubjectTeacherDto,
|
||||
type UpdateClassAccountRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -45,11 +51,25 @@ async function loadClasses(): Promise<void> {
|
||||
const formVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({ name: '', grade: '', code: '', termWeeks: 20, headTeacherId: '' });
|
||||
/**
|
||||
* 班级称呼是「XXXX级X班」(入学年份 + 班号),不是「高一(1)班」:
|
||||
* 后者每年都要改一遍,前者一路跟着这届学生到毕业(升级只改年级、称呼不变)。
|
||||
*/
|
||||
const form = reactive({
|
||||
grade: '',
|
||||
enrollmentYear: currentAcademicYearStart(),
|
||||
classIndex: 1,
|
||||
code: '',
|
||||
termWeeks: 20,
|
||||
headTeacherId: '',
|
||||
});
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
|
||||
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
|
||||
enrollmentYear: [{ required: true, message: '请输入入学年份', trigger: 'blur' }],
|
||||
classIndex: [{ required: true, message: '请输入班号', trigger: 'blur' }],
|
||||
};
|
||||
/** 表单里实时预览班级称呼 */
|
||||
const previewName = computed(() => formatClassName(form.enrollmentYear, form.classIndex));
|
||||
|
||||
/** 管理员可选班主任(教师 + 管理员账号);「新建班级」才设置,「编辑」走单独的"更换班主任" */
|
||||
const staffOptions = ref<UserDto[]>([]);
|
||||
@@ -62,8 +82,9 @@ async function loadStaffOptions(): Promise<void> {
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.grade = '';
|
||||
form.enrollmentYear = currentAcademicYearStart();
|
||||
form.classIndex = classes.value.length + 1;
|
||||
form.code = '';
|
||||
form.termWeeks = 20;
|
||||
form.headTeacherId = auth.user?.id ?? '';
|
||||
@@ -73,8 +94,10 @@ function openCreate(): void {
|
||||
|
||||
function openEdit(row: ClassDto): void {
|
||||
editingId.value = row.id;
|
||||
form.name = row.name;
|
||||
form.grade = row.grade;
|
||||
// 老数据可能没有届别:回填当前学年起始年份,管理员可在表单里改正
|
||||
form.enrollmentYear = row.enrollmentYear ?? currentAcademicYearStart();
|
||||
form.classIndex = row.classIndex ?? 1;
|
||||
form.code = '';
|
||||
form.termWeeks = row.termWeeks ?? 20;
|
||||
// 必须回填当前班主任:编辑弹窗里的下拉与"新建"共用同一个 form.headTeacherId,
|
||||
@@ -92,8 +115,9 @@ async function submitForm(): Promise<void> {
|
||||
|
||||
if (editingId.value) {
|
||||
await classApi.update(editingId.value, {
|
||||
name: form.name,
|
||||
grade: form.grade,
|
||||
enrollmentYear: form.enrollmentYear,
|
||||
classIndex: form.classIndex,
|
||||
termWeeks: form.termWeeks,
|
||||
});
|
||||
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
|
||||
@@ -106,15 +130,16 @@ async function submitForm(): Promise<void> {
|
||||
ElMessage.success('班级已更新');
|
||||
} else {
|
||||
await classApi.create({
|
||||
name: form.name,
|
||||
grade: form.grade,
|
||||
enrollmentYear: form.enrollmentYear,
|
||||
classIndex: form.classIndex,
|
||||
// 学期周数:新建时也要提交。原先只提交名称/年级/班级码,表单里填的周数被直接丢弃,
|
||||
// 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。
|
||||
termWeeks: form.termWeeks,
|
||||
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
|
||||
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
|
||||
});
|
||||
ElMessage.success('班级创建成功');
|
||||
ElMessage.success(`班级 ${previewName.value} 创建成功`);
|
||||
}
|
||||
formVisible.value = false;
|
||||
await loadClasses();
|
||||
@@ -144,7 +169,7 @@ const accountSaving = ref(false);
|
||||
const accountTarget = ref<ClassDto | null>(null);
|
||||
const accountForm = reactive({ code: '', password: '' });
|
||||
|
||||
/** 打开班级账号设置:班级码即学生端「班级登录」的账号 */
|
||||
/** 打开班级账号设置:班级码即 ClassHelper 班级端「班级登录」的账号 */
|
||||
function openAccount(row: ClassDto): void {
|
||||
accountTarget.value = row;
|
||||
accountForm.code = row.code ?? '';
|
||||
@@ -231,28 +256,36 @@ async function refreshDetail(): Promise<void> {
|
||||
/* ------------------------------------------------------------ 学生 */
|
||||
|
||||
const studentFormVisible = ref(false);
|
||||
const studentForm = reactive({ username: '', name: '', password: '' });
|
||||
/** 学生只有名单:学号 + 姓名(没有密码、没有账号) */
|
||||
const studentForm = reactive({
|
||||
studentNo: '',
|
||||
name: '',
|
||||
gender: '' as '' | 'MALE' | 'FEMALE',
|
||||
guardianPhone: '',
|
||||
});
|
||||
const studentRules: FormRules = {
|
||||
username: [{ required: true, message: '请输入学号/用户名', trigger: 'blur' }],
|
||||
studentNo: [{ required: true, message: '请输入学号', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
async function addStudent(): Promise<void> {
|
||||
if (!detail.value) return;
|
||||
if (!studentForm.username.trim() || !studentForm.name.trim()) {
|
||||
ElMessage.warning('请填写用户名和姓名');
|
||||
if (!studentForm.studentNo.trim() || !studentForm.name.trim()) {
|
||||
ElMessage.warning('请填写学号和姓名');
|
||||
return;
|
||||
}
|
||||
const created = await classApi.addStudent(detail.value.id, {
|
||||
username: studentForm.username.trim(),
|
||||
studentNo: studentForm.studentNo.trim(),
|
||||
name: studentForm.name.trim(),
|
||||
...(studentForm.password ? { password: studentForm.password } : {}),
|
||||
...(studentForm.gender ? { gender: studentForm.gender } : {}),
|
||||
...(studentForm.guardianPhone.trim() ? { guardianPhone: studentForm.guardianPhone.trim() } : {}),
|
||||
});
|
||||
ElMessage.success(`已加入学生:${created.name}`);
|
||||
ElMessage.success(`已加入学生:${created.name}(${created.studentNo})`);
|
||||
studentFormVisible.value = false;
|
||||
studentForm.username = '';
|
||||
studentForm.studentNo = '';
|
||||
studentForm.name = '';
|
||||
studentForm.password = '';
|
||||
studentForm.gender = '';
|
||||
studentForm.guardianPhone = '';
|
||||
await refreshDetail();
|
||||
await loadClasses();
|
||||
}
|
||||
@@ -290,33 +323,114 @@ async function removeCourse(row: CourseDto): Promise<void> {
|
||||
await refreshDetail();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 协作教师 */
|
||||
/* ------------------------------------------------------------ 任课老师(班级 + 科目 + 教师) */
|
||||
|
||||
/**
|
||||
* **这是「科任老师」这条关系的唯一写入口**。
|
||||
*
|
||||
* 作业与成绩能不能被某位老师改动,查的就是这里写下的 `Course.teacherId`:
|
||||
* 科任老师只能动自己任教的科目;班主任若不教这一科,同样改不了这一科。
|
||||
* 同一位老师可以既是班主任又是某科科任 —— 两条记录指向同一个账号,
|
||||
* 不重复建号,权限在服务端合并。
|
||||
*/
|
||||
const teachers = ref<UserDto[]>([]);
|
||||
const selectedTeacherId = ref<string>('');
|
||||
const subjectDraft = reactive({ subjectName: '', teacherId: '' });
|
||||
|
||||
async function loadTeachers(): Promise<void> {
|
||||
teachers.value = await teacherApi.list();
|
||||
}
|
||||
|
||||
async function assignTeacher(): Promise<void> {
|
||||
if (!detail.value || !selectedTeacherId.value) {
|
||||
ElMessage.warning('请选择教师');
|
||||
/** 该班还没有的科目(下拉里列出来,选中即自动建课) */
|
||||
const assignableSubjects = computed(() => {
|
||||
const existing = new Set((detail.value?.subjectTeachers ?? []).map((item) => item.subjectName));
|
||||
return SUBJECT_CATALOG.filter((name) => !existing.has(name));
|
||||
});
|
||||
|
||||
const subjectSaving = ref(false);
|
||||
|
||||
async function saveSubjectTeacher(): Promise<void> {
|
||||
if (!detail.value || !subjectDraft.subjectName.trim() || !subjectDraft.teacherId) {
|
||||
ElMessage.warning('请选择科目与任课老师');
|
||||
return;
|
||||
}
|
||||
await classApi.assignTeacher(detail.value.id, selectedTeacherId.value);
|
||||
ElMessage.success('教师已分配');
|
||||
selectedTeacherId.value = '';
|
||||
subjectSaving.value = true;
|
||||
try {
|
||||
await classApi.assignSubjectTeachers(detail.value.id, [
|
||||
{ subjectName: subjectDraft.subjectName.trim(), teacherId: subjectDraft.teacherId },
|
||||
]);
|
||||
ElMessage.success('任课老师已设置');
|
||||
subjectDraft.subjectName = '';
|
||||
subjectDraft.teacherId = '';
|
||||
await refreshDetail();
|
||||
} finally {
|
||||
subjectSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 改某一科的任课老师(就地改,不新建科目) */
|
||||
async function changeSubjectTeacher(row: SubjectTeacherDto, teacherId: string): Promise<void> {
|
||||
if (!detail.value || !teacherId) return;
|
||||
await classApi.assignSubjectTeachers(detail.value.id, [
|
||||
{ courseId: row.courseId, subjectName: row.subjectName, teacherId },
|
||||
]);
|
||||
ElMessage.success(`「${row.subjectName}」的任课老师已更新`);
|
||||
await refreshDetail();
|
||||
}
|
||||
|
||||
async function removeTeacher(teacherId: string): Promise<void> {
|
||||
/** 解除某科的任课老师(课程保留,作业与成绩仍挂在它上面) */
|
||||
async function clearSubjectTeacher(row: SubjectTeacherDto): Promise<void> {
|
||||
if (!detail.value) return;
|
||||
await classApi.removeTeacher(detail.value.id, teacherId);
|
||||
ElMessage.success('已取消分配');
|
||||
await classApi.assignSubjectTeachers(detail.value.id, [
|
||||
{ courseId: row.courseId, subjectName: row.subjectName, teacherId: null },
|
||||
]);
|
||||
ElMessage.success(`已解除「${row.subjectName}」的任课老师`);
|
||||
await refreshDetail();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 班级设置:成绩查询开关 / 学年升级 */
|
||||
|
||||
const gradeQuerySaving = ref(false);
|
||||
|
||||
/** 是否允许教室的 ClassHelper 班级端按学号查询本班学生的成绩明细 */
|
||||
async function toggleGradeQuery(enabled: boolean): Promise<void> {
|
||||
if (!detail.value) return;
|
||||
gradeQuerySaving.value = true;
|
||||
try {
|
||||
await classApi.setGradeQuery(detail.value.id, enabled);
|
||||
detail.value.studentGradeQueryEnabled = enabled;
|
||||
ElMessage.success(enabled ? '已允许班级端按学号查成绩' : '已关闭班级端成绩查询');
|
||||
} finally {
|
||||
gradeQuerySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const promoteForm = reactive({ grade: '', saving: false });
|
||||
|
||||
/** 学年升级:只改年级,**不归档**,班级记录/学生名单/学号/作业成绩全部沿用 */
|
||||
async function promote(): Promise<void> {
|
||||
if (!detail.value || !promoteForm.grade.trim()) {
|
||||
ElMessage.warning('请填写升级后的年级,例如「高二」');
|
||||
return;
|
||||
}
|
||||
const target = promoteForm.grade.trim();
|
||||
await ElMessageBox.confirm(
|
||||
`把「${detail.value.name}」的年级改为「${target}」?升级只改年级,不归档:` +
|
||||
'班级记录、学生名单、学号、作业与成绩全部沿用。',
|
||||
'学年升级',
|
||||
{ type: 'info', confirmButtonText: '确认升级' },
|
||||
);
|
||||
promoteForm.saving = true;
|
||||
try {
|
||||
await classApi.promote([detail.value.id], target);
|
||||
ElMessage.success('已升级,数据继续沿用');
|
||||
promoteForm.grade = '';
|
||||
await refreshDetail();
|
||||
await loadClasses();
|
||||
} finally {
|
||||
promoteForm.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 实时刷新 */
|
||||
|
||||
function onClassChanged(): void {
|
||||
@@ -361,8 +475,16 @@ onUnmounted(() => {
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="classes" empty-text="暂无班级,点击右上角新建">
|
||||
<el-table-column prop="name" label="班级" min-width="140" />
|
||||
<el-table-column prop="grade" label="年级" width="100" />
|
||||
<el-table-column prop="name" label="班级" min-width="130" />
|
||||
<el-table-column label="届别" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.enrollmentYear" size="small" effect="plain">
|
||||
{{ row.enrollmentYear }} 级 · {{ row.classIndex }} 班
|
||||
</el-tag>
|
||||
<span v-else class="text-muted">未设置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="grade" label="年级" width="90" />
|
||||
<el-table-column label="班主任" width="140">
|
||||
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
@@ -378,7 +500,7 @@ onUnmounted(() => {
|
||||
<el-table-column label="通知" width="80">
|
||||
<template #default="{ row }">{{ row.notificationCount ?? 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="班级账号(学生端登录)" width="220">
|
||||
<el-table-column label="班级账号(班级端登录)" width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.code">
|
||||
<el-tag type="info" effect="plain">{{ row.code }}</el-tag>
|
||||
@@ -403,12 +525,12 @@ onUnmounted(() => {
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 班级账号:班级码 + 班级密码(学生端「班级登录」凭据) -->
|
||||
<el-dialog v-model="accountVisible" title="班级账号(学生端登录)" width="460px">
|
||||
<!-- 班级账号:班级码 + 班级密码(教室机器上 ClassHelper 班级端的登录凭据) -->
|
||||
<el-dialog v-model="accountVisible" title="班级账号(ClassHelper 班级端登录)" width="480px">
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-12">
|
||||
<template #title>
|
||||
学生端以「班级」为主体登录:班级码 + 班级密码。登录后本机代表整个班级,
|
||||
作业完成、通知已读都会按全班记录。
|
||||
教室机器以「班级」为主体登录:班级码 + 班级密码。登录后本机代表整个班级,
|
||||
作业完成、通知已读都会按全班记录。<b>学生没有个人账号,不能登录</b>。
|
||||
</template>
|
||||
</el-alert>
|
||||
<el-form label-width="90px">
|
||||
@@ -436,10 +558,20 @@ onUnmounted(() => {
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建 / 编辑 -->
|
||||
<el-dialog v-model="formVisible" :title="editingId ? '编辑班级' : '新建班级'" width="420px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="班级名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="例如 高一(1)班" />
|
||||
<el-dialog v-model="formVisible" :title="editingId ? '编辑班级' : '新建班级'" width="460px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="入学年份" prop="enrollmentYear">
|
||||
<el-input-number v-model="form.enrollmentYear" :min="2000" :max="2100" :step="1" />
|
||||
<span class="text-muted ml-8">这一届学生入学的年份(届别)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="班号" prop="classIndex">
|
||||
<el-input-number v-model="form.classIndex" :min="1" :max="99" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="班级称呼">
|
||||
<el-tag type="primary" effect="plain" size="large">{{ previewName }}</el-tag>
|
||||
<span class="text-muted ml-8">
|
||||
称呼由「入学年份 + 班号」生成;学年升级只改年级,称呼一路跟着这届学生到毕业
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="年级" prop="grade">
|
||||
<el-input v-model="form.grade" placeholder="例如 高一" />
|
||||
@@ -477,9 +609,17 @@ onUnmounted(() => {
|
||||
<el-drawer v-model="detailVisible" size="60%" :title="detail ? `${detail.name} · 详情` : '班级详情'">
|
||||
<div v-loading="detailLoading">
|
||||
<el-descriptions v-if="detail" :column="detailColumns" border size="small">
|
||||
<el-descriptions-item label="届别">
|
||||
{{ detail.enrollmentYear ? `${detail.enrollmentYear} 级` : '未设置' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="年级">{{ detail.grade }}</el-descriptions-item>
|
||||
<el-descriptions-item label="班主任">{{ detail.teacher?.name ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学生数">{{ detail.students.length }}</el-descriptions-item>
|
||||
<el-descriptions-item label="班级端成绩查询">
|
||||
<el-tag :type="detail.studentGradeQueryEnabled ? 'success' : 'info'" size="small">
|
||||
{{ detail.studentGradeQueryEnabled ? '已开放' : '已关闭' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 更换班主任(仅管理员):班主任决定谁能管这个班的课表与成绩 -->
|
||||
@@ -500,7 +640,7 @@ onUnmounted(() => {
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="text-muted">切换后立即生效:原班主任仍保留在下方「协作教师」名单里</span>
|
||||
<span class="text-muted">切换班主任不影响任课关系:各科科任老师在下方「任课老师」里单独配</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="mt-12">
|
||||
@@ -512,8 +652,13 @@ onUnmounted(() => {
|
||||
<span class="text-muted">共 {{ detail?.students.length ?? 0 }} 人</span>
|
||||
</div>
|
||||
<el-table :data="detail?.students ?? []" size="small" class="mt-12" empty-text="暂无学生">
|
||||
<el-table-column prop="username" label="用户名" width="140" />
|
||||
<el-table-column prop="studentNo" label="学号" width="140" />
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ STUDENT_STATUS_LABELS[row.status as StudentStatus] }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="加入时间" width="180">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||
</el-table-column>
|
||||
@@ -545,18 +690,52 @@ onUnmounted(() => {
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="协作教师" name="teachers">
|
||||
<el-table :data="detail?.teachers ?? []" size="small" class="mt-12" empty-text="暂无协作教师">
|
||||
<el-table-column prop="name" label="姓名" width="140" />
|
||||
<el-table-column prop="username" label="用户名" width="140" />
|
||||
<el-table-column label="操作" width="110">
|
||||
<el-tab-pane label="任课老师" name="subjects">
|
||||
<el-alert type="info" :closable="false" class="mt-12">
|
||||
这是「班级 + 科目 + 教师」的任课关系:<b>科任老师只能发布 / 修改 / 删除自己任教科目的作业和成绩</b>;
|
||||
班主任若不教这一科,同样改不了这一科。同一位老师既可以是班主任、又可以教某一科 ——
|
||||
系统里只有他一个账号,权限自动合并,不会重复建号。
|
||||
</el-alert>
|
||||
<el-table :data="detail?.subjectTeachers ?? []" size="small" class="mt-12" empty-text="还没有配置任课老师">
|
||||
<el-table-column prop="subjectName" label="科目" width="140" />
|
||||
<el-table-column label="任课老师" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removeTeacher(row.id)">取消分配</el-button>
|
||||
<el-select
|
||||
v-if="isAdmin"
|
||||
:model-value="row.teacherId"
|
||||
placeholder="选择老师"
|
||||
style="width: 220px"
|
||||
filterable
|
||||
@change="(value: string) => changeSubjectTeacher(row, value)"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in teachers"
|
||||
:key="item.id"
|
||||
:label="`${item.name}(${item.username})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-else>{{ row.teacherName || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isAdmin" label="操作" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="clearSubjectTeacher(row)">解除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="toolbar mt-12">
|
||||
<el-select v-model="selectedTeacherId" placeholder="选择教师" style="width: 220px" filterable>
|
||||
|
||||
<div v-if="isAdmin" class="toolbar mt-12">
|
||||
<el-select
|
||||
v-model="subjectDraft.subjectName"
|
||||
placeholder="选择科目"
|
||||
style="width: 180px"
|
||||
filterable
|
||||
allow-create
|
||||
>
|
||||
<el-option v-for="name in assignableSubjects" :key="name" :label="name" :value="name" />
|
||||
</el-select>
|
||||
<el-select v-model="subjectDraft.teacherId" placeholder="选择任课老师" style="width: 220px" filterable>
|
||||
<el-option
|
||||
v-for="item in teachers"
|
||||
:key="item.id"
|
||||
@@ -564,24 +743,68 @@ onUnmounted(() => {
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="assignTeacher">分配</el-button>
|
||||
<el-button type="primary" :loading="subjectSaving" @click="saveSubjectTeacher">设为任课老师</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="班级设置" name="settings">
|
||||
<div class="setting-row mt-12">
|
||||
<div>
|
||||
<div class="setting-title">ClassHelper 班级端按学号查询成绩明细</div>
|
||||
<div class="text-muted">
|
||||
打开后,教室里那台 ClassHelper 班级端可以按学号查到本班学生的成绩明细(含等级);
|
||||
关闭后班级端查成绩一律 403,教师端不受影响。
|
||||
</div>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="detail?.studentGradeQueryEnabled ?? true"
|
||||
:loading="gradeQuerySaving"
|
||||
:disabled="!isAdmin && auth.role !== 'TEACHER'"
|
||||
@change="(value: boolean) => toggleGradeQuery(value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div v-if="isAdmin" class="setting-row">
|
||||
<div>
|
||||
<div class="setting-title">学年升级</div>
|
||||
<div class="text-muted">
|
||||
把本班年级改成下一学年(例如高一 → 高二)。<b>升级不归档</b>:
|
||||
班级记录、学生名单、学号、作业与成绩全部沿用,只动「年级」这一个字段。
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-input v-model="promoteForm.grade" placeholder="升级后的年级,如 高二" style="width: 200px" />
|
||||
<el-button type="primary" :loading="promoteForm.saving" @click="promote">升级</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 添加学生 -->
|
||||
<el-dialog v-model="studentFormVisible" title="添加学生到本班" width="440px">
|
||||
<el-form :model="studentForm" :rules="studentRules" label-width="90px">
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="studentForm.username" placeholder="学号或登录名,已存在则直接转入本班" />
|
||||
<!-- 添加学生(只有名单:没有密码、不建账号) -->
|
||||
<el-dialog v-model="studentFormVisible" title="添加学生到本班" width="460px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
|
||||
学生只是班级名单里的记录:没有账号、没有密码、不能登录(教室机器统一用班级码 + 班级密码)。
|
||||
学号已存在时会直接转入本班,并留下一條调班历史。
|
||||
</el-alert>
|
||||
<el-form :model="studentForm" :rules="studentRules" label-width="100px">
|
||||
<el-form-item label="学号" prop="studentNo">
|
||||
<el-input v-model="studentForm.studentNo" placeholder="学生的唯一标识,如 202601" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="studentForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始密码">
|
||||
<el-input v-model="studentForm.password" placeholder="留空则使用默认密码" />
|
||||
<el-form-item label="性别">
|
||||
<el-select v-model="studentForm.gender" placeholder="未填" clearable style="width: 100%">
|
||||
<el-option label="男" value="MALE" />
|
||||
<el-option label="女" value="FEMALE" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="家长手机号">
|
||||
<el-input v-model="studentForm.guardianPhone" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -602,4 +825,16 @@ onUnmounted(() => {
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.setting-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,6 +2,10 @@
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
DEFAULT_LETTER_LEVELS,
|
||||
GRADE_LEVEL_TYPES,
|
||||
GRADE_LEVEL_TYPE_HINTS,
|
||||
GRADE_LEVEL_TYPE_LABELS,
|
||||
SOCKET_EVENTS,
|
||||
SUBJECT_CATALOG,
|
||||
formatDate,
|
||||
@@ -10,8 +14,10 @@ import {
|
||||
type ClassDto,
|
||||
type CourseDto,
|
||||
type GradeDto,
|
||||
type GradeLevelType,
|
||||
type GradeStats,
|
||||
type StudentDto,
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, gradeApi } from '@/api';
|
||||
import ScoreBarChart from '@/components/ScoreBarChart.vue';
|
||||
@@ -23,15 +29,13 @@ import { useRealtimeStore } from '@/stores/realtime';
|
||||
const auth = useAuthStore();
|
||||
|
||||
/**
|
||||
* 成绩写入/导入权限:管理员,或"所选班级的班主任"(需求 6:老师端可用且不越权)。
|
||||
* 科任老师只能查看列表与统计,写入会被后端 403(需求 7:科任仅作业/叫人/通知)。
|
||||
* 是否显示成绩的写入入口(**纯展示控制**)。
|
||||
*
|
||||
* 真正的权限在后端:管理员可以写全部;班主任与科任老师**只能写自己任教科目**的成绩
|
||||
* (查 `Course.teacherId`),越权一律 403。因此这里只要是教师就放行按钮,
|
||||
* 让老师能看到入口并在被拒时得到明确的后端提示,而不是"按钮凭空消失、不知道为什么"。
|
||||
*/
|
||||
const canManageGrades = computed(() => {
|
||||
if (auth.role === 'ADMIN') return true;
|
||||
if (auth.role !== 'TEACHER') return false;
|
||||
const current = classes.value.find((item) => item.id === filter.classId);
|
||||
return Boolean(current && current.teacherId === auth.user?.id);
|
||||
});
|
||||
const canManageGrades = computed(() => auth.role === 'ADMIN' || auth.role === 'TEACHER');
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -136,35 +140,48 @@ async function resolveCourseId(value: string): Promise<string | null> {
|
||||
/* ------------------------------------------------------------ 单条录入 */
|
||||
|
||||
const singleVisible = ref(false);
|
||||
const singleForm = reactive({ userId: '', courseId: '', examName: '', score: 90, totalScore: 100 });
|
||||
const singleForm = reactive({
|
||||
studentId: '',
|
||||
courseId: '',
|
||||
examName: '',
|
||||
score: 90,
|
||||
totalScore: 100,
|
||||
levelType: 'percent' as GradeLevelType,
|
||||
level: '',
|
||||
});
|
||||
|
||||
function openSingle(): void {
|
||||
if (!filter.classId) {
|
||||
ElMessage.warning('请先选择班级');
|
||||
return;
|
||||
}
|
||||
singleForm.userId = students.value[0]?.id ?? '';
|
||||
singleForm.studentId = students.value[0]?.id ?? '';
|
||||
singleForm.courseId = filter.courseId || courses.value[0]?.id || '';
|
||||
singleForm.examName = filter.examName || '期中考试';
|
||||
singleForm.score = 90;
|
||||
singleForm.totalScore = 100;
|
||||
singleForm.levelType = 'percent';
|
||||
singleForm.level = '';
|
||||
singleVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitSingle(): Promise<void> {
|
||||
if (!singleForm.userId || !singleForm.examName.trim()) {
|
||||
if (!singleForm.studentId || !singleForm.examName.trim()) {
|
||||
ElMessage.warning('请选择学生并填写考试名称');
|
||||
return;
|
||||
}
|
||||
await gradeApi.create({
|
||||
classId: filter.classId,
|
||||
courseId: await resolveCourseId(singleForm.courseId),
|
||||
userId: singleForm.userId,
|
||||
studentId: singleForm.studentId,
|
||||
examName: singleForm.examName.trim(),
|
||||
score: singleForm.score,
|
||||
totalScore: singleForm.totalScore,
|
||||
levelType: singleForm.levelType,
|
||||
// percent 口径留空时由服务端按得分率换算,所以这里只传用户真的填了的值
|
||||
...(singleForm.level.trim() ? { level: singleForm.level.trim() } : {}),
|
||||
});
|
||||
ElMessage.success('成绩已录入,已实时推送给该学生');
|
||||
ElMessage.success('成绩已录入,已实时推送给教室端');
|
||||
singleVisible.value = false;
|
||||
await loadGrades();
|
||||
}
|
||||
@@ -172,8 +189,13 @@ async function submitSingle(): Promise<void> {
|
||||
/* ------------------------------------------------------------ 批量录入 */
|
||||
|
||||
const bulkVisible = ref(false);
|
||||
const bulkForm = reactive({ courseId: '', examName: '期中考试', totalScore: 100 });
|
||||
const bulkRows = ref<Array<{ userId: string; name: string; username: string; score: number | null }>>([]);
|
||||
const bulkForm = reactive({
|
||||
courseId: '',
|
||||
examName: '期中考试',
|
||||
totalScore: 100,
|
||||
levelType: 'percent' as GradeLevelType,
|
||||
});
|
||||
const bulkRows = ref<Array<{ studentId: string; name: string; studentNo: string; score: number | null; level: string }>>([]);
|
||||
|
||||
function openBulk(): void {
|
||||
if (students.value.length === 0) {
|
||||
@@ -183,11 +205,13 @@ function openBulk(): void {
|
||||
bulkForm.courseId = filter.courseId || courses.value[0]?.id || '';
|
||||
bulkForm.examName = filter.examName || '期中考试';
|
||||
bulkForm.totalScore = 100;
|
||||
bulkForm.levelType = 'percent';
|
||||
bulkRows.value = students.value.map((item) => ({
|
||||
userId: item.id,
|
||||
studentId: item.id,
|
||||
name: item.name,
|
||||
username: item.username,
|
||||
studentNo: item.studentNo,
|
||||
score: null,
|
||||
level: '',
|
||||
}));
|
||||
bulkVisible.value = true;
|
||||
}
|
||||
@@ -195,7 +219,11 @@ function openBulk(): void {
|
||||
async function submitBulk(): Promise<void> {
|
||||
const items = bulkRows.value
|
||||
.filter((row) => row.score !== null && row.score !== undefined)
|
||||
.map((row) => ({ userId: row.userId, score: Number(row.score) }));
|
||||
.map((row) => ({
|
||||
studentId: row.studentId,
|
||||
score: Number(row.score),
|
||||
...(row.level.trim() ? { level: row.level.trim() } : {}),
|
||||
}));
|
||||
|
||||
if (items.length === 0) {
|
||||
ElMessage.warning('至少录入一位学生的成绩');
|
||||
@@ -211,6 +239,7 @@ async function submitBulk(): Promise<void> {
|
||||
courseId: await resolveCourseId(bulkForm.courseId),
|
||||
examName: bulkForm.examName.trim(),
|
||||
totalScore: bulkForm.totalScore,
|
||||
levelType: bulkForm.levelType,
|
||||
items,
|
||||
});
|
||||
ElMessage.success(`已录入 ${result.count} 条成绩并广播到班级`);
|
||||
@@ -218,6 +247,87 @@ async function submitBulk(): Promise<void> {
|
||||
await loadGrades();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 等级:单个 / 批量编辑 */
|
||||
|
||||
/** 表格里直接改一条等级(percent 口径下相当于"手动覆盖自动换算的结果") */
|
||||
async function saveLevel(row: GradeDto, level: string): Promise<void> {
|
||||
await gradeApi.update(row.id, { level });
|
||||
ElMessage.success('等级已更新');
|
||||
await loadGrades();
|
||||
}
|
||||
|
||||
const levelBatchVisible = ref(false);
|
||||
const levelBatchSaving = ref(false);
|
||||
const levelBatchForm = reactive({
|
||||
courseId: '',
|
||||
examName: '',
|
||||
levelType: 'letter' as GradeLevelType,
|
||||
});
|
||||
|
||||
function openLevelBatch(): void {
|
||||
if (!filter.classId) {
|
||||
ElMessage.warning('请先选择班级');
|
||||
return;
|
||||
}
|
||||
levelBatchForm.courseId = filter.courseId || courses.value[0]?.id || '';
|
||||
levelBatchForm.examName = filter.examName || examOptions.value[0] || '';
|
||||
levelBatchForm.levelType = 'letter';
|
||||
levelBatchVisible.value = true;
|
||||
}
|
||||
|
||||
/** 把某个考试某门课的成绩整批改成另一个等级口径(例如百分制 → A/B/C/D) */
|
||||
async function submitLevelBatch(): Promise<void> {
|
||||
if (!levelBatchForm.examName.trim()) {
|
||||
ElMessage.warning('请选择要调整的考试');
|
||||
return;
|
||||
}
|
||||
levelBatchSaving.value = true;
|
||||
try {
|
||||
const result = await gradeApi.updateLevels({
|
||||
classId: filter.classId,
|
||||
examName: levelBatchForm.examName.trim(),
|
||||
courseId: await resolveCourseId(levelBatchForm.courseId),
|
||||
levelType: levelBatchForm.levelType,
|
||||
});
|
||||
ElMessage.success(`已把 ${result.updated} 条成绩改成「${GRADE_LEVEL_TYPE_LABELS[levelBatchForm.levelType]}」`);
|
||||
levelBatchVisible.value = false;
|
||||
await loadGrades();
|
||||
} finally {
|
||||
levelBatchSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 按学号查成绩明细 */
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detailNo = ref('');
|
||||
const detail = ref<StudentGradeDetailDto | null>(null);
|
||||
|
||||
function openDetail(): void {
|
||||
if (!filter.classId) {
|
||||
ElMessage.warning('请先选择班级');
|
||||
return;
|
||||
}
|
||||
detailNo.value = '';
|
||||
detail.value = null;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
async function queryDetail(): Promise<void> {
|
||||
const studentNo = detailNo.value.trim();
|
||||
if (!studentNo) {
|
||||
ElMessage.warning('请输入学号');
|
||||
return;
|
||||
}
|
||||
detailLoading.value = true;
|
||||
try {
|
||||
detail.value = await gradeApi.studentDetail(studentNo, filter.classId);
|
||||
} finally {
|
||||
detailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 表格导入 */
|
||||
|
||||
const importVisible = ref(false);
|
||||
@@ -303,9 +413,16 @@ onUnmounted(() => {
|
||||
<el-button v-if="canManageGrades" type="warning" :icon="'Document'" @click="openImport">
|
||||
导入表格
|
||||
</el-button>
|
||||
<el-button v-if="canManageGrades" :icon="'Sort'" @click="openLevelBatch">批量改等级</el-button>
|
||||
<el-button :icon="'Search'" @click="openDetail">按学号查明细</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" style="margin-top: 8px">
|
||||
成绩按<b>科目</b>授权:科任老师只能录入 / 修改 / 删除<b>自己任教科目</b>的成绩;
|
||||
班主任若不教这一科,同样改不了这一科(不指定科目的内容归班主任与管理员)。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="6">
|
||||
<div class="stat-card">
|
||||
@@ -335,27 +452,49 @@ onUnmounted(() => {
|
||||
|
||||
<el-card shadow="never" class="mt-16 table-card">
|
||||
<el-table v-loading="loading" :data="grades" empty-text="暂无成绩记录" max-height="520">
|
||||
<el-table-column label="学生" width="140">
|
||||
<el-table-column label="学生" width="130">
|
||||
<template #default="{ row }">{{ row.student?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户名" width="130">
|
||||
<template #default="{ row }">{{ row.student?.username ?? '-' }}</template>
|
||||
<el-table-column label="学号" width="130">
|
||||
<template #default="{ row }">{{ row.student?.studentNo ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="课程" width="110">
|
||||
<el-table-column label="科目" width="110">
|
||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="examName" label="考试" width="150" />
|
||||
<el-table-column label="分数" width="130">
|
||||
<el-table-column label="分数" width="120">
|
||||
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="得分率" width="190">
|
||||
<el-table-column label="得分率" width="170">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="gradePercent(row.score, row.totalScore)"
|
||||
:stroke-width="10"
|
||||
:status="gradePercent(row.score, row.totalScore) >= 60 ? 'success' : 'exception'"
|
||||
/>
|
||||
<span class="text-muted">{{ gradeLevel(gradePercent(row.score, row.totalScore)) }} 等</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="level-cell">
|
||||
<el-tag size="small" effect="plain">{{ GRADE_LEVEL_TYPE_LABELS[row.levelType as GradeLevelType] }}</el-tag>
|
||||
<!-- 单个编辑:percent 口径下相当于手动覆盖自动换算的结果 -->
|
||||
<el-select
|
||||
v-if="canManageGrades"
|
||||
:model-value="row.level"
|
||||
size="small"
|
||||
style="width: 88px"
|
||||
@change="(value: string) => saveLevel(row, value)"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in [...new Set([row.level, ...DEFAULT_LETTER_LEVELS, '优', '良', '合格'])]"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
<span v-else>{{ row.level || `${gradeLevel(gradePercent(row.score, row.totalScore))} 等` }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间" width="170">
|
||||
@@ -370,21 +509,21 @@ onUnmounted(() => {
|
||||
</el-card>
|
||||
|
||||
<!-- 单条录入 -->
|
||||
<el-dialog v-model="singleVisible" title="单条成绩录入" width="480px">
|
||||
<el-dialog v-model="singleVisible" title="单条成绩录入" width="520px">
|
||||
<el-form :model="singleForm" label-width="90px">
|
||||
<el-form-item label="学生">
|
||||
<el-select v-model="singleForm.userId" filterable style="width: 100%">
|
||||
<el-select v-model="singleForm.studentId" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in students"
|
||||
:key="item.id"
|
||||
:label="`${item.name}(${item.username})`"
|
||||
:label="`${item.name}(${item.studentNo})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</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" :value="item.id" />
|
||||
<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="name in subjectOptions"
|
||||
@@ -398,12 +537,36 @@ onUnmounted(() => {
|
||||
<el-form-item label="考试名称">
|
||||
<el-input v-model="singleForm.examName" placeholder="例如 第一次月考" />
|
||||
</el-form-item>
|
||||
<el-form-item label="等级口径">
|
||||
<el-select v-model="singleForm.levelType" style="width: 100%">
|
||||
<el-option
|
||||
v-for="type in GRADE_LEVEL_TYPES"
|
||||
:key="type"
|
||||
:label="GRADE_LEVEL_TYPE_LABELS[type]"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分数">
|
||||
<el-input-number v-model="singleForm.score" :min="0" :max="singleForm.totalScore" />
|
||||
<span style="margin: 0 8px">/</span>
|
||||
<el-input-number v-model="singleForm.totalScore" :min="1" :max="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="等级">
|
||||
<el-select
|
||||
v-model="singleForm.level"
|
||||
:placeholder="singleForm.levelType === 'percent' ? '留空按得分率自动换算' : '请选择或输入等级'"
|
||||
allow-create
|
||||
filterable
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="item in DEFAULT_LETTER_LEVELS" :key="item" :label="item" :value="item" />
|
||||
<el-option v-for="item in ['优', '良', '合格', '待提高']" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="dialog-hint">{{ GRADE_LEVEL_TYPE_HINTS[singleForm.levelType] }}</div>
|
||||
<template #footer>
|
||||
<el-button @click="singleVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitSingle">录入并推送</el-button>
|
||||
@@ -411,7 +574,7 @@ onUnmounted(() => {
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量录入 -->
|
||||
<el-dialog v-model="bulkVisible" title="批量录入成绩" width="620px">
|
||||
<el-dialog v-model="bulkVisible" title="批量录入成绩" width="720px">
|
||||
<el-form :model="bulkForm" label-width="90px" inline>
|
||||
<el-form-item label="科目">
|
||||
<el-select v-model="bulkForm.courseId" clearable filterable style="width: 150px">
|
||||
@@ -437,16 +600,34 @@ onUnmounted(() => {
|
||||
<el-form-item label="总分">
|
||||
<el-input-number v-model="bulkForm.totalScore" :min="1" :max="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="等级口径">
|
||||
<el-select v-model="bulkForm.levelType" style="width: 180px">
|
||||
<el-option
|
||||
v-for="type in GRADE_LEVEL_TYPES"
|
||||
:key="type"
|
||||
:label="GRADE_LEVEL_TYPE_LABELS[type]"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="bulkRows" height="360" size="small">
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column prop="username" label="用户名" width="140" />
|
||||
<el-table-column prop="name" label="姓名" width="110" />
|
||||
<el-table-column prop="studentNo" label="学号" width="130" />
|
||||
<el-table-column label="分数">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.score" :min="0" :max="bulkForm.totalScore" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级(留空则自动换算)">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.level" size="small" allow-create filterable clearable style="width: 140px">
|
||||
<el-option v-for="item in DEFAULT_LETTER_LEVELS" :key="item" :label="item" :value="item" />
|
||||
<el-option v-for="item in ['优', '良', '合格', '待提高']" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template #footer>
|
||||
@@ -455,6 +636,89 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量改等级:把一个考试某门课整批改成另一种等级口径 -->
|
||||
<el-dialog v-model="levelBatchVisible" title="批量修改等级" width="520px">
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
|
||||
把某个考试某门课的成绩<b>整批</b>换成另一种等级口径。百分制会按得分率重新换算 A~E,
|
||||
等级制 / 自定义则直接套用所选口径(原有等级文本保留,可再到表格里逐条微调)。
|
||||
</el-alert>
|
||||
<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="name in subjectOptions"
|
||||
:key="name"
|
||||
:label="name"
|
||||
:value="`${SUBJECT_PREFIX}${name}`"
|
||||
/>
|
||||
</el-option-group>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="考试">
|
||||
<el-select v-model="levelBatchForm.examName" filterable allow-create style="width: 100%">
|
||||
<el-option v-for="item in examOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="改为">
|
||||
<el-select v-model="levelBatchForm.levelType" style="width: 100%">
|
||||
<el-option
|
||||
v-for="type in GRADE_LEVEL_TYPES"
|
||||
:key="type"
|
||||
:label="GRADE_LEVEL_TYPE_LABELS[type]"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="levelBatchVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="levelBatchSaving" @click="submitLevelBatch">确认修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 按学号查成绩明细(教师端;教室机器在自己的客户端里查同一份数据) -->
|
||||
<el-drawer v-model="detailVisible" title="按学号查询成绩明细" size="720px">
|
||||
<div class="detail-toolbar">
|
||||
<el-input
|
||||
v-model="detailNo"
|
||||
placeholder="输入学号,如 202601"
|
||||
style="width: 240px"
|
||||
@keyup.enter="queryDetail"
|
||||
/>
|
||||
<el-button type="primary" :loading="detailLoading" @click="queryDetail">查询</el-button>
|
||||
</div>
|
||||
|
||||
<template v-if="detail">
|
||||
<el-descriptions :column="2" border class="mt-12">
|
||||
<el-descriptions-item label="姓名">{{ detail.student.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学号">{{ detail.student.studentNo }}</el-descriptions-item>
|
||||
<el-descriptions-item label="班级">{{ detail.className ?? '未分班' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="平均得分率">{{ detail.averagePercent }}%</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table :data="detail.items" class="mt-12" empty-text="该生还没有成绩记录" max-height="440">
|
||||
<el-table-column prop="examName" label="考试" width="150" />
|
||||
<el-table-column label="科目" width="110">
|
||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分数" width="110">
|
||||
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.level || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间">
|
||||
<template #default="{ row }">{{ formatDate(row.publishedAt, true) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
<el-empty v-else description="输入学号后点查询,即可看到该生的全部成绩明细" />
|
||||
</el-drawer>
|
||||
|
||||
<!-- 表格导入(xlsx/xls/csv:模板下载 + 预览 + 字段映射 + 结果统计) -->
|
||||
<TableImportDialog
|
||||
v-model="importVisible"
|
||||
@@ -464,3 +728,24 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.level-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.detail-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-hint {
|
||||
font-size: 12px;
|
||||
color: var(--ch-text-muted);
|
||||
padding-left: 90px;
|
||||
margin-top: -6px;
|
||||
}
|
||||
</style>
|
||||
@@ -71,7 +71,7 @@ const submissionsHomeworkId = ref('');
|
||||
let submissionsRequestId = 0;
|
||||
|
||||
const notSubmittedNames = computed(() =>
|
||||
submissionStudents.value.filter((item) => notSubmittedIds.value.includes(item.userId)),
|
||||
submissionStudents.value.filter((item) => notSubmittedIds.value.includes(item.studentId)),
|
||||
);
|
||||
|
||||
/** 打开详情时顺带读一次未交名单(教师/管理员都有权限) */
|
||||
@@ -83,7 +83,7 @@ async function loadSubmissions(homeworkId: string): Promise<boolean> {
|
||||
// 迟到的响应:用户已经切到别的作业了,直接丢弃
|
||||
if (requestId !== submissionsRequestId) return false;
|
||||
submissionStudents.value = result.students;
|
||||
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
|
||||
notSubmittedIds.value = result.notSubmitted.map((item) => item.studentId);
|
||||
submissionsHomeworkId.value = homeworkId;
|
||||
return true;
|
||||
} catch {
|
||||
@@ -115,7 +115,7 @@ async function saveSubmissions(): Promise<void> {
|
||||
try {
|
||||
const result = await homeworkApi.saveSubmissions(current.value.id, notSubmittedIds.value);
|
||||
submissionStudents.value = result.students;
|
||||
notSubmittedIds.value = result.notSubmitted.map((item) => item.userId);
|
||||
notSubmittedIds.value = result.notSubmitted.map((item) => item.studentId);
|
||||
if (current.value) current.value.completedCount = result.completedCount;
|
||||
ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`);
|
||||
submissionsVisible.value = false;
|
||||
@@ -131,7 +131,7 @@ function markAllSubmitted(): void {
|
||||
}
|
||||
|
||||
function markAllNotSubmitted(): void {
|
||||
notSubmittedIds.value = submissionStudents.value.map((item) => item.userId);
|
||||
notSubmittedIds.value = submissionStudents.value.map((item) => item.studentId);
|
||||
}
|
||||
|
||||
async function loadClasses(): Promise<void> {
|
||||
@@ -488,7 +488,7 @@ onUnmounted(() => {
|
||||
:cell-class-name="dayCellClass"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span class="form-hint">作业会出现在这一天(按天查看与学生端都按它归类)</span>
|
||||
<span class="form-hint">作业会出现在这一天(按天查看与 ClassHelper 班级端都按它归类)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input v-model="form.title" maxlength="120" show-word-limit />
|
||||
@@ -527,7 +527,7 @@ onUnmounted(() => {
|
||||
<template v-else>
|
||||
<el-tag
|
||||
v-for="student in notSubmittedNames"
|
||||
:key="student.userId"
|
||||
:key="student.studentId"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="light"
|
||||
@@ -554,7 +554,7 @@ onUnmounted(() => {
|
||||
class="mt-16"
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="学生端实时同步"
|
||||
title="ClassHelper 班级端实时同步"
|
||||
:description="`学生客户端在收到 homework:new / homework:status 事件后会自动刷新列表,并在离线时使用本地缓存(摘要:${truncate(current.content, 30)})`"
|
||||
/>
|
||||
</template>
|
||||
@@ -575,11 +575,11 @@ onUnmounted(() => {
|
||||
<div v-loading="submissionsLoading" class="submission-list">
|
||||
<el-empty
|
||||
v-if="submissionStudents.length === 0 && !submissionsLoading"
|
||||
description="该班还没有学生账号"
|
||||
description="该班还没有学生,请先在学生名单里添加"
|
||||
/>
|
||||
<el-checkbox-group v-model="notSubmittedIds">
|
||||
<el-checkbox v-for="student in submissionStudents" :key="student.userId" :value="student.userId">
|
||||
{{ student.name }}({{ student.username }})
|
||||
<el-checkbox v-for="student in submissionStudents" :key="student.studentId" :value="student.studentId">
|
||||
{{ student.name }}(学号 {{ student.studentNo }})
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||
CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
|
||||
CLASSISLAND_NOTIFICATION_MAX_DURATION,
|
||||
CLASS_HELPER_STATUS_REFRESH_SECONDS,
|
||||
CLASSISLAND_TIME_STATE_LABELS,
|
||||
NOTIFICATION_PRIORITIES,
|
||||
PRIORITY_LABELS,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
formatDate,
|
||||
relativeTime,
|
||||
type ClassDto,
|
||||
type ClassHelperStatusDto,
|
||||
type ClassIslandNotificationChannel,
|
||||
type ClassIslandStateEvent,
|
||||
type IntegrationDeviceDto,
|
||||
@@ -27,7 +29,7 @@ import { useRealtimeStore } from '@/stores/realtime';
|
||||
* 两块内容:
|
||||
* 1. **设备接入**:为每个班签发设备令牌(`chci_...`),老师把令牌填进教室机器的
|
||||
* ClassIsland 插件设置,插件就会把该机课表/上课状态上报上来,并接收本页下发的提醒;
|
||||
* 2. **下发提醒**:把一条提醒推到该班的 ClassIsland 上全屏弹出(学生端通知中心同时留档)。
|
||||
* 2. **下发提醒**:把一条提醒推到该班的 ClassIsland 上全屏弹出(ClassHelper 班级端通知中心同时留档)。
|
||||
*
|
||||
* 权限与后端对齐:本页只对 ADMIN / TEACHER 开放(路由 meta.roles),
|
||||
* 具体到"能不能管这个班"由后端 assertCanManageSchedule / assertCanPublishContent 判定。
|
||||
@@ -41,6 +43,45 @@ const classes = ref<ClassDto[]>([]);
|
||||
const devices = ref<IntegrationDeviceDto[]>([]);
|
||||
const filter = reactive({ classId: '' });
|
||||
|
||||
/* ------------------------------------------------------------ ClassHelper 班级端在线状态
|
||||
*
|
||||
* 需求「教师网页支持查看班级 ClassHelper 在线状态」的落点:
|
||||
* 管理员看全部班级,普通教师看自己担任班主任的班级 ∪ 任课的班级(后端按 classScope 过滤)。
|
||||
* 在线判定统一是"最近 60 秒内有心跳"(共享常量 CLASS_HELPER_ONLINE_WINDOW_MS),
|
||||
* 与设备列表里那个 3 分钟容差的展示窗口是两回事 —— 这里给的是准确口径。
|
||||
*/
|
||||
const statusList = ref<ClassHelperStatusDto[]>([]);
|
||||
const statusLoading = ref(false);
|
||||
const statusServerTime = ref<string>('');
|
||||
const statusWindowMs = ref(60_000);
|
||||
let statusTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function loadStatus(): Promise<void> {
|
||||
statusLoading.value = true;
|
||||
try {
|
||||
const result = await integrationApi.classHelperStatus();
|
||||
statusList.value = result.classes;
|
||||
statusServerTime.value = result.serverTime;
|
||||
statusWindowMs.value = result.onlineWindowMs;
|
||||
} finally {
|
||||
statusLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 在线班级数 / 总数(卡片标题上的概览) */
|
||||
const onlineClassCount = computed(() => statusList.value.filter((item) => item.online).length);
|
||||
|
||||
/**
|
||||
* 距今多久(按**服务器时间**算,不用本机时钟)。
|
||||
* 教室机器常年不校时,本机与服务器可能差几分钟,用它算"距今"会得出负数或虚高的值。
|
||||
*/
|
||||
function sinceServer(value: string | null): string {
|
||||
if (!value) return '从未';
|
||||
const base = statusServerTime.value ? new Date(statusServerTime.value).getTime() : Date.now();
|
||||
const diff = base - new Date(value).getTime();
|
||||
return relativeTime(new Date(Date.now() - diff).toISOString());
|
||||
}
|
||||
|
||||
/** deviceId → 最近一次 socket 推送的实时状态(页面停留期间最新) */
|
||||
const liveStates = ref<Record<string, ClassIslandStateEvent>>({});
|
||||
|
||||
@@ -217,6 +258,30 @@ async function toggleMirror(device: IntegrationDeviceDto): Promise<void> {
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* 「从教室机器获取课表」——**现在取教室课表的唯一入口**。
|
||||
*
|
||||
* 自动回传已在 2026-10-06 起默认关闭:它会在老师手排课之后被教室的旧课表悄悄覆盖。
|
||||
* 因此这里做成一次显式的人工动作,并且**点之前必须弹警告**说明后果。
|
||||
*/
|
||||
async function pullScheduleFromDevice(device: IntegrationDeviceDto): Promise<void> {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
[
|
||||
`将从「${device.name}」读取它当前的课表,并覆盖 / 合并到本班的课表。`,
|
||||
'',
|
||||
'本班在 ClassHelper 上手工排过的课可能被改动;教室里删掉或挪动的课会同步过来。',
|
||||
'确认要从教室机器取一次课表吗?',
|
||||
].join('\n'),
|
||||
'从教室机器获取课表',
|
||||
{ type: 'warning', confirmButtonText: '确认获取', cancelButtonText: '取消' },
|
||||
).catch(() => null);
|
||||
if (!confirmed) return;
|
||||
|
||||
const result = await integrationApi.requestSchedule(device.id);
|
||||
ElMessage.success(result.nextHeartbeatHint);
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
async function resetToken(device: IntegrationDeviceDto): Promise<void> {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`重置后「${device.name}」的旧令牌立即失效,教室机器需要重新填写新令牌。确定继续?`,
|
||||
@@ -330,18 +395,22 @@ function handleState(payload: unknown): void {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await reload().catch(() => undefined);
|
||||
await Promise.all([reload().catch(() => undefined), loadStatus().catch(() => undefined)]);
|
||||
realtime.on(SOCKET_EVENTS.classislandState, handleState);
|
||||
// 在线状态是"距今多久"的判断,需要定时重算,否则页面开着时间不会变
|
||||
tickTimer = setInterval(() => {
|
||||
nowTick.value = Date.now();
|
||||
}, 30_000);
|
||||
// ClassHelper 班级端在线状态另有自动刷新(需求:支持手动刷新和自动刷新)
|
||||
statusTimer = setInterval(() => void loadStatus(), CLASS_HELPER_STATUS_REFRESH_SECONDS * 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
realtime.off(SOCKET_EVENTS.classislandState, handleState);
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
if (statusTimer) clearInterval(statusTimer);
|
||||
statusTimer = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -349,9 +418,9 @@ onUnmounted(() => {
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">ClassIsland 联动</h2>
|
||||
<h2 class="page-title">ClassHelper 联动</h2>
|
||||
<p class="page-subtitle">
|
||||
教室机器装上「班级小助手联动」插件后:课表与上课状态会自动上报到这里,这里下发的提醒会在 ClassIsland
|
||||
教室机器装上「ClassHelper 联动」插件后:课表与上课状态会自动上报到这里,这里下发的提醒会在 ClassIsland
|
||||
上全屏弹出
|
||||
</p>
|
||||
</div>
|
||||
@@ -374,13 +443,83 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ClassHelper 班级端在线状态(管理员看全部班级;教师看自己班主任/任课的班级) -->
|
||||
<el-card shadow="never" class="mb-16">
|
||||
<template #header>
|
||||
<div class="status-header">
|
||||
<span>
|
||||
ClassHelper 班级端在线状态
|
||||
<el-tag size="small" :type="onlineClassCount > 0 ? 'success' : 'info'" effect="light" class="ml-8">
|
||||
在线 {{ onlineClassCount }} / {{ statusList.length }} 个班
|
||||
</el-tag>
|
||||
</span>
|
||||
<div class="toolbar">
|
||||
<span class="text-muted">
|
||||
判定口径:最近 {{ Math.round(statusWindowMs / 1000) }} 秒内有心跳即为在线 ·
|
||||
每 {{ CLASS_HELPER_STATUS_REFRESH_SECONDS }} 秒自动刷新
|
||||
</span>
|
||||
<el-button size="small" :icon="'Refresh'" :loading="statusLoading" @click="loadStatus">
|
||||
手动刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="statusList" size="small" empty-text="还没有接入 ClassHelper 班级端" max-height="320">
|
||||
<el-table-column prop="className" label="班级" width="140" />
|
||||
<el-table-column label="ClassHelper 设备" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.devices.length">
|
||||
<div v-for="device in row.devices" :key="device.deviceId" class="device-line">
|
||||
<span>{{ device.deviceName }}</span>
|
||||
<span class="text-muted device-key">{{ device.deviceKey }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="text-muted">未接入</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.online ? 'success' : 'danger'" effect="light">
|
||||
{{ row.online ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后在线" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ sinceServer(row.devices[0]?.lastSeenAt ?? null) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最后心跳" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ sinceServer(row.devices[0]?.lastHeartbeatAt ?? null) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.devices[0]" class="text-muted">
|
||||
插件 {{ row.devices[0].pluginVersion ?? '-' }} · ClassIsland
|
||||
{{ row.devices[0].classIslandVersion ?? '-' }}
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="班级端查成绩" width="130">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.studentGradeQueryEnabled ? 'success' : 'info'" effect="plain">
|
||||
{{ row.studentGradeQueryEnabled ? '已开放' : '已关闭' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
v-if="filteredDevices.length === 0"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="还没有接入 ClassIsland 设备"
|
||||
description="在教室机器的 ClassIsland 上安装「班级小助手联动」插件并启用,然后点右上角「接入新设备」生成设备令牌,填入插件的「设置 → 班级小助手联动」即可。"
|
||||
description="在教室机器的 ClassIsland 上安装「ClassHelper 联动」插件并启用,然后点右上角「接入新设备」生成设备令牌,填入插件的「设置 → ClassHelper 联动」即可。"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
@@ -462,7 +601,7 @@ onUnmounted(() => {
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="290" fixed="right">
|
||||
<el-table-column label="操作" width="360" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="toggleEnabled(row)">
|
||||
{{ row.enabled ? '停用' : '启用' }}
|
||||
@@ -470,6 +609,7 @@ onUnmounted(() => {
|
||||
<el-button link type="primary" @click="toggleMirror(row)">
|
||||
{{ row.mirrorScheduleToClassIsland ? '关镜像' : '开镜像' }}
|
||||
</el-button>
|
||||
<el-button link type="success" @click="pullScheduleFromDevice(row)">获取课表</el-button>
|
||||
<el-button link type="warning" @click="resetToken(row)">重置令牌</el-button>
|
||||
<el-button link type="danger" @click="removeDevice(row)">删除</el-button>
|
||||
</template>
|
||||
@@ -598,7 +738,7 @@ onUnmounted(() => {
|
||||
</el-form-item>
|
||||
<el-form-item label="同步通知中心">
|
||||
<el-switch v-model="notifyForm.saveToNotifications" />
|
||||
<span class="form-hint">同时在班级通知中心留一条(学生端也能事后查看)</span>
|
||||
<span class="form-hint">同时在班级通知中心留一条(ClassHelper 班级端也能事后查看)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -610,6 +750,24 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.device-line {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-key {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
|
||||
@@ -36,7 +36,7 @@ async function submit(): Promise<void> {
|
||||
<div class="login-brand">
|
||||
<img class="login-logo" src="/logo.png" alt="" width="44" height="44" />
|
||||
<div>
|
||||
<h1 class="login-title">班级小助手</h1>
|
||||
<h1 class="login-title">ClassHelper</h1>
|
||||
<p class="login-subtitle">Web 管理端 · 教师 / 管理员登录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -382,7 +382,7 @@ onUnmounted(() => {
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">通知发布</h2>
|
||||
<p class="page-subtitle">发布后按班级房间广播(notification:new),学生端 5 秒内到达</p>
|
||||
<p class="page-subtitle">发布后按班级房间广播(notification:new),ClassHelper 班级端 5 秒内到达</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
@@ -515,7 +515,7 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 叫人:选班级 → 选学生 → 快捷短语 / 自定义消息 → 学生端灵动岛立即弹出 -->
|
||||
<!-- 叫人:选班级 → 选学生 → 快捷短语 / 自定义消息 → ClassHelper 班级端灵动岛立即弹出 -->
|
||||
<el-dialog v-model="callVisible" title="叫人" width="520px">
|
||||
<el-form label-width="76px">
|
||||
<el-form-item label="班级">
|
||||
@@ -528,7 +528,7 @@ onUnmounted(() => {
|
||||
<el-option
|
||||
v-for="item in callStudents"
|
||||
:key="item.id"
|
||||
:label="`${item.name}(${item.username})`"
|
||||
:label="`${item.name}(${item.studentNo})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
@@ -569,8 +569,8 @@ onUnmounted(() => {
|
||||
:closable="false"
|
||||
:title="
|
||||
callUrgent
|
||||
? '紧急叫人:学生端桌面无视上课时段立即展开,需学生点「收到」'
|
||||
: '普通叫人:学生端课间先显示胶囊、点击展开;上课时段只排队,下课后弹出'
|
||||
? '紧急叫人:ClassHelper 班级端桌面无视上课时段立即展开,需学生点「收到」'
|
||||
: '普通叫人:ClassHelper 班级端课间先显示胶囊、点击展开;上课时段只排队,下课后弹出'
|
||||
"
|
||||
/>
|
||||
<template #footer>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
CALL_QUICK_PHRASES,
|
||||
ROLE_LABELS,
|
||||
STUDENT_GENDERS,
|
||||
STUDENT_GENDER_LABELS,
|
||||
STUDENT_STATUSES,
|
||||
STUDENT_STATUS_LABELS,
|
||||
STUDENT_STATUS_TAG_TYPES,
|
||||
formatDate,
|
||||
type ClassDto,
|
||||
type StudentDto,
|
||||
type StudentStatus,
|
||||
type StudentTransferDto,
|
||||
} from '@classhelper/shared';
|
||||
import { callApi, classApi, studentApi } from '@/api';
|
||||
import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||
@@ -14,7 +20,11 @@ import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||
const loading = ref(false);
|
||||
const students = ref<StudentDto[]>([]);
|
||||
const classes = ref<ClassDto[]>([]);
|
||||
const filter = reactive({ classId: '', keyword: '' });
|
||||
const filter = reactive({ classId: '', keyword: '', status: '' as '' | StudentStatus });
|
||||
/** 表格勾选(批量调班 / 批量转出用) */
|
||||
const selection = ref<StudentDto[]>([]);
|
||||
|
||||
const classOptions = computed(() => classes.value);
|
||||
|
||||
async function loadClasses(): Promise<void> {
|
||||
classes.value = await classApi.list();
|
||||
@@ -23,9 +33,10 @@ async function loadClasses(): Promise<void> {
|
||||
async function loadStudents(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: { classId?: string; keyword?: string } = {};
|
||||
const params: { classId?: string; keyword?: string; status?: StudentStatus } = {};
|
||||
if (filter.classId) params.classId = filter.classId;
|
||||
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
|
||||
if (filter.status) params.status = filter.status;
|
||||
students.value = await studentApi.list(params);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -47,94 +58,64 @@ function openImport(): void {
|
||||
const formVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const editingId = ref<string | null>(null);
|
||||
/** 表单不再暴露「用户名」:新建时由前端自动生成学号式名单标识(见 autoCreateStudent) */
|
||||
const form = reactive({ name: '', classId: '' });
|
||||
/** 学号是学生的唯一标识与查询键,必须由管理员显式填写(不再自动生成) */
|
||||
const form = reactive({
|
||||
studentNo: '',
|
||||
name: '',
|
||||
classId: '',
|
||||
gender: '' as '' | 'MALE' | 'FEMALE',
|
||||
guardianPhone: '',
|
||||
});
|
||||
const rules: FormRules = {
|
||||
studentNo: [{ required: true, message: '请输入学号', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null;
|
||||
form.studentNo = '';
|
||||
form.name = '';
|
||||
form.classId = filter.classId;
|
||||
form.gender = '';
|
||||
form.guardianPhone = '';
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: StudentDto): void {
|
||||
editingId.value = row.id;
|
||||
form.studentNo = row.studentNo;
|
||||
form.name = row.name;
|
||||
form.classId = row.classId ?? '';
|
||||
form.gender = row.gender;
|
||||
form.guardianPhone = row.guardianPhone;
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动生成学生名单标识(学号式:student01、student02 …,与种子数据同风格)。
|
||||
*
|
||||
* 后端 `POST /api/students` 的 username 必填且全局唯一(教师/管理员账号同样占位),
|
||||
* 界面已按需求隐藏「用户名」输入,因此这里用「全量学生 + 递增序号」生成,
|
||||
* 并在服务端返回 409(唯一约束冲突)时换下一个序号重试;其它错误立即抛出,避免刷屏。
|
||||
* 学生没有密码、不能登录,username 仅作名单标识。
|
||||
*/
|
||||
function studentUsernameAt(index: number): string {
|
||||
return index < 100 ? `student${String(index).padStart(2, '0')}` : `student${index}`;
|
||||
}
|
||||
|
||||
async function autoCreateStudent(): Promise<void> {
|
||||
const existing = new Set((await studentApi.list()).map((item) => item.username));
|
||||
let index = existing.size + 1;
|
||||
let lastError: unknown = new Error('自动生成名单标识失败');
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
let username = studentUsernameAt(index);
|
||||
while (existing.has(username)) {
|
||||
index += 1;
|
||||
username = studentUsernameAt(index);
|
||||
}
|
||||
try {
|
||||
await studentApi.create({
|
||||
username,
|
||||
name: form.name.trim(),
|
||||
classId: form.classId || null,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
const status = (error as { response?: { status?: number } }).response?.status;
|
||||
if (status !== 409) throw error;
|
||||
lastError = error;
|
||||
existing.add(username);
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function submitForm(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
const payload = {
|
||||
studentNo: form.studentNo.trim(),
|
||||
name: form.name.trim(),
|
||||
classId: form.classId || null,
|
||||
gender: form.gender,
|
||||
guardianPhone: form.guardianPhone.trim(),
|
||||
};
|
||||
|
||||
if (editingId.value) {
|
||||
// 用户名不再由界面维护:编辑只改姓名与班级
|
||||
await studentApi.update(editingId.value, {
|
||||
name: form.name.trim(),
|
||||
classId: form.classId || null,
|
||||
});
|
||||
await studentApi.update(editingId.value, payload);
|
||||
ElMessage.success('学生信息已更新');
|
||||
} else {
|
||||
try {
|
||||
await autoCreateStudent();
|
||||
ElMessage.success('学生已创建(名单标识已自动生成)');
|
||||
} catch {
|
||||
// 接口层已弹出服务端原因,这里补充可执行的兜底建议
|
||||
ElMessage.error('创建失败:可稍后重试,或用「导入名单」在表格里显式指定用户名');
|
||||
return;
|
||||
}
|
||||
await studentApi.create(payload);
|
||||
ElMessage.success('学生已加入名单');
|
||||
}
|
||||
formVisible.value = false;
|
||||
await loadStudents();
|
||||
}
|
||||
|
||||
async function removeStudent(row: StudentDto): Promise<void> {
|
||||
await ElMessageBox.confirm(`删除学生「${row.name}」及其成绩、作业记录?`, '危险操作', {
|
||||
await ElMessageBox.confirm(`删除学生「${row.name}(${row.studentNo})」及其成绩、作业记录?`, '危险操作', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认删除',
|
||||
});
|
||||
@@ -143,6 +124,87 @@ async function removeStudent(row: StudentDto): Promise<void> {
|
||||
await loadStudents();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 调班 / 转出 */
|
||||
// 规则(需求「管理员调班」):只改学生当前班级,**学号不变**、不创建账号;
|
||||
// 原班级/新班级/操作人/时间记入历史;历史作业与成绩保留原归属。
|
||||
|
||||
const transferVisible = ref(false);
|
||||
const transferSaving = ref(false);
|
||||
const transferTargets = ref<StudentDto[]>([]);
|
||||
const transferForm = reactive({ toClassId: '', note: '' });
|
||||
|
||||
function openTransfer(rows: StudentDto[]): void {
|
||||
if (rows.length === 0) {
|
||||
ElMessage.warning('请先勾选要调动的学生');
|
||||
return;
|
||||
}
|
||||
transferTargets.value = rows;
|
||||
transferForm.toClassId = '';
|
||||
transferForm.note = '';
|
||||
transferVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitTransfer(): Promise<void> {
|
||||
transferSaving.value = true;
|
||||
try {
|
||||
const result = await studentApi.transfer(
|
||||
transferTargets.value.map((item) => item.id),
|
||||
transferForm.toClassId || null,
|
||||
transferForm.note.trim() || undefined,
|
||||
);
|
||||
ElMessage.success(`已调动 ${result.moved} 名学生(学号不变,历史可查)`);
|
||||
transferVisible.value = false;
|
||||
await loadStudents();
|
||||
} finally {
|
||||
transferSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function transferOut(rows: StudentDto[]): Promise<void> {
|
||||
if (rows.length === 0) {
|
||||
ElMessage.warning('请先勾选要办理转出的学生');
|
||||
return;
|
||||
}
|
||||
const names = rows.map((item) => item.name).join('、');
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
`将 ${rows.length} 名学生(${names})标记为「已转出」。\n` +
|
||||
'转出表示学籍离开本校:班级归属与历史成绩、作业都会保留,只是不再计入在读名单。',
|
||||
'办理学生转出',
|
||||
{ inputPlaceholder: '转出去向 / 原因(可选)', inputValue: '', confirmButtonText: '确认转出' },
|
||||
);
|
||||
const result = await studentApi.transferOut(
|
||||
rows.map((item) => item.id),
|
||||
(value ?? '').trim() || undefined,
|
||||
);
|
||||
ElMessage.success(`已办理 ${result.transferred} 名学生转出`);
|
||||
await loadStudents();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 调班历史 */
|
||||
|
||||
const historyVisible = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const historyRows = ref<StudentTransferDto[]>([]);
|
||||
const historyTarget = ref<StudentDto | null>(null);
|
||||
|
||||
async function openHistory(row: StudentDto): Promise<void> {
|
||||
historyTarget.value = row;
|
||||
historyVisible.value = true;
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const result = await studentApi.transfersOf(row.id);
|
||||
historyRows.value = result.items;
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const TRANSFER_MODE_LABELS: Record<string, string> = {
|
||||
single: '单个调班',
|
||||
batch: '批量调班',
|
||||
'transfer-out': '转出',
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadClasses(), loadStudents()]);
|
||||
});
|
||||
@@ -200,21 +262,37 @@ async function submitCall(): Promise<void> {
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">学生管理</h2>
|
||||
<p class="page-subtitle">学生名单、分班与叫人(学生端统一用班级账号登录,学生本身没有密码)</p>
|
||||
<p class="page-subtitle">
|
||||
学生是班级名单里的记录:<b>没有个人账号、不能登录</b>,学号是唯一标识;教室机器用班级码 + 班级密码登录
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
v-model="filter.classId"
|
||||
placeholder="全部班级"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
style="width: 170px"
|
||||
@change="loadStudents"
|
||||
>
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filter.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
@change="loadStudents"
|
||||
>
|
||||
<el-option
|
||||
v-for="status in STUDENT_STATUSES"
|
||||
:key="status"
|
||||
:label="STUDENT_STATUS_LABELS[status]"
|
||||
:value="status"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="filter.keyword"
|
||||
placeholder="姓名"
|
||||
placeholder="学号 / 姓名"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="loadStudents"
|
||||
@@ -227,10 +305,42 @@ async function submitCall(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="students" empty-text="暂无学生数据">
|
||||
<el-table-column prop="name" label="姓名" width="120" />
|
||||
<el-table-column label="角色" width="90">
|
||||
<template #default="{ row }">{{ ROLE_LABELS[row.role as 'STUDENT'] ?? row.role }}</template>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>共 {{ students.length }} 名学生</span>
|
||||
<div class="toolbar">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:icon="'Switch'"
|
||||
:disabled="selection.length === 0"
|
||||
@click="openTransfer(selection)"
|
||||
>
|
||||
批量调班<span v-if="selection.length">({{ selection.length }})</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:icon="'Right'"
|
||||
:disabled="selection.length === 0"
|
||||
@click="transferOut(selection)"
|
||||
>
|
||||
批量转出<span v-if="selection.length">({{ selection.length }})</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="students"
|
||||
empty-text="暂无学生数据"
|
||||
@selection-change="(rows: StudentDto[]) => (selection = rows)"
|
||||
>
|
||||
<el-table-column type="selection" width="46" />
|
||||
<el-table-column prop="studentNo" label="学号" width="130" />
|
||||
<el-table-column prop="name" label="姓名" width="110" />
|
||||
<el-table-column label="性别" width="80">
|
||||
<template #default="{ row }">{{ STUDENT_GENDER_LABELS[row.gender as '' | 'MALE' | 'FEMALE'] }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="班级" width="140">
|
||||
<template #default="{ row }">
|
||||
@@ -238,15 +348,24 @@ async function submitCall(): Promise<void> {
|
||||
<span v-else class="text-muted">未分班</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="年级" width="90">
|
||||
<template #default="{ row }">{{ row.grade ?? '-' }}</template>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="STUDENT_STATUS_TAG_TYPES[row.status as StudentStatus]">
|
||||
{{ STUDENT_STATUS_LABELS[row.status as StudentStatus] }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="guardianPhone" label="家长手机号" width="140">
|
||||
<template #default="{ row }">{{ row.guardianPhone || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<el-table-column label="操作" width="330" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="success" @click="openCall(row)">叫人</el-button>
|
||||
<el-button link type="primary" @click="openTransfer([row])">调班</el-button>
|
||||
<el-button link @click="openHistory(row)">调班历史</el-button>
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="removeStudent(row)">删除</el-button>
|
||||
</template>
|
||||
@@ -254,7 +373,52 @@ async function submitCall(): Promise<void> {
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 叫人:选中学生 + 快捷短语/自定义消息 → 学生端灵动岛立即弹出 -->
|
||||
<!-- 调班:只改当前班级,学号不变、历史作业与成绩保留原归属 -->
|
||||
<el-dialog v-model="transferVisible" title="调班" width="480px">
|
||||
<el-alert type="info" :closable="false" class="call-alert">
|
||||
调班只改学生**当前班级**:学号不变、不创建账号;原班级 / 新班级 / 操作人 / 时间会记入调班历史,
|
||||
该生已有的成绩与作业仍留在原班级。
|
||||
</el-alert>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="学生">
|
||||
<span>{{ transferTargets.length }} 名({{ transferTargets.map((s) => s.name).join('、') }})</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="调入班级">
|
||||
<el-select v-model="transferForm.toClassId" placeholder="留空 = 移出班级(未分班)" clearable style="width: 100%">
|
||||
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="transferForm.note" maxlength="200" placeholder="例如:按分班考试成绩调整(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="transferSaving" @click="submitTransfer">确认调动</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 调班历史 -->
|
||||
<el-drawer v-model="historyVisible" :title="`调班历史 — ${historyTarget?.name ?? ''}`" size="640px">
|
||||
<el-table v-loading="historyLoading" :data="historyRows" empty-text="该生还没有调班记录">
|
||||
<el-table-column label="时间" width="160">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="方式" width="100">
|
||||
<template #default="{ row }">{{ TRANSFER_MODE_LABELS[row.mode] ?? row.mode }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原班级" width="130">
|
||||
<template #default="{ row }">{{ row.fromClassName || '未分班' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="新班级" width="130">
|
||||
<template #default="{ row }">{{ row.toClassName || '未分班' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operatorName" label="操作人" width="100" />
|
||||
<el-table-column prop="note" label="备注" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 叫人:选中学生 + 快捷短语/自定义消息 → 教室机器立即弹出 -->
|
||||
<el-dialog v-model="callVisible" title="叫人" width="480px">
|
||||
<el-alert
|
||||
v-if="callTarget"
|
||||
@@ -264,7 +428,7 @@ async function submitCall(): Promise<void> {
|
||||
class="call-alert"
|
||||
>
|
||||
<template #default>
|
||||
发送后学生端桌面会浮出「请 {{ callTarget.name }} 同学找 XXX 老师」;
|
||||
发送后教室的 ClassHelper 班级端会浮出「请 {{ callTarget.name }} 同学找 XXX 老师」;
|
||||
<b>紧急</b>
|
||||
叫人无视上课时段立即展开,
|
||||
<b>普通</b>
|
||||
@@ -314,14 +478,30 @@ async function submitCall(): Promise<void> {
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="formVisible" :title="editingId ? '编辑学生' : '新建学生'" width="460px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-dialog v-model="formVisible" :title="editingId ? '编辑学生' : '新建学生'" width="480px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="学号" prop="studentNo">
|
||||
<el-input v-model="form.studentNo" placeholder="学生的唯一标识,如 202601" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="性别">
|
||||
<el-select v-model="form.gender" placeholder="未填" clearable style="width: 100%">
|
||||
<el-option
|
||||
v-for="gender in STUDENT_GENDERS.filter((g) => g !== '')"
|
||||
:key="gender"
|
||||
:label="STUDENT_GENDER_LABELS[gender]"
|
||||
:value="gender"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="家长手机号">
|
||||
<el-input v-model="form.guardianPhone" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="班级">
|
||||
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
@@ -342,6 +522,14 @@ async function submitCall(): Promise<void> {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.call-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||
* 教师管理(**仅管理员可见/可用**,与后端 `requireRole('ADMIN')` 对应)。
|
||||
*
|
||||
* 与「学生管理」保持同一套交互:单个录入 / 表格导入名单 / 编辑 / 修改密码 / 删除。
|
||||
* 学生端主体是班级账号,教师账号则决定"谁是班主任、谁能录课表与成绩",
|
||||
* ClassHelper 班级端主体是班级账号,教师账号则决定"谁是班主任、谁能录课表与成绩",
|
||||
* 属于系统级配置,所以录入权限只给管理员。
|
||||
*/
|
||||
|
||||
@@ -32,11 +32,18 @@ async function loadTeachers(): Promise<void> {
|
||||
const formVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({ username: '', name: '', password: '', role: 'TEACHER' as 'TEACHER' | 'ADMIN' });
|
||||
const form = reactive({
|
||||
username: '',
|
||||
name: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
role: 'TEACHER' as 'TEACHER' | 'ADMIN',
|
||||
});
|
||||
const rules: FormRules = {
|
||||
// 教师的 `username` 就是**工号**(也是登录名)—— 界面上统一叫「工号」
|
||||
username: [
|
||||
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
|
||||
{ min: 3, max: 32, message: '用户名 3~32 位', trigger: 'blur' },
|
||||
{ required: true, message: '请输入工号', trigger: 'blur' },
|
||||
{ min: 3, max: 32, message: '工号 3~32 位', trigger: 'blur' },
|
||||
{ pattern: /^[A-Za-z0-9_.-]+$/, message: '只能用字母、数字、下划线、点、短横线', trigger: 'blur' },
|
||||
],
|
||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
@@ -47,6 +54,7 @@ function openCreate(): void {
|
||||
editingId.value = null;
|
||||
form.username = '';
|
||||
form.name = '';
|
||||
form.phone = '';
|
||||
form.password = '';
|
||||
form.role = 'TEACHER';
|
||||
formVisible.value = true;
|
||||
@@ -56,6 +64,7 @@ function openEdit(row: UserDto): void {
|
||||
editingId.value = row.id;
|
||||
form.username = row.username;
|
||||
form.name = row.name;
|
||||
form.phone = row.phone ?? '';
|
||||
form.password = '';
|
||||
form.role = row.role === 'ADMIN' ? 'ADMIN' : 'TEACHER';
|
||||
formVisible.value = true;
|
||||
@@ -71,6 +80,7 @@ async function submitForm(): Promise<void> {
|
||||
await teacherApi.update(editingId.value, {
|
||||
username: form.username.trim(),
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
role: form.role,
|
||||
});
|
||||
ElMessage.success('教师信息已更新');
|
||||
@@ -78,6 +88,7 @@ async function submitForm(): Promise<void> {
|
||||
await teacherApi.create({
|
||||
username: form.username.trim(),
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
role: form.role,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
});
|
||||
@@ -150,13 +161,13 @@ onMounted(loadTeachers);
|
||||
<div>
|
||||
<h2 class="page-title">教师管理</h2>
|
||||
<p class="page-subtitle">
|
||||
录入教师账号(单个录入或导入名单)、修改姓名与角色、修改密码(仅管理员可见)
|
||||
教师是全局账号,<b>工号</b>就是登录名;谁是班主任、谁教哪个班的哪一科,在「班级管理」里配(仅管理员可见)
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="姓名 / 用户名"
|
||||
placeholder="姓名 / 工号"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="loadTeachers"
|
||||
@@ -171,7 +182,10 @@ onMounted(loadTeachers);
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="teachers" empty-text="还没有教师账号,点击右上角新建">
|
||||
<el-table-column prop="name" label="姓名" width="140" />
|
||||
<el-table-column prop="username" label="用户名" width="180" />
|
||||
<el-table-column prop="username" label="工号" width="160" />
|
||||
<el-table-column prop="phone" label="手机号" width="150">
|
||||
<template #default="{ row }">{{ row.phone || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'primary'" effect="plain" size="small">
|
||||
@@ -197,8 +211,11 @@ onMounted(loadTeachers);
|
||||
<el-form-item label="姓名" prop="name">
|
||||
<el-input v-model="form.name" placeholder="例如 张老师" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="登录用,例如 teacher3" />
|
||||
<el-form-item label="工号" prop="username">
|
||||
<el-input v-model="form.username" placeholder="登录用,例如 T1001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!editingId" label="初始密码">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="留空使用默认密码" />
|
||||
@@ -209,7 +226,8 @@ onMounted(loadTeachers);
|
||||
<el-radio value="ADMIN">管理员</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="text-muted" style="width: 100%">
|
||||
管理员可以管理班级、学生、教师与全部课表;普通教师仅能管理自己负责的班级。
|
||||
管理员可以管理班级、学生、教师、归档与全部课表;
|
||||
普通教师只能管自己担任班主任的班级,以及自己任教科目的作业与成绩。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 学期周次(仅管理员)。
|
||||
*
|
||||
* 为什么要有这一页:课表、作业、成绩都按**教学周**组织,而"开学日期 + 每周七天"的
|
||||
* 线性推算遇到法定节假日调休、周末补课、错峰开学就会**整体错位**(错一周全错)。
|
||||
* 所以这里支持逐周指定实际的起止日期:
|
||||
*
|
||||
* 1. 先填学期开始日期 → 点「按开学日期生成」得到一份标准学期;
|
||||
* 2. 再把调休那几周的日期范围改掉(也可以先点「拉取放假安排」看看今年哪些天不上课);
|
||||
* 3. 保存后变更会随课表**下发给教室的 ClassHelper 班级端**。
|
||||
*
|
||||
* `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
MAX_TERM_WEEK,
|
||||
type ClassDto,
|
||||
type HolidaySuggestionDto,
|
||||
type TermWeekDto,
|
||||
type TermWeeksDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, termApi } from '@/api';
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const classes = ref<ClassDto[]>([]);
|
||||
const term = ref<TermWeeksDto | null>(null);
|
||||
/** '' = 全校默认 */
|
||||
const scope = ref('');
|
||||
/** 本地编辑中的逐周区间(保存前的草稿) */
|
||||
const weeks = ref<TermWeekDto[]>([]);
|
||||
const termStartDate = ref('');
|
||||
|
||||
const scopeLabel = computed(() => (scope.value ? (classes.value.find((item) => item.id === scope.value)?.name ?? scope.value) : '全校默认'));
|
||||
|
||||
/** 有没有改动(保存按钮的可用状态) */
|
||||
const dirty = computed(() => {
|
||||
const current = term.value;
|
||||
if (!current) return false;
|
||||
if (termStartDate.value !== current.termStartDate) return true;
|
||||
if (weeks.value.length !== current.weeks.length) return true;
|
||||
return weeks.value.some((week, index) => {
|
||||
const origin = current.weeks[index];
|
||||
return (
|
||||
!origin ||
|
||||
origin.weekNumber !== week.weekNumber ||
|
||||
origin.startDate !== week.startDate ||
|
||||
origin.endDate !== week.endDate ||
|
||||
origin.note !== week.note
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await termApi.get(scope.value || undefined);
|
||||
term.value = result;
|
||||
weeks.value = result.weeks.map((item) => ({ ...item }));
|
||||
termStartDate.value = result.termStartDate;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onScopeChange(): Promise<void> {
|
||||
await load();
|
||||
}
|
||||
|
||||
/** 一键生成:按学期开始日期铺满整个学期(配置的起点,之后逐周微调) */
|
||||
async function autoGenerate(): Promise<void> {
|
||||
if (!termStartDate.value) {
|
||||
ElMessage.warning('请先填写学期开始日期(第 1 教学周的周一)');
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await termApi.auto({
|
||||
classId: scope.value || null,
|
||||
termStartDate: termStartDate.value,
|
||||
maxWeek: term.value?.maxWeek ?? MAX_TERM_WEEK,
|
||||
});
|
||||
term.value = result;
|
||||
weeks.value = result.weeks.map((item) => ({ ...item }));
|
||||
ElMessage.success(`已生成 ${result.weeks.length} 周的日期区间,可继续手动调整`);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (weeks.value.length === 0) {
|
||||
await ElMessageBox.confirm(
|
||||
'当前没有逐周区间,保存后会回到"按学期开始日期线性推算"的方式。确认继续?',
|
||||
'确认',
|
||||
{ type: 'warning' },
|
||||
);
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const result = await termApi.save({
|
||||
classId: scope.value || null,
|
||||
termStartDate: termStartDate.value,
|
||||
weeks: weeks.value.map((week) => ({
|
||||
weekNumber: week.weekNumber,
|
||||
startDate: week.startDate,
|
||||
endDate: week.endDate,
|
||||
note: week.note,
|
||||
})),
|
||||
});
|
||||
term.value = result;
|
||||
weeks.value = result.weeks.map((item) => ({ ...item }));
|
||||
ElMessage.success('已保存;变更会随课表下发给教室的 ClassHelper 班级端');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addWeek(): void {
|
||||
const next = (weeks.value[weeks.value.length - 1]?.weekNumber ?? 0) + 1;
|
||||
weeks.value.push({ weekNumber: next, startDate: '', endDate: '', note: '' });
|
||||
}
|
||||
|
||||
function removeWeek(index: number): void {
|
||||
weeks.value.splice(index, 1);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 调休建议(联网,可选) */
|
||||
|
||||
const holiday = ref<HolidaySuggestionDto | null>(null);
|
||||
const holidayLoading = ref(false);
|
||||
|
||||
/**
|
||||
* 拉取法定节假日安排。
|
||||
* **机房没有外网时 `ok:false` 是正常结果**,这里只提示、不影响手动配置。
|
||||
*/
|
||||
async function fetchHolidays(): Promise<void> {
|
||||
holidayLoading.value = true;
|
||||
try {
|
||||
const result = await termApi.holidays(new Date().getFullYear());
|
||||
holiday.value = result;
|
||||
if (result.ok) {
|
||||
ElMessage.success(`已获取 ${result.year} 年的放假安排(${result.holidays.length} 天)`);
|
||||
} else {
|
||||
ElMessage.warning(result.error ?? '暂时无法获取放假安排,可手动调整周次');
|
||||
}
|
||||
} finally {
|
||||
holidayLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
classes.value = await classApi.list().catch(() => []);
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">学期周次</h2>
|
||||
<p class="page-subtitle">
|
||||
逐周指定实际日期区间(调休、周末补课、错峰开学都能表达)。
|
||||
保存后会随课表<b>下发给教室的 ClassHelper 班级端</b>,让教室里的"第几周"与这里一致
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-select v-model="scope" placeholder="全校默认" clearable style="width: 200px" @change="onScopeChange">
|
||||
<el-option label="全校默认(所有班级)" value="" />
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
|
||||
<el-button type="warning" :icon="'Calendar'" :loading="holidayLoading" @click="fetchHolidays">
|
||||
拉取放假安排
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
|
||||
<template #title>
|
||||
当前配置范围:{{ scopeLabel }} · 共 {{ weeks.length }} 周
|
||||
<span v-if="term">(当前第 {{ term.currentWeek }} 教学周,学期周数上限 {{ term.maxWeek }})</span>
|
||||
<span v-if="term && !term.configured"> · 尚未逐周配置,按学期开始日期线性推算</span>
|
||||
</template>
|
||||
<template #default>
|
||||
留空某个班时,该班使用「全校默认」。班里有特殊安排(例如整周外出实习)时,再单独为它配一遍。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-card shadow="never" style="margin-bottom: 12px">
|
||||
<div class="toolbar">
|
||||
<span class="label">学期开始日期</span>
|
||||
<el-date-picker
|
||||
v-model="termStartDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="第 1 教学周的周一"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<el-button type="primary" plain :loading="saving" @click="autoGenerate">
|
||||
按开学日期生成
|
||||
</el-button>
|
||||
<span class="hint">先生成一份标准学期,再把调休那几周改掉 —— 比一周周填快得多</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 联网拉到的放假安排(只作参考,不会自动改任何数据) -->
|
||||
<el-card v-if="holiday?.ok && holiday.holidays.length > 0" shadow="never" style="margin-bottom: 12px">
|
||||
<template #header>
|
||||
<span>{{ holiday.year }} 年放假安排(参考,不会自动改周次)</span>
|
||||
</template>
|
||||
<el-tag v-for="item in holiday.holidays" :key="item.startDate" size="small" effect="plain" class="holiday-tag">
|
||||
{{ item.name }} {{ item.startDate }}
|
||||
</el-tag>
|
||||
</el-card>
|
||||
|
||||
<el-card v-loading="loading" shadow="never" class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>逐周日期区间({{ weeks.length }} 周)</span>
|
||||
<el-button size="small" :icon="'Plus'" @click="addWeek">添加一周</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="weeks" empty-text="还没有逐周配置,可点「按开学日期生成」" max-height="560">
|
||||
<el-table-column label="教学周" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.weekNumber" :min="1" :max="60" size="small" controls-position="right" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日期区间" width="320">
|
||||
<template #default="{ row }">
|
||||
<!-- 用 :model-value + @update 而不是 v-model:数据模型里是 startDate/endDate 两个字段,
|
||||
el-date-picker 的 daterange 给的是一个数组,直接 v-model 会写到一个不存在的 range 上 -->
|
||||
<el-date-picker
|
||||
:model-value="row.startDate && row.endDate ? [row.startDate, row.endDate] : null"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
range-separator="→"
|
||||
start-placeholder="开始"
|
||||
end-placeholder="结束"
|
||||
size="small"
|
||||
style="width: 300px"
|
||||
@update:model-value="
|
||||
(value: [string, string] | null) => {
|
||||
row.startDate = value?.[0] ?? '';
|
||||
row.endDate = value?.[1] ?? '';
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.note" size="small" maxlength="64" placeholder="例如:国庆调休、期中考试周" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" @click="removeWeek($index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 13px;
|
||||
color: var(--ch-text-secondary);
|
||||
min-width: 84px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--ch-text-muted);
|
||||
}
|
||||
|
||||
.holiday-tag {
|
||||
margin: 0 6px 6px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user