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:
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 };
|
||||
Reference in new issue
Block a user