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

+136 -15
View File
@@ -9,8 +9,9 @@ import { hashPassword } from './password.js';
/**
* 首次启动时的数据库初始化(SQLite/libSQL)。
*
* 场景:安装程序首次运行、Docker 挂载了空数据卷、用户手动删除了数据库文件。
* 做法:若目标库中还没有业务表,就把随包发布的 Prisma 迁移 SQL 依次执行一遍。
* 场景:安装程序首次运行、覆盖安装升级、Docker 挂载了空数据卷、用户手动删除了数据库文件。
* 做法:以 `_ch_migrations` 账本表记录已执行的迁移;启动时按时间戳顺序补跑未记录的迁移。
* 全新库会跑全部迁移,旧库只补跑新增迁移(已存在的表/索引自动跳过)。
*
* 说明:
* - 只处理 SQLite(本项目打包安装的默认形态);
@@ -58,6 +59,103 @@ async function schemaExists(): Promise<boolean> {
}
}
/**
* 迁移账本表名。
*
* 老版本安装(1.0 之前)只区分「空库 / 非空库」,因此升级安装时不会补建新表。
* 现在用一张账本表记录已执行过的迁移目录名,启动时只补跑未记录的迁移,
* 这样「覆盖安装升级」也能自动获得新表/新索引。
*/
const MIGRATION_LEDGER = '_ch_migrations';
/** 这些错误表示对象已存在,属于「旧版本已经建过」的正常情况,可安全忽略 */
const IGNORABLE_SQL_ERROR = /already exists|duplicate column name|duplicate index/i;
/** 拆分 migration.sql 为独立语句(去掉注释行,过滤空语句) */
function splitStatements(sql: string): string[] {
return sql
.split(/\r?\n/)
.filter((line) => !line.trim().startsWith('--'))
.join('\n')
.split(';')
.map((statement) => statement.trim())
.filter((statement) => statement.length > 0);
}
/** 确保账本表存在,返回已记录的迁移名 */
async function loadAppliedMigrations(client: {
execute: (sql: string) => Promise<unknown>;
}): Promise<Set<string>> {
await client.execute(
`CREATE TABLE IF NOT EXISTS "${MIGRATION_LEDGER}" ("name" TEXT NOT NULL PRIMARY KEY, "appliedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
);
const rows = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
`SELECT name FROM "${MIGRATION_LEDGER}"`,
);
return new Set(rows.map((row) => row.name));
}
interface MigrationOutcome {
file: string;
applied: number;
skipped: number;
}
/** 执行单个迁移文件:逐条语句执行,已存在的对象跳过,其余错误上抛 */
async function applyMigration(
client: { execute: (sql: string) => Promise<unknown> },
file: string,
): Promise<MigrationOutcome> {
const sql = fs.readFileSync(file, 'utf8');
const outcome: MigrationOutcome = { file, applied: 0, skipped: 0 };
for (const statement of splitStatements(sql)) {
try {
await client.execute(statement);
outcome.applied += 1;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (IGNORABLE_SQL_ERROR.test(message)) {
outcome.skipped += 1;
continue;
}
throw new Error(
`迁移 ${path.basename(path.dirname(file))} 执行失败:${message}\nSQL: ${statement.slice(0, 200)}`,
);
}
}
await client.execute({
// libsql 的 execute 支持参数化对象形式
sql: `INSERT OR REPLACE INTO "${MIGRATION_LEDGER}" ("name", "appliedAt") VALUES (?, CURRENT_TIMESTAMP)`,
args: [path.basename(path.dirname(file))],
} as never);
return outcome;
}
/** 计算待执行的迁移文件 */
export async function listPendingMigrations(): Promise<string[]> {
const files = listMigrationFiles();
if (files.length === 0) return [];
const client = createClient({ url: env.databaseUrl });
try {
const applied = await loadAppliedMigrations(
client as unknown as {
execute: (sql: string) => Promise<unknown>;
},
);
return files.filter((file) => !applied.has(path.basename(path.dirname(file))));
} catch (error) {
logger.warn('读取迁移账本失败,按全部待执行处理', error);
return files;
} finally {
client.close();
}
}
export interface BootstrapResult {
applied: number;
skipped: boolean;
@@ -95,8 +193,12 @@ export async function ensureInitialAdmin(): Promise<{ created: boolean; username
}
/**
* 确保数据库结构存在。
* 确保数据库结构存在(增量迁移)。
* 仅在 AUTO_MIGRATE=true 时由启动流程调用。
*
* - 全新数据库:依次执行全部迁移;
* - 旧版本升级:只补跑账本里没有记录的迁移(已存在的对象自动跳过);
* - 已是最新:直接返回,不做任何写操作。
*/
export async function ensureSchema(): Promise<BootstrapResult> {
if (env.databaseProvider !== 'sqlite') {
@@ -107,27 +209,46 @@ export async function ensureSchema(): Promise<BootstrapResult> {
};
}
if (await schemaExists()) {
return { applied: 0, skipped: true, detail: '数据库结构已存在,跳过初始化' };
}
const files = listMigrationFiles();
if (files.length === 0) {
return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' };
}
const freshInstall = !(await schemaExists());
const client = createClient({ url: env.databaseUrl });
let applied = 0;
try {
for (const file of files) {
const sql = fs.readFileSync(file, 'utf8');
await client.executeMultiple(sql);
applied += 1;
logger.info(`已应用迁移:${path.basename(path.dirname(file))}`);
const applied = await loadAppliedMigrations(
client as unknown as {
execute: (sql: string) => Promise<unknown>;
},
);
const pending = files.filter((file) => !applied.has(path.basename(path.dirname(file))));
if (pending.length === 0) {
return { applied: 0, skipped: true, detail: '数据库结构已是最新,无需迁移' };
}
let reused = 0;
for (const file of pending) {
const outcome = await applyMigration(
client as unknown as { execute: (sql: string) => Promise<unknown> },
file,
);
reused += outcome.skipped;
logger.info(
`已应用迁移:${path.basename(path.dirname(file))}` +
(outcome.skipped > 0 ? `(跳过 ${outcome.skipped} 个已存在对象)` : ''),
);
}
const mode = freshInstall ? '全新安装' : '升级安装';
return {
applied: pending.length,
skipped: false,
detail: `${mode}:已应用 ${pending.length} 个迁移文件${reused > 0 ? `,跳过 ${reused} 个已存在对象` : ''}`,
};
} finally {
client.close();
}
return { applied, skipped: false, detail: `已应用 ${applied} 个迁移文件` };
}
+6
View File
@@ -22,6 +22,12 @@ export type ApiErrorCode =
| 'TOO_MANY_REQUESTS'
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
| 'URGENT_DURING_CLASS'
/** 导入:空文件 / 文件过大 / 格式错误 / 内容不合法 / 没有有效数据 */
| 'IMPORT_EMPTY_FILE'
| 'IMPORT_TOO_LARGE'
| 'IMPORT_FORMAT_INVALID'
| 'IMPORT_INVALID'
| 'IMPORT_EMPTY'
| 'INTERNAL_ERROR';
/** 业务异常:中间件会把它转换成统一错误响应体 */
@@ -28,6 +28,12 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
}
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */
export function validatedBody<T>(req: Request): T {
// validate() 会把解析结果写回 req.body
return req.body as T;
}
export function validatedQuery<T>(req: Request): T {
return (req.validatedQuery ?? {}) as T;
}
@@ -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,