Files
ClassHelper/packages/web-admin/src/views/TeachersView.vue
T
LoveChengke 845fbdd863 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 全绿。服务端安装实例与客户端安装目录已就地更新。
2026-09-13 10:34:46 +08:00

204 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>