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
@@ -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