feat(teachers,classes,schedules,web): 教师录入(仅管理员)+ 班主任可设置/更改 + 授课科目全校统一 + 登录页去示例

① 支持录入教师(与学生录入对齐),权限只有管理员
- 新增 Web「教师管理」页(`Views/TeachersView.vue`):单个录入(姓名/用户名/初始密码/角色)、
  导入名单(xlsx/csv:用户名、姓名、初始密码、角色)、编辑(改名/改角色)、重置密码、删除
- 服务端 `teachers` 模块补齐:GET / POST / PATCH /:id / POST /:id/reset-password / DELETE /:id,
  全部 `requireRole('ADMIN')`;表格导入新增 kind=teachers(`IMPORT_FIELDS.teachers`、`commitTeachers`、
  教师名单模板),教师调用预览/提交一律 403
- 删除护栏:还是班主任(`Class.teacherId`)或持有课程/班级分配的账号拒绝删除(409,提示先更换班主任)——
  教师是数据创建者,级联删除会连带删掉班级数据
- 前端路由加 `meta.roles`:`/classes`、`/students`、`/teachers` 仅 ADMIN,教师直接输网址会被挡回仪表盘
  (菜单隐藏只是"看不见",这次补上真正的守卫);新增环境变量 `DEFAULT_TEACHER_PASSWORD`

② 创建班级仅管理员 + 设置 / 更改班主任
- 新建班级时可直接指定班主任(不填则创建者本人),班级详情顶部「更换班主任」下拉随时改:
  新增 `PATCH /api/classes/:id/head-teacher`(仅管理员,校验目标账号是教师/管理员,广播 class:updated)
- 原班主任保留在协作教师名单里,避免换人后丢掉历史协作关系

③ 授课科目全校统一(用户澄清:"统一"指的是授课科目固定,不是各班课表相同)
- `SUBJECT_CATALOG` 改为用户给定的 18 科:语文/数学/英语/政治/历史/物理/化学/地理/生物/信息技术/
  体育/美术/音乐/班会/早读/晚读/通用技术/听力
- 课表「新增课表」与成绩「单条/批量录入」的科目下拉直接列出统一目录(该班还没有的进
  「统一科目(选中后自动建课)」分组),选中后自动为该班建同名课程再落库 —— 不必按班级一个个录科目
- 各班课表仍然各自独立(各班的课表本来就不一样)

④ 登录页去掉所有示例内容
- Web 登录页删除「演示账号(点击填充)」标签列表与"种子数据见 README"提示;客户端班级码占位符
  由「例如 G101(由老师在班级管理中提供)」改为「请输入班级码」
- `verify:web` 与 `verify:desktop` 各自断言登录页正文/占位符不含 `演示/示例/admin123/teacher123/G101`

⑤ 验证脚本不再依赖演示数据(并修掉自身残留)
- 清空服务端作业/通知后,联网自检改为**自建**一条作业/通知并在收尾删除(`seedSmokeContent`);
  权限用例需要"其它教师的班级/科任班级"时现建临时班级并在收尾删除
- e2e 新增清理:实时推送用例造的通知/作业、上课时段用例造的两条通知、教师名单/临时班级
  (此前这几条会留在库里);班级详情相关断言不再依赖种子班级布局
- ui-smoke 新增 7 项:登录页无示例、教师端访问 /teachers 被挡回、统一科目下拉 + 自动建课 + 收尾删除
  课表与课程、管理员真人录入教师 → 列表出现 → 删除
- 冒烟里"岛就绪"与"截图留档"改为轮询等待(偶发丢一张留档图会误判"形态尺寸递增")

实测:verify:e2e 163/163(安装实例)、verify:web 27/27、verify:desktop 77/77(离线复跑 69/69)、
verify:packaged 对 release/win-unpacked 与 D:\class\@classhelperdesktop-client 均 77/77 且 packaged=true;
typecheck / lint / format:check 全绿。服务端安装实例与客户端安装目录已就地更新。
This commit is contained in:
LoveChengke committed 2026-09-13 10:34:46 +08:00
1 parent 0ff2dc228b
commit 845fbdd863
27 files changed
+1655 -200

No files matched your search

+309 -9
View File
@@ -73,6 +73,7 @@ async function main() {
body: { username: 'teacher1', password: 'teacher123' },
});
const teacherToken = teacherLogin.payload?.data?.token;
const teacher1Id = teacherLogin.payload?.data?.user?.id;
record('教师登录 teacher1', Boolean(teacherToken), `status=${teacherLogin.status}`);
if (!teacherToken) process.exit(1);
@@ -144,19 +145,38 @@ async function main() {
process.exit(1);
}
// teacher2 名下但 teacher1 无权访问的班级,用于验证教师间权限隔离
// teacher2 名下但 teacher1 无权访问的班级,用于验证教师间权限隔离。
// 注意:演示库里可能被人工改过(管理员自己建班、给别的班加协作教师),
// 因此这里**不允许依赖种子数据的班级布局**:没有现成的就现建一个临时班级,收尾删除。
const teacher2Login = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: 'teacher123' },
});
const teacher2Token = teacher2Login.payload?.data?.token;
const teacher2Id = teacher2Login.payload?.data?.user?.id;
const teacher1ClassIds = new Set(classes.map((item) => item.id));
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
const foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = [];
let foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
if (!foreignClass) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `权限校验班 ${Date.now()}`,
grade: '高一',
code: `PV${Date.now().toString().slice(-6)}`,
...(teacher2Id ? { teacherId: teacher2Id } : {}),
},
});
foreignClass = created.payload?.data ?? null;
if (foreignClass?.id) probeClassIds.push(foreignClass.id);
}
record(
'教师权限隔离测试数据就绪',
'教师权限隔离测试数据就绪(无现成班级时自建临时班级)',
Boolean(foreignClass),
foreignClass ? `外部班级:${foreignClass.name}` : '未找到其它教师的班级',
foreignClass ? `外部班级:${foreignClass.name}` : '未能准备其它教师的班级',
);
const studentList = await api(`/students?classId=${classId}`, { token: adminToken });
@@ -835,6 +855,22 @@ async function main() {
record('清理探测课表', removedProbe.status === 200, `status=${removedProbe.status}`);
}
// 上课时段用例造的两条通知:立即清理,避免在用户的库里留下"验证通知"
const duringClassNoticeIds = [
confirmedUrgent.payload?.data?.id,
normalDuringClass.payload?.data?.id,
].filter(Boolean);
for (const noticeId of duringClassNoticeIds) {
await api(`/notifications/${noticeId}`, { method: 'DELETE', token: teacherToken });
}
const duringClassNoticesLeft =
(await api(`/notifications?classId=${classId}`, { token: teacherToken })).payload?.data ?? [];
record(
'清理:上课时段用例的通知已删除(不在库里留验证通知)',
!duringClassNoticesLeft.some((item) => String(item.title).includes('上课时段')),
`剩余含"上课时段"的通知=${duringClassNoticesLeft.filter((item) => String(item.title).includes('上课时段')).length}`,
);
// ---------------------------------------------------------------- 6.3 叫人
if (classId && studentUser) {
const callByTeacher = await api('/calls', {
@@ -935,13 +971,35 @@ async function main() {
{
record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录');
// 科任老师:teacher2 在高二(3)班是协作(科任)老师
const subjectClass = teacher2Classes.find(
(item) => item.teacherId !== teacher2Login.payload?.data?.user?.id,
);
// 科任老师:teacher2 是某班的协作(科任)老师。
// 同样不依赖演示数据:推断出来的班级不可用(被人工改过权限 / 与外部班级同一个)时现建一个:
// teacher1 当班主任 + teacher2 协作,收尾删除。
let subjectClass = teacher2Classes.find((item) => item.teacherId !== teacher2Id) ?? null;
if (!subjectClass || subjectClass.id === foreignClass?.id) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `科任校验班 ${Date.now()}`,
grade: '高一',
code: `SJ${Date.now().toString().slice(-6)}`,
teacherId: teacher1Id,
},
});
const createdId = created.payload?.data?.id;
if (createdId) {
await api(`/classes/${createdId}/teachers`, {
method: 'POST',
token: adminToken,
body: { teacherId: teacher2Id },
});
subjectClass = { id: createdId, name: created.payload?.data?.name ?? '科任校验班' };
probeClassIds.push(createdId);
}
}
const subjectClassId = subjectClass?.id;
record(
'存在"科任老师"场景的班级',
'存在"科任老师"场景的班级(无现成班级时自建)',
Boolean(subjectClassId) && subjectClassId !== foreignClass?.id,
`classId=${subjectClassId ?? '-'} name=${subjectClass?.name ?? '-'}`,
);
@@ -1958,6 +2016,248 @@ async function main() {
`原值=${originalClassAccount.code || '(未设置)'} 还原后=${restoreAccount.payload?.data?.code ?? '-'} 登录=${restoredLogin.status}`,
);
// ---------------------------------------------------------------- 教师录入与班主任(仅管理员)
// 需求:"支持录入学生也要支持录入老师,录入老师的权限只有 admin;
// 创建班级只有 admin 有权限,并且支持设置或更改班主任"
const adminTeachers = await api('/teachers', { token: adminToken });
const staffList = adminTeachers.payload?.data ?? [];
const teacher1 = staffList.find((item) => item.username === 'teacher1');
const teacher2 = staffList.find((item) => item.username === 'teacher2');
record(
'教师列表(管理员)可用',
adminTeachers.status === 200 && staffList.length >= 2,
`status=${adminTeachers.status} 教师数=${staffList.length}`,
);
const teacherSeesTeachers = await api('/teachers', { token: teacherToken });
const teacherCreatesTeacher = await api('/teachers', {
method: 'POST',
token: teacherToken,
body: { username: 'smoke_denied_teacher', name: '越权教师', password: 'smoke123456' },
});
record(
'仅管理员可录入教师(教师读取/新建均 403)',
teacherSeesTeachers.status === 403 && teacherCreatesTeacher.status === 403,
`列表=${teacherSeesTeachers.status} 新建=${teacherCreatesTeacher.status} message=${teacherCreatesTeacher.payload?.message ?? ''}`,
);
// 录入一个教师账号 → 编辑 → 重置密码 → 新密码可登录
const smokeTeacherUsername = `smoke_teacher_${Date.now()}`;
const createdTeacher = await api('/teachers', {
method: 'POST',
token: adminToken,
body: { username: smokeTeacherUsername, name: '冒烟教师', password: 'smoke123456', role: 'TEACHER' },
});
const smokeTeacherId = createdTeacher.payload?.data?.id;
record(
'管理员录入教师账号(POST /teachers → 201)',
createdTeacher.status === 201 &&
Boolean(smokeTeacherId) &&
createdTeacher.payload?.data?.role === 'TEACHER',
`status=${createdTeacher.status} id=${smokeTeacherId ?? '-'} 角色=${createdTeacher.payload?.data?.role ?? '-'}`,
);
const teacherLoginBefore = await api('/auth/login', {
method: 'POST',
body: { username: smokeTeacherUsername, password: 'smoke123456' },
});
record(
'新教师账号可登录(初始密码生效)',
teacherLoginBefore.status === 200 && Boolean(teacherLoginBefore.payload?.data?.token),
`status=${teacherLoginBefore.status} 角色=${teacherLoginBefore.payload?.data?.user?.role ?? '-'}`,
);
const updatedTeacher = await api(`/teachers/${smokeTeacherId}`, {
method: 'PATCH',
token: adminToken,
body: { name: '冒烟教师(改名)', role: 'ADMIN' },
});
record(
'编辑教师(姓名 + 角色升为管理员)',
updatedTeacher.status === 200 &&
updatedTeacher.payload?.data?.name === '冒烟教师(改名)' &&
updatedTeacher.payload?.data?.role === 'ADMIN',
`status=${updatedTeacher.status} 姓名=${updatedTeacher.payload?.data?.name ?? '-'} 角色=${updatedTeacher.payload?.data?.role ?? '-'}`,
);
const resetTeacherPassword = await api(`/teachers/${smokeTeacherId}/reset-password`, {
method: 'POST',
token: adminToken,
body: { newPassword: 'teach999999' },
});
const teacherLoginNew = await api('/auth/login', {
method: 'POST',
body: { username: smokeTeacherUsername, password: 'teach999999' },
});
const teacherLoginOld = await api('/auth/login', {
method: 'POST',
body: { username: smokeTeacherUsername, password: 'smoke123456' },
});
record(
'重置教师密码(新密码可用 / 旧密码失效)',
resetTeacherPassword.status === 200 && teacherLoginNew.status === 200 && teacherLoginOld.status === 401,
`重置=${resetTeacherPassword.status} 新密码=${teacherLoginNew.status} 旧密码=${teacherLoginOld.status}`,
);
// 教师名单表格导入:教师预览 403;管理员预览 + 提交(教师与班级无关,classId 可省略)
const teacherCsv =
'\ufeff用户名,姓名,初始密码,角色\n' +
`imp_t_${Date.now().toString().slice(-8)},冒烟导入教师,import123456,教师\n`;
const teacherCsvBase64 = Buffer.from(teacherCsv, 'utf8').toString('base64');
const importedTeacherUsername = teacherCsv.split('\n')[1]?.split(',')[0] ?? '';
const teacherPreviewDenied = await api('/imports/table/preview', {
method: 'POST',
token: teacherToken,
body: { kind: 'teachers', fileName: 'teachers.csv', contentBase64: teacherCsvBase64 },
});
const adminTeacherPreview = await api('/imports/table/preview', {
method: 'POST',
token: adminToken,
body: { kind: 'teachers', fileName: 'teachers.csv', contentBase64: teacherCsvBase64 },
});
const adminTeacherCommit = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
body: {
kind: 'teachers',
fileName: 'teachers.csv',
contentBase64: teacherCsvBase64,
mapping: { username: '用户名', name: '姓名', password: '初始密码', role: '角色' },
mode: 'upsert',
},
});
const importedTeacherLogin = await api('/auth/login', {
method: 'POST',
body: { username: importedTeacherUsername, password: 'import123456' },
});
record(
'教师名单表格导入(教师 403 / 管理员预览+导入成功,且导入账号可登录)',
teacherPreviewDenied.status === 403 &&
adminTeacherPreview.status === 200 &&
Boolean(adminTeacherPreview.payload?.data?.suggestedMapping?.username) &&
adminTeacherCommit.status === 200 &&
adminTeacherCommit.payload?.data?.inserted === 1 &&
importedTeacherLogin.status === 200,
`教师预览=${teacherPreviewDenied.status} 管理员预览=${adminTeacherPreview.status} ` +
`建议映射=${JSON.stringify(adminTeacherPreview.payload?.data?.suggestedMapping ?? {})} ` +
`导入新增=${adminTeacherCommit.payload?.data?.inserted ?? '-'} 登录=${importedTeacherLogin.status}`,
);
// 删除护栏:还是班主任的账号不允许删除;没有班级职责的可以删
const deleteHeadTeacher = teacher1
? await api(`/teachers/${teacher1.id}`, { method: 'DELETE', token: adminToken })
: { status: 0, payload: null };
const deleteSmokeTeacher = await api(`/teachers/${smokeTeacherId}`, {
method: 'DELETE',
token: adminToken,
});
record(
'删除教师(有班级职责拒绝 409 / 无职责可删除)',
deleteHeadTeacher.status === 409 && deleteSmokeTeacher.status === 200,
`班主任账号=${deleteHeadTeacher.status}(${deleteHeadTeacher.payload?.message ?? ''}) 冒烟账号=${deleteSmokeTeacher.status}`,
);
// 创建班级仅管理员 + 设置/更改班主任
const teacherCreateClass = await api('/classes', {
method: 'POST',
token: teacherToken,
body: { name: '越权班级', grade: '高一' },
});
record(
'仅管理员可创建班级(教师 403)',
teacherCreateClass.status === 403,
`status=${teacherCreateClass.status} message=${teacherCreateClass.payload?.message ?? ''}`,
);
const tempClassCode = `SM${Date.now().toString().slice(-6)}`;
const createdClassWithHead = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `冒烟班级 ${Date.now()}`,
grade: '高一',
code: tempClassCode,
...(teacher2 ? { teacherId: teacher2.id } : {}),
},
});
const tempClassId = createdClassWithHead.payload?.data?.id;
record(
'创建班级时可指定班主任',
createdClassWithHead.status === 201 &&
Boolean(tempClassId) &&
(!teacher2 || createdClassWithHead.payload?.data?.teacher?.id === teacher2.id),
`status=${createdClassWithHead.status} 班主任=${createdClassWithHead.payload?.data?.teacher?.name ?? '-'}`,
);
const teacherChangeHead = await api(`/classes/${tempClassId}/head-teacher`, {
method: 'PATCH',
token: teacherToken,
body: { teacherId: teacher1?.id ?? '' },
});
const changedHead = await api(`/classes/${tempClassId}/head-teacher`, {
method: 'PATCH',
token: adminToken,
body: { teacherId: teacher1?.id ?? '' },
});
const changedDetail = await api(`/classes/${tempClassId}`, { token: adminToken });
record(
'设置 / 更改班主任(教师 403 / 管理员生效并落库)',
teacherChangeHead.status === 403 &&
changedHead.status === 200 &&
changedDetail.payload?.data?.teacher?.id === teacher1?.id,
`教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`,
);
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
const realtimeNoticeId = createdNotification.payload?.data?.id;
if (realtimeNoticeId) {
await api(`/notifications/${realtimeNoticeId}`, { method: 'DELETE', token: teacherToken });
}
if (homeworkId) {
await api(`/homeworks/${homeworkId}`, { method: 'DELETE', token: teacherToken });
}
const realtimeLeftovers = await api(`/notifications?classId=${classId}`, { token: teacherToken });
const homeworkLeftovers = await api(`/homeworks?classId=${classId}`, { token: teacherToken });
record(
'清理:实时推送用例的通知/作业已删除',
!(realtimeLeftovers.payload?.data ?? []).some((item) => String(item.title).includes('联调验证')) &&
!(homeworkLeftovers.payload?.data ?? []).some((item) => String(item.title).includes('联调验证')),
`残留通知=${(realtimeLeftovers.payload?.data ?? []).filter((item) => String(item.title).includes('联调验证')).length} ` +
`残留作业=${(homeworkLeftovers.payload?.data ?? []).filter((item) => String(item.title).includes('联调验证')).length}`,
);
if (tempClassId) await api(`/classes/${tempClassId}`, { method: 'DELETE', token: adminToken });
for (const probeClassId of probeClassIds) {
if (probeClassId && probeClassId !== tempClassId) {
await api(`/classes/${probeClassId}`, { method: 'DELETE', token: adminToken });
}
}
const importedTeacherRow = (await api('/teachers', { token: adminToken })).payload?.data?.find(
(item) => item.username === importedTeacherUsername,
);
if (importedTeacherRow) {
await api(`/teachers/${importedTeacherRow.id}`, { method: 'DELETE', token: adminToken });
}
const isProbeAccount = (username) =>
String(username).startsWith('smoke_') || String(username).startsWith('imp_t_');
const teachersAfterCleanup = await api('/teachers', { token: adminToken });
const classesAfterCleanup = await api('/classes', { token: adminToken });
const leftoverClasses = (classesAfterCleanup.payload?.data ?? []).filter(
(item) => item.name?.includes('校验班') || item.name?.includes('冒烟'),
);
record(
'清理:临时班级与冒烟教师账号已删除(不污染演示数据)',
!(teachersAfterCleanup.payload?.data ?? []).some((item) => isProbeAccount(item.username)) &&
leftoverClasses.length === 0,
`剩余教师=${(teachersAfterCleanup.payload?.data ?? []).length} 冒烟残留=${
(teachersAfterCleanup.payload?.data ?? [])
.filter((item) => isProbeAccount(item.username))
.map((item) => item.username)
.join(',') || '无'
} 残留班级=${leftoverClasses.map((item) => item.name).join(',') || '无'}`,
);
socket.close();
// ---------------------------------------------------------------- 汇总
+4
View File
@@ -30,6 +30,8 @@ const envSchema = z.object({
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'),
TERM_START_DATE: z.string().default(''),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
@@ -143,6 +145,8 @@ export const env = {
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
/** 新建班级时的默认班级密码(班级账号登录) */
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
/** 新建/重置教师账号时的初始密码 */
defaultTeacherPassword: raw.DEFAULT_TEACHER_PASSWORD,
termStartDate: raw.TERM_START_DATE,
logLevel: raw.LOG_LEVEL,
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
@@ -6,6 +6,7 @@ import { validate, validatedParams, validatedQuery } from '../../middleware/vali
import { defineModule } from '../module.types.js';
import {
addStudentSchema,
assignHeadTeacherSchema,
assignTeacherSchema,
classStudentParamSchema,
classTeacherParamSchema,
@@ -89,6 +90,22 @@ router.patch(
},
);
/**
* PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任
* 仅管理员:班主任决定谁能管这个班的课表与成绩,属于人员分配权限
*/
router.patch(
'/:id/head-teacher',
requireRole('ADMIN'),
validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const { teacherId } = req.body as { teacherId: string };
sendOk(res, await classService.updateHeadTeacher(user, id, teacherId), '班主任已更新');
},
);
/** GET /api/classes/:id/students - 班级学生名单 */
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -49,6 +49,11 @@ export const assignTeacherSchema = z.object({
teacherId: z.string().min(1, '请选择教师'),
});
/** 设置 / 更改班主任(仅管理员) */
export const assignHeadTeacherSchema = z.object({
teacherId: z.string().min(1, '请选择班主任'),
});
export const classStudentParamSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
@@ -142,7 +142,37 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
}
/**
* 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。
* 设置 / 更改班主任(仅管理员)。
*
* 班主任决定了"谁能管这个班的课表与成绩",所以更换时要保证目标账号确实是教师/管理员;
* 原班主任保留在协作教师名单里(`ClassTeacher`)不变,避免换人后丢掉历史协作关系。
*/
export async function updateHeadTeacher(
user: TokenPayload,
classId: string,
teacherId: string,
): Promise<ClassDto> {
assertCanAssignTeachers(user);
const target = await prisma.user.findUnique({ where: { id: teacherId } });
if (!target || (target.role !== 'TEACHER' && target.role !== 'ADMIN')) {
throw ApiError.badRequest('指定的班主任不存在或角色不是教师');
}
const updated = await prisma.class.update({
where: { id: classId },
data: { teacherId: target.id },
include: {
teacher: { select: { id: true, name: true, username: true } },
_count: { select: countSelect },
},
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'head-teacher-changed' });
return toClassDtoWithAccount(user, updated);
}
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。
* 班级码即学生端"班级登录"的账号;密码留空表示不修改。
*/
export async function updateClassAccount(
@@ -4,6 +4,7 @@ import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
IMPORT_KINDS,
tableCommitSchema,
tableFileSchema,
timeLayoutImportSchema,
@@ -26,15 +27,29 @@ import {
const router = Router();
router.use(authenticate());
/** 名单类导入(学生/教师账号)只有管理员能做;成绩类按班级权限判定 */
function assertImportKindAllowed(kind: (typeof IMPORT_KINDS)[number], role: string): void {
if ((kind === 'students' || kind === 'teachers') && role !== 'ADMIN') {
throw ApiError.forbidden(
kind === 'teachers' ? '只有管理员可以录入或导入教师账号' : '只有管理员可以导入学生名单',
);
}
}
/** 查询参数里的 kind 收敛为合法值(默认 grades,避免非法值被当成成绩悄悄写入) */
function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
return IMPORT_KINDS.includes(value as (typeof IMPORT_KINDS)[number])
? (value as (typeof IMPORT_KINDS)[number])
: 'grades';
}
/**
* GET /api/imports/template?kind=grades|students&format=csv|xlsx - 下载导入模板
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);名单模板:仅管理员
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
*/
router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
if (req.query.kind === 'students' && getAuthUser(req).role !== 'ADMIN') {
throw ApiError.forbidden('只有管理员可以导入学生名单');
}
const kind = req.query.kind === 'students' ? 'students' : 'grades';
const kind = resolveImportKind(req.query.kind);
assertImportKindAllowed(kind, getAuthUser(req).role);
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
if (format === 'xlsx') {
const base64 = buildTemplateXlsx(kind);
@@ -52,7 +67,7 @@ router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
/**
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
* 成绩:管理员或班主任;学生名单:仅管理员(与写入权限保持一致)
* 成绩:管理员或班主任;学生/教师名单:仅管理员(与写入权限保持一致)
*/
router.post(
'/table/preview',
@@ -60,9 +75,7 @@ router.post(
validate({ body: tableFileSchema }),
(req, res) => {
const input = validatedBody<TableFileInput>(req);
if (input.kind === 'students' && getAuthUser(req).role !== 'ADMIN') {
throw ApiError.forbidden('只有管理员可以导入学生名单');
}
assertImportKindAllowed(input.kind, getAuthUser(req).role);
sendOk(res, previewTable(input), '表格解析成功');
},
);
@@ -78,7 +91,13 @@ router.post(
requireRole('ADMIN', 'TEACHER'),
validate({ body: tableCommitSchema }),
async (req, res) => {
const result = await commitTable(getAuthUser(req), validatedBody<TableCommitInput>(req));
const input = validatedBody<TableCommitInput>(req);
assertImportKindAllowed(input.kind, getAuthUser(req).role);
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
if (input.kind !== 'teachers' && !input.classId) {
throw ApiError.badRequest('请先选择要导入的班级');
}
const result = await commitTable(getAuthUser(req), input);
sendOk(
res,
result,
@@ -1,7 +1,7 @@
import { z } from 'zod';
/** 导入类型:成绩 / 学生名单 / 课表时间配置 */
export const IMPORT_KINDS = ['grades', 'students'] as const;
/** 导入类型:成绩 / 学生名单 / 教师名单(仅管理员) */
export const IMPORT_KINDS = ['grades', 'students', 'teachers'] as const;
/** 表格导入:上传的文件(base64)+ 类型 */
export const tableFileSchema = z.object({
@@ -17,7 +17,8 @@ export const tableFileSchema = z.object({
/** 表格提交:在预览基础上带字段映射、目标班级与写入模式 */
export const tableCommitSchema = tableFileSchema.extend({
classId: z.string().min(1, '请选择班级'),
/** 目标班级:成绩/学生名单必填;教师名单与班级无关(可省略) */
classId: z.string().min(1, '请选择班级').optional(),
/** 列映射:规范字段 → 文件列名(或列索引字符串) */
mapping: z.record(z.string(), z.string().min(1)),
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按用户名) */
@@ -50,6 +50,17 @@ export const IMPORT_FIELDS: Record<
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
],
teachers: [
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '工号', '账号'] },
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '教师', '教师姓名'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
{
key: 'role',
label: '角色',
required: false,
synonyms: ['角色', 'role', '身份', '权限', '管理员'],
},
],
};
export interface TablePreview {
@@ -133,7 +144,9 @@ export function buildTemplateCsv(kind: ImportKind): string {
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', '92', '100', '数学']
: ['student01', '王小明', 'student123'];
: kind === 'teachers'
? ['teacher3', '王老师', 'teacher123', '教师']
: ['student01', '王小明', 'student123'];
const sampleLine = fields
.map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '')
.join(',');
@@ -147,10 +160,13 @@ export function buildTemplateXlsx(kind: ImportKind): string {
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', 92, 100, '数学']
: ['student01', '王小明', 'student123'];
: kind === 'teachers'
? ['teacher3', '王老师', 'teacher123', '教师']
: ['student01', '王小明', 'student123'];
const sheet = XLSX.utils.aoa_to_sheet([header, sample]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, kind === 'grades' ? '成绩' : '学生名单');
const sheetName = kind === 'grades' ? '成绩' : kind === 'teachers' ? '教师名单' : '学生名单';
XLSX.utils.book_append_sheet(workbook, sheet, sheetName);
return XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
}
@@ -221,19 +237,21 @@ function cellValue(row: string[], columns: string[], mapping: Record<string, str
/** 导入成绩:学生按用户名/姓名匹配;重复(同班级+学生+考试+课程)按 mode 处理 */
export async function commitGrades(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
const classId = input.classId;
if (!classId) throw ApiError.badRequest('导入成绩需要先选择班级');
// 成绩导入:管理员或本班班主任(需求 6);科任 403
await assertCanManageGrades(user, input.classId);
await assertCanManageGrades(user, classId);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const students = await prisma.user.findMany({
where: { classId: input.classId, role: 'STUDENT' },
where: { classId, role: 'STUDENT' },
select: { id: true, username: true, name: true },
});
const byUsername = new Map(students.map((item) => [item.username.toLowerCase(), item]));
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({
where: { classId: input.classId },
where: { classId },
select: { id: true, name: true },
});
@@ -287,7 +305,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
warnings.push(`第 ${rowNumber} 行:课程「${courseName}」不存在,已按"不指定课程"导入`);
const existing = await prisma.grade.findFirst({
where: { classId: input.classId, userId: student.id, examName, courseId: course?.id ?? null },
where: { classId: classId, userId: student.id, examName, courseId: course?.id ?? null },
select: { id: true },
});
@@ -304,7 +322,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
} else {
await prisma.grade.create({
data: {
classId: input.classId,
classId: classId,
userId: student.id,
courseId: course?.id ?? null,
examName,
@@ -327,7 +345,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
warnings,
};
logger.info(
`成绩导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
`成绩导入:班级=${classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
@@ -335,10 +353,12 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
/** 导入学生名单:按用户名去重(append 跳过 / upsert 更新姓名与班级),仅管理员可用 */
export async function commitStudents(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
const classId = input.classId;
if (!classId) throw ApiError.badRequest('导入学生名单需要先选择班级');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const cls = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
const cls = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!cls) throw ApiError.notFound('班级不存在');
const errors: ImportRowError[] = [];
@@ -372,7 +392,7 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
skipped += 1;
continue;
}
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: input.classId } });
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: classId } });
updated += 1;
} else {
const initialPassword = password || env.defaultStudentPassword;
@@ -381,7 +401,7 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
username,
name,
role: 'STUDENT',
classId: input.classId,
classId: classId,
passwordHash: await bcrypt.hash(initialPassword, 10),
},
});
@@ -400,11 +420,97 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
warnings,
};
logger.info(
`学生名单导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
`学生名单导入:班级=${classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
return input.kind === 'grades' ? commitGrades(user, input) : commitStudents(user, input);
/**
* 导入教师名单:按用户名去重(append 跳过 / upsert 更新姓名与角色),**仅管理员**可用。
*
* 与班级无关(教师不属于班级),因此不需要 classId,模板里也没有班级列。
* 角色列可选:填「管理员 / ADMIN / admin」建管理员账号,其余一律建教师账号。
*/
export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (existing.role === 'STUDENT') {
errors.push({ row: rowNumber, message: `用户名「${username}」已被学生账号占用` });
continue;
}
if (input.mode === 'append') {
skipped += 1;
continue;
}
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await prisma.user.update({ where: { id: existing.id }, data: { name, role: nextRole } });
updated += 1;
} else {
const initialPassword = password || env.defaultTeacherPassword;
await prisma.user.create({
data: {
username,
name,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await bcrypt.hash(initialPassword, 10),
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'teachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
/** 角色列解析:只认「管理员」类词;其余(含空)返回 null 交给调用方取默认值 */
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
const value = cell.trim().toUpperCase();
if (!value) return null;
if (value === 'ADMIN' || cell.includes('管理员') || value === '管理员') return 'ADMIN';
return 'TEACHER';
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
if (input.kind === 'teachers') return commitTeachers(user, input);
if (input.kind === 'grades') return commitGrades(user, input);
return commitStudents(user, input);
}
@@ -1,26 +1,70 @@
import { Router } from 'express';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { createTeacherSchema, listTeachersQuerySchema, type CreateTeacherInput } from './teachers.schemas.js';
import {
createTeacherSchema,
listTeachersQuerySchema,
resetTeacherPasswordSchema,
updateTeacherSchema,
type CreateTeacherInput,
type ResetTeacherPasswordInput,
type UpdateTeacherInput,
} from './teachers.schemas.js';
import * as teacherService from './teachers.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/teachers?keyword= - 教师列表(分配协作教师时使用) */
/**
* 教师账号管理:**全部接口仅管理员**。
* 学生端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
* 因此录入/修改/重置密码/删除都只开放给 ADMIN(路由层 + 前端菜单双重收敛)。
*/
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
});
/** POST /api/teachers - 新建教师账号(仅管理员) */
/** POST /api/teachers - 录入教师账号(仅管理员) */
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {
getAuthUser(req);
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
});
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
router.patch(
'/:id',
requireRole('ADMIN'),
validate({ params: idParamSchema, body: updateTeacherSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await teacherService.updateTeacher(id, req.body as UpdateTeacherInput), '教师信息更新成功');
},
);
/** POST /api/teachers/:id/reset-password - 重置教师密码 */
router.post(
'/:id/reset-password',
requireRole('ADMIN'),
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.resetTeacherPassword(id, req.body as ResetTeacherPasswordInput);
sendOk(res, { id }, '密码已重置');
},
);
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id);
sendOk(res, { id }, '教师账号已删除');
});
export const teachersModule = defineModule({
name: 'teachers',
basePath: '/teachers',
@@ -8,8 +8,22 @@ export const listTeachersQuerySchema = z.object({
export const createTeacherSchema = z.object({
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
name: z.string().trim().min(1, '请输入姓名').max(32),
password: z.string().min(6, '密码至少 6 位').max(128),
/** 留空则用默认初始密码(DEFAULT_TEACHER_PASSWORD) */
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
role: z.enum(['TEACHER', 'ADMIN']).optional(),
});
/** 编辑教师:姓名 / 用户名 / 角色(角色可升为管理员,也可降回教师) */
export const updateTeacherSchema = z.object({
username: z.string().trim().min(3).max(32).optional(),
name: z.string().trim().min(1).max(32).optional(),
role: z.enum(['TEACHER', 'ADMIN']).optional(),
});
export const resetTeacherPasswordSchema = z.object({
newPassword: z.string().min(6, '新密码至少 6 位').max(128).optional(),
});
export type CreateTeacherInput = z.infer<typeof createTeacherSchema>;
export type UpdateTeacherInput = z.infer<typeof updateTeacherSchema>;
export type ResetTeacherPasswordInput = z.infer<typeof resetTeacherPasswordSchema>;
@@ -1,13 +1,22 @@
import type { UserDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { toUserDto } from '../../lib/mappers.js';
import { hashPassword } from '../../lib/password.js';
import type { CreateTeacherInput } from './teachers.schemas.js';
import type {
CreateTeacherInput,
ResetTeacherPasswordInput,
UpdateTeacherInput,
} from './teachers.schemas.js';
/**
* 教师列表:用于"分配协作教师"等需要选择教师的场景。
* 仅返回教师/管理员账号,字段与 UserDto 对齐。
* 教师管理(**仅管理员**,路由层已用 requireRole('ADMIN') 兜住):
* 录入教师是管理员专属操作,班主任与科任老师都没有该权限。
* 学生名单在 students 模块,两个模块结构保持一致(列表 / 新建 / 编辑 / 重置密码 / 删除)。
*/
/** 教师列表(教师 + 管理员账号) */
export async function listTeachers(keyword?: string): Promise<UserDto[]> {
const teachers = await prisma.user.findMany({
where: {
@@ -15,7 +24,7 @@ export async function listTeachers(keyword?: string): Promise<UserDto[]> {
...(keyword ? { OR: [{ name: { contains: keyword } }, { username: { contains: keyword } }] } : {}),
},
orderBy: [{ role: 'asc' }, { username: 'asc' }],
take: 200,
take: 500,
});
return teachers.map(toUserDto);
}
@@ -26,8 +35,74 @@ export async function createTeacher(input: CreateTeacherInput): Promise<UserDto>
username: input.username,
name: input.name,
role: input.role ?? 'TEACHER',
passwordHash: await hashPassword(input.password),
passwordHash: await hashPassword(input.password ?? env.defaultTeacherPassword),
},
});
return toUserDto(created);
}
/** 取教师账号(不存在或不是教师/管理员一律 404,避免越权探测) */
async function findTeacherOrThrow(teacherId: string): Promise<{ id: string; role: string; name: string }> {
const teacher = await prisma.user.findUnique({
where: { id: teacherId },
select: { id: true, role: true, name: true },
});
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
throw ApiError.notFound('教师账号不存在');
}
return teacher;
}
export async function updateTeacher(teacherId: string, input: UpdateTeacherInput): Promise<UserDto> {
const teacher = await findTeacherOrThrow(teacherId);
const updated = await prisma.user.update({
where: { id: teacher.id },
data: {
...(input.name ? { name: input.name } : {}),
...(input.username ? { username: input.username } : {}),
...(input.role ? { role: input.role } : {}),
},
});
return toUserDto(updated);
}
/** 重置教师密码(未指定时用默认初始密码) */
export async function resetTeacherPassword(
teacherId: string,
input: ResetTeacherPasswordInput,
): Promise<void> {
const teacher = await findTeacherOrThrow(teacherId);
await prisma.user.update({
where: { id: teacher.id },
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultTeacherPassword) },
});
}
/**
* 删除教师账号。
*
* 安全护栏:教师是数据的创建者,`Class.teacherId` / `Course.teacherId` / 作业通知的 createdBy
* 都是级联删除 —— 直接删班主任会连带删掉整个班级的数据。因此只有"没有任何班级职责、
* 也不持有课程"的账号才允许删除(实践中就是刚录入错的那条),否则返回 409 让管理员先转移。
*/
export async function deleteTeacher(userId: string): Promise<void> {
const teacher = await findTeacherOrThrow(userId);
const [ownedClasses, assignedClasses, courses] = await Promise.all([
prisma.class.count({ where: { teacherId: teacher.id } }),
prisma.classTeacher.count({ where: { teacherId: teacher.id } }),
prisma.course.count({ where: { teacherId: teacher.id } }),
]);
if (ownedClasses > 0) {
throw ApiError.conflict(
`「${teacher.name}」还是 ${ownedClasses} 个班级的班主任,请先在班级管理里更换班主任再删除`,
);
}
if (assignedClasses > 0 || courses > 0) {
throw ApiError.conflict(
`「${teacher.name}」还有 ${assignedClasses} 条班级分配 / ${courses} 门课程,请先解除后再删除`,
);
}
await prisma.user.delete({ where: { id: teacher.id } });
}