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
@@ -126,7 +126,13 @@ async function captureIsland(
|
||||
// 截图统计总是执行(像素断言不依赖环境变量);ISLAND_SHOTS_DIR 只控制是否落盘留档
|
||||
const dir = process.env.ISLAND_SHOTS_DIR ?? '';
|
||||
const win = island.getWindow();
|
||||
if (!win || win.isDestroyed() || !win.isVisible()) return null;
|
||||
if (!win || win.isDestroyed()) return null;
|
||||
// 窗口可能正处在"隐藏 → 重新显示"的过渡里(例如下课后自动弹出):
|
||||
// 先等它可见,避免偶发丢掉一张留档图导致"形态尺寸递增"这类断言缺数据。
|
||||
for (let attempt = 0; attempt < 15 && !win.isVisible(); attempt += 1) {
|
||||
await sleep(100);
|
||||
}
|
||||
if (!win.isVisible()) return null;
|
||||
try {
|
||||
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
|
||||
await win.webContents.executeJavaScript(
|
||||
@@ -1475,11 +1481,19 @@ async function runIslandChecks(
|
||||
const urgentReddish = urgentShot?.reddish ?? 0;
|
||||
const outsideReddish = urgentShot?.outsideReddish ?? 0;
|
||||
const pillShot = shotDetails.find((shot) => shot.name.includes('pill'));
|
||||
// "形态尺寸递增":胶囊 < 某个展开形态 < 紧急形态。
|
||||
// 展开形态优先取 after-class 留档,缺图时退回其它展开态(避免一张可选留档缺失就误判)。
|
||||
const expandedShot = [
|
||||
shotDetails.find((shot) => shot.name.includes('after-class')),
|
||||
shotDetails.find((shot) => shot.name.includes('clicked')),
|
||||
shotDetails.find((shot) => shot.name.includes('homework')),
|
||||
].find(Boolean);
|
||||
const sizesOrdered = Boolean(
|
||||
pillShot &&
|
||||
expandedShot &&
|
||||
urgentShot &&
|
||||
pillShot.height < (shotDetails.find((shot) => shot.name.includes('after-class'))?.height ?? 0) &&
|
||||
(shotDetails.find((shot) => shot.name.includes('after-class'))?.height ?? 0) < urgentShot.height,
|
||||
pillShot.height < expandedShot.height &&
|
||||
expandedShot.height < urgentShot.height,
|
||||
);
|
||||
|
||||
record(
|
||||
@@ -2403,6 +2417,22 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
`text=${dom?.text ?? ''}`,
|
||||
);
|
||||
|
||||
// 需求:"删掉登录页所有示例内容" —— 占位符/提示里不允许出现示例班级码或示例账号
|
||||
const loginExampleLeak = await win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const text = (document.body.innerText || '') + ' ' + Array.from(document.querySelectorAll('input'))
|
||||
.map((node) => node.getAttribute('placeholder') || '')
|
||||
.join(' ');
|
||||
const tokens = ['例如 G101', 'G101', '例如G101', 'admin123', 'teacher123', '演示', '示例'];
|
||||
return { leaked: tokens.filter((item) => text.includes(item)) };
|
||||
})()`,
|
||||
);
|
||||
record(
|
||||
'登录页不含示例内容(无示例班级码/演示账号)',
|
||||
(loginExampleLeak?.leaked ?? ['?']).length === 0,
|
||||
`越界文案=[${(loginExampleLeak?.leaked ?? []).join(',')}]`,
|
||||
);
|
||||
|
||||
// preload 桥接
|
||||
const bridge = await win.webContents.executeJavaScript(
|
||||
`(() => ({
|
||||
|
||||
+110
-3
@@ -123,6 +123,93 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
};
|
||||
}
|
||||
|
||||
/** 自检临时造的作业/通知(收尾必须删掉,避免污染用户的库) */
|
||||
let temporaryContent: { token: string; homeworks: string[]; notifications: string[] } | null = null;
|
||||
|
||||
/**
|
||||
* 服务端业务数据可能被管理员清空(用户明确要求过"删掉所有作业和通知"),
|
||||
* 因此联网自检在列表为空时**自建**一条作业/通知来验证链路,收尾(sessionCleanup)删除。
|
||||
*/
|
||||
async function seedSmokeContent(
|
||||
classId: string | null,
|
||||
need: { homework: boolean; notification: boolean },
|
||||
): Promise<SmokeCheckResult> {
|
||||
if (!classId) return { ok: false, detail: '缺少班级,无法自建数据' };
|
||||
const serverUrl = 'http://127.0.0.1:4000';
|
||||
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
|
||||
|
||||
try {
|
||||
const login = await fetch(`${serverUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
|
||||
}).then((response) => response.json());
|
||||
const token: string | undefined = login?.data?.token;
|
||||
if (!token) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
|
||||
|
||||
const homeworks: string[] = [];
|
||||
const notifications: string[] = [];
|
||||
|
||||
if (need.homework) {
|
||||
const created = await fetch(`${serverUrl}/api/homeworks`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
classId,
|
||||
courseId: null,
|
||||
title: `自动化验证作业 ${stamp}`,
|
||||
content: '服务端没有作业数据时由冒烟自检临时创建,验证结束后立即删除,不会留在库里。',
|
||||
}),
|
||||
}).then((response) => response.json());
|
||||
const id: string | undefined = created?.data?.id;
|
||||
if (!id) return { ok: false, detail: `自建作业失败:${JSON.stringify(created).slice(0, 160)}` };
|
||||
homeworks.push(id);
|
||||
}
|
||||
|
||||
if (need.notification) {
|
||||
const created = await fetch(`${serverUrl}/api/notifications`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
classId,
|
||||
title: `自动化验证通知 ${stamp}`,
|
||||
content: '服务端没有通知数据时由冒烟自检临时创建,验证结束后立即删除,不会留在库里。',
|
||||
priority: 'NORMAL',
|
||||
}),
|
||||
}).then((response) => response.json());
|
||||
const id: string | undefined = created?.data?.id;
|
||||
if (!id) return { ok: false, detail: `自建通知失败:${JSON.stringify(created).slice(0, 160)}` };
|
||||
notifications.push(id);
|
||||
}
|
||||
|
||||
temporaryContent = { token, homeworks, notifications };
|
||||
return { ok: true, detail: `作业+${homeworks.length} 通知+${notifications.length}` };
|
||||
} catch (error) {
|
||||
return { ok: false, detail: `自建数据异常:${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除自检临时造的作业/通知 */
|
||||
async function cleanupTemporaryContent(): Promise<string> {
|
||||
if (!temporaryContent) return '';
|
||||
const { token, homeworks, notifications } = temporaryContent;
|
||||
temporaryContent = null;
|
||||
const serverUrl = 'http://127.0.0.1:4000';
|
||||
const removals = [
|
||||
...homeworks.map((id) => `/api/homeworks/${id}`),
|
||||
...notifications.map((id) => `/api/notifications/${id}`),
|
||||
];
|
||||
let failed = 0;
|
||||
for (const path of removals) {
|
||||
const response = await fetch(`${serverUrl}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) failed += 1;
|
||||
}
|
||||
return `自检临时数据已清理 ${removals.length - failed}/${removals.length} 条`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
|
||||
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
|
||||
@@ -165,12 +252,31 @@ export async function onlineScenario(credentials?: {
|
||||
await appStore.init(serverUrl);
|
||||
detail.push(`reachable=${appStore.serverReachable}`);
|
||||
|
||||
const [classes, homeworks, notifications, grades] = await Promise.all([
|
||||
const [classes, initialHomeworks, initialNotifications, grades] = await Promise.all([
|
||||
api.classApi.list(),
|
||||
api.homeworkApi.list(),
|
||||
api.notificationApi.list(),
|
||||
api.gradeApi.my(),
|
||||
]);
|
||||
|
||||
/**
|
||||
* 自检**不允许依赖演示种子数据**:管理员随时可能清空业务数据
|
||||
* (例如"删掉服务端所有作业和通知"),此时这几条联网断言不能变成"数据被清空就失败"。
|
||||
* 因此列表为空时用教师账号造一条**本次自检专用**的作业/通知,收尾在 sessionCleanup 里删除。
|
||||
*/
|
||||
let homeworks = initialHomeworks;
|
||||
let notifications = initialNotifications;
|
||||
if (homeworks.length === 0 || notifications.length === 0) {
|
||||
const seeded = await seedSmokeContent(auth.classId ?? null, {
|
||||
homework: homeworks.length === 0,
|
||||
notification: notifications.length === 0,
|
||||
});
|
||||
detail.push(`自建数据(${seeded.detail})`);
|
||||
if (seeded.ok) {
|
||||
[homeworks, notifications] = await Promise.all([api.homeworkApi.list(), api.notificationApi.list()]);
|
||||
}
|
||||
}
|
||||
|
||||
const term = await api.dashboardApi.term();
|
||||
const schedules = await api.scheduleApi.list({ week: term.currentWeek });
|
||||
|
||||
@@ -582,16 +688,17 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
|
||||
/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */
|
||||
export async function sessionCleanup(): Promise<SmokeCheckResult> {
|
||||
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
|
||||
import('../stores/auth.js'),
|
||||
import('../stores/realtime.js'),
|
||||
]);
|
||||
try {
|
||||
const cleaned = await cleanupTemporaryContent();
|
||||
useRealtimeStore().disconnect();
|
||||
await useAuthStore().logout();
|
||||
return { ok: true, detail: '已断开实时通道并清理登录态' };
|
||||
return { ok: true, detail: `已断开实时通道并清理登录态${cleaned ? `;${cleaned}` : ''}` };
|
||||
} catch (error) {
|
||||
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
|
||||
@@ -106,12 +106,7 @@ async function enterOffline(): Promise<void> {
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="班级码" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
placeholder="例如 G101(由老师在班级管理中提供)"
|
||||
clearable
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
<el-input v-model="form.code" placeholder="请输入班级码" clearable @keyup.enter="submit">
|
||||
<template #prefix>
|
||||
<el-icon><School /></el-icon>
|
||||
</template>
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -67,26 +67,29 @@ export const WEEKDAY_LABELS: Record<number, string> = {
|
||||
export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
/**
|
||||
* 常用科目目录:作业/课表里"新建课程"时可直接选(也用于种子数据与导入时的科目建议)。
|
||||
* 顺序按中学常见排课习惯,前 9 个是文化课,后面是技术与体艺类。
|
||||
* 统一授课科目目录(**全校固定**):作业、课表、成绩的"科目"都从这里选,
|
||||
* 不需要按班级一个个建课 —— 选中后由前端自动为该班建好同名课程(已存在则复用)。
|
||||
* 顺序按用户给定的作息习惯:文化课 → 技术 → 体艺 → 班会与早晚读 → 通用技术 → 听力。
|
||||
*/
|
||||
export const SUBJECT_CATALOG: readonly string[] = [
|
||||
'语文',
|
||||
'数学',
|
||||
'英语',
|
||||
'物理',
|
||||
'化学',
|
||||
'生物',
|
||||
'政治',
|
||||
'历史',
|
||||
'物理',
|
||||
'化学',
|
||||
'地理',
|
||||
'生物',
|
||||
'信息技术',
|
||||
'通用技术',
|
||||
'体育',
|
||||
'音乐',
|
||||
'美术',
|
||||
'心理健康',
|
||||
'劳动',
|
||||
'音乐',
|
||||
'班会',
|
||||
'早读',
|
||||
'晚读',
|
||||
'通用技术',
|
||||
'听力',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,11 +74,11 @@ export function canManageGrades(classRole: ClassRole): boolean {
|
||||
}
|
||||
|
||||
/** 可导入的数据类型 */
|
||||
export type ImportKind = 'grades' | 'students' | 'scheduleTimes';
|
||||
export type ImportKind = 'grades' | 'students' | 'teachers' | 'scheduleTimes';
|
||||
|
||||
/** 导入权限:成绩=管理员或本班班主任;名单=仅管理员;课表时间=管理员或本班班主任 */
|
||||
/** 导入权限:成绩=管理员或本班班主任;学生/教师名单=仅管理员;课表时间=管理员或本班班主任 */
|
||||
export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind): boolean {
|
||||
if (kind === 'students') return canManageRoster(role);
|
||||
if (kind === 'students' || kind === 'teachers') return canManageRoster(role);
|
||||
if (kind === 'scheduleTimes') return canManageSchedule(classRole);
|
||||
return canManageGrades(classRole);
|
||||
}
|
||||
@@ -93,6 +93,7 @@ export const PERMISSION_MATRIX: readonly {
|
||||
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
|
||||
{ action: '分配班主任与科任老师', admin: true, head: false, subject: false },
|
||||
{ action: '学生名单管理(增删 / 重置密码 / 导入)', admin: true, head: false, subject: false },
|
||||
{ action: '教师录入(新建 / 导入名单 / 重置密码)', admin: true, head: false, subject: false },
|
||||
{ action: '课表管理(增删改 / 时间配置导入)', admin: true, head: true, subject: false },
|
||||
{ action: '布置作业', admin: true, head: true, subject: true },
|
||||
{ action: '叫人', admin: true, head: true, subject: true },
|
||||
|
||||
@@ -65,6 +65,9 @@ export const classApi = {
|
||||
api.delete(`${API_PATHS.classes}/${id}/students/${userId}`),
|
||||
assignTeacher: (id: string, teacherId: string): Promise<unknown> =>
|
||||
api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }),
|
||||
/** 设置 / 更改班主任:仅管理员 */
|
||||
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}`),
|
||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||
@@ -200,7 +203,7 @@ export const dashboardApi = {
|
||||
api.get('/dashboard/term', classId ? { classId } : undefined),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 协作教师 */
|
||||
/* ------------------------------------------------------------------ 教师管理(仅管理员) */
|
||||
|
||||
export const teacherApi = {
|
||||
list: (keyword?: string): Promise<UserDto[]> =>
|
||||
@@ -208,9 +211,16 @@ export const teacherApi = {
|
||||
create: (payload: {
|
||||
username: string;
|
||||
name: string;
|
||||
password: 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' },
|
||||
): 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),
|
||||
};
|
||||
@@ -220,22 +230,22 @@ export const teacherApi = {
|
||||
export const importApi = {
|
||||
/** 模板下载地址(CSV 走 JSON,XLSX 走二进制) */
|
||||
template: (
|
||||
kind: 'grades' | 'students',
|
||||
kind: 'grades' | 'students' | 'teachers',
|
||||
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';
|
||||
kind: 'grades' | 'students' | 'teachers';
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
}): Promise<TableImportPreview> => api.post(`${API_PATHS.imports}/table/preview`, payload),
|
||||
|
||||
/** 确认字段映射与写入模式后执行导入 */
|
||||
/** 确认字段映射与写入模式后执行导入(教师名单与班级无关,classId 可省略) */
|
||||
commitTable: (payload: {
|
||||
kind: 'grades' | 'students';
|
||||
classId: string;
|
||||
kind: 'grades' | 'students' | 'teachers';
|
||||
classId?: string;
|
||||
fileName: string;
|
||||
contentBase64: string;
|
||||
mapping: Record<string, string>;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { API_BASE_URL } from '@/config';
|
||||
import { importApi } from '@/api';
|
||||
|
||||
type Kind = 'grades' | 'students';
|
||||
type Kind = 'grades' | 'students' | 'teachers';
|
||||
|
||||
interface FieldDef {
|
||||
key: string;
|
||||
@@ -44,12 +44,19 @@ const FIELDS: Record<Kind, FieldDef[]> = {
|
||||
{ key: 'name', label: '姓名', required: true },
|
||||
{ key: 'password', label: '初始密码', required: false },
|
||||
],
|
||||
teachers: [
|
||||
{ key: 'username', label: '用户名', required: true },
|
||||
{ key: 'name', label: '姓名', required: true },
|
||||
{ key: 'password', label: '初始密码', required: false },
|
||||
{ key: 'role', label: '角色', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
kind: Kind;
|
||||
classId: string;
|
||||
/** 教师名单与班级无关,可以不传 */
|
||||
classId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -73,7 +80,12 @@ const result = ref<TableImportResult | null>(null);
|
||||
const fileInputRef = ref<HTMLInputElement>();
|
||||
|
||||
const fields = computed(() => FIELDS[props.kind]);
|
||||
const title = computed(() => (props.kind === 'grades' ? '导入成绩表格' : '导入学生名单'));
|
||||
const title = computed(() => {
|
||||
if (props.kind === 'grades') return '导入成绩表格';
|
||||
return props.kind === 'teachers' ? '导入教师名单' : '导入学生名单';
|
||||
});
|
||||
/** 教师名单不需要班级(教师不属于任何班级) */
|
||||
const needsClass = computed(() => props.kind !== 'teachers');
|
||||
|
||||
watch(visible, (value) => {
|
||||
if (value) reset();
|
||||
@@ -178,7 +190,7 @@ const mappedRequired = computed(() =>
|
||||
);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!props.classId) {
|
||||
if (needsClass.value && !props.classId) {
|
||||
ElMessage.warning('请先在页面上选择班级');
|
||||
return;
|
||||
}
|
||||
@@ -195,7 +207,7 @@ async function submit(): Promise<void> {
|
||||
try {
|
||||
const data = await importApi.commitTable({
|
||||
kind: props.kind,
|
||||
classId: props.classId,
|
||||
...(needsClass.value && props.classId ? { classId: props.classId } : {}),
|
||||
fileName: fileName.value,
|
||||
contentBase64: contentBase64.value,
|
||||
mapping: mapping.value,
|
||||
|
||||
@@ -16,7 +16,7 @@ const { isMobile } = useResponsive();
|
||||
|
||||
/**
|
||||
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
|
||||
* - 班级管理 / 学生管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
|
||||
* - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
|
||||
* - 课表管理:管理员 + 班主任(页面内再按"是否本班班主任"控制增删改按钮)
|
||||
* - 成绩录入:管理员 + 教师;页面内只对"管理员或所选班级的班主任"显示写入/导入按钮
|
||||
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
|
||||
@@ -26,6 +26,7 @@ const menuItems = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] },
|
||||
{ path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] },
|
||||
{ path: '/teachers', title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] },
|
||||
{ path: '/schedules', title: '课表管理', icon: 'Calendar', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
|
||||
|
||||
@@ -23,13 +23,19 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'classes',
|
||||
name: 'classes',
|
||||
component: () => import('@/views/ClassesView.vue'),
|
||||
meta: { title: '班级管理', icon: 'School' },
|
||||
meta: { title: '班级管理', icon: 'School', roles: ['ADMIN'] },
|
||||
},
|
||||
{
|
||||
path: 'students',
|
||||
name: 'students',
|
||||
component: () => import('@/views/StudentsView.vue'),
|
||||
meta: { title: '学生管理', icon: 'User' },
|
||||
meta: { title: '学生管理', icon: 'User', roles: ['ADMIN'] },
|
||||
},
|
||||
{
|
||||
path: 'teachers',
|
||||
name: 'teachers',
|
||||
component: () => import('@/views/TeachersView.vue'),
|
||||
meta: { title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] },
|
||||
},
|
||||
{
|
||||
path: 'schedules',
|
||||
@@ -70,6 +76,18 @@ export const router = createRouter({
|
||||
routes,
|
||||
});
|
||||
|
||||
/** 读取已保存的登录用户角色(路由守卫用;未登录/解析失败返回 null) */
|
||||
function storedRole(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.user);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { role?: string };
|
||||
return parsed?.role ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const token = localStorage.getItem(STORAGE_KEYS.token);
|
||||
if (to.meta.public) {
|
||||
@@ -79,6 +97,15 @@ router.beforeEach((to) => {
|
||||
if (!token) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
// 仅管理员可见的页面(班级/学生/教师管理):直接输网址也要挡回去,
|
||||
// 不能依赖"菜单隐藏"——菜单隐藏只是看不见,不是权限。
|
||||
const roles = to.meta.roles;
|
||||
if (Array.isArray(roles) && roles.length > 0) {
|
||||
const role = storedRole();
|
||||
if (!role || !(roles as string[]).includes(role)) {
|
||||
return { name: 'dashboard' };
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -44,18 +44,29 @@ 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 });
|
||||
const form = reactive({ name: '', grade: '', code: '', termWeeks: 20, headTeacherId: '' });
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
|
||||
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
/** 管理员可选班主任(教师 + 管理员账号);「新建班级」才设置,「编辑」走单独的"更换班主任" */
|
||||
const staffOptions = ref<UserDto[]>([]);
|
||||
|
||||
async function loadStaffOptions(): Promise<void> {
|
||||
if (!isAdmin.value) return;
|
||||
if (staffOptions.value.length > 0) return;
|
||||
staffOptions.value = await teacherApi.list().catch(() => []);
|
||||
}
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.grade = '';
|
||||
form.code = '';
|
||||
form.termWeeks = 20;
|
||||
form.headTeacherId = auth.user?.id ?? '';
|
||||
void loadStaffOptions();
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -65,6 +76,7 @@ function openEdit(row: ClassDto): void {
|
||||
form.grade = row.grade;
|
||||
form.code = '';
|
||||
form.termWeeks = row.termWeeks ?? 20;
|
||||
void loadStaffOptions();
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -78,12 +90,20 @@ async function submitForm(): Promise<void> {
|
||||
grade: form.grade,
|
||||
termWeeks: form.termWeeks,
|
||||
});
|
||||
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
|
||||
if (isAdmin.value && form.headTeacherId) {
|
||||
const target = classes.value.find((item) => item.id === editingId.value);
|
||||
if (target && target.teacher?.id !== form.headTeacherId) {
|
||||
await classApi.assignHeadTeacher(editingId.value, form.headTeacherId);
|
||||
}
|
||||
}
|
||||
ElMessage.success('班级已更新');
|
||||
} else {
|
||||
await classApi.create({
|
||||
name: form.name,
|
||||
grade: form.grade,
|
||||
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
|
||||
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
|
||||
});
|
||||
ElMessage.success('班级创建成功');
|
||||
}
|
||||
@@ -91,6 +111,23 @@ async function submitForm(): Promise<void> {
|
||||
await loadClasses();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 更换班主任(仅管理员) */
|
||||
|
||||
const headTeacherSaving = ref(false);
|
||||
|
||||
/** 详情页更换班主任:写 PATCH /api/classes/:id/head-teacher */
|
||||
async function changeHeadTeacher(classId: string, teacherId: string): Promise<void> {
|
||||
if (!teacherId) return;
|
||||
headTeacherSaving.value = true;
|
||||
try {
|
||||
await classApi.assignHeadTeacher(classId, teacherId);
|
||||
ElMessage.success('班主任已更换');
|
||||
await Promise.all([loadClasses(), refreshDetail()]);
|
||||
} finally {
|
||||
headTeacherSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 班级账号(班级码 + 班级密码) */
|
||||
|
||||
const accountVisible = ref(false);
|
||||
@@ -401,6 +438,19 @@ onUnmounted(() => {
|
||||
<el-form-item v-if="!editingId" label="班级码">
|
||||
<el-input v-model="form.code" placeholder="留空自动生成(4~16 位字母数字)" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isAdmin" label="班主任">
|
||||
<el-select v-model="form.headTeacherId" placeholder="选择班主任" style="width: 100%" filterable>
|
||||
<el-option
|
||||
v-for="item in staffOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name}(${item.username})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="text-muted" style="width: 100%">
|
||||
班主任可以管理本班课表与成绩;留空则默认由创建者本人担任
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="学期周数">
|
||||
<el-input-number v-model="form.termWeeks" :min="1" :max="40" :step="1" />
|
||||
<span class="text-muted ml-8">
|
||||
@@ -423,6 +473,27 @@ onUnmounted(() => {
|
||||
<el-descriptions-item label="学生数">{{ detail.students.length }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 更换班主任(仅管理员):班主任决定谁能管这个班的课表与成绩 -->
|
||||
<div v-if="isAdmin && detail" class="toolbar mt-12">
|
||||
<span class="text-muted">更换班主任</span>
|
||||
<el-select
|
||||
:model-value="detail.teacher?.id ?? ''"
|
||||
placeholder="选择教师"
|
||||
style="width: 240px"
|
||||
filterable
|
||||
:loading="headTeacherSaving"
|
||||
@change="(value: string) => changeHeadTeacher(detail!.id, value)"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in staffOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name}(${item.username})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="text-muted">切换后立即生效:原班主任仍保留在下方「协作教师」名单里</span>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="mt-12">
|
||||
<el-tab-pane label="学生名单" name="students">
|
||||
<div class="toolbar mt-12">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import * as echarts from 'echarts';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
SUBJECT_CATALOG,
|
||||
formatDate,
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
@@ -138,6 +139,30 @@ function handleResize(): void {
|
||||
courseChart.value?.resize();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 统一科目
|
||||
*
|
||||
* 与课表一致:"科目"是全校统一的固定目录(shared 的 SUBJECT_CATALOG),
|
||||
* 下拉里直接列出该班还没有的科目,选中后自动建课 —— 不需要先按班级单独录入课程。
|
||||
*/
|
||||
|
||||
const SUBJECT_PREFIX = 'subject:';
|
||||
|
||||
const subjectOptions = computed(() => {
|
||||
const existing = new Set(courses.value.map((item) => item.name));
|
||||
return SUBJECT_CATALOG.filter((name) => !existing.has(name));
|
||||
});
|
||||
|
||||
/** 把下拉值解析成真实 courseId:`subject:语文` 这类伪值先建课(已存在则复用) */
|
||||
async function resolveCourseId(value: string): Promise<string | null> {
|
||||
if (!value.startsWith(SUBJECT_PREFIX)) return value || null;
|
||||
const name = value.slice(SUBJECT_PREFIX.length);
|
||||
const existing = courses.value.find((item) => item.name === name);
|
||||
if (existing) return existing.id;
|
||||
const created = await courseApi.create({ classId: filter.classId, name });
|
||||
courses.value = [...courses.value, created];
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 单条录入 */
|
||||
|
||||
const singleVisible = ref(false);
|
||||
@@ -163,7 +188,7 @@ async function submitSingle(): Promise<void> {
|
||||
}
|
||||
await gradeApi.create({
|
||||
classId: filter.classId,
|
||||
courseId: singleForm.courseId || null,
|
||||
courseId: await resolveCourseId(singleForm.courseId),
|
||||
userId: singleForm.userId,
|
||||
examName: singleForm.examName.trim(),
|
||||
score: singleForm.score,
|
||||
@@ -213,7 +238,7 @@ async function submitBulk(): Promise<void> {
|
||||
|
||||
const result = await gradeApi.bulkCreate({
|
||||
classId: filter.classId,
|
||||
courseId: bulkForm.courseId || null,
|
||||
courseId: await resolveCourseId(bulkForm.courseId),
|
||||
examName: bulkForm.examName.trim(),
|
||||
totalScore: bulkForm.totalScore,
|
||||
items,
|
||||
@@ -391,9 +416,17 @@ onUnmounted(() => {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="课程">
|
||||
<el-select v-model="singleForm.courseId" clearable style="width: 100%">
|
||||
<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-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="考试名称">
|
||||
@@ -414,14 +447,22 @@ onUnmounted(() => {
|
||||
<!-- 批量录入 -->
|
||||
<el-dialog v-model="bulkVisible" title="批量录入成绩" width="620px">
|
||||
<el-form :model="bulkForm" label-width="90px" inline>
|
||||
<el-form-item label="课程">
|
||||
<el-select v-model="bulkForm.courseId" clearable style="width: 150px">
|
||||
<el-form-item label="科目">
|
||||
<el-select v-model="bulkForm.courseId" clearable filterable style="width: 150px">
|
||||
<el-option
|
||||
v-for="item in teacherCourseSuggestions"
|
||||
: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="考试">
|
||||
|
||||
@@ -15,17 +15,6 @@ const rules: FormRules = {
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const demoAccounts = [
|
||||
{ role: '管理员', username: 'admin', password: 'admin123' },
|
||||
{ role: '教师', username: 'teacher1', password: 'teacher123' },
|
||||
{ role: '教师', username: 'teacher2', password: 'teacher123' },
|
||||
];
|
||||
|
||||
function fill(username: string, password: string): void {
|
||||
form.username = username;
|
||||
form.password = password;
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
@@ -70,20 +59,6 @@ async function submit(): Promise<void> {
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<el-divider>演示账号(点击填充)</el-divider>
|
||||
<div class="demo-list">
|
||||
<el-tag
|
||||
v-for="item in demoAccounts"
|
||||
:key="item.username"
|
||||
class="demo-tag"
|
||||
effect="plain"
|
||||
@click="fill(item.username, item.password)"
|
||||
>
|
||||
{{ item.role }}:{{ item.username }} / {{ item.password }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p class="login-tip">学生请使用班级小助手桌面客户端登录;种子数据见 README。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -121,11 +96,6 @@ async function submit(): Promise<void> {
|
||||
.login-brand {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.demo-tag {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
@@ -149,22 +119,4 @@ async function submit(): Promise<void> {
|
||||
.login-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.demo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-tag {
|
||||
cursor: pointer;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.login-tip {
|
||||
margin: 16px 0 0;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
SUBJECT_CATALOG,
|
||||
WEEKDAYS,
|
||||
WEEKDAY_LABELS,
|
||||
WEEK_PARITY_LABELS,
|
||||
@@ -99,6 +100,33 @@ async function loadCourses(): Promise<void> {
|
||||
courses.value = query.classId ? await courseApi.list(query.classId) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一授课科目(需求:"课表统一而不是单个班级录入")。
|
||||
*
|
||||
* 学校固定的科目目录来自 shared 的 `SUBJECT_CATALOG`(语文/数学/…/听力),
|
||||
* 与班级无关:**该班还没有的科目也直接出现在下拉里**,选中后自动为该班建好同名课程,
|
||||
* 因此不需要为了排课先去"班级 → 课程"里一个个录一遍。
|
||||
* 下拉只有一个 v-model,统一科目用 `subject:语文` 这种带前缀的伪值表达。
|
||||
*/
|
||||
const SUBJECT_PREFIX = 'subject:';
|
||||
|
||||
const subjectOptions = computed(() => {
|
||||
const existing = new Set(courses.value.map((item) => item.name));
|
||||
return SUBJECT_CATALOG.filter((name) => !existing.has(name));
|
||||
});
|
||||
|
||||
/** 把表单里的选中值解析成真实 courseId:伪值先建课(已存在则复用) */
|
||||
async function resolveCourseId(): Promise<string | null> {
|
||||
const value = form.courseId;
|
||||
if (!value.startsWith(SUBJECT_PREFIX)) return value || null;
|
||||
const name = value.slice(SUBJECT_PREFIX.length);
|
||||
const existing = courses.value.find((item) => item.name === name);
|
||||
if (existing) return existing.id;
|
||||
const created = await courseApi.create({ classId: query.classId, name });
|
||||
courses.value = [...courses.value, created];
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function loadSchedules(): Promise<void> {
|
||||
if (!query.classId) {
|
||||
grid.value = null;
|
||||
@@ -153,7 +181,9 @@ function openCreate(): void {
|
||||
return;
|
||||
}
|
||||
editingId.value = null;
|
||||
form.courseId = courses.value[0]?.id ?? '';
|
||||
// 默认选第一个统一科目(不是"该班已建课程",避免新班级下拉为空)
|
||||
form.courseId =
|
||||
courses.value[0]?.id ?? (SUBJECT_CATALOG[0] ? `${SUBJECT_PREFIX}${SUBJECT_CATALOG[0]}` : '');
|
||||
form.dayOfWeek = 1;
|
||||
form.startTime = '08:00';
|
||||
form.endTime = '08:45';
|
||||
@@ -185,9 +215,15 @@ async function submitForm(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const courseId = await resolveCourseId();
|
||||
if (!courseId) {
|
||||
ElMessage.warning('请选择科目');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: CreateScheduleRequest = {
|
||||
classId: query.classId,
|
||||
courseId: form.courseId,
|
||||
courseId,
|
||||
dayOfWeek: Number(form.dayOfWeek),
|
||||
startTime: form.startTime,
|
||||
endTime: form.endTime,
|
||||
@@ -205,7 +241,7 @@ async function submitForm(): Promise<void> {
|
||||
ElMessage.success('课表已新增');
|
||||
}
|
||||
formVisible.value = false;
|
||||
await loadSchedules();
|
||||
await Promise.all([loadCourses(), loadSchedules()]);
|
||||
}
|
||||
|
||||
async function removeSchedule(item: ScheduleDto): Promise<void> {
|
||||
@@ -341,10 +377,22 @@ onUnmounted(() => {
|
||||
|
||||
<el-dialog v-model="formVisible" :title="editingId ? '编辑课表' : '新增课表'" width="480px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="课程" prop="courseId">
|
||||
<el-select v-model="form.courseId" placeholder="选择课程" style="width: 100%">
|
||||
<el-form-item label="科目" prop="courseId">
|
||||
<el-select v-model="form.courseId" placeholder="选择科目" style="width: 100%" filterable>
|
||||
<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>
|
||||
<span class="text-muted" style="width: 100%">
|
||||
科目是全校统一的固定目录(语文/数学/…/听力),不必按班级单独录入;
|
||||
选这里没有的科目时会自动为该班建好同名课程
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="星期" prop="dayOfWeek">
|
||||
<el-select v-model="form.dayOfWeek" style="width: 100%">
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { ROLE_LABELS, formatDate, type UserDto } from '@classhelper/shared';
|
||||
import { teacherApi } from '@/api';
|
||||
import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||
|
||||
/**
|
||||
* 教师管理(**仅管理员可见/可用**,与后端 `requireRole('ADMIN')` 对应)。
|
||||
*
|
||||
* 与「学生管理」保持同一套交互:单个录入 / 表格导入名单 / 编辑 / 重置密码 / 删除。
|
||||
* 学生端主体是班级账号,教师账号则决定"谁是班主任、谁能录课表与成绩",
|
||||
* 属于系统级配置,所以录入权限只给管理员。
|
||||
*/
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const teachers = ref<UserDto[]>([]);
|
||||
const keyword = ref('');
|
||||
|
||||
async function loadTeachers(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
teachers.value = await teacherApi.list(keyword.value.trim() || undefined);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 录入 / 编辑 */
|
||||
|
||||
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 rules: FormRules = {
|
||||
username: [
|
||||
{ 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' }],
|
||||
password: [{ min: 6, message: '密码至少 6 位', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
function openCreate(): void {
|
||||
editingId.value = null;
|
||||
form.username = '';
|
||||
form.name = '';
|
||||
form.password = '';
|
||||
form.role = 'TEACHER';
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: UserDto): void {
|
||||
editingId.value = row.id;
|
||||
form.username = row.username;
|
||||
form.name = row.name;
|
||||
form.password = '';
|
||||
form.role = row.role === 'ADMIN' ? 'ADMIN' : 'TEACHER';
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitForm(): Promise<void> {
|
||||
const valid = await formRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await teacherApi.update(editingId.value, {
|
||||
username: form.username.trim(),
|
||||
name: form.name.trim(),
|
||||
role: form.role,
|
||||
});
|
||||
ElMessage.success('教师信息已更新');
|
||||
} else {
|
||||
await teacherApi.create({
|
||||
username: form.username.trim(),
|
||||
name: form.name.trim(),
|
||||
role: form.role,
|
||||
...(form.password ? { password: form.password } : {}),
|
||||
});
|
||||
ElMessage.success(
|
||||
form.password ? '教师账号已创建' : '教师账号已创建(使用默认初始密码,请提醒本人尽快修改)',
|
||||
);
|
||||
}
|
||||
formVisible.value = false;
|
||||
await loadTeachers();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(row: UserDto): Promise<void> {
|
||||
await ElMessageBox.confirm(`将「${row.name}」的密码重置为默认初始密码?`, '重置密码', {
|
||||
type: 'warning',
|
||||
});
|
||||
await teacherApi.resetPassword(row.id);
|
||||
ElMessage.success('密码已重置为默认初始密码');
|
||||
}
|
||||
|
||||
async function removeTeacher(row: UserDto): Promise<void> {
|
||||
await ElMessageBox.confirm(
|
||||
`删除教师账号「${row.name}(${row.username})」?` +
|
||||
'若 TA 还是班主任或持有课程,系统会拒绝删除,请先在班级管理里更换班主任。',
|
||||
'危险操作',
|
||||
{ type: 'warning', confirmButtonText: '确认删除' },
|
||||
);
|
||||
await teacherApi.remove(row.id);
|
||||
ElMessage.success('教师账号已删除');
|
||||
await loadTeachers();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 导入名单 */
|
||||
|
||||
const importVisible = ref(false);
|
||||
|
||||
function roleLabel(role: string): string {
|
||||
return ROLE_LABELS[role as keyof typeof ROLE_LABELS] ?? role;
|
||||
}
|
||||
|
||||
onMounted(loadTeachers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">教师管理</h2>
|
||||
<p class="page-subtitle">
|
||||
录入教师账号(单个录入或导入名单)、修改姓名与角色、重置密码(仅管理员可见)
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="姓名 / 用户名"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
@keyup.enter="loadTeachers"
|
||||
/>
|
||||
<el-button :icon="'Search'" @click="loadTeachers">查询</el-button>
|
||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建教师</el-button>
|
||||
<el-button type="warning" :icon="'Document'" @click="importVisible = true">导入名单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 label="角色" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'primary'" effect="plain" size="small">
|
||||
{{ roleLabel(row.role) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="warning" @click="resetPassword(row)">重置密码</el-button>
|
||||
<el-button link type="danger" @click="removeTeacher(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<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="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>
|
||||
<el-form-item v-if="!editingId" label="初始密码">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="留空使用默认密码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-radio-group v-model="form.role">
|
||||
<el-radio value="TEACHER">教师</el-radio>
|
||||
<el-radio value="ADMIN">管理员</el-radio>
|
||||
</el-radio-group>
|
||||
<div class="text-muted" style="width: 100%">
|
||||
管理员可以管理班级、学生、教师与全部课表;普通教师仅能管理自己负责的班级。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 导入教师名单:与班级无关,因此不需要选班级 -->
|
||||
<TableImportDialog v-model="importVisible" kind="teachers" @imported="loadTeachers" />
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user