chore(release): 1.3.3 发布并退役旧联动
This commit is contained in:
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;
|
||||
}
|
||||
|
||||
/** 班级任课老师的角色列:班主任 / 科任 */
|
||||
|
||||
Reference in new issue
Block a user