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,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>;