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 设初始密码。
This commit is contained in:
1 parent
7ea0e0f7c9
commit
d27f9a4b9e
11 files changed
+121
-104
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomInt } from 'node:crypto';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { ApiError } from './http.js';
|
import { ApiError } from './http.js';
|
||||||
import { hashPassword, verifyPassword } from './password.js';
|
import { hashPassword, verifyPassword } from './password.js';
|
||||||
@@ -30,7 +31,9 @@ export async function generateClassCode(prefix = ''): Promise<string> {
|
|||||||
const length = Math.max(4, 6 - prefix.length);
|
const length = Math.max(4, 6 - prefix.length);
|
||||||
let body = '';
|
let body = '';
|
||||||
for (let index = 0; index < length; index += 1) {
|
for (let index = 0; index < length; index += 1) {
|
||||||
body += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
|
// 用 CSPRNG 而不是 Math.random():班级码会作为学生端登录的账号名打印/张贴,
|
||||||
|
// 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差)
|
||||||
|
body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)];
|
||||||
}
|
}
|
||||||
const code = `${prefix}${body}`.toUpperCase();
|
const code = `${prefix}${body}`.toUpperCase();
|
||||||
const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } });
|
||||||
|
|||||||
@@ -19,14 +19,16 @@ export async function login(input: LoginInput): Promise<LoginResponse> {
|
|||||||
const user = await prisma.user.findUnique({ where: { username: input.username } });
|
const user = await prisma.user.findUnique({ where: { username: input.username } });
|
||||||
if (!user) throw ApiError.unauthorized('用户名或密码错误');
|
if (!user) throw ApiError.unauthorized('用户名或密码错误');
|
||||||
|
|
||||||
const passwordMatched = await verifyPassword(input.password, user.passwordHash);
|
// 角色判定必须放在密码校验**之前**:否则"密码错"与"密码对但学生账号已停用"会给出
|
||||||
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
|
// 不同的响应(401 / 403),攻击者据此就能把学生账号的密码当成可离线验证的预言机。
|
||||||
|
// 文案与状态码保持不变(客户端按 403 提示"请用班级码登录")。
|
||||||
// 个人学生账号已停用登录入口(需求:以班级为单位,删掉学生用自己用户名登录)
|
|
||||||
if (user.role === 'STUDENT') {
|
if (user.role === 'STUDENT') {
|
||||||
throw ApiError.forbidden('学生请使用「班级码 + 班级密码」登录(个人学生账号已停用)');
|
throw ApiError.forbidden('学生请使用「班级码 + 班级密码」登录(个人学生账号已停用)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const passwordMatched = await verifyPassword(input.password, user.passwordHash);
|
||||||
|
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
|
||||||
|
|
||||||
const payload: TokenPayload = {
|
const payload: TokenPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { CLASSISLAND_NOTIFICATION_CHANNELS } from '@classhelper/shared';
|
import { CLASSISLAND_NOTIFICATION_CHANNELS, MAX_TERM_WEEK } from '@classhelper/shared';
|
||||||
import { keywordSchema } from '../../lib/schemas.js';
|
import { keywordSchema } from '../../lib/schemas.js';
|
||||||
|
|
||||||
export const createClassSchema = z.object({
|
export const createClassSchema = z.object({
|
||||||
@@ -13,13 +13,19 @@ export const createClassSchema = z.object({
|
|||||||
.trim()
|
.trim()
|
||||||
.regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字')
|
.regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字')
|
||||||
.optional(),
|
.optional(),
|
||||||
|
/**
|
||||||
|
* 本学期教学周数。上限与课表周次口径统一取 MAX_TERM_WEEK(30):
|
||||||
|
* 周次选择器与 weekNumberSchema 都只到 30,允许更大的值只会让用户设出一个
|
||||||
|
* 系统兑现不了的学期长度(第 31 周之后永远为空)。
|
||||||
|
*/
|
||||||
|
termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateClassSchema = z.object({
|
export const updateClassSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(64).optional(),
|
name: z.string().trim().min(1).max(64).optional(),
|
||||||
grade: z.string().trim().min(1).max(32).optional(),
|
grade: z.string().trim().min(1).max(32).optional(),
|
||||||
/** 本学期教学周数(1~40,班主任可调) */
|
/** 本学期教学周数(1~30,管理员可调) */
|
||||||
termWeeks: z.coerce.number().int().min(1).max(40).optional(),
|
termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||||
@@ -43,7 +49,6 @@ export const listClassesQuerySchema = z.object({
|
|||||||
export const addStudentSchema = z.object({
|
export const addStudentSchema = z.object({
|
||||||
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
||||||
name: z.string().trim().min(1, '请输入姓名').max(32),
|
name: z.string().trim().min(1, '请输入姓名').max(32),
|
||||||
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const assignTeacherSchema = z.object({
|
export const assignTeacherSchema = z.object({
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
toStudentDto,
|
toStudentDto,
|
||||||
toTeacherBrief,
|
toTeacherBrief,
|
||||||
} from '../../lib/mappers.js';
|
} from '../../lib/mappers.js';
|
||||||
import { hashPassword } from '../../lib/password.js';
|
|
||||||
import { emitToClass } from '../../realtime/bus.js';
|
import { emitToClass } from '../../realtime/bus.js';
|
||||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||||
import type {
|
import type {
|
||||||
@@ -152,6 +151,8 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
|
|||||||
teacherId,
|
teacherId,
|
||||||
code: account.code,
|
code: account.code,
|
||||||
passwordHash: account.passwordHash,
|
passwordHash: account.passwordHash,
|
||||||
|
// 新建时就能指定学期周数(之前只有编辑接口支持,Web 端表单里填了也会被丢弃)
|
||||||
|
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
teacher: { select: { id: true, name: true, username: true } },
|
teacher: { select: { id: true, name: true, username: true } },
|
||||||
@@ -228,7 +229,7 @@ export async function updateClass(
|
|||||||
data: {
|
data: {
|
||||||
...(input.name ? { name: input.name } : {}),
|
...(input.name ? { name: input.name } : {}),
|
||||||
...(input.grade ? { grade: input.grade } : {}),
|
...(input.grade ? { grade: input.grade } : {}),
|
||||||
// 教学周数(班主任可调):课表周次选择与默认 weekEnd 都用它
|
// 教学周数(仅管理员可调,见 assertCanManageClasses):课表周次选择与默认 weekEnd 都用它
|
||||||
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -260,9 +261,9 @@ export async function listClassStudents(user: TokenPayload, classId: string): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加学生到班级。
|
* 添加学生到班级(名单实体)。
|
||||||
* 已存在的学生账号直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。
|
* 已存在的学生直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。
|
||||||
* 新账号使用 DEFAULT_STUDENT_PASSWORD 作为初始密码。
|
* 学生没有账号属性:不设密码、不能登录,学生端统一用班级码 + 班级密码。
|
||||||
*/
|
*/
|
||||||
export async function addStudent(
|
export async function addStudent(
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
@@ -292,7 +293,8 @@ export async function addStudent(
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
role: 'STUDENT',
|
role: 'STUDENT',
|
||||||
classId,
|
classId,
|
||||||
passwordHash: await hashPassword(input.password ?? env.defaultStudentPassword),
|
// 空串占位:User.passwordHash 必填,但学生的 403 登录判定在密码校验之前,永远用不到
|
||||||
|
passwordHash: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
studentId = created.id;
|
studentId = created.id;
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ import { defineModule } from '../module.types.js';
|
|||||||
import {
|
import {
|
||||||
createStudentSchema,
|
createStudentSchema,
|
||||||
listStudentsQuerySchema,
|
listStudentsQuerySchema,
|
||||||
resetPasswordSchema,
|
|
||||||
updateStudentSchema,
|
updateStudentSchema,
|
||||||
type CreateStudentInput,
|
type CreateStudentInput,
|
||||||
type ResetPasswordInput,
|
|
||||||
type UpdateStudentInput,
|
type UpdateStudentInput,
|
||||||
} from './students.schemas.js';
|
} from './students.schemas.js';
|
||||||
import * as studentService from './students.service.js';
|
import * as studentService from './students.service.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate());
|
router.use(authenticate());
|
||||||
|
|
||||||
/** GET /api/students?classId=&keyword= - 学生名单(教师/管理员) */
|
/**
|
||||||
|
* GET /api/students?classId=&keyword= - 学生名单
|
||||||
|
*
|
||||||
|
* 注意是**仅管理员**(不是教师/管理员):学生名单自带用户名,而学生初始密码是统一的默认值,
|
||||||
|
* 因此名单属于管理员专属数据;教师查看本班学生请用 `GET /api/classes/:id/students`
|
||||||
|
* (那条按班级权限校验,班级设备也能读)。
|
||||||
|
*/
|
||||||
router.get('/', requireRole('ADMIN'), validate({ query: listStudentsQuerySchema }), async (req, res) => {
|
router.get('/', requireRole('ADMIN'), validate({ query: listStudentsQuerySchema }), async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const query = validatedQuery<{ classId?: string; keyword?: string }>(req);
|
const query = validatedQuery<{ classId?: string; keyword?: string }>(req);
|
||||||
@@ -51,20 +54,7 @@ router.patch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** POST /api/students/:id/reset-password - 重置密码 */
|
/** DELETE /api/students - 删除学生名单记录 */
|
||||||
router.post(
|
|
||||||
'/:id/reset-password',
|
|
||||||
requireRole('ADMIN'),
|
|
||||||
validate({ params: idParamSchema, body: resetPasswordSchema }),
|
|
||||||
async (req, res) => {
|
|
||||||
const user = getAuthUser(req);
|
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
|
||||||
await studentService.resetPassword(user, id, req.body as ResetPasswordInput);
|
|
||||||
sendOk(res, { id }, '密码已重置');
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
/** DELETE /api/students/:id - 删除学生账号 */
|
|
||||||
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ export const listStudentsQuerySchema = z.object({
|
|||||||
keyword: keywordSchema,
|
keyword: keywordSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 学生只有"名单"没有"账号":不设密码、不能登录(2026-10-01 起学生个人账号已全部清理,
|
||||||
|
// 学生端统一用班级码 + 班级密码),因此这里没有也不该有任何密码字段。
|
||||||
export const createStudentSchema = z.object({
|
export const createStudentSchema = z.object({
|
||||||
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
||||||
name: z.string().trim().min(1, '请输入姓名').max(32),
|
name: z.string().trim().min(1, '请输入姓名').max(32),
|
||||||
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
|
|
||||||
classId: z.string().min(1).nullish(),
|
classId: z.string().min(1).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,10 +20,5 @@ export const updateStudentSchema = z.object({
|
|||||||
classId: z.string().min(1).nullish(),
|
classId: z.string().min(1).nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetPasswordSchema = z.object({
|
|
||||||
newPassword: z.string().min(6, '新密码至少 6 位').max(128).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CreateStudentInput = z.infer<typeof createStudentSchema>;
|
export type CreateStudentInput = z.infer<typeof createStudentSchema>;
|
||||||
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>;
|
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>;
|
||||||
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import type { StudentDto } from '@classhelper/shared';
|
import type { StudentDto } from '@classhelper/shared';
|
||||||
import { env } from '../../config/env.js';
|
|
||||||
import { assertCanManageRoster, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js';
|
import { assertCanManageRoster, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { toStudentDto } from '../../lib/mappers.js';
|
import { toStudentDto } from '../../lib/mappers.js';
|
||||||
import { hashPassword } from '../../lib/password.js';
|
import type { CreateStudentInput, UpdateStudentInput } from './students.schemas.js';
|
||||||
import type { CreateStudentInput, ResetPasswordInput, UpdateStudentInput } from './students.schemas.js';
|
|
||||||
|
|
||||||
export interface ListStudentOptions {
|
export interface ListStudentOptions {
|
||||||
classId?: string;
|
classId?: string;
|
||||||
@@ -35,18 +33,18 @@ export async function listStudents(user: TokenPayload, options: ListStudentOptio
|
|||||||
return students.map(toStudentDto);
|
return students.map(toStudentDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 新建学生账号(可选直接分班) */
|
/** 新建学生(名单实体,可选直接分班)。学生不设密码:登录一律走班级账号,见 createStudentSchema 的说明 */
|
||||||
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
|
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
|
||||||
if (input.classId) assertCanManageRoster(user);
|
if (input.classId) assertCanManageRoster(user);
|
||||||
|
|
||||||
const passwordHash = await hashPassword(input.password ?? env.defaultStudentPassword);
|
|
||||||
const created = await prisma.user.create({
|
const created = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username: input.username,
|
username: input.username,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
role: 'STUDENT',
|
role: 'STUDENT',
|
||||||
classId: input.classId ?? null,
|
classId: input.classId ?? null,
|
||||||
passwordHash,
|
// User.passwordHash 是必填列,学生用空串占位(登录的 STUDENT 403 判定在密码校验之前,永远不会用到它)
|
||||||
|
passwordHash: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,24 +124,3 @@ export async function deleteStudent(user: TokenPayload, studentId: string): Prom
|
|||||||
|
|
||||||
await prisma.user.delete({ where: { id: studentId } });
|
await prisma.user.delete({ where: { id: studentId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置学生密码(未指定时使用默认初始密码) */
|
|
||||||
export async function resetPassword(
|
|
||||||
user: TokenPayload,
|
|
||||||
studentId: string,
|
|
||||||
input: ResetPasswordInput,
|
|
||||||
): Promise<void> {
|
|
||||||
const student = await prisma.user.findUnique({ where: { id: studentId } });
|
|
||||||
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
|
|
||||||
|
|
||||||
if (student.classId) {
|
|
||||||
assertCanManageRoster(user);
|
|
||||||
} else if (!isAdmin(user)) {
|
|
||||||
throw ApiError.forbidden('该学生尚未分班,仅管理员可以重置密码');
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: studentId },
|
|
||||||
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultStudentPassword) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -81,23 +81,47 @@ export async function resetTeacherPassword(
|
|||||||
/**
|
/**
|
||||||
* 删除教师账号。
|
* 删除教师账号。
|
||||||
*
|
*
|
||||||
* 安全护栏:教师是数据的创建者,`Class.teacherId` / `Course.teacherId` / 作业通知的 createdBy
|
* 安全护栏:教师是**班级共享内容**的创建者,而下面这些外键都是 `onDelete: Cascade`,
|
||||||
* 都是级联删除 —— 直接删班主任会连带删掉整个班级的数据。因此只有"没有任何班级职责、
|
* 删除账号会连带删掉整份内容(不是"作者名变空",是内容真的没了):
|
||||||
* 也不持有课程"的账号才允许删除(实践中就是刚录入错的那条),否则返回 409 让管理员先转移。
|
*
|
||||||
|
* | 关系 | 后果 |
|
||||||
|
* | ---- | ---- |
|
||||||
|
* | `Class.teacherId` | 整个班级(含课表/作业/通知/成绩)被删 |
|
||||||
|
* | `Course.teacherId` | 该教师名下课程被删 |
|
||||||
|
* | `ClassTeacher.teacherId` | 仅协作关系(无害,但一并列出以免漏项) |
|
||||||
|
* | `Homework.createdBy` | **该教师发布过的全部作业被删** |
|
||||||
|
* | `Notification.createdBy` | **该教师发布过的全部通知被删**(含"叫人"消息) |
|
||||||
|
*
|
||||||
|
* 前三条历史上被检查过,后两条(Homework / Notification)曾漏检 —— 于是"班主任转给别人 →
|
||||||
|
* 不在任何协作班级 → 名下无课程"的账号会被放行,删除时静默清掉他在各班发过的作业与通知。
|
||||||
|
* **改 schema 时请回到本函数核对一遍**:任何新增的"指向 User 且 Cascade"的关系都要加到这里
|
||||||
|
* (`HomeworkStatus` / `NotificationRead` / `Grade` / `Enrollment` 的 `userId` 属于个人数据,
|
||||||
|
* 随账号删除是正确行为,不算漏项)。
|
||||||
|
*
|
||||||
|
* 因此只有"没有留下任何班级共享内容"的账号才允许删除(实践中就是刚录入错的那条),
|
||||||
|
* 否则返回 409 让管理员先转移/清理。
|
||||||
*/
|
*/
|
||||||
export async function deleteTeacher(userId: string): Promise<void> {
|
export async function deleteTeacher(userId: string): Promise<void> {
|
||||||
const teacher = await findTeacherOrThrow(userId);
|
const teacher = await findTeacherOrThrow(userId);
|
||||||
|
|
||||||
const [ownedClasses, assignedClasses, courses] = await Promise.all([
|
const [ownedClasses, assignedClasses, courses, homeworks, notifications] = await Promise.all([
|
||||||
prisma.class.count({ where: { teacherId: teacher.id } }),
|
prisma.class.count({ where: { teacherId: teacher.id } }),
|
||||||
prisma.classTeacher.count({ where: { teacherId: teacher.id } }),
|
prisma.classTeacher.count({ where: { teacherId: teacher.id } }),
|
||||||
prisma.course.count({ where: { teacherId: teacher.id } }),
|
prisma.course.count({ where: { teacherId: teacher.id } }),
|
||||||
|
prisma.homework.count({ where: { createdBy: teacher.id } }),
|
||||||
|
prisma.notification.count({ where: { createdBy: teacher.id } }),
|
||||||
]);
|
]);
|
||||||
if (ownedClasses > 0) {
|
if (ownedClasses > 0) {
|
||||||
throw ApiError.conflict(
|
throw ApiError.conflict(
|
||||||
`「${teacher.name}」还是 ${ownedClasses} 个班级的班主任,请先在班级管理里更换班主任再删除`,
|
`「${teacher.name}」还是 ${ownedClasses} 个班级的班主任,请先在班级管理里更换班主任再删除`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (homeworks > 0 || notifications > 0) {
|
||||||
|
throw ApiError.conflict(
|
||||||
|
`「${teacher.name}」发布过 ${homeworks} 条作业 / ${notifications} 条通知,` +
|
||||||
|
'删除账号会连带删除这些内容,请先删除内容或改为保留账号',
|
||||||
|
);
|
||||||
|
}
|
||||||
if (assignedClasses > 0 || courses > 0) {
|
if (assignedClasses > 0 || courses > 0) {
|
||||||
throw ApiError.conflict(
|
throw ApiError.conflict(
|
||||||
`「${teacher.name}」还有 ${assignedClasses} 条班级分配 / ${courses} 门课程,请先解除后再删除`,
|
`「${teacher.name}」还有 ${assignedClasses} 条班级分配 / ${courses} 门课程,请先解除后再删除`,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
|||||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
|
MAX_TERM_WEEK,
|
||||||
formatDate,
|
formatDate,
|
||||||
type ClassDetailDto,
|
type ClassDetailDto,
|
||||||
type ClassDto,
|
type ClassDto,
|
||||||
@@ -76,6 +77,11 @@ function openEdit(row: ClassDto): void {
|
|||||||
form.grade = row.grade;
|
form.grade = row.grade;
|
||||||
form.code = '';
|
form.code = '';
|
||||||
form.termWeeks = row.termWeeks ?? 20;
|
form.termWeeks = row.termWeeks ?? 20;
|
||||||
|
// 必须回填当前班主任:编辑弹窗里的下拉与"新建"共用同一个 form.headTeacherId,
|
||||||
|
// 而 submitForm 会在"值不同"时直接调 assignHeadTeacher。不回填的话,只要之前打开过一次
|
||||||
|
// 「新建班级」(openCreate 会把它设成当前管理员自己),之后保存任何一次编辑都会把
|
||||||
|
// 该班班主任静默改成管理员本人 —— 而班主任决定谁能管这个班的课表与成绩。
|
||||||
|
form.headTeacherId = row.teacher?.id ?? '';
|
||||||
void loadStaffOptions();
|
void loadStaffOptions();
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -102,6 +108,9 @@ async function submitForm(): Promise<void> {
|
|||||||
await classApi.create({
|
await classApi.create({
|
||||||
name: form.name,
|
name: form.name,
|
||||||
grade: form.grade,
|
grade: form.grade,
|
||||||
|
// 学期周数:新建时也要提交。原先只提交名称/年级/班级码,表单里填的周数被直接丢弃,
|
||||||
|
// 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。
|
||||||
|
termWeeks: form.termWeeks,
|
||||||
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
|
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
|
||||||
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
|
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
|
||||||
});
|
});
|
||||||
@@ -452,9 +461,9 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="学期周数">
|
<el-form-item label="学期周数">
|
||||||
<el-input-number v-model="form.termWeeks" :min="1" :max="40" :step="1" />
|
<el-input-number v-model="form.termWeeks" :min="1" :max="MAX_TERM_WEEK" :step="1" />
|
||||||
<span class="text-muted ml-8">
|
<span class="text-muted ml-8">
|
||||||
本学期一共多少教学周(默认 20);课表周次选择与默认结束周都按它来
|
本学期一共多少教学周(默认 20,最多 {{ MAX_TERM_WEEK }} 周);课表周次选择与默认结束周都按它来
|
||||||
</span>
|
</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ function openImport(): void {
|
|||||||
const formVisible = ref(false);
|
const formVisible = ref(false);
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const editingId = ref<string | null>(null);
|
const editingId = ref<string | null>(null);
|
||||||
/** 表单不再暴露「用户名」:新建时由前端自动生成学号式登录名(见 autoCreateStudent) */
|
/** 表单不再暴露「用户名」:新建时由前端自动生成学号式名单标识(见 autoCreateStudent) */
|
||||||
const form = reactive({ name: '', password: '', classId: '' });
|
const form = reactive({ name: '', classId: '' });
|
||||||
const rules: FormRules = {
|
const rules: FormRules = {
|
||||||
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||||
};
|
};
|
||||||
@@ -56,7 +56,6 @@ const rules: FormRules = {
|
|||||||
function openCreate(): void {
|
function openCreate(): void {
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
form.name = '';
|
form.name = '';
|
||||||
form.password = '';
|
|
||||||
form.classId = filter.classId;
|
form.classId = filter.classId;
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
}
|
}
|
||||||
@@ -64,18 +63,17 @@ function openCreate(): void {
|
|||||||
function openEdit(row: StudentDto): void {
|
function openEdit(row: StudentDto): void {
|
||||||
editingId.value = row.id;
|
editingId.value = row.id;
|
||||||
form.name = row.name;
|
form.name = row.name;
|
||||||
form.password = '';
|
|
||||||
form.classId = row.classId ?? '';
|
form.classId = row.classId ?? '';
|
||||||
formVisible.value = true;
|
formVisible.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动生成学生登录名(学号式:student01、student02 …,与种子数据 student01~student15 同风格)。
|
* 自动生成学生名单标识(学号式:student01、student02 …,与种子数据同风格)。
|
||||||
*
|
*
|
||||||
* 后端 `POST /api/students` 的 username 是必填且全局唯一的(教师/管理员账号同样占位),
|
* 后端 `POST /api/students` 的 username 必填且全局唯一(教师/管理员账号同样占位),
|
||||||
* 界面已按需求隐藏「用户名」输入,因此这里用「全量学生账号 + 递增序号」生成,
|
* 界面已按需求隐藏「用户名」输入,因此这里用「全量学生 + 递增序号」生成,
|
||||||
* 并在服务端返回 409(唯一约束冲突,例如并发创建或撞上教师账号)时换下一个序号重试;
|
* 并在服务端返回 409(唯一约束冲突)时换下一个序号重试;其它错误立即抛出,避免刷屏。
|
||||||
* 其它错误(姓名/密码不合法等)立即抛出,不再重试,避免刷屏。
|
* 学生没有密码、不能登录,username 仅作名单标识。
|
||||||
*/
|
*/
|
||||||
function studentUsernameAt(index: number): string {
|
function studentUsernameAt(index: number): string {
|
||||||
return index < 100 ? `student${String(index).padStart(2, '0')}` : `student${index}`;
|
return index < 100 ? `student${String(index).padStart(2, '0')}` : `student${index}`;
|
||||||
@@ -84,7 +82,7 @@ function studentUsernameAt(index: number): string {
|
|||||||
async function autoCreateStudent(): Promise<void> {
|
async function autoCreateStudent(): Promise<void> {
|
||||||
const existing = new Set((await studentApi.list()).map((item) => item.username));
|
const existing = new Set((await studentApi.list()).map((item) => item.username));
|
||||||
let index = existing.size + 1;
|
let index = existing.size + 1;
|
||||||
let lastError: unknown = new Error('自动生成学生账号失败');
|
let lastError: unknown = new Error('自动生成名单标识失败');
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
let username = studentUsernameAt(index);
|
let username = studentUsernameAt(index);
|
||||||
@@ -97,7 +95,6 @@ async function autoCreateStudent(): Promise<void> {
|
|||||||
username,
|
username,
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
classId: form.classId || null,
|
classId: form.classId || null,
|
||||||
...(form.password ? { password: form.password } : {}),
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -125,7 +122,7 @@ async function submitForm(): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await autoCreateStudent();
|
await autoCreateStudent();
|
||||||
ElMessage.success('学生账号创建成功(登录名已自动生成)');
|
ElMessage.success('学生已创建(名单标识已自动生成)');
|
||||||
} catch {
|
} catch {
|
||||||
// 接口层已弹出服务端原因,这里补充可执行的兜底建议
|
// 接口层已弹出服务端原因,这里补充可执行的兜底建议
|
||||||
ElMessage.error('创建失败:可稍后重试,或用「导入名单」在表格里显式指定用户名');
|
ElMessage.error('创建失败:可稍后重试,或用「导入名单」在表格里显式指定用户名');
|
||||||
@@ -146,12 +143,6 @@ async function removeStudent(row: StudentDto): Promise<void> {
|
|||||||
await loadStudents();
|
await loadStudents();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetPassword(row: StudentDto): Promise<void> {
|
|
||||||
await ElMessageBox.confirm(`将「${row.name}」的密码重置为默认密码?`, '重置密码', { type: 'warning' });
|
|
||||||
await studentApi.resetPassword(row.id);
|
|
||||||
ElMessage.success('密码已重置为默认密码');
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadClasses(), loadStudents()]);
|
await Promise.all([loadClasses(), loadStudents()]);
|
||||||
});
|
});
|
||||||
@@ -209,7 +200,7 @@ async function submitCall(): Promise<void> {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">学生管理</h2>
|
<h2 class="page-title">学生管理</h2>
|
||||||
<p class="page-subtitle">学生账号、分班与密码重置(数据范围按班级权限收敛)</p>
|
<p class="page-subtitle">学生名单、分班与叫人(学生端统一用班级账号登录,学生本身没有密码)</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-select
|
<el-select
|
||||||
@@ -257,7 +248,6 @@ async function submitCall(): Promise<void> {
|
|||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="success" @click="openCall(row)">叫人</el-button>
|
<el-button link type="success" @click="openCall(row)">叫人</el-button>
|
||||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
<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="removeStudent(row)">删除</el-button>
|
<el-button link type="danger" @click="removeStudent(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -329,9 +319,6 @@ async function submitCall(): Promise<void> {
|
|||||||
<el-form-item label="姓名" prop="name">
|
<el-form-item label="姓名" prop="name">
|
||||||
<el-input v-model="form.name" />
|
<el-input v-model="form.name" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="!editingId" label="初始密码">
|
|
||||||
<el-input v-model="form.password" placeholder="留空使用默认密码" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="班级">
|
<el-form-item label="班级">
|
||||||
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
|
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
|
||||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import TableImportDialog from '@/components/TableImportDialog.vue';
|
|||||||
/**
|
/**
|
||||||
* 教师管理(**仅管理员可见/可用**,与后端 `requireRole('ADMIN')` 对应)。
|
* 教师管理(**仅管理员可见/可用**,与后端 `requireRole('ADMIN')` 对应)。
|
||||||
*
|
*
|
||||||
* 与「学生管理」保持同一套交互:单个录入 / 表格导入名单 / 编辑 / 重置密码 / 删除。
|
* 与「学生管理」保持同一套交互:单个录入 / 表格导入名单 / 编辑 / 修改密码 / 删除。
|
||||||
* 学生端主体是班级账号,教师账号则决定"谁是班主任、谁能录课表与成绩",
|
* 学生端主体是班级账号,教师账号则决定"谁是班主任、谁能录课表与成绩",
|
||||||
* 属于系统级配置,所以录入权限只给管理员。
|
* 属于系统级配置,所以录入权限只给管理员。
|
||||||
*/
|
*/
|
||||||
@@ -92,12 +92,33 @@ async function submitForm(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resetPassword(row: UserDto): Promise<void> {
|
/**
|
||||||
await ElMessageBox.confirm(`将「${row.name}」的密码重置为默认初始密码?`, '重置密码', {
|
* 修改教师密码(管理员给教师改密)。
|
||||||
type: 'warning',
|
*
|
||||||
});
|
* 后端 `POST /teachers/:id/reset-password` 本来就接受可选的 `newPassword`(留空才用默认初始密码),
|
||||||
await teacherApi.resetPassword(row.id);
|
* 但界面原先只发空请求、按钮还叫「重置密码」 —— 于是管理员**没有任何办法**把某个教师的密码改成
|
||||||
ElMessage.success('密码已重置为默认初始密码');
|
* 指定值,只能重置成默认的 `DEFAULT_TEACHER_PASSWORD`(用户反馈:"教师账号没有办法修改密码",
|
||||||
|
* 并且指着教师列表问"教师修改密码呢")。
|
||||||
|
* 因此这里把入口正名为「修改密码」:填了就设成填的值,留空才是"重置为默认初始密码"。
|
||||||
|
*/
|
||||||
|
async function changePassword(row: UserDto): Promise<void> {
|
||||||
|
const result = await ElMessageBox.prompt(
|
||||||
|
'输入新密码即可直接为该账号设置密码;留空则重置为默认初始密码。',
|
||||||
|
`修改「${row.name}」的密码`,
|
||||||
|
{
|
||||||
|
confirmButtonText: '确认修改',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'password',
|
||||||
|
inputPlaceholder: '新密码(至少 6 位,留空 = 默认初始密码)',
|
||||||
|
// 默认校验器不允许空输入,这里必须放行"留空"这一条路
|
||||||
|
inputValidator: (value: string) => !value || value.length >= 6 || '新密码至少 6 位',
|
||||||
|
},
|
||||||
|
).catch(() => null);
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const newPassword = (result.value ?? '').trim();
|
||||||
|
await teacherApi.resetPassword(row.id, newPassword || undefined);
|
||||||
|
ElMessage.success(newPassword ? '教师密码已修改' : '密码已重置为默认初始密码');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeTeacher(row: UserDto): Promise<void> {
|
async function removeTeacher(row: UserDto): Promise<void> {
|
||||||
@@ -129,7 +150,7 @@ onMounted(loadTeachers);
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">教师管理</h2>
|
<h2 class="page-title">教师管理</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
录入教师账号(单个录入或导入名单)、修改姓名与角色、重置密码(仅管理员可见)
|
录入教师账号(单个录入或导入名单)、修改姓名与角色、修改密码(仅管理员可见)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
@@ -139,6 +160,7 @@ onMounted(loadTeachers);
|
|||||||
clearable
|
clearable
|
||||||
style="width: 180px"
|
style="width: 180px"
|
||||||
@keyup.enter="loadTeachers"
|
@keyup.enter="loadTeachers"
|
||||||
|
@clear="loadTeachers"
|
||||||
/>
|
/>
|
||||||
<el-button :icon="'Search'" @click="loadTeachers">查询</el-button>
|
<el-button :icon="'Search'" @click="loadTeachers">查询</el-button>
|
||||||
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建教师</el-button>
|
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建教师</el-button>
|
||||||
@@ -163,7 +185,7 @@ onMounted(loadTeachers);
|
|||||||
<el-table-column label="操作" width="220" fixed="right">
|
<el-table-column label="操作" width="220" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||||
<el-button link type="warning" @click="resetPassword(row)">重置密码</el-button>
|
<el-button link type="warning" @click="changePassword(row)">修改密码</el-button>
|
||||||
<el-button link type="danger" @click="removeTeacher(row)">删除</el-button>
|
<el-button link type="danger" @click="removeTeacher(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
Reference in new issue
Block a user