Files
ClassHelper/packages/web-admin/src/views/ClassesView.vue
T
LoveChengke d27f9a4b9e feat(students,teachers,classes,auth,web): 学生个人账号彻底清理,学生只是名单记录
学生不再有登录能力:User.passwordHash 以空串占位,role=STUDENT 在密码校验之前
一律 403 并提示改用班级登录(否则「密码错 401 / 密码对但已停用 403」的差异会把
学生账号密码变成可离线验证的预言机)。成绩、未交名单、叫人、已读全部按名单记。

配套改动:
- 删除 POST /students/:id/reset-password(调用返回 404,e2e 有负断言);
- 新建学生不设密码,StudentsView 移除密码输入与「重置密码」入口,
  列表标识仍自动生成为学号式(student01…);
- class-account.ts / auth.service.ts 收紧班级会话语义;
- 经管「修改密码」入口收敛到 Web 端:教师改本人密码走顶栏下拉,
  班级密码由管理员在班级管理里维护,教师可用 POST /teachers/:id/reset-password 设初始密码。
2026-10-02 00:25:04 +08:00

606 lines
23 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 { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
MAX_TERM_WEEK,
formatDate,
type ClassDetailDto,
type ClassDto,
type CourseDto,
type StudentDto,
type UpdateClassAccountRequest,
type UserDto,
} from '@classhelper/shared';
import { classApi, courseApi, teacherApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { useResponsive } from '@/composables/useResponsive';
const auth = useAuthStore();
const realtime = useRealtimeStore();
const { isMobile } = useResponsive();
/** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */
const isAdmin = computed(() => auth.role === 'ADMIN');
/** 小屏下详情描述改为单列,避免文字被压成竖排 */
const detailColumns = computed(() => (isMobile.value ? 1 : 3));
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const keyword = ref('');
async function loadClasses(): Promise<void> {
loading.value = true;
try {
classes.value = await classApi.list(keyword.value ? { keyword: keyword.value } : undefined);
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 新建 / 编辑班级 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
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;
}
function openEdit(row: ClassDto): void {
editingId.value = row.id;
form.name = row.name;
form.grade = row.grade;
form.code = '';
form.termWeeks = row.termWeeks ?? 20;
// 必须回填当前班主任:编辑弹窗里的下拉与"新建"共用同一个 form.headTeacherId,
// 而 submitForm 会在"值不同"时直接调 assignHeadTeacher。不回填的话,只要之前打开过一次
// 「新建班级」(openCreate 会把它设成当前管理员自己),之后保存任何一次编辑都会把
// 该班班主任静默改成管理员本人 —— 而班主任决定谁能管这个班的课表与成绩。
form.headTeacherId = row.teacher?.id ?? '';
void loadStaffOptions();
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (editingId.value) {
await classApi.update(editingId.value, {
name: form.name,
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,
// 学期周数:新建时也要提交。原先只提交名称/年级/班级码,表单里填的周数被直接丢弃,
// 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。
termWeeks: form.termWeeks,
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
});
ElMessage.success('班级创建成功');
}
formVisible.value = false;
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);
const accountSaving = ref(false);
const accountTarget = ref<ClassDto | null>(null);
const accountForm = reactive({ code: '', password: '' });
/** 打开班级账号设置:班级码即学生端「班级登录」的账号 */
function openAccount(row: ClassDto): void {
accountTarget.value = row;
accountForm.code = row.code ?? '';
accountForm.password = '';
accountVisible.value = true;
}
/** 一键生成一个易读的班级码(避开 0/O/1/I 等易混字符) */
function suggestCode(): void {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let body = '';
for (let index = 0; index < 6; index += 1) {
body += alphabet[Math.floor(Math.random() * alphabet.length)];
}
accountForm.code = body;
}
async function submitAccount(): Promise<void> {
const target = accountTarget.value;
if (!target) return;
if (!accountForm.code.trim() && !accountForm.password) {
ElMessage.warning('请填写新的班级码或班级密码');
return;
}
accountSaving.value = true;
try {
const payload: UpdateClassAccountRequest = {};
if (accountForm.code.trim() && accountForm.code.trim() !== target.code) {
payload.code = accountForm.code.trim().toUpperCase();
}
if (accountForm.password) payload.password = accountForm.password;
const updated = await classApi.updateAccount(target.id, payload);
ElMessage.success(
`班级账号已更新:班级码 ${updated.code ?? '-'}${accountForm.password ? ',班级密码已重置' : ''}`,
);
accountVisible.value = false;
await loadClasses();
} finally {
accountSaving.value = false;
}
}
async function removeClass(row: ClassDto): Promise<void> {
await ElMessageBox.confirm(
`删除「${row.name}」将同时删除其课表、作业、通知与成绩,确认继续?`,
'危险操作',
{ type: 'warning', confirmButtonText: '确认删除' },
);
await classApi.remove(row.id);
ElMessage.success('班级已删除');
await loadClasses();
}
/* ------------------------------------------------------------ 班级详情抽屉 */
const detailVisible = ref(false);
const detailLoading = ref(false);
const detail = ref<ClassDetailDto | null>(null);
const activeTab = ref('students');
async function openDetail(row: ClassDto): Promise<void> {
detailVisible.value = true;
detailLoading.value = true;
activeTab.value = 'students';
try {
detail.value = await classApi.detail(row.id);
} finally {
detailLoading.value = false;
}
}
async function refreshDetail(): Promise<void> {
if (!detail.value) return;
detailLoading.value = true;
try {
detail.value = await classApi.detail(detail.value.id);
} finally {
detailLoading.value = false;
}
}
/* ------------------------------------------------------------ 学生 */
const studentFormVisible = ref(false);
const studentForm = reactive({ username: '', name: '', password: '' });
const studentRules: FormRules = {
username: [{ required: true, message: '请输入学号/用户名', trigger: 'blur' }],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
};
async function addStudent(): Promise<void> {
if (!detail.value) return;
if (!studentForm.username.trim() || !studentForm.name.trim()) {
ElMessage.warning('请填写用户名和姓名');
return;
}
const created = await classApi.addStudent(detail.value.id, {
username: studentForm.username.trim(),
name: studentForm.name.trim(),
...(studentForm.password ? { password: studentForm.password } : {}),
});
ElMessage.success(`已加入学生:${created.name}`);
studentFormVisible.value = false;
studentForm.username = '';
studentForm.name = '';
studentForm.password = '';
await refreshDetail();
await loadClasses();
}
async function removeStudent(row: StudentDto): Promise<void> {
if (!detail.value) return;
await ElMessageBox.confirm(`将「${row.name}」移出本班级?`, '确认', { type: 'warning' });
await classApi.removeStudent(detail.value.id, row.id);
ElMessage.success('已移出班级');
await refreshDetail();
await loadClasses();
}
/* ------------------------------------------------------------ 课程 */
const courseForm = reactive({ name: '' });
async function addCourse(): Promise<void> {
if (!detail.value || !courseForm.name.trim()) {
ElMessage.warning('请输入课程名称');
return;
}
await courseApi.create({ name: courseForm.name.trim(), classId: detail.value.id });
ElMessage.success('课程已添加');
courseForm.name = '';
await refreshDetail();
}
async function removeCourse(row: CourseDto): Promise<void> {
await ElMessageBox.confirm(`删除课程「${row.name}」?相关课表与作业关联会被清除。`, '确认', {
type: 'warning',
});
await courseApi.remove(row.id);
ElMessage.success('课程已删除');
await refreshDetail();
}
/* ------------------------------------------------------------ 协作教师 */
const teachers = ref<UserDto[]>([]);
const selectedTeacherId = ref<string>('');
async function loadTeachers(): Promise<void> {
teachers.value = await teacherApi.list();
}
async function assignTeacher(): Promise<void> {
if (!detail.value || !selectedTeacherId.value) {
ElMessage.warning('请选择教师');
return;
}
await classApi.assignTeacher(detail.value.id, selectedTeacherId.value);
ElMessage.success('教师已分配');
selectedTeacherId.value = '';
await refreshDetail();
}
async function removeTeacher(teacherId: string): Promise<void> {
if (!detail.value) return;
await classApi.removeTeacher(detail.value.id, teacherId);
ElMessage.success('已取消分配');
await refreshDetail();
}
/* ------------------------------------------------------------ 实时刷新 */
function onClassChanged(): void {
void loadClasses();
if (detailVisible.value) void refreshDetail();
}
onMounted(async () => {
await Promise.all([loadClasses(), loadTeachers()]);
realtime.on(SOCKET_EVENTS.classUpdated, onClassChanged);
realtime.on(SOCKET_EVENTS.notificationNew, onClassChanged);
realtime.on(SOCKET_EVENTS.homeworkNew, onClassChanged);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.classUpdated, onClassChanged);
realtime.off(SOCKET_EVENTS.notificationNew, onClassChanged);
realtime.off(SOCKET_EVENTS.homeworkNew, onClassChanged);
});
</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: 220px"
@keyup.enter="loadClasses"
@clear="loadClasses"
/>
<el-button :icon="'Search'" @click="loadClasses">查询</el-button>
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
</div>
</div>
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="classes" empty-text="暂无班级,点击右上角新建">
<el-table-column prop="name" label="班级" min-width="140" />
<el-table-column prop="grade" label="年级" width="100" />
<el-table-column label="班主任" width="140">
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="学生" width="80">
<template #default="{ row }">{{ row.studentCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="课程" width="80">
<template #default="{ row }">{{ row.courseCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="作业" width="80">
<template #default="{ row }">{{ row.homeworkCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="通知" width="80">
<template #default="{ row }">{{ row.notificationCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="班级账号(学生端登录)" width="220">
<template #default="{ row }">
<template v-if="row.code">
<el-tag type="info" effect="plain">{{ row.code }}</el-tag>
<el-tag :type="row.hasPassword ? 'success' : 'warning'" size="small" class="account-tag">
{{ row.hasPassword ? '已设密码' : '未设密码' }}
</el-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="310" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button v-if="isAdmin" link type="warning" @click="openAccount(row)">班级账号</el-button>
<el-button v-if="isAdmin" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="isAdmin" link type="danger" @click="removeClass(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 班级账号:班级码 + 班级密码(学生端「班级登录」凭据) -->
<el-dialog v-model="accountVisible" title="班级账号(学生端登录)" width="460px">
<el-alert type="info" :closable="false" show-icon class="mb-12">
<template #title>
学生端以「班级」为主体登录:班级码 + 班级密码。登录后本机代表整个班级,
作业完成、通知已读都会按全班记录。
</template>
</el-alert>
<el-form label-width="90px">
<el-form-item label="班级">
<el-input :model-value="accountTarget?.name ?? ''" disabled />
</el-form-item>
<el-form-item label="班级码">
<el-input v-model="accountForm.code" placeholder="4~16 位字母或数字" style="width: 220px" />
<el-button link type="primary" class="ml-8" @click="suggestCode">随机生成</el-button>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="accountForm.password"
type="password"
show-password
placeholder="留空表示不修改(至少 6 位)"
style="width: 220px"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="accountVisible = false">取消</el-button>
<el-button type="primary" :loading="accountSaving" @click="submitAccount">保存</el-button>
</template>
</el-dialog>
<!-- 新建 / 编辑 -->
<el-dialog v-model="formVisible" :title="editingId ? '编辑班级' : '新建班级'" width="420px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="班级名称" prop="name">
<el-input v-model="form.name" placeholder="例如 高一(1)班" />
</el-form-item>
<el-form-item label="年级" prop="grade">
<el-input v-model="form.grade" placeholder="例如 高一" />
</el-form-item>
<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="MAX_TERM_WEEK" :step="1" />
<span class="text-muted ml-8">
本学期一共多少教学周(默认 20,最多 {{ MAX_TERM_WEEK }} 周);课表周次选择与默认结束周都按它来
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
</template>
</el-dialog>
<!-- 班级详情 -->
<el-drawer v-model="detailVisible" size="60%" :title="detail ? `${detail.name} · 详情` : '班级详情'">
<div v-loading="detailLoading">
<el-descriptions v-if="detail" :column="detailColumns" border size="small">
<el-descriptions-item label="年级">{{ detail.grade }}</el-descriptions-item>
<el-descriptions-item label="班主任">{{ detail.teacher?.name ?? '-' }}</el-descriptions-item>
<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">
<el-button type="primary" size="small" :icon="'Plus'" @click="studentFormVisible = true">
添加学生
</el-button>
<span class="text-muted">共 {{ detail?.students.length ?? 0 }} 人</span>
</div>
<el-table :data="detail?.students ?? []" size="small" class="mt-12" empty-text="暂无学生">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="加入时间" width="180">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeStudent(row)">移出</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="课程" name="courses">
<el-table :data="detail?.courses ?? []" size="small" class="mt-12" empty-text="暂无课程">
<el-table-column prop="name" label="课程" min-width="140" />
<el-table-column label="任课教师" width="140">
<template #default="{ row }">{{ row.teacher?.name ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button link type="danger" @click="removeCourse(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-input
v-model="courseForm.name"
placeholder="输入课程名称后回车快速添加"
class="mt-12"
@keyup.enter="addCourse"
/>
</el-tab-pane>
<el-tab-pane label="协作教师" name="teachers">
<el-table :data="detail?.teachers ?? []" size="small" class="mt-12" empty-text="暂无协作教师">
<el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column label="操作" width="110">
<template #default="{ row }">
<el-button link type="danger" @click="removeTeacher(row.id)">取消分配</el-button>
</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-select v-model="selectedTeacherId" placeholder="选择教师" style="width: 220px" filterable>
<el-option
v-for="item in teachers"
:key="item.id"
:label="`${item.name}(${item.username})`"
:value="item.id"
/>
</el-select>
<el-button type="primary" @click="assignTeacher">分配</el-button>
</div>
</el-tab-pane>
</el-tabs>
</div>
</el-drawer>
<!-- 添加学生 -->
<el-dialog v-model="studentFormVisible" title="添加学生到本班" width="440px">
<el-form :model="studentForm" :rules="studentRules" label-width="90px">
<el-form-item label="用户名" prop="username">
<el-input v-model="studentForm.username" placeholder="学号或登录名,已存在则直接转入本班" />
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input v-model="studentForm.name" />
</el-form-item>
<el-form-item label="初始密码">
<el-input v-model="studentForm.password" placeholder="留空则使用默认密码" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="studentFormVisible = false">取消</el-button>
<el-button type="primary" @click="addStudent">确认添加</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.account-tag {
margin-left: 6px;
}
.mb-12 {
margin-bottom: 12px;
}
.ml-8 {
margin-left: 8px;
}
</style>