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:
1 parent
0ff2dc228b
commit
845fbdd863
27 files changed
+1655
-200
No files matched your search
@@ -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();
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
Reference in new issue
Block a user