2026-10-23 22:55:21 +08:00
|
|
|
|
import { assertPermission } from '../../lib/permissions.js';
|
|
|
|
|
|
import type { TokenPayload } from '../../lib/jwt.js';
|
2026-09-12 15:35:01 +08:00
|
|
|
|
import { Router } from 'express';
|
2026-09-12 16:21:39 +08:00
|
|
|
|
import { ApiError, sendOk } from '../../lib/http.js';
|
2026-10-02 00:25:21 +08:00
|
|
|
|
import { idParamSchema } from '../../lib/schemas.js';
|
2026-10-23 22:55:21 +08:00
|
|
|
|
import {
|
|
|
|
|
|
authenticate,
|
|
|
|
|
|
getAuthUser,
|
|
|
|
|
|
requireStaffPermission,
|
|
|
|
|
|
requirePermission,
|
|
|
|
|
|
requireAnyPermission,
|
|
|
|
|
|
} from '../../middleware/auth.js';
|
2026-10-02 00:25:21 +08:00
|
|
|
|
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
2026-09-12 15:35:01 +08:00
|
|
|
|
import { defineModule } from '../module.types.js';
|
|
|
|
|
|
import {
|
2026-09-13 10:34:46 +08:00
|
|
|
|
IMPORT_KINDS,
|
2026-09-12 15:35:01 +08:00
|
|
|
|
tableCommitSchema,
|
|
|
|
|
|
tableFileSchema,
|
|
|
|
|
|
timeLayoutImportSchema,
|
2026-09-12 23:48:59 +08:00
|
|
|
|
classPlanImportSchema,
|
2026-09-12 15:35:01 +08:00
|
|
|
|
timeLayoutQuerySchema,
|
|
|
|
|
|
type TableCommitInput,
|
|
|
|
|
|
type TableFileInput,
|
|
|
|
|
|
type TimeLayoutImportInput,
|
2026-09-12 23:48:59 +08:00
|
|
|
|
type ClassPlanImportInput,
|
2026-09-12 15:35:01 +08:00
|
|
|
|
} from './imports.schemas.js';
|
|
|
|
|
|
import { buildTemplateCsv, buildTemplateXlsx, commitTable, previewTable } from './table-import.service.js';
|
2026-09-12 23:48:59 +08:00
|
|
|
|
import { importClassPlan, previewClassPlan } from './class-plan.service.js';
|
2026-09-12 15:35:01 +08:00
|
|
|
|
import {
|
|
|
|
|
|
deleteTimeLayout,
|
|
|
|
|
|
importTimeLayout,
|
|
|
|
|
|
listTimeLayouts,
|
|
|
|
|
|
previewClassIslandTimeLayout,
|
|
|
|
|
|
} from './time-layout.service.js';
|
|
|
|
|
|
|
|
|
|
|
|
const router = Router();
|
|
|
|
|
|
router.use(authenticate());
|
|
|
|
|
|
|
2026-10-23 22:55:21 +08:00
|
|
|
|
/** 导入类型分别校验对应导入键,名单还校验管理键。 */
|
|
|
|
|
|
async function assertImportKindAllowed(
|
|
|
|
|
|
kind: (typeof IMPORT_KINDS)[number],
|
|
|
|
|
|
user: TokenPayload,
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const key = {
|
|
|
|
|
|
students: 'import:students',
|
|
|
|
|
|
teachers: 'import:teachers',
|
|
|
|
|
|
grades: 'grade:import',
|
|
|
|
|
|
classTeachers: 'import:class-teachers',
|
|
|
|
|
|
scheduleTimes: 'import:schedule',
|
|
|
|
|
|
} as const;
|
|
|
|
|
|
await assertPermission(user, key[kind]);
|
|
|
|
|
|
if (kind === 'students') await assertPermission(user, 'roster:manage');
|
|
|
|
|
|
if (kind === 'teachers') await assertPermission(user, 'teacher:manage');
|
2026-09-13 10:34:46 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 查询参数里的 kind 收敛为合法值(默认 grades,避免非法值被当成成绩悄悄写入) */
|
|
|
|
|
|
function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
|
|
|
|
|
|
return IMPORT_KINDS.includes(value as (typeof IMPORT_KINDS)[number])
|
|
|
|
|
|
? (value as (typeof IMPORT_KINDS)[number])
|
|
|
|
|
|
: 'grades';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 15:35:01 +08:00
|
|
|
|
/**
|
2026-09-13 10:34:46 +08:00
|
|
|
|
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
|
2026-10-23 22:55:21 +08:00
|
|
|
|
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:对应导入与管理键
|
2026-09-12 15:35:01 +08:00
|
|
|
|
*/
|
2026-10-17 03:06:46 +08:00
|
|
|
|
router.get(
|
|
|
|
|
|
'/template',
|
2026-10-23 22:55:21 +08:00
|
|
|
|
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
|
|
|
|
|
|
async (req, res) => {
|
2026-10-17 03:06:46 +08:00
|
|
|
|
const kind = resolveImportKind(req.query.kind);
|
2026-10-23 22:55:21 +08:00
|
|
|
|
await assertImportKindAllowed(kind, getAuthUser(req));
|
2026-10-17 03:06:46 +08:00
|
|
|
|
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) },
|
|
|
|
|
|
'模板生成成功',
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-12 15:35:01 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
|
2026-10-23 22:55:21 +08:00
|
|
|
|
* 成绩:管理员或班主任;学生/教师名单按对应导入与管理键校验
|
2026-09-12 15:35:01 +08:00
|
|
|
|
*/
|
2026-09-12 16:21:39 +08:00
|
|
|
|
router.post(
|
|
|
|
|
|
'/table/preview',
|
2026-10-23 22:55:21 +08:00
|
|
|
|
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
|
2026-09-12 16:21:39 +08:00
|
|
|
|
validate({ body: tableFileSchema }),
|
2026-10-23 22:55:21 +08:00
|
|
|
|
async (req, res) => {
|
2026-09-12 16:21:39 +08:00
|
|
|
|
const input = validatedBody<TableFileInput>(req);
|
2026-10-23 22:55:21 +08:00
|
|
|
|
await assertImportKindAllowed(input.kind, getAuthUser(req));
|
2026-09-12 16:21:39 +08:00
|
|
|
|
sendOk(res, previewTable(input), '表格解析成功');
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-12 15:35:01 +08:00
|
|
|
|
|
2026-09-12 16:21:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* POST /api/imports/table/commit - 执行导入(字段映射 + 写入模式 + 结果统计)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 路由放行 ADMIN|TEACHER,真正的权限在 service 内按 kind + 班级二次校验:
|
2026-10-23 22:55:21 +08:00
|
|
|
|
* 成绩按班级与科目收口;学生名单按管理键、导入键与班主任/全校范围校验。
|
2026-09-12 16:21:39 +08:00
|
|
|
|
*/
|
2026-09-12 15:35:01 +08:00
|
|
|
|
router.post(
|
|
|
|
|
|
'/table/commit',
|
2026-10-23 22:55:21 +08:00
|
|
|
|
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
|
2026-09-12 15:35:01 +08:00
|
|
|
|
validate({ body: tableCommitSchema }),
|
|
|
|
|
|
async (req, res) => {
|
2026-09-13 10:34:46 +08:00
|
|
|
|
const input = validatedBody<TableCommitInput>(req);
|
2026-10-23 22:55:21 +08:00
|
|
|
|
await assertImportKindAllowed(input.kind, getAuthUser(req));
|
2026-09-13 10:34:46 +08:00
|
|
|
|
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
|
2026-10-17 03:06:46 +08:00
|
|
|
|
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) {
|
2026-09-13 10:34:46 +08:00
|
|
|
|
throw ApiError.badRequest('请先选择要导入的班级');
|
|
|
|
|
|
}
|
|
|
|
|
|
const result = await commitTable(getAuthUser(req), input);
|
2026-09-12 15:35:01 +08:00
|
|
|
|
sendOk(
|
|
|
|
|
|
res,
|
|
|
|
|
|
result,
|
|
|
|
|
|
`导入完成:新增 ${result.inserted} / 更新 ${result.updated} / 跳过 ${result.skipped} / 失败 ${result.failed}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* POST /api/imports/time-layout/preview - 预览 ClassIsland 课表时间 JSON(只解析不写库)
|
|
|
|
|
|
* 权限与"课表管理"一致:管理员或本班班主任
|
|
|
|
|
|
*/
|
2026-10-23 22:55:21 +08:00
|
|
|
|
router.post(
|
|
|
|
|
|
'/time-layout/preview',
|
|
|
|
|
|
requirePermission('import:schedule', 'schedule:write'),
|
|
|
|
|
|
validate({ body: timeLayoutImportSchema }),
|
|
|
|
|
|
async (req, res) => {
|
|
|
|
|
|
const input = validatedBody<TimeLayoutImportInput>(req);
|
|
|
|
|
|
// 预览也要校验班级权限(与导入一致),避免越权探测
|
|
|
|
|
|
const { assertCanImportSchedule } = await import('../../lib/access.js');
|
|
|
|
|
|
await assertCanImportSchedule(getAuthUser(req), input.classId);
|
|
|
|
|
|
sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成');
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-12 15:35:01 +08:00
|
|
|
|
|
|
|
|
|
|
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
|
2026-10-23 22:55:21 +08:00
|
|
|
|
router.get(
|
|
|
|
|
|
'/time-layout',
|
|
|
|
|
|
requireStaffPermission('schedule:read'),
|
|
|
|
|
|
validate({ query: timeLayoutQuerySchema }),
|
|
|
|
|
|
async (req, res) => {
|
|
|
|
|
|
const { classId } = validatedQuery<{ classId: string }>(req);
|
|
|
|
|
|
sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功');
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-12 15:35:01 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
|
|
|
|
|
|
* mode=replace 覆盖 / merge 按开始时间合并;解析失败返回 400 且不修改原配置。
|
|
|
|
|
|
*/
|
2026-10-17 12:55:40 +08:00
|
|
|
|
router.post(
|
|
|
|
|
|
'/time-layout',
|
2026-10-23 22:55:21 +08:00
|
|
|
|
requirePermission('timetable:manage', 'import:schedule'),
|
2026-10-17 12:55:40 +08:00
|
|
|
|
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} 节`,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-12 15:35:01 +08:00
|
|
|
|
|
|
|
|
|
|
/** DELETE /api/imports/time-layout/:id - 删除某份时间配置 */
|
2026-10-17 12:55:40 +08:00
|
|
|
|
router.delete(
|
|
|
|
|
|
'/time-layout/:id',
|
|
|
|
|
|
requirePermission('timetable:manage'),
|
|
|
|
|
|
validate({ params: idParamSchema }),
|
|
|
|
|
|
async (req, res) => {
|
|
|
|
|
|
const { id } = validatedParams<{ id: string }>(req);
|
|
|
|
|
|
await deleteTimeLayout(getAuthUser(req), id);
|
|
|
|
|
|
sendOk(res, { id }, '时间配置已删除');
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-12 15:35:01 +08:00
|
|
|
|
|
2026-09-12 23:48:59 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
|
|
|
|
|
|
* 真实档案里单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。
|
|
|
|
|
|
*/
|
2026-10-23 22:55:21 +08:00
|
|
|
|
router.post(
|
|
|
|
|
|
'/class-plan/preview',
|
|
|
|
|
|
requirePermission('import:schedule', 'schedule:write'),
|
|
|
|
|
|
validate({ body: classPlanImportSchema }),
|
|
|
|
|
|
async (req, res) => {
|
|
|
|
|
|
const result = await previewClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
|
|
|
|
|
|
sendOk(
|
|
|
|
|
|
res,
|
|
|
|
|
|
result,
|
|
|
|
|
|
`解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-12 23:48:59 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* POST /api/imports/class-plan - 导入 ClassIsland 课程表(支持单双周)
|
|
|
|
|
|
* replace 清空后写入 / merge 按 星期+开始时间+单双周 去重;解析失败返回 400 且不改动现有课表。
|
|
|
|
|
|
*/
|
2026-10-23 22:55:21 +08:00
|
|
|
|
router.post(
|
|
|
|
|
|
'/class-plan',
|
|
|
|
|
|
requirePermission('import:schedule', 'schedule:write'),
|
|
|
|
|
|
validate({ body: classPlanImportSchema }),
|
|
|
|
|
|
async (req, res) => {
|
|
|
|
|
|
const result = await importClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
|
|
|
|
|
|
sendOk(
|
|
|
|
|
|
res,
|
|
|
|
|
|
result,
|
|
|
|
|
|
`导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` +
|
|
|
|
|
|
`${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`,
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2026-09-12 23:48:59 +08:00
|
|
|
|
|
2026-09-12 15:35:01 +08:00
|
|
|
|
export const importsModule = defineModule({
|
|
|
|
|
|
name: 'imports',
|
|
|
|
|
|
basePath: '/imports',
|
|
|
|
|
|
router,
|
|
|
|
|
|
});
|