feat(imports): 成绩/名单表格导入 + ClassIsland 时间配置导入 + 安装版增量迁移

服务端:
- 新增 imports 模块: 模板下载(csv/xlsx)、表格解析预览、字段映射导入(append/upsert)、
  错误行号与结果统计; ClassIsland 时间配置容错解析 + replace/merge 导入 + 失败回滚(400 不写库)
- TimeLayout 模型 + 迁移; 修复 contentBase64 空值被 422 拦截, 改由 service 返回 IMPORT_EMPTY_FILE
- merge 语义改为按开始时间槽位覆盖, 同开始时间的旧节次不再被静默丢弃
- db-bootstrap 增加 _ch_migrations 账本: 覆盖安装升级也能自动补跑新增迁移(旧库实测通过)

Web 管理端:
- TableImportDialog(成绩/名单): 模板下载、预览、字段映射、重复处理模式、结果统计与错误行
- TimeLayoutImportDialog: 粘贴/选 .json、解析预览、覆盖/合并、已导入列表与删除
- 成绩录入/学生管理/课表管理接入入口(按权限矩阵显示)

验证: verify:e2e 107/107(新增导入 22 项) verify:web 18/18(新增时间配置导入弹窗真实点击)
      verify:desktop 40/40
文档: README 新增导入章节、增量迁移说明、API 表与验收对照更新
This commit is contained in:
LoveChengke committed 2026-09-12 15:35:01 +08:00
1 parent 0dcab6f369
commit 8b85079d81
23 files changed
+2735 -108

No files matched your search

@@ -0,0 +1,113 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
tableCommitSchema,
tableFileSchema,
timeLayoutImportSchema,
timeLayoutQuerySchema,
type TableCommitInput,
type TableFileInput,
type TimeLayoutImportInput,
} from './imports.schemas.js';
import { buildTemplateCsv, buildTemplateXlsx, commitTable, previewTable } from './table-import.service.js';
import {
deleteTimeLayout,
importTimeLayout,
listTimeLayouts,
previewClassIslandTimeLayout,
} from './time-layout.service.js';
const router = Router();
router.use(authenticate());
/**
* GET /api/imports/template?kind=grades|students&format=csv|xlsx - 下载导入模板
* 成绩模板=管理员;名单模板=管理员(与写入权限一致,不额外开入口)
*/
router.get('/template', requireRole('ADMIN'), (req, res) => {
const kind = req.query.kind === 'students' ? 'students' : 'grades';
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
if (format === 'xlsx') {
const base64 = buildTemplateXlsx(kind);
res.setHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('content-disposition', `attachment; filename="template-${kind}.xlsx"`);
res.send(Buffer.from(base64, 'base64'));
return;
}
sendOk(
res,
{ kind, format, fileName: `template-${kind}.csv`, content: buildTemplateCsv(kind) },
'模板生成成功',
);
});
/**
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
* 仅管理员:与"成绩录入 / 名单管理"权限一致
*/
router.post('/table/preview', requireRole('ADMIN'), validate({ body: tableFileSchema }), (req, res) => {
sendOk(res, previewTable(validatedBody<TableFileInput>(req)), '表格解析成功');
});
/** POST /api/imports/table/commit - 执行导入(字段映射 + 写入模式 + 结果统计) */
router.post(
'/table/commit',
requireRole('ADMIN'),
validate({ body: tableCommitSchema }),
async (req, res) => {
const result = await commitTable(getAuthUser(req), validatedBody<TableCommitInput>(req));
sendOk(
res,
result,
`导入完成:新增 ${result.inserted} / 更新 ${result.updated} / 跳过 ${result.skipped} / 失败 ${result.failed}`,
);
},
);
/**
* POST /api/imports/time-layout/preview - 预览 ClassIsland 课表时间 JSON(只解析不写库)
* 权限与"课表管理"一致:管理员或本班班主任
*/
router.post('/time-layout/preview', validate({ body: timeLayoutImportSchema }), async (req, res) => {
const input = validatedBody<TimeLayoutImportInput>(req);
// 预览也要校验班级权限(与导入一致),避免越权探测
const { assertCanManageSchedule } = await import('../../lib/access.js');
await assertCanManageSchedule(getAuthUser(req), input.classId);
sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成');
});
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (req, res) => {
const { classId } = validatedQuery<{ classId: string }>(req);
sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功');
});
/**
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。
*/
router.post('/time-layout', validate({ body: timeLayoutImportSchema }), async (req, res) => {
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
sendOk(
res,
result,
result.mode === 'merge'
? `合并导入成功:合并 ${result.merged} 节 / 新增 ${result.replaced} 节`
: `覆盖导入成功:共 ${result.replaced} 节`,
);
});
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
router.delete('/time-layout/:id', async (req, res) => {
await deleteTimeLayout(getAuthUser(req), req.params.id ?? '');
sendOk(res, { id: req.params.id }, '时间配置已删除');
});
export const importsModule = defineModule({
name: 'imports',
basePath: '/imports',
router,
});
@@ -0,0 +1,51 @@
import { z } from 'zod';
/** 导入类型:成绩 / 学生名单 / 课表时间配置 */
export const IMPORT_KINDS = ['grades', 'students'] as const;
/** 表格导入:上传的文件(base64)+ 类型 */
export const tableFileSchema = z.object({
kind: z.enum(IMPORT_KINDS),
fileName: z.string().trim().min(1, '缺少文件名').max(200),
/**
* base64(不含 data: 前缀)。
* 这里刻意不限制最小长度:空内容交给 service 返回 IMPORT_EMPTY_FILE,
* 这样前端能拿到"文件为空"这一精确原因,而不是笼统的参数校验错误。
*/
contentBase64: z.string(),
});
/** 表格提交:在预览基础上带字段映射、目标班级与写入模式 */
export const tableCommitSchema = tableFileSchema.extend({
classId: z.string().min(1, '请选择班级'),
/** 列映射:规范字段 → 文件列名(或列索引字符串) */
mapping: z.record(z.string(), z.string().min(1)),
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按用户名) */
mode: z.enum(['append', 'upsert']).default('upsert'),
});
/** ClassIsland 课表时间导入:先校验/预览,再提交 */
export const timeLayoutImportSchema = z.object({
classId: z.string().min(1, '请选择班级'),
name: z.string().trim().max(64).optional(),
/** replace = 覆盖该班现有时间配置;merge = 按开始时间合并 */
mode: z.enum(['replace', 'merge']).default('replace'),
/**
* ClassIsland 导出的 JSON(对象或字符串均可)。
* 字符串是为了兼容"直接粘贴 JSON 文本"的场景。
*/
payload: z.union([
z.string().min(1, '请粘贴或选择 ClassIsland 导出的 JSON'),
z.record(z.string(), z.unknown()),
z.array(z.unknown()),
]),
});
/** 时间配置查询 */
export const timeLayoutQuerySchema = z.object({
classId: z.string().min(1, '请选择班级'),
});
export type TableFileInput = z.infer<typeof tableFileSchema>;
export type TableCommitInput = z.infer<typeof tableCommitSchema>;
export type TimeLayoutImportInput = z.infer<typeof timeLayoutImportSchema>;
@@ -0,0 +1,409 @@
import bcrypt from 'bcryptjs';
import * as XLSX from 'xlsx';
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 { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
/**
* 表格导入(xlsx / xls / csv)。
*
* 流程:上传(base64) → 解析首个工作表 → 预览(列名/前 N 行/校验问题/建议映射)
* → 前端确认字段映射与写入模式 → 提交(逐行校验,重复按模式处理)→ 结果统计。
*
* 异常处理:空文件、超大文件、格式错误、缺列、非法数值、重复数据、未知学生等
* 都会给出明确原因与行号,绝不静默失败。
*/
const MAX_IMPORT_BYTES = 8 * 1024 * 1024; // 8MB
const PREVIEW_ROWS = 20;
export type ImportKind = TableFileInput['kind'];
/** 规范字段定义(预览、模板、映射提示共用) */
export const IMPORT_FIELDS: Record<
ImportKind,
{ key: string; label: string; required: boolean; synonyms: string[] }[]
> = {
grades: [
{
key: 'username',
label: '学生用户名',
required: false,
synonyms: ['用户名', 'username', '学号', '账号'],
},
{ key: 'name', label: '学生姓名', required: false, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{
key: 'examName',
label: '考试名称',
required: true,
synonyms: ['考试', '考试名称', 'exam', 'examName', '测验'],
},
{ key: 'score', label: '分数', required: true, synonyms: ['分数', 'score', '成绩', '得分'] },
{ key: 'totalScore', label: '总分', required: false, synonyms: ['总分', 'totalScore', '满分'] },
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
],
students: [
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
],
};
export interface TablePreview {
kind: ImportKind;
columns: string[];
rows: string[][];
totalRows: number;
suggestedMapping: Record<string, string>;
errors: string[];
warnings: string[];
templateCsv: string;
}
interface ParsedTable {
columns: string[];
rows: string[][];
}
function decodeBase64(contentBase64: string): Buffer {
const cleaned = contentBase64.includes(',')
? contentBase64.slice(contentBase64.indexOf(',') + 1)
: contentBase64;
const buffer = Buffer.from(cleaned, 'base64');
if (buffer.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '文件内容为空,请确认导出后重新上传');
if (buffer.length > MAX_IMPORT_BYTES) {
throw new ApiError(
413,
'IMPORT_TOO_LARGE',
`文件过大(${(buffer.length / 1024 / 1024).toFixed(1)}MB),请拆分后分批导入(上限 ${MAX_IMPORT_BYTES / 1024 / 1024}MB)`,
);
}
return buffer;
}
/** 解析表格:xlsx/xls 走 SheetJS,csv/tsv 也走 SheetJS(自动识别分隔符) */
function parseTable(buffer: Buffer, fileName: string): ParsedTable {
let workbook: XLSX.WorkBook;
try {
workbook = XLSX.read(buffer, { type: 'buffer', raw: false, cellDates: false });
} catch (error) {
throw new ApiError(
400,
'IMPORT_FORMAT_INVALID',
`文件解析失败:${(error as Error).message}(支持 .xlsx/.xls/.csv)`,
{
fileName,
},
);
}
const sheetName = workbook.SheetNames[0];
const sheet = sheetName ? workbook.Sheets[sheetName] : undefined;
if (!sheet) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格里没有工作表,请确认文件内容');
const matrix = XLSX.utils.sheet_to_json<string[]>(sheet, {
header: 1,
blankrows: false,
defval: '',
raw: false,
});
const cleaned = matrix
.map((row) => (Array.isArray(row) ? row.map((cell) => String(cell ?? '').trim()) : []))
.filter((row) => row.some((cell) => cell !== ''));
if (cleaned.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格是空的(没有任何数据行)');
const headerRow = cleaned[0] ?? [];
const columns = headerRow.map((cell, index) => (cell === '' ? `第 ${index + 1} 列` : cell));
const rows = cleaned.slice(1).map((row) => {
const values = [...row];
while (values.length < columns.length) values.push('');
return values.slice(0, columns.length);
});
if (rows.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格只有表头,没有数据行');
return { columns, rows };
}
/** 生成模板 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', '数学']
: ['student01', '王小明', 'student123'];
const sampleLine = fields
.map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '')
.join(',');
return `\ufeff${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, '数学']
: ['student01', '王小明', 'student123'];
const sheet = XLSX.utils.aoa_to_sheet([header, sample]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, kind === 'grades' ? '成绩' : '学生名单');
return XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
}
/** 按同义词自动建议列映射 */
function suggestMapping(kind: ImportKind, columns: string[]): Record<string, string> {
const mapping: Record<string, string> = {};
for (const field of IMPORT_FIELDS[kind]) {
const match = columns.find(
(column) =>
field.synonyms.some((synonym) => column.toLowerCase() === synonym.toLowerCase()) ||
field.synonyms.some((synonym) => column.toLowerCase().includes(synonym.toLowerCase())),
);
if (match) mapping[field.key] = match;
}
return mapping;
}
/** 预览:解析 + 必填列校验 + 建议映射(不写库) */
export function previewTable(input: TableFileInput): TablePreview {
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const errors: string[] = [];
const warnings: string[] = [];
const suggestedMapping = suggestMapping(input.kind, parsed.columns);
for (const field of IMPORT_FIELDS[input.kind]) {
if (field.required && !suggestedMapping[field.key]) {
errors.push(`缺少必填列「${field.label}」:请在表格里补上该列,或在下一步手动映射到某一列`);
}
}
if (parsed.rows.length > 2000) warnings.push(`数据行较多(${parsed.rows.length} 行),导入可能需要几秒钟`);
return {
kind: input.kind,
columns: parsed.columns,
rows: parsed.rows.slice(0, PREVIEW_ROWS),
totalRows: parsed.rows.length,
suggestedMapping,
errors,
warnings,
templateCsv: buildTemplateCsv(input.kind),
};
}
export interface ImportRowError {
row: number;
message: string;
}
export interface ImportResult {
kind: ImportKind;
total: number;
inserted: number;
updated: number;
skipped: number;
failed: number;
errors: ImportRowError[];
warnings: string[];
}
function cellValue(row: string[], columns: string[], mapping: Record<string, string>, key: string): string {
const column = mapping[key];
if (!column) return '';
const index = columns.indexOf(column);
if (index < 0) return '';
return (row[index] ?? '').trim();
}
/** 导入成绩:学生按用户名/姓名匹配;重复(同班级+学生+考试+课程)按 mode 处理 */
export async function commitGrades(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageGrades(user);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const students = await prisma.user.findMany({
where: { classId: input.classId, role: 'STUDENT' },
select: { id: true, username: true, name: true },
});
const byUsername = new Map(students.map((item) => [item.username.toLowerCase(), item]));
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({
where: { classId: input.classId },
select: { id: true, name: true },
});
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2; // 含表头,从 Excel 视角的行号
const username = cellValue(row, parsed.columns, input.mapping, 'username');
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');
if (!username && !name) {
errors.push({ row: rowNumber, message: '缺少学生标识(用户名与姓名至少填写一个)' });
continue;
}
const student =
(username ? byUsername.get(username.toLowerCase()) : undefined) ??
(name ? byName.get(name.toLowerCase()) : undefined);
if (!student) {
errors.push({ row: rowNumber, message: `找不到该班学生:${username || name}` });
continue;
}
if (!examName) {
errors.push({ row: rowNumber, message: '缺少考试名称' });
continue;
}
const score = Number(scoreText);
if (!Number.isFinite(score)) {
errors.push({ row: rowNumber, message: `分数「${scoreText}」不是数字` });
continue;
}
const totalScore = totalText ? Number(totalText) : 100;
if (!Number.isFinite(totalScore) || totalScore <= 0) {
errors.push({ row: rowNumber, message: `总分「${totalText || '100'}」不合法` });
continue;
}
if (score < 0 || score > totalScore) {
errors.push({ row: rowNumber, message: `分数 ${score} 超出 0~${totalScore} 范围` });
continue;
}
const course = courseName ? courses.find((item) => item.name === courseName) : null;
if (courseName && !course)
warnings.push(`第 ${rowNumber} 行:课程「${courseName}」不存在,已按"不指定课程"导入`);
const existing = await prisma.grade.findFirst({
where: { classId: input.classId, userId: student.id, examName, courseId: course?.id ?? null },
select: { id: true },
});
if (existing) {
if (input.mode === 'append') {
skipped += 1;
continue;
}
await prisma.grade.update({
where: { id: existing.id },
data: { score, totalScore, publishedAt: new Date() },
});
updated += 1;
} else {
await prisma.grade.create({
data: {
classId: input.classId,
userId: student.id,
courseId: course?.id ?? null,
examName,
score,
totalScore,
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'grades',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`成绩导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
/** 导入学生名单:按用户名去重(append 跳过 / upsert 更新姓名与班级),仅管理员可用 */
export async function commitStudents(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const cls = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
if (!cls) throw ApiError.notFound('班级不存在');
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
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;
}
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: input.classId } });
updated += 1;
} else {
const initialPassword = password || env.defaultStudentPassword;
await prisma.user.create({
data: {
username,
name,
role: 'STUDENT',
classId: input.classId,
passwordHash: await bcrypt.hash(initialPassword, 10),
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'students',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`学生名单导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
return input.kind === 'grades' ? commitGrades(user, input) : commitStudents(user, input);
}
@@ -0,0 +1,358 @@
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js';
import { assertCanManageSchedule } from '../../lib/access.js';
import type { TimeLayoutImportInput } from './imports.schemas.js';
/**
* ClassIsland 课表时间(TimeLayout)导入。
*
* ClassIsland 的导出结构在不同版本里略有差异,常见形态:
* 1. 顶层就是数组:`[{ StartTime: "8:0:0", EndTime: "8:45:0", TimeType: 0 }, ...]`
* 2. 包在对象里:`{ TimeLayouts: [...] }` / `{ TimeLayoutItems: [...] }` / `{ items: [...] }`
* 3. 时间用秒表示:`{ StartSecond: 28800, EndSecond: 31500 }`
* 4. 字段名大小写/命名不同:`StartTime` / `startTime` / `Start`;`Name` / `Title`
*
* 因此这里做**容错解析**:先把可能的数组抠出来,再逐条按候选字段名解析时间,
* 解析失败的行进入 errors(不写库),成功解析但可疑的进入 warnings(可继续导入)。
*/
export type TimeLayoutItemType = 'class' | 'break' | 'divider';
export interface ParsedTimeLayoutItem {
index: number;
name: string;
startTime: string;
endTime: string;
type: TimeLayoutItemType;
skipped: boolean;
}
export interface TimeLayoutParseResult {
items: ParsedTimeLayoutItem[];
errors: string[];
warnings: string[];
}
const TIME_KEYS = ['StartTime', 'startTime', 'start', 'Start'] as const;
const END_KEYS = ['EndTime', 'endTime', 'end', 'End'] as const;
const START_SECOND_KEYS = ['StartSecond', 'startSecond', 'StartSeconds'] as const;
const END_SECOND_KEYS = ['EndSecond', 'endSecond', 'EndSeconds'] as const;
const NAME_KEYS = ['Name', 'name', 'Title', 'title', 'Subject'] as const;
const TYPE_KEYS = ['TimeType', 'timeType', 'Type', 'type'] as const;
const SKIP_KEYS = ['IsSkipped', 'isSkipped', 'Skipped', 'skip'] as const;
function pick(record: Record<string, unknown>, keys: readonly string[]): unknown {
for (const key of keys) {
if (record[key] !== undefined && record[key] !== null) return record[key];
}
return undefined;
}
/** 把 ClassIsland 的时间写法统一成 HH:mm */
function normalizeTime(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) {
// 数字:优先按"秒"解释(ClassIsland 的 StartSecond),小于 100 时按"分钟"
const seconds = value > 100 ? value : value * 60;
return secondsToHHmm(seconds);
}
if (typeof value !== 'string') return null;
const text = value.trim();
if (!text) return null;
// HH:mm / H:mm / HH:mm:ss / H:mm:ss
const match = /^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/.exec(text);
if (match) {
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return null;
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
// ISO 字符串(ClassIsland 某些版本导出 "2024-01-01T08:00:00")
const parsed = new Date(text);
if (!Number.isNaN(parsed.getTime())) {
return `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`;
}
return null;
}
function secondsToHHmm(seconds: number): string {
const total = Math.max(0, Math.round(seconds));
const hour = Math.floor(total / 3600) % 24;
const minute = Math.floor((total % 3600) / 60);
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
/** 从任意形态里抠出"可能是时间表条目"的数组 */
function extractItems(input: unknown): { items: unknown[]; shape: string } {
if (Array.isArray(input)) return { items: input, shape: 'array' };
if (input && typeof input === 'object') {
const record = input as Record<string, unknown>;
for (const key of ['TimeLayouts', 'TimeLayoutItems', 'Items', 'items', 'Layouts', 'TimeLayout']) {
const value = record[key];
if (Array.isArray(value)) return { items: value, shape: key };
// 有些导出是 { TimeLayouts: { items: [...] } }
if (value && typeof value === 'object' && Array.isArray((value as Record<string, unknown>).items)) {
return { items: (value as Record<string, unknown>).items as unknown[], shape: `${key}.items` };
}
}
}
return { items: [], shape: 'unknown' };
}
function normalizeType(record: Record<string, unknown>): TimeLayoutItemType {
const raw = pick(record, TYPE_KEYS);
const numeric = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
if (numeric === 1) return 'break';
if (numeric === 2) return 'divider';
return 'class';
}
export function parseClassIslandTimeLayout(input: unknown): TimeLayoutParseResult {
let payload = input;
if (typeof payload === 'string') {
try {
payload = JSON.parse(payload);
} catch (error) {
return { items: [], errors: [`JSON 解析失败:${(error as Error).message}`], warnings: [] };
}
}
const { items: rawItems, shape } = extractItems(payload);
const errors: string[] = [];
const warnings: string[] = [];
if (rawItems.length === 0) {
return {
items: [],
errors: [
'未识别到时间表条目:请确认是 ClassIsland 导出的课表时间 JSON(数组或含 TimeLayouts/TimeLayoutItems 字段)',
],
warnings: [],
};
}
if (shape === 'unknown') {
warnings.push('未识别外层结构,已尝试按数组解析');
}
const parsed: ParsedTimeLayoutItem[] = [];
rawItems.forEach((raw, index) => {
if (!raw || typeof raw !== 'object') {
errors.push(`第 ${index + 1} 条:不是对象,已跳过`);
return;
}
const record = raw as Record<string, unknown>;
const start =
normalizeTime(pick(record, TIME_KEYS)) ?? normalizeTime(pick(record, START_SECOND_KEYS)) ?? null;
const end = normalizeTime(pick(record, END_KEYS)) ?? normalizeTime(pick(record, END_SECOND_KEYS)) ?? null;
const skipped = pick(record, SKIP_KEYS) === true || pick(record, SKIP_KEYS) === 'true';
const type = normalizeType(record);
const nameRaw = pick(record, NAME_KEYS);
const name = typeof nameRaw === 'string' && nameRaw.trim() ? nameRaw.trim() : '';
if (!start || !end) {
if (type === 'divider') {
warnings.push(`第 ${index + 1} 条:分割线没有时间,已忽略`);
return;
}
errors.push(
`第 ${index + 1} 条(${name || '未命名'}):无法解析开始/结束时间` +
`(支持 StartTime: "8:0:0"、StartSecond: 28800 等写法)`,
);
return;
}
if (start >= end) {
errors.push(`第 ${index + 1} 条(${name || '未命名'}):结束时间 ${end} 不晚于开始时间 ${start}`);
return;
}
if (skipped) warnings.push(`第 ${index + 1} 条(${name || '未命名'}):原配置标记为跳过`);
parsed.push({
index: parsed.length + 1,
name: name || `第 ${parsed.length + 1} 节`,
startTime: start,
endTime: end,
type,
skipped,
});
});
// 排序 + 重叠检测(不阻断导入,仅提示)
parsed.sort((a, b) => a.startTime.localeCompare(b.startTime));
for (let index = 1; index < parsed.length; index += 1) {
const previous = parsed[index - 1];
const current = parsed[index];
if (
previous &&
current &&
previous.type === 'class' &&
current.type === 'class' &&
current.startTime < previous.endTime
) {
warnings.push(
`时间重叠:${previous.startTime}-${previous.endTime} 与 ${current.startTime}-${current.endTime}`,
);
}
}
return { items: parsed, errors, warnings };
}
export interface TimeLayoutDto {
id: string;
classId: string;
name: string;
source: string;
items: ParsedTimeLayoutItem[];
createdAt: string;
updatedAt: string;
}
function toDto(record: {
id: string;
classId: string;
name: string;
source: string;
items: string;
createdAt: Date;
updatedAt: Date;
}): TimeLayoutDto {
let items: ParsedTimeLayoutItem[] = [];
try {
items = JSON.parse(record.items) as ParsedTimeLayoutItem[];
} catch {
items = [];
}
return {
id: record.id,
classId: record.classId,
name: record.name,
source: record.source,
items,
createdAt: record.createdAt.toISOString(),
updatedAt: record.updatedAt.toISOString(),
};
}
/** 预览:只解析不落库(失败时原配置不受影响) */
export function previewClassIslandTimeLayout(payload: unknown): TimeLayoutParseResult & { shape: string } {
return {
...parseClassIslandTimeLayout(payload),
shape: extractItems(typeof payload === 'string' ? safeJson(payload) : payload).shape,
};
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return text;
}
}
export async function listTimeLayouts(user: TokenPayload, classId: string): Promise<TimeLayoutDto[]> {
await assertCanManageSchedule(user, classId);
const records = await prisma.timeLayout.findMany({ where: { classId }, orderBy: { updatedAt: 'desc' } });
return records.map(toDto);
}
/**
* 导入(写入):解析 → 有错误直接 400(不写库)→ 按 replace/merge 落库。
* 权限与"课表管理"一致:管理员或本班班主任。
*/
export async function importTimeLayout(
user: TokenPayload,
input: TimeLayoutImportInput,
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
await assertCanManageSchedule(user, input.classId);
const parsed = parseClassIslandTimeLayout(input.payload);
if (parsed.errors.length > 0) {
throw new ApiError(400, 'IMPORT_INVALID', '课表时间配置解析失败,已保持原有配置不变', {
errors: parsed.errors,
warnings: parsed.warnings,
});
}
if (parsed.items.length === 0) {
throw new ApiError(400, 'IMPORT_EMPTY', '没有解析到任何有效节次,已保持原有配置不变', {
warnings: parsed.warnings,
});
}
const name = input.name?.trim() || '默认时间表';
const existing = await prisma.timeLayout.findMany({
where: { classId: input.classId },
orderBy: { updatedAt: 'desc' },
});
const current = existing.find((item) => item.name === name) ?? existing[0] ?? null;
if (input.mode === 'merge' && current) {
let previousItems: ParsedTimeLayoutItem[] = [];
try {
previousItems = JSON.parse(current.items) as ParsedTimeLayoutItem[];
} catch {
previousItems = [];
}
// 以"开始时间"为槽位标识:
// - 导入项与旧配置的开始时间相同 → 视为同一节次被覆盖(计入 merged)
// - 开始时间不同 → 视为新增(计入 replaced),旧节次全部保留
// 注意:旧配置里可能存在开始时间相同的多条(例如分割线/跳过的节次),
// 只有被此次导入显式覆盖的那些才移除,避免合并时静默丢数据。
const incomingStarts = new Set(parsed.items.map((item) => item.startTime));
const previousStarts = new Set(previousItems.map((item) => item.startTime));
const merged = parsed.items.filter((item) => previousStarts.has(item.startTime)).length;
const replaced = parsed.items.length - merged;
const preserved = previousItems.filter((item) => !incomingStarts.has(item.startTime));
const combined = [...preserved, ...parsed.items].sort((a, b) => a.startTime.localeCompare(b.startTime));
const updated = await prisma.timeLayout.update({
where: { id: current.id },
data: {
items: JSON.stringify(combined.map((item, index) => ({ ...item, index: index + 1 }))),
source: 'classisland',
},
});
logger.info(
`课表时间配置合并导入:班级=${input.classId} 覆盖=${merged} 新增=${replaced} 共 ${combined.length} 节`,
);
return { layout: toDto(updated), mode: 'merge', warnings: parsed.warnings, replaced, merged };
}
// replace:同一班级同名配置整体替换;没有则新建
if (current) {
const updated = await prisma.timeLayout.update({
where: { id: current.id },
data: { items: JSON.stringify(parsed.items), name, source: 'classisland' },
});
logger.info(`课表时间配置覆盖导入:班级=${input.classId} 共 ${parsed.items.length} 节`);
return {
layout: toDto(updated),
mode: 'replace',
warnings: parsed.warnings,
replaced: parsed.items.length,
merged: 0,
};
}
const created = await prisma.timeLayout.create({
data: { classId: input.classId, name, source: 'classisland', items: JSON.stringify(parsed.items) },
});
logger.info(`课表时间配置新建:班级=${input.classId} 共 ${parsed.items.length} 节`);
return {
layout: toDto(created),
mode: 'replace',
warnings: parsed.warnings,
replaced: parsed.items.length,
merged: 0,
};
}
export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Promise<void> {
const record = await prisma.timeLayout.findUnique({ where: { id: layoutId } });
if (!record) throw ApiError.notFound('时间配置不存在');
await assertCanManageSchedule(user, record.classId);
await prisma.timeLayout.delete({ where: { id: layoutId } });
}
+2
View File
@@ -5,6 +5,7 @@ import { coursesModule } from './courses/courses.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js';
import { gradesModule } from './grades/grades.module.js';
import { homeworksModule } from './homeworks/homeworks.module.js';
import { importsModule } from './imports/imports.module.js';
import { notificationsModule } from './notifications/notifications.module.js';
import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js';
@@ -26,6 +27,7 @@ export const apiModules: ApiModule[] = [
homeworksModule,
notificationsModule,
callsModule,
importsModule,
gradesModule,
studentsModule,
teachersModule,