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