① 支持录入教师(与学生录入对齐),权限只有管理员
- 新增 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 全绿。服务端安装实例与客户端安装目录已就地更新。
204 lines
7.6 KiB
Vue
204 lines
7.6 KiB
Vue
<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>
|