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:
LoveChengke committed 2026-10-06 19:41:18 +08:00
1 parent 17f55864bf
commit 9d5ac7adf9
172 files changed
+8761 -1896

No files matched your search

+154 -30
View File
@@ -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>
+1 -1
View File
@@ -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 · 管理端';
+10
View File
@@ -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">
+17 -3
View File
@@ -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;
+15 -10
View File
@@ -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>
+292 -57
View File
@@ -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>
+317 -32
View File
@@ -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>
+10 -10
View File
@@ -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));
+1 -1
View File
@@ -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>
+265 -77
View File
@@ -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;
}
+28 -10
View File
@@ -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>