feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)

学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。

学生不是账号
- 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段
- 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支
- 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher
- 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失

任课关系唯一来源 = Course(班级 + 科目 + 教师)
- 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号
- 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员
- 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关)

成绩与名单
- 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级
- 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制
- 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属
- 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业)
- 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」

ClassHelper 班级端在线状态
- Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳
- lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开

学期周次
- 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置
- 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常)
- 改周次后随课表下发给教室的 ClassHelper 班级端

课表方向反转
- 主方向改为「服务端 → 教室」自动下发;教室不再自动回传
- 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报

改名
- 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、
  安装目录、systemd 描述、插件清单与设置页、官网、文档站、README

BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe),
安装目录随之改变,不支持覆盖安装,需先卸载旧版。
服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢),
升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-06 19:41:18 +08:00
1 parent 17f55864bf
commit 9d5ac7adf9
172 files changed
+8761 -1896

No files matched your search

@@ -1,7 +1,7 @@
import { z } from 'zod';
/** 导入类型:成绩 / 学生名单 / 教师名单(仅管理员) */
export const IMPORT_KINDS = ['grades', 'students', 'teachers'] as const;
/** 导入类型:成绩 / 学生名单 / 教师名单 / 班级任课老师 */
export const IMPORT_KINDS = ['grades', 'students', 'teachers', 'classTeachers'] as const;
/** 表格导入:上传的文件(base64)+ 类型 */
export const tableFileSchema = z.object({
@@ -17,12 +17,17 @@ export const tableFileSchema = z.object({
/** 表格提交:在预览基础上带字段映射、目标班级与写入模式 */
export const tableCommitSchema = tableFileSchema.extend({
/** 目标班级:成绩/学生名单必填;教师名单与班级无关(可省略) */
/** 目标班级:成绩/学生名单必填;教师名单与本班任课老师也可用表格里的班级列 */
classId: z.string().min(1, '请选择班级').optional(),
/** 列映射:规范字段 → 文件列名(或列索引字符串) */
mapping: z.record(z.string(), z.string().min(1)),
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按用户名) */
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按学号) */
mode: z.enum(['append', 'upsert']).default('upsert'),
/**
* **班级任课老师导入**专用:是否用「工号」去匹配已有教师账号。
* 打开时优先按工号匹配(匹配不到就按工号新建账号);关闭时只按姓名匹配、匹配不到即报错。
*/
useTeacherNo: z.boolean().optional(),
});
/** ClassIsland 课表时间导入:先校验/预览,再提交 */
@@ -1,11 +1,13 @@
import * as XLSX from 'xlsx';
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
import { prisma } from '../../lib/db.js';
import { env } from '../../config/env.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { hashPassword } from '../../lib/password.js';
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
import { resolveUserClassRole } from '../../lib/access.js';
import { canImport } from '@classhelper/shared';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/**
@@ -16,23 +18,32 @@ import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
*
* 异常处理:空文件、超大文件、格式错误、缺列、非法数值、重复数据、未知学生等
* 都会给出明确原因与行号,绝不静默失败。
*
* **学生一律按「学号」匹配**(原「用户名」列已改名):
* 学生不是账号,导入只生成班级名单,不产生任何登录凭证。
*/
const MAX_IMPORT_BYTES = 8 * 1024 * 1024; // 8MB
const PREVIEW_ROWS = 20;
export type ImportKind = TableFileInput['kind'];
interface ImportField {
key: string;
label: string;
required: boolean;
synonyms: string[];
}
/** 规范字段定义(预览、模板、映射提示共用) */
export const IMPORT_FIELDS: Record<
ImportKind,
{ key: string; label: string; required: boolean; synonyms: string[] }[]
> = {
export const IMPORT_FIELDS: Record<ImportKind, ImportField[]> = {
grades: [
{
key: 'username',
label: '学生用户名',
key: 'studentNo',
label: '学生学号',
required: false,
synonyms: ['用户名', 'username', '学号', '账号'],
// 「用户名 / username」保留为兼容别名:老模板导出的文件还能直接导入,
// 但界面上、提示里一律叫「学号」。
synonyms: ['学号', 'studentNo', '用户名', 'username', '账号', '编号'],
},
{ key: 'name', label: '学生姓名', required: false, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{
@@ -43,16 +54,46 @@ export const IMPORT_FIELDS: Record<
},
{ key: 'score', label: '分数', required: true, synonyms: ['分数', 'score', '成绩', '得分'] },
{ key: 'totalScore', label: '总分', required: false, synonyms: ['总分', 'totalScore', '满分'] },
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
{ key: 'courseName', label: '科目', required: false, synonyms: ['科目', '课程', 'course', 'courseName'] },
{
key: 'level',
label: '等级',
required: false,
synonyms: ['等级', 'level', '档次', '评级'],
},
],
students: [
// 学生只有名单没有账号(不能登录、无密码),模板不再有"初始密码"列
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
// 学生只有名单没有账号(不能登录、无密码),模板里既没有密码列也不该有
{
key: 'studentNo',
label: '学号',
required: true,
synonyms: ['学号', 'studentNo', '用户名', 'username', '账号', '学籍号', '编号'],
},
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{
key: 'className',
label: '班级',
required: false,
synonyms: ['班级', 'class', 'className', '班', '所在班级'],
},
{ key: 'gender', label: '性别', required: false, synonyms: ['性别', 'gender', 'sex'] },
{
key: 'guardianPhone',
label: '家长手机号',
required: false,
synonyms: ['家长手机号', '家长电话', '手机号', '联系电话', 'phone', '电话', '家长联系方式'],
},
],
teachers: [
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '工号', '账号'] },
{
key: 'username',
label: '工号',
required: true,
synonyms: ['工号', '用户名', 'username', '账号', '教师工号'],
},
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '教师', '教师姓名'] },
{ key: 'phone', label: '手机号', required: false, synonyms: ['手机号', '电话', 'phone', '联系方式'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
{
key: 'role',
@@ -61,6 +102,39 @@ export const IMPORT_FIELDS: Record<
synonyms: ['角色', 'role', '身份', '权限', '管理员'],
},
],
classTeachers: [
{
key: 'className',
label: '班级',
required: true,
synonyms: ['班级', 'class', 'className', '班', '班级名称'],
},
{
key: 'subjectName',
label: '科目',
required: false,
// 班主任行没有科目;科任行必须有
synonyms: ['科目', '课程', '学科', 'subject', 'subjectName'],
},
{
key: 'teacherNo',
label: '教师工号',
required: true,
synonyms: ['工号', '教师工号', '用户名', 'teacherNo', 'username', '账号'],
},
{
key: 'teacherName',
label: '教师姓名',
required: false,
synonyms: ['姓名', '教师姓名', '教师', 'name', 'teacherName'],
},
{
key: 'role',
label: '角色',
required: true,
synonyms: ['角色', 'role', '身份', '职务'],
},
],
};
export interface TablePreview {
@@ -105,9 +179,7 @@ function parseTable(buffer: Buffer, fileName: string): ParsedTable {
400,
'IMPORT_FORMAT_INVALID',
`文件解析失败:${(error as Error).message}(支持 .xlsx/.xls/.csv)`,
{
fileName,
},
{ fileName },
);
}
const sheetName = workbook.SheetNames[0];
@@ -137,36 +209,37 @@ function parseTable(buffer: Buffer, fileName: string): ParsedTable {
return { columns, rows };
}
/** 各类导入的模板示例行(与 IMPORT_FIELDS 的顺序一一对应) */
const TEMPLATE_SAMPLES: Record<ImportKind, (string | number)[]> = {
grades: ['202601', '王小明', '期中考试', 92, 100, '数学', 'A'],
students: ['202601', '王小明', '2026级1班', '男', '13800000000'],
teachers: ['T1001', '王老师', '13800000000', 'teacher123', '教师'],
classTeachers: ['2026级1班', '数学', 'T1001', '王老师', '科任'],
};
const TEMPLATE_SHEET_NAMES: Record<ImportKind, string> = {
grades: '成绩',
students: '学生名单',
teachers: '教师名单',
classTeachers: '班级任课老师',
};
/** 生成模板 CSV(同时用于预览弹窗的"下载模板") */
export function buildTemplateCsv(kind: ImportKind): string {
const fields = IMPORT_FIELDS[kind];
const header = fields.map((field) => field.label).join(',');
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', '92', '100', '数学']
: kind === 'teachers'
? ['teacher3', '王老师', 'teacher123', '教师']
: ['student01', '王小明', 'student123'];
const sampleLine = fields
.map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '')
.join(',');
return `\ufeff${header}\n${sampleLine}\n`;
const sample = TEMPLATE_SAMPLES[kind];
const sampleLine = fields.map((_, index) => sample[index] ?? '').join(',');
return `${header}\n${sampleLine}\n`;
}
/** 生成模板 XLSX(Base64),让老师可以直接下载后填写 */
export function buildTemplateXlsx(kind: ImportKind): string {
const fields = IMPORT_FIELDS[kind];
const header = fields.map((field) => field.label);
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', 92, 100, '数学']
: kind === 'teachers'
? ['teacher3', '王老师', 'teacher123', '教师']
: ['student01', '王小明', 'student123'];
const sheet = XLSX.utils.aoa_to_sheet([header, sample]);
const sheet = XLSX.utils.aoa_to_sheet([header, TEMPLATE_SAMPLES[kind]]);
const workbook = XLSX.utils.book_new();
const sheetName = kind === 'grades' ? '成绩' : kind === 'teachers' ? '教师名单' : '学生名单';
XLSX.utils.book_append_sheet(workbook, sheet, sheetName);
XLSX.utils.book_append_sheet(workbook, sheet, TEMPLATE_SHEET_NAMES[kind]);
return XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
}
@@ -235,20 +308,32 @@ function cellValue(row: string[], columns: string[], mapping: Record<string, str
return (row[index] ?? '').trim();
}
/** 导入成绩:学生按用户名/姓名匹配;重复(同班级+学生+考试+课程)按 mode 处理 */
/** 性别文本归一:男/M/1 → MALE;女/F/2 → FEMALE;其余空 */
function parseGender(value: string): '' | 'MALE' | 'FEMALE' {
const text = value.trim();
if (!text) return '';
if (/^(男|male|m|1)$/i.test(text)) return 'MALE';
if (/^(女|female|f|2)$/i.test(text)) return 'FEMALE';
return '';
}
/* ------------------------------------------------------------------ 成绩 */
/** 导入成绩:学生按**学号**匹配(学号缺失时退化为按姓名匹配) */
export async function commitGrades(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
const classId = input.classId;
if (!classId) throw ApiError.badRequest('导入成绩需要先选择班级');
// 成绩导入:管理员或本班班主任(需求 6);科任 403
await assertCanManageGrades(user, classId);
await assertImportAllowed(user, classId, 'grades');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const students = await prisma.user.findMany({
where: { classId, role: 'STUDENT' },
select: { id: true, username: true, name: true },
const students = await prisma.student.findMany({
where: { classId },
select: { id: true, studentNo: true, name: true },
});
const byUsername = new Map(students.map((item) => [item.username.toLowerCase(), item]));
// 学号在库里已归一化为大写,比对前统一一次
const byStudentNo = new Map(students.map((item) => [normalizeStudentNo(item.studentNo), item]));
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({
where: { classId },
@@ -263,22 +348,23 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2; // 含表头,从 Excel 视角的行号
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const studentNo = cellValue(row, parsed.columns, input.mapping, 'studentNo');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const examName = cellValue(row, parsed.columns, input.mapping, 'examName');
const scoreText = cellValue(row, parsed.columns, input.mapping, 'score');
const totalText = cellValue(row, parsed.columns, input.mapping, 'totalScore');
const courseName = cellValue(row, parsed.columns, input.mapping, 'courseName');
const levelText = cellValue(row, parsed.columns, input.mapping, 'level');
if (!username && !name) {
errors.push({ row: rowNumber, message: '缺少学生标识(用户名与姓名至少填写一个)' });
if (!studentNo && !name) {
errors.push({ row: rowNumber, message: '缺少学生标识(学号与姓名至少填写一个)' });
continue;
}
const student =
(username ? byUsername.get(username.toLowerCase()) : undefined) ??
(studentNo ? byStudentNo.get(normalizeStudentNo(studentNo)) : undefined) ??
(name ? byName.get(name.toLowerCase()) : undefined);
if (!student) {
errors.push({ row: rowNumber, message: `找不到该班学生:${username || name}` });
errors.push({ row: rowNumber, message: `找不到该班学生:${studentNo || name}` });
continue;
}
if (!examName) {
@@ -286,7 +372,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
continue;
}
const score = Number(scoreText);
if (!Number.isFinite(score)) {
if (scoreText === '' || !Number.isFinite(score)) {
errors.push({ row: rowNumber, message: `分数「${scoreText}」不是数字` });
continue;
}
@@ -302,10 +388,12 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
const course = courseName ? courses.find((item) => item.name === courseName) : null;
if (courseName && !course)
warnings.push(`第 ${rowNumber} 行:课程「${courseName}」不存在,已按"不指定课程"导入`);
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
const levelType = levelText ? 'custom' : 'percent';
const existing = await prisma.grade.findFirst({
where: { classId: classId, userId: student.id, examName, courseId: course?.id ?? null },
where: { classId, studentId: student.id, examName, courseId: course?.id ?? null },
select: { id: true },
});
@@ -316,18 +404,20 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
}
await prisma.grade.update({
where: { id: existing.id },
data: { score, totalScore, publishedAt: new Date() },
data: { score, totalScore, levelType, level: levelText, publishedAt: new Date() },
});
updated += 1;
} else {
await prisma.grade.create({
data: {
classId: classId,
userId: student.id,
classId,
studentId: student.id,
courseId: course?.id ?? null,
examName,
score,
totalScore,
levelType,
level: levelText,
},
});
inserted += 1;
@@ -350,16 +440,34 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
return result;
}
/** 导入学生名单:按用户名去重(append 跳过 / upsert 更新姓名与班级),仅管理员可用 */
/* ------------------------------------------------------------------ 学生名单 */
/**
* 导入学生名单:**按学号**去重(append 跳过 / upsert 更新姓名、班级、性别、家长手机号)。
*
* 只写 `Student`(名单记录),**不创建任何账号** —— 学生没有密码、不能登录。
* 班级列可选:填了按班级名解析(重名报错),否则用弹窗里选中的班级。
*/
export async function commitStudents(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
const classId = input.classId;
if (!classId) throw ApiError.badRequest('导入学生名单需要先选择班级');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const cls = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!cls) throw ApiError.notFound('班级不存在');
// 班级列可用时按名字解析;同名班级(跨年级)会让导入变得含糊,直接报错让老师改用班级码
const hasClassColumn = Boolean(input.mapping.className);
const fallbackClassId = input.classId ?? null;
if (fallbackClassId) await assertImportAllowed(user, fallbackClassId, 'students');
else if (!hasClassColumn) throw ApiError.badRequest('导入学生名单需要选择班级,或在表格里提供「班级」列');
const allClasses = await prisma.class.findMany({
where: { archivedYearId: null },
select: { id: true, name: true, enrollmentYear: true, classIndex: true },
});
const classByName = new Map<string, { id: string; name: string }>();
for (const item of allClasses) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key); // 重名 → 标记为不可用
else classByName.set(key, item);
}
const errors: ImportRowError[] = [];
const warnings: string[] = [];
@@ -369,40 +477,91 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const studentNoRaw = cellValue(row, parsed.columns, input.mapping, 'studentNo');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const className = cellValue(row, parsed.columns, input.mapping, 'className');
const gender = parseGender(cellValue(row, parsed.columns, input.mapping, 'gender'));
const guardianPhone = cellValue(row, parsed.columns, input.mapping, 'guardianPhone');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
if (!studentNoRaw || !name) {
errors.push({ row: rowNumber, message: '学号与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
const studentNo = normalizeStudentNo(studentNoRaw);
if (!/^[A-Za-z0-9._-]{1,32}$/.test(studentNo)) {
errors.push({ row: rowNumber, message: `学号「${studentNoRaw}」格式不合法(1~32 位字母/数字/._-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (existing.role !== 'STUDENT') {
errors.push({ row: rowNumber, message: `用户名「${username}」已被教师/管理员账号占用` });
// 目标班级:表格里的班级列优先,其次弹窗选的班级
let classId = fallbackClassId;
if (className) {
const matched = classByName.get(className.toLowerCase());
if (!matched) {
errors.push({
row: rowNumber,
message: `找不到班级「${className}」(可能已毕业归档或名称重复,请核对后在班级管理里查看)`,
});
continue;
}
classId = matched.id;
if (fallbackClassId && fallbackClassId !== matched.id) {
// 表格里写了班级就以表格为准,只是提醒一下
warnings.push(`第 ${rowNumber} 行:按表格里的班级「${className}」导入(与所选班级不同)`);
}
}
if (!classId) {
errors.push({ row: rowNumber, message: '该行没有班级信息,请填写「班级」列或在上一步选择班级' });
continue;
}
await assertImportAllowed(user, classId, 'students');
const existing = await prisma.student.findUnique({
where: { studentNo },
select: { id: true, classId: true },
});
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
}
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: classId } });
if (existing.classId !== classId) {
// 换班要留痕,与手动调班同一种历史
const [from, to] = await Promise.all([
classNameOf(existing.classId),
classNameOf(classId),
]);
await prisma.studentClassTransfer.create({
data: {
studentId: existing.id,
studentNo,
studentName: name,
fromClassId: existing.classId,
fromClassName: from,
toClassId: classId,
toClassName: to,
operatorId: user.sub,
operatorName: user.name,
mode: 'batch',
note: '学生名单导入',
},
});
}
await prisma.student.update({
where: { id: existing.id },
data: {
name,
classId,
status: 'active',
...(gender ? { gender } : {}),
...(guardianPhone ? { guardianPhone } : {}),
},
});
updated += 1;
} else {
await prisma.user.create({
data: {
username,
name,
role: 'STUDENT',
classId: classId,
// 学生不设密码(空串占位,登录 403 判定在密码校验之前),见"学生只有名单没有账号"
passwordHash: '',
},
await prisma.student.create({
data: { studentNo, name, classId, gender, guardianPhone },
});
inserted += 1;
}
@@ -419,19 +578,19 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
warnings,
};
logger.info(
`学生名单导入:班级=${classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
`学生名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
/* ------------------------------------------------------------------ 教师名单 */
/**
* 导入教师名单:按用户名去重(append 跳过 / upsert 更新姓名与角色),**仅管理员**可用。
*
* 与班级无关(教师不属于班级),因此不需要 classId,模板里也没有班级列。
* 角色列可选:填「管理员 / ADMIN / admin」建管理员账号,其余一律建教师账号。
* 导入教师名单:按**工号**去重(append 跳过 / upsert 更新姓名与角色),**仅管理员**可用。
* 与班级无关(教师不属于班级),因此不需要 classId。
*/
export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
await assertImportAllowed(user, undefined, 'teachers');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
@@ -445,30 +604,30 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (existing.role === 'STUDENT') {
errors.push({ row: rowNumber, message: `用户名「${username}」已被学生账号占用` });
continue;
}
if (input.mode === 'append') {
skipped += 1;
continue;
}
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await prisma.user.update({ where: { id: existing.id }, data: { name, role: nextRole } });
await prisma.user.update({
where: { id: existing.id },
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
});
updated += 1;
} else {
const initialPassword = password || env.defaultTeacherPassword;
@@ -476,6 +635,7 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
data: {
username,
name,
phone,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await hashPassword(initialPassword),
},
@@ -494,12 +654,216 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
errors,
warnings,
};
logger.info(`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`);
return result;
}
/* ------------------------------------------------------------------ 班级任课老师 */
/**
* 导入班级任课老师(班级 + 科目 + 工号 + 姓名 + 角色),**仅管理员**。
*
* 角色列:
* - 「班主任」→ 写 `Class.teacherId`(每班 1 人,后导入的覆盖先导入的);
* - 「科任」 → 写 `Course.teacherId`(该班该科的任课老师,没有该科目就自动建课)。
*
* **不产生重复教师**:同一工号在表格里出现多次(班主任行 + 科任行)时,
* 后面几行指向的还是同一个账号(`useTeacherNo` 打开时按工号匹配,关闭时按姓名唯一匹配),
* 因此"班主任同时是科任老师"只会有一条教师记录,权限在 `resolveClassRole` 里合并。
*/
export async function commitClassTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
await assertImportAllowed(user, undefined, 'classTeachers');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
const classes = await prisma.class.findMany({
where: { archivedYearId: null },
select: { id: true, name: true, teacherId: true },
});
const classByName = new Map<string, { id: string; name: string; teacherId: string }>();
for (const item of classes) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key);
else classByName.set(key, item);
}
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
const createdTeachers = new Map<string, string>();
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const className = cellValue(row, parsed.columns, input.mapping, 'className');
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!className || !teacherNo) {
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
continue;
}
const targetClass = classByName.get(className.toLowerCase());
if (!targetClass) {
errors.push({ row: rowNumber, message: `找不到班级「${className}」(可能已毕业归档或名称重复)` });
continue;
}
const role = parseClassTeacherRole(roleCell);
if (!role) {
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
continue;
}
if (role === 'SUBJECT' && !subjectName) {
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
continue;
}
// ---- 匹配或新建教师账号
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
if (!teacherId) {
if (useTeacherNo) {
const byNo = await prisma.user.findUnique({
where: { username: teacherNo },
select: { id: true },
});
if (byNo) {
teacherId = byNo.id;
}
} else if (teacherName) {
const byName = await prisma.user.findMany({
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
select: { id: true },
take: 2,
});
if (byName.length === 1) {
teacherId = byName[0]!.id;
} else if (byName.length > 1) {
errors.push({
row: rowNumber,
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
});
continue;
}
}
}
if (!teacherId) {
if (!useTeacherNo) {
errors.push({
row: rowNumber,
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
});
continue;
}
const created = await prisma.user.create({
data: {
username: teacherNo,
name: teacherName || teacherNo,
role: 'TEACHER',
passwordHash: await hashPassword(env.defaultTeacherPassword),
},
select: { id: true },
});
teacherId = created.id;
inserted += 1;
warnings.push(
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
);
}
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
// ---- 写入角色
if (role === 'HEAD') {
if (targetClass.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.class.update({ where: { id: targetClass.id }, data: { teacherId } });
targetClass.teacherId = teacherId;
updated += 1;
} else {
const existing = await prisma.course.findUnique({
where: { classId_name: { classId: targetClass.id, name: subjectName } },
select: { id: true, teacherId: true },
});
if (existing) {
if (existing.teacherId === teacherId) {
skipped += 1;
continue;
}
await prisma.course.update({ where: { id: existing.id }, data: { teacherId } });
} else {
await prisma.course.create({
data: { name: subjectName, classId: targetClass.id, teacherId },
});
}
updated += 1;
}
}
const result: ImportResult = {
kind: 'classTeachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
if (input.kind === 'teachers') return commitTeachers(user, input);
if (input.kind === 'classTeachers') return commitClassTeachers(user, input);
if (input.kind === 'grades') return commitGrades(user, input);
return commitStudents(user, input);
}
/** 供预览接口复用的模板生成(Web 端「下载模板」) */
export function templateFor(kind: TableImportKind): { csv: string; xlsx: string } {
return { csv: buildTemplateCsv(kind), xlsx: buildTemplateXlsx(kind) };
}
/* ------------------------------------------------------------------ 内部工具 */
/** 导入权限:与学生名单/成绩同一套口径(见 shared 的 canImport) */
async function assertImportAllowed(
user: TokenPayload,
classId: string | undefined,
kind: 'grades' | 'students' | 'teachers' | 'classTeachers' | 'scheduleTimes',
): Promise<void> {
const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE';
if (canImport(user.role, classRole, kind)) return;
if (kind === 'students' || kind === 'grades') {
throw ApiError.forbidden(
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
: '只能导入自己担任班主任的班级的数据',
);
}
throw ApiError.forbidden('该导入仅限管理员使用');
}
async function classNameOf(classId: string | null): Promise<string> {
if (!classId) return '';
const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } });
return record?.name ?? '';
}
/** 角色列解析:只认「管理员」类词;其余(含空)返回 null 交给调用方取默认值 */
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
const value = cell.trim().toUpperCase();
@@ -508,8 +872,14 @@ function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
return 'TEACHER';
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
if (input.kind === 'teachers') return commitTeachers(user, input);
if (input.kind === 'grades') return commitGrades(user, input);
return commitStudents(user, input);
/** 班级任课老师的角色列:班主任 / 科任 */
function parseClassTeacherRole(cell: string): 'HEAD' | 'SUBJECT' | null {
const value = cell.trim();
if (!value) return null;
if (value.includes('班主任') || value.toUpperCase() === 'HEAD') return 'HEAD';
if (value.includes('科任') || value.includes('任课') || value.toUpperCase() === 'SUBJECT') return 'SUBJECT';
return null;
}
/** 导出:把班级名与入学年份生成统一称呼(供未来导出功能复用) */
export { formatClassName };