chore(release): 1.3.3 发布并退役旧联动

This commit is contained in:
LoveChengke committed 2026-10-17 03:06:46 +08:00
1 parent b7cdc7c9ea
commit c0ebf88138
184 files changed
+5450 -11705

No files matched your search

@@ -97,7 +97,7 @@ function normalizeTime(value: unknown): string | null {
/**
* `WeekCountDiv` / `WeekCountDivTotal` → 我们课表的 weekParity。
* 口径集中在 `@classhelper/shared` 的 `weekParityFromDiv`(插件上报走同一份实现),
* 口径集中在 `@classhelper/shared` 的 `weekParityFromDiv`,
* 这里只是本模块内的别名,保证"手动导入 JSON"与"插件实时上报"落到库里完全一致。
*/
const resolveParity = weekParityFromDiv;
@@ -294,13 +294,14 @@ export async function importClassPlan(
const classRecord = await prisma.class.findUnique({
where: { id: input.classId },
select: { id: true, teacherId: true },
select: { id: true, teacherId: true, termWeeks: true },
});
if (!classRecord) throw ApiError.notFound('班级不存在');
// 时间配置(导入的时间表)里如果只有部分节次,weekEnd 用默认 20 周
// 导入范围与本班配置的学期长度一致。
const weekStart = input.termStartWeek ?? 1;
const weekEnd = 20;
const weekEnd = classRecord.termWeeks;
if (weekStart > weekEnd) throw ApiError.badRequest('导入起始周不能超出本班学期周数');
const result = await prisma.$transaction(async (tx) => {
// 1) 补建缺失课程(用班主任作为课程任课老师)
@@ -48,23 +48,33 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
*/
router.get('/template', requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'), (req, res) => {
const kind = resolveImportKind(req.query.kind);
assertImportKindAllowed(kind, getAuthUser(req).role);
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) },
'模板生成成功',
);
});
router.get(
'/template',
requireAnyPermission(
'import:students',
'grade:import',
'import:teachers',
'import:schedule',
'import:class-teachers',
),
(req, res) => {
const kind = resolveImportKind(req.query.kind);
assertImportKindAllowed(kind, getAuthUser(req).role);
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 - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
@@ -72,7 +82,13 @@ router.get('/template', requireAnyPermission('import:students', 'grade:import',
*/
router.post(
'/table/preview',
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
requireAnyPermission(
'import:students',
'grade:import',
'import:teachers',
'import:schedule',
'import:class-teachers',
),
validate({ body: tableFileSchema }),
(req, res) => {
const input = validatedBody<TableFileInput>(req);
@@ -89,13 +105,19 @@ router.post(
*/
router.post(
'/table/commit',
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
requireAnyPermission(
'import:students',
'grade:import',
'import:teachers',
'import:schedule',
'import:class-teachers',
),
validate({ body: tableCommitSchema }),
async (req, res) => {
const input = validatedBody<TableCommitInput>(req);
assertImportKindAllowed(input.kind, getAuthUser(req).role);
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
if (input.kind !== 'teachers' && !input.classId) {
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) {
throw ApiError.badRequest('请先选择要导入的班级');
}
const result = await commitTable(getAuthUser(req), input);
@@ -334,7 +334,16 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
});
// 学号在库里已归一化为大写,比对前统一一次
const byStudentNo = new Map(students.map((item) => [normalizeStudentNo(item.studentNo), item]));
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const nameCounts = new Map<string, number>();
for (const item of students) {
const key = item.name.trim().toLowerCase();
nameCounts.set(key, (nameCounts.get(key) ?? 0) + 1);
}
const byName = new Map(
students
.filter((item) => nameCounts.get(item.name.trim().toLowerCase()) === 1)
.map((item) => [item.name.trim().toLowerCase(), item]),
);
const courses = await prisma.course.findMany({
where: { classId },
select: { id: true, name: true, teacherId: true },
@@ -370,9 +379,19 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
errors.push({ row: rowNumber, message: '缺少学生标识(学号与姓名至少填写一个)' });
continue;
}
if (
!byStudentNo.has(normalizeStudentNo(studentNo)) &&
(nameCounts.get(name.trim().toLowerCase()) ?? 0) > 1
) {
errors.push({
row: rowNumber,
message: `该班有 ${nameCounts.get(name.trim().toLowerCase())} 个同名同学「${name}」,请填写正确学号`,
});
continue;
}
const student =
(studentNo ? byStudentNo.get(normalizeStudentNo(studentNo)) : undefined) ??
(name ? byName.get(name.toLowerCase()) : undefined);
(name ? byName.get(name.trim().toLowerCase()) : undefined);
if (!student) {
errors.push({ row: rowNumber, message: `找不到该班学生:${studentNo || name}` });
continue;
@@ -492,7 +511,8 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
const classByName = new Map<string, { id: string; name: string }>();
for (const item of allClasses) {
const key = item.name.toLowerCase();
if (classByName.has(key)) classByName.delete(key); // 重名 → 标记为不可用
if (classByName.has(key))
classByName.delete(key); // 重名 → 标记为不可用
else classByName.set(key, item);
}
@@ -555,10 +575,7 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
}
if (existing.classId !== classId) {
// 换班要留痕,与手动调班同一种历史
const [from, to] = await Promise.all([
classNameOf(existing.classId),
classNameOf(classId),
]);
const [from, to] = await Promise.all([classNameOf(existing.classId), classNameOf(classId)]);
await prisma.studentClassTransfer.create({
data: {
studentId: existing.id,
@@ -681,7 +698,9 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
errors,
warnings,
};
logger.info(`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`);
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
@@ -698,7 +717,10 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
* 后面几行指向的还是同一个账号(`useTeacherNo` 打开时按工号匹配,关闭时按姓名唯一匹配),
* 因此"班主任同时是科任老师"只会有一条教师记录,权限在 `resolveClassRole` 里合并。
*/
export async function commitClassTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
export async function commitClassTeachers(
user: TokenPayload,
input: TableCommitInput,
): Promise<ImportResult> {
await assertImportAllowed(user, undefined, 'classTeachers');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
@@ -891,12 +913,13 @@ async function classNameOf(classId: string | null): Promise<string> {
return record?.name ?? '';
}
/** 角色列解析:只认「管理员」类词;其余(含空)返回 null 交给调用方取默认值 */
/** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
const value = cell.trim().toUpperCase();
if (!value) return null;
if (value === 'ADMIN' || cell.includes('管理员') || value === '管理员') return 'ADMIN';
return 'TEACHER';
if (value === 'TEACHER' || value === '教师' || value === '老师') return 'TEACHER';
return null;
}
/** 班级任课老师的角色列:班主任 / 科任 */