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
@@ -34,6 +34,7 @@
|
||||
"helmet": "^8.3.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"xlsx": "0.18.5",
|
||||
"zod": "^4.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TimeLayout" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL DEFAULT 'classisland',
|
||||
"items" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "TimeLayout_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TimeLayout_classId_idx" ON "TimeLayout"("classId");
|
||||
@@ -66,6 +66,7 @@ model Class {
|
||||
notifications Notification[]
|
||||
grades Grade[]
|
||||
enrollments Enrollment[]
|
||||
timeLayouts TimeLayout[]
|
||||
|
||||
@@index([teacherId])
|
||||
}
|
||||
@@ -212,6 +213,24 @@ model NotificationRead {
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
|
||||
/// 课表时间配置(ClassIsland 同款 JSON 导入后的节次时间表)
|
||||
model TimeLayout {
|
||||
id String @id @default(cuid())
|
||||
classId String
|
||||
name String
|
||||
/// 来源:classisland | manual
|
||||
source String @default("classisland")
|
||||
/// 节次数组(JSON 字符串):[{ index, name, startTime, endTime, type, skipped }]
|
||||
items String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([classId])
|
||||
}
|
||||
|
||||
/// 成绩
|
||||
model Grade {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -847,6 +847,393 @@ async function main() {
|
||||
`涉及 ${homeworkClassIds.size} 个班级`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------- 8. 导入能力(课表时间配置 + 表格导入)
|
||||
const NL = String.fromCharCode(10);
|
||||
|
||||
// 8.1 导入模板下载
|
||||
const templateCsv = await api('/imports/template?kind=grades&format=csv', { token: adminToken });
|
||||
const templateCsvText = templateCsv.payload?.data?.content ?? '';
|
||||
record(
|
||||
'成绩导入模板(CSV)可下载且含必填列',
|
||||
templateCsv.status === 200 &&
|
||||
templateCsvText.includes('考试名称') &&
|
||||
templateCsvText.includes('分数') &&
|
||||
templateCsvText.charCodeAt(0) === 0xfeff,
|
||||
`status=${templateCsv.status} len=${templateCsvText.length}`,
|
||||
);
|
||||
|
||||
const templateXlsxResponse = await fetch(`${BASE_URL}/api/imports/template?kind=students&format=xlsx`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const templateXlsxBuffer = Buffer.from(await templateXlsxResponse.arrayBuffer());
|
||||
record(
|
||||
'名单导入模板(XLSX)可下载',
|
||||
templateXlsxResponse.status === 200 &&
|
||||
templateXlsxBuffer.length > 500 &&
|
||||
templateXlsxBuffer.subarray(0, 2).toString() === 'PK',
|
||||
`status=${templateXlsxResponse.status} bytes=${templateXlsxBuffer.length}`,
|
||||
);
|
||||
|
||||
const templateDenied = await api('/imports/template?kind=grades&format=csv', { token: teacherToken });
|
||||
record('教师下载成绩模板被拒绝(403)', templateDenied.status === 403, `status=${templateDenied.status}`);
|
||||
|
||||
// 8.2 ClassIsland 课表时间配置:预览 -> 导入 -> 失败回滚 -> 合并 -> 越权 -> 清理
|
||||
const sampleLayout = {
|
||||
TimeLayouts: [
|
||||
{ Name: '第 1 节', StartTime: '8:0:0', EndTime: '8:45:0', TimeType: 0 },
|
||||
{ Name: '课间休息', StartTime: '8:45:0', EndTime: '8:55:0', TimeType: 1 },
|
||||
{ Name: '第 2 节', StartSecond: 31500, EndSecond: 34200, TimeType: 0 },
|
||||
],
|
||||
};
|
||||
|
||||
const layoutPreview = await api('/imports/time-layout/preview', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { classId, payload: sampleLayout },
|
||||
});
|
||||
const previewItems = layoutPreview.payload?.data?.items ?? [];
|
||||
record(
|
||||
'ClassIsland 时间配置预览(时分与秒两种写法)',
|
||||
layoutPreview.status === 200 && previewItems.length === 3 && previewItems[0]?.startTime === '08:00',
|
||||
`status=${layoutPreview.status} 节数=${previewItems.length} shape=${layoutPreview.payload?.data?.shape}`,
|
||||
);
|
||||
|
||||
const brokenPreview = await api('/imports/time-layout/preview', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { classId, payload: '[{"StartTime":"8:0:0","EndTime":"7:0:0"}]' },
|
||||
});
|
||||
record(
|
||||
'时间配置校验可定位错误(预览返回 errors)',
|
||||
brokenPreview.status === 200 && (brokenPreview.payload?.data?.errors ?? []).length > 0,
|
||||
`errors=${(brokenPreview.payload?.data?.errors ?? []).length}`,
|
||||
);
|
||||
|
||||
const layoutImport = await api('/imports/time-layout', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { classId, name: '默认时间表', mode: 'replace', payload: sampleLayout },
|
||||
});
|
||||
const layoutId = layoutImport.payload?.data?.layout?.id ?? null;
|
||||
record(
|
||||
'班主任导入本班时间配置(replace)',
|
||||
layoutImport.status === 200 && layoutImport.payload?.data?.layout?.items?.length === 3,
|
||||
`status=${layoutImport.status} 节数=${layoutImport.payload?.data?.layout?.items?.length}`,
|
||||
);
|
||||
|
||||
const layoutList = await api(`/imports/time-layout?classId=${classId}`, { token: teacherToken });
|
||||
record(
|
||||
'时间配置已持久化可查询',
|
||||
(layoutList.payload?.data ?? []).some((item) => item.id === layoutId),
|
||||
`共 ${(layoutList.payload?.data ?? []).length} 份`,
|
||||
);
|
||||
|
||||
const badImport = await api('/imports/time-layout', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: {
|
||||
classId,
|
||||
name: '默认时间表',
|
||||
mode: 'replace',
|
||||
payload: '[{"StartTime":"9:0:0","EndTime":"8:0:0"}]',
|
||||
},
|
||||
});
|
||||
const afterBadList = await api(`/imports/time-layout?classId=${classId}`, { token: teacherToken });
|
||||
const afterBadItems = (afterBadList.payload?.data ?? []).find((item) => item.id === layoutId)?.items ?? [];
|
||||
record(
|
||||
'非法时间配置导入失败且原配置不变(失败回滚)',
|
||||
badImport.status === 400 && badImport.payload?.code === 'IMPORT_INVALID' && afterBadItems.length === 3,
|
||||
`status=${badImport.status} code=${badImport.payload?.code} 原配置=${afterBadItems.length} 节`,
|
||||
);
|
||||
|
||||
const mergeImport = await api('/imports/time-layout', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: {
|
||||
classId,
|
||||
name: '默认时间表',
|
||||
mode: 'merge',
|
||||
payload: [
|
||||
{ StartTime: '8:0:0', EndTime: '8:50:0', Name: '第 1 节(调整)' },
|
||||
{ StartTime: '10:0:0', EndTime: '10:45:0', Name: '第 3 节' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const mergeData = mergeImport.payload?.data;
|
||||
record(
|
||||
'合并导入按开始时间去重(合并 1 节 / 新增 1 节)',
|
||||
mergeImport.status === 200 &&
|
||||
mergeData?.merged === 1 &&
|
||||
mergeData?.replaced === 1 &&
|
||||
mergeData?.layout?.items?.length === 4,
|
||||
`合并=${mergeData?.merged} 新增=${mergeData?.replaced} 共=${mergeData?.layout?.items?.length}`,
|
||||
);
|
||||
|
||||
if (foreignClass) {
|
||||
const crossImport = await api('/imports/time-layout', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { classId: foreignClass.id, mode: 'replace', payload: sampleLayout },
|
||||
});
|
||||
record('向他人班级导入时间配置被拒绝(403)', crossImport.status === 403, `status=${crossImport.status}`);
|
||||
}
|
||||
|
||||
const studentLayoutList = await api(`/imports/time-layout?classId=${classId}`, { token: studentToken });
|
||||
record(
|
||||
'学生访问时间配置管理接口被拒绝(403)',
|
||||
studentLayoutList.status === 403,
|
||||
`status=${studentLayoutList.status}`,
|
||||
);
|
||||
|
||||
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
|
||||
const gradesCsv = [
|
||||
'用户名,姓名,考试名称,分数,总分,课程',
|
||||
'student01,,期中导入考试,92,100,',
|
||||
`,${targetStudent?.name ?? '张同学'},期中导入考试,不是数字,100,`,
|
||||
'unknown-user,,期中导入考试,88,100,',
|
||||
].join(NL);
|
||||
const gradesCsvBase64 = Buffer.from(`\ufeff${gradesCsv}${NL}`, 'utf8').toString('base64');
|
||||
|
||||
const gradesPreview = await api('/imports/table/preview', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { kind: 'grades', fileName: 'grades.csv', contentBase64: gradesCsvBase64 },
|
||||
});
|
||||
const previewData = gradesPreview.payload?.data;
|
||||
record(
|
||||
'CSV 成绩表预览(列名 + 行数 + 建议映射)',
|
||||
gradesPreview.status === 200 &&
|
||||
previewData?.columns?.length === 6 &&
|
||||
previewData?.totalRows === 3 &&
|
||||
previewData?.suggestedMapping?.score === '分数',
|
||||
`status=${gradesPreview.status} 行数=${previewData?.totalRows} 映射=${JSON.stringify(previewData?.suggestedMapping ?? {})}`,
|
||||
);
|
||||
|
||||
const missingColumn = await api('/imports/table/preview', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: {
|
||||
kind: 'grades',
|
||||
fileName: 'missing.csv',
|
||||
contentBase64: Buffer.from(`\ufeff姓名${NL}王小明${NL}`, 'utf8').toString('base64'),
|
||||
},
|
||||
});
|
||||
record(
|
||||
'缺少必填列的表格给出明确提示',
|
||||
missingColumn.status === 200 && (missingColumn.payload?.data?.errors ?? []).length >= 2,
|
||||
`errors=${(missingColumn.payload?.data?.errors ?? []).length}`,
|
||||
);
|
||||
|
||||
const emptyFile = await api('/imports/table/preview', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { kind: 'grades', fileName: 'empty.csv', contentBase64: '' },
|
||||
});
|
||||
record(
|
||||
'空文件被明确拒绝(IMPORT_EMPTY_FILE)',
|
||||
emptyFile.status === 400 && emptyFile.payload?.code === 'IMPORT_EMPTY_FILE',
|
||||
`status=${emptyFile.status} code=${emptyFile.payload?.code}`,
|
||||
);
|
||||
|
||||
const xlsxNamespace = await import('xlsx');
|
||||
const XLSX = xlsxNamespace.default ?? xlsxNamespace;
|
||||
const xlsxSheet = XLSX.utils.aoa_to_sheet([
|
||||
['学号', '姓名', '考试', '得分', '满分', '科目'],
|
||||
['student02', '', '期中导入考试', 85, 100, ''],
|
||||
['student01', '', '期中导入考试', 78, 100, ''],
|
||||
]);
|
||||
const xlsxBook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(xlsxBook, xlsxSheet, '成绩');
|
||||
const xlsxBase64 = XLSX.write(xlsxBook, { type: 'base64', bookType: 'xlsx' });
|
||||
|
||||
const xlsxPreview = await api('/imports/table/preview', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { kind: 'grades', fileName: 'grades.xlsx', contentBase64: xlsxBase64 },
|
||||
});
|
||||
record(
|
||||
'XLSX 成绩表预览(同义词自动映射 学号/得分)',
|
||||
xlsxPreview.status === 200 &&
|
||||
xlsxPreview.payload?.data?.totalRows === 2 &&
|
||||
xlsxPreview.payload?.data?.suggestedMapping?.username === '学号' &&
|
||||
xlsxPreview.payload?.data?.suggestedMapping?.score === '得分',
|
||||
`status=${xlsxPreview.status} 映射=${JSON.stringify(xlsxPreview.payload?.data?.suggestedMapping ?? {})}`,
|
||||
);
|
||||
|
||||
const commitMapping = {
|
||||
username: '学号',
|
||||
name: '姓名',
|
||||
examName: '考试',
|
||||
score: '得分',
|
||||
totalScore: '满分',
|
||||
courseName: '科目',
|
||||
};
|
||||
const gradesCommitBody = {
|
||||
kind: 'grades',
|
||||
classId,
|
||||
fileName: 'grades.xlsx',
|
||||
contentBase64: xlsxBase64,
|
||||
mapping: commitMapping,
|
||||
};
|
||||
|
||||
const gradesCommit = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { ...gradesCommitBody, mode: 'append' },
|
||||
});
|
||||
record(
|
||||
'成绩导入新增成功且返回结果统计',
|
||||
gradesCommit.status === 200 &&
|
||||
gradesCommit.payload?.data?.inserted === 2 &&
|
||||
gradesCommit.payload?.data?.failed === 0,
|
||||
`新增=${gradesCommit.payload?.data?.inserted} 失败=${gradesCommit.payload?.data?.failed}`,
|
||||
);
|
||||
|
||||
const gradesAgain = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { ...gradesCommitBody, mode: 'append' },
|
||||
});
|
||||
record(
|
||||
'重复成绩按 append 跳过(不重复写入)',
|
||||
gradesAgain.status === 200 &&
|
||||
gradesAgain.payload?.data?.skipped === 2 &&
|
||||
gradesAgain.payload?.data?.inserted === 0,
|
||||
`跳过=${gradesAgain.payload?.data?.skipped} 新增=${gradesAgain.payload?.data?.inserted}`,
|
||||
);
|
||||
|
||||
const gradesUpsert = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { ...gradesCommitBody, mode: 'upsert' },
|
||||
});
|
||||
record(
|
||||
'重复成绩按 upsert 更新',
|
||||
gradesUpsert.status === 200 && gradesUpsert.payload?.data?.updated === 2,
|
||||
`更新=${gradesUpsert.payload?.data?.updated}`,
|
||||
);
|
||||
|
||||
const gradesBroken = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: {
|
||||
kind: 'grades',
|
||||
classId,
|
||||
fileName: 'grades.csv',
|
||||
contentBase64: gradesCsvBase64,
|
||||
mapping: {
|
||||
username: '用户名',
|
||||
name: '姓名',
|
||||
examName: '考试名称',
|
||||
score: '分数',
|
||||
totalScore: '总分',
|
||||
courseName: '课程',
|
||||
},
|
||||
mode: 'append',
|
||||
},
|
||||
});
|
||||
const brokenErrors = gradesBroken.payload?.data?.errors ?? [];
|
||||
record(
|
||||
'非法数据行给出具体行号与原因',
|
||||
gradesBroken.status === 200 &&
|
||||
gradesBroken.payload?.data?.failed === 2 &&
|
||||
brokenErrors.length === 2 &&
|
||||
brokenErrors.every((item) => item.row >= 2 && item.message.length > 0),
|
||||
`失败=${gradesBroken.payload?.data?.failed} 首条=第 ${brokenErrors[0]?.row} 行:${brokenErrors[0]?.message ?? ''}`,
|
||||
);
|
||||
|
||||
const gradesByTeacher = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { ...gradesCommitBody, mode: 'append' },
|
||||
});
|
||||
record(
|
||||
'教师(非管理员)导入成绩被拒绝(403)',
|
||||
gradesByTeacher.status === 403,
|
||||
`status=${gradesByTeacher.status}`,
|
||||
);
|
||||
|
||||
// 8.4 学生名单导入(仅管理员)+ 数据清理
|
||||
const importedUsername = `imp${Date.now().toString(36).slice(-6)}`;
|
||||
const studentsCsv = [
|
||||
'用户名,姓名,初始密码',
|
||||
`${importedUsername},导入测试生,imp123456`,
|
||||
'bad user,非法用户名,imp123456',
|
||||
].join(NL);
|
||||
const studentsCommit = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: {
|
||||
kind: 'students',
|
||||
classId,
|
||||
fileName: 'students.csv',
|
||||
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
|
||||
mapping: { username: '用户名', name: '姓名', password: '初始密码' },
|
||||
mode: 'append',
|
||||
},
|
||||
});
|
||||
record(
|
||||
'学生名单导入(新增 1 / 非法用户名进错误行 1)',
|
||||
studentsCommit.status === 200 &&
|
||||
studentsCommit.payload?.data?.inserted === 1 &&
|
||||
studentsCommit.payload?.data?.failed === 1,
|
||||
`新增=${studentsCommit.payload?.data?.inserted} 失败=${studentsCommit.payload?.data?.failed}`,
|
||||
);
|
||||
|
||||
const studentsByTeacher = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: {
|
||||
kind: 'students',
|
||||
classId,
|
||||
fileName: 'students.csv',
|
||||
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
|
||||
mapping: { username: '用户名', name: '姓名', password: '初始密码' },
|
||||
mode: 'append',
|
||||
},
|
||||
});
|
||||
record(
|
||||
'教师导入学生名单被拒绝(403)',
|
||||
studentsByTeacher.status === 403,
|
||||
`status=${studentsByTeacher.status}`,
|
||||
);
|
||||
|
||||
// 清理:删除导入产生的成绩、时间配置与学生,保持环境可重复验证
|
||||
const importedGrades = await api(
|
||||
`/grades?classId=${classId}&examName=${encodeURIComponent('期中导入考试')}`,
|
||||
{
|
||||
token: adminToken,
|
||||
},
|
||||
);
|
||||
let gradesCleaned = 0;
|
||||
for (const grade of importedGrades.payload?.data ?? []) {
|
||||
const removed = await api(`/grades/${grade.id}`, { method: 'DELETE', token: adminToken });
|
||||
if (removed.status === 200) gradesCleaned += 1;
|
||||
}
|
||||
record('导入的成绩可正常删除(清理验证数据)', gradesCleaned >= 2, `已删除 ${gradesCleaned} 条`);
|
||||
|
||||
const roster = await api(`/students?classId=${classId}`, { token: adminToken });
|
||||
const importedStudent = (roster.payload?.data ?? []).find((item) => item.username === importedUsername);
|
||||
if (importedStudent) {
|
||||
const removedStudent = await api(`/students/${importedStudent.id}`, {
|
||||
method: 'DELETE',
|
||||
token: adminToken,
|
||||
});
|
||||
record(
|
||||
'导入的学生可正常删除(清理验证数据)',
|
||||
removedStudent.status === 200,
|
||||
`status=${removedStudent.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (layoutId) {
|
||||
const layoutDeleted = await api(`/imports/time-layout/${layoutId}`, {
|
||||
method: 'DELETE',
|
||||
token: teacherToken,
|
||||
});
|
||||
record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`);
|
||||
}
|
||||
|
||||
socket.close();
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
@@ -9,8 +9,9 @@ import { hashPassword } from './password.js';
|
||||
/**
|
||||
* 首次启动时的数据库初始化(SQLite/libSQL)。
|
||||
*
|
||||
* 场景:安装程序首次运行、Docker 挂载了空数据卷、用户手动删除了数据库文件。
|
||||
* 做法:若目标库中还没有业务表,就把随包发布的 Prisma 迁移 SQL 依次执行一遍。
|
||||
* 场景:安装程序首次运行、覆盖安装升级、Docker 挂载了空数据卷、用户手动删除了数据库文件。
|
||||
* 做法:以 `_ch_migrations` 账本表记录已执行的迁移;启动时按时间戳顺序补跑未记录的迁移。
|
||||
* 全新库会跑全部迁移,旧库只补跑新增迁移(已存在的表/索引自动跳过)。
|
||||
*
|
||||
* 说明:
|
||||
* - 只处理 SQLite(本项目打包安装的默认形态);
|
||||
@@ -58,6 +59,103 @@ async function schemaExists(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移账本表名。
|
||||
*
|
||||
* 老版本安装(1.0 之前)只区分「空库 / 非空库」,因此升级安装时不会补建新表。
|
||||
* 现在用一张账本表记录已执行过的迁移目录名,启动时只补跑未记录的迁移,
|
||||
* 这样「覆盖安装升级」也能自动获得新表/新索引。
|
||||
*/
|
||||
const MIGRATION_LEDGER = '_ch_migrations';
|
||||
|
||||
/** 这些错误表示对象已存在,属于「旧版本已经建过」的正常情况,可安全忽略 */
|
||||
const IGNORABLE_SQL_ERROR = /already exists|duplicate column name|duplicate index/i;
|
||||
|
||||
/** 拆分 migration.sql 为独立语句(去掉注释行,过滤空语句) */
|
||||
function splitStatements(sql: string): string[] {
|
||||
return sql
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0);
|
||||
}
|
||||
|
||||
/** 确保账本表存在,返回已记录的迁移名 */
|
||||
async function loadAppliedMigrations(client: {
|
||||
execute: (sql: string) => Promise<unknown>;
|
||||
}): Promise<Set<string>> {
|
||||
await client.execute(
|
||||
`CREATE TABLE IF NOT EXISTS "${MIGRATION_LEDGER}" ("name" TEXT NOT NULL PRIMARY KEY, "appliedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
|
||||
`SELECT name FROM "${MIGRATION_LEDGER}"`,
|
||||
);
|
||||
return new Set(rows.map((row) => row.name));
|
||||
}
|
||||
|
||||
interface MigrationOutcome {
|
||||
file: string;
|
||||
applied: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/** 执行单个迁移文件:逐条语句执行,已存在的对象跳过,其余错误上抛 */
|
||||
async function applyMigration(
|
||||
client: { execute: (sql: string) => Promise<unknown> },
|
||||
file: string,
|
||||
): Promise<MigrationOutcome> {
|
||||
const sql = fs.readFileSync(file, 'utf8');
|
||||
const outcome: MigrationOutcome = { file, applied: 0, skipped: 0 };
|
||||
|
||||
for (const statement of splitStatements(sql)) {
|
||||
try {
|
||||
await client.execute(statement);
|
||||
outcome.applied += 1;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (IGNORABLE_SQL_ERROR.test(message)) {
|
||||
outcome.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`迁移 ${path.basename(path.dirname(file))} 执行失败:${message}\nSQL: ${statement.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.execute({
|
||||
// libsql 的 execute 支持参数化对象形式
|
||||
sql: `INSERT OR REPLACE INTO "${MIGRATION_LEDGER}" ("name", "appliedAt") VALUES (?, CURRENT_TIMESTAMP)`,
|
||||
args: [path.basename(path.dirname(file))],
|
||||
} as never);
|
||||
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** 计算待执行的迁移文件 */
|
||||
export async function listPendingMigrations(): Promise<string[]> {
|
||||
const files = listMigrationFiles();
|
||||
if (files.length === 0) return [];
|
||||
|
||||
const client = createClient({ url: env.databaseUrl });
|
||||
try {
|
||||
const applied = await loadAppliedMigrations(
|
||||
client as unknown as {
|
||||
execute: (sql: string) => Promise<unknown>;
|
||||
},
|
||||
);
|
||||
return files.filter((file) => !applied.has(path.basename(path.dirname(file))));
|
||||
} catch (error) {
|
||||
logger.warn('读取迁移账本失败,按全部待执行处理', error);
|
||||
return files;
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
|
||||
export interface BootstrapResult {
|
||||
applied: number;
|
||||
skipped: boolean;
|
||||
@@ -95,8 +193,12 @@ export async function ensureInitialAdmin(): Promise<{ created: boolean; username
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保数据库结构存在。
|
||||
* 确保数据库结构存在(增量迁移)。
|
||||
* 仅在 AUTO_MIGRATE=true 时由启动流程调用。
|
||||
*
|
||||
* - 全新数据库:依次执行全部迁移;
|
||||
* - 旧版本升级:只补跑账本里没有记录的迁移(已存在的对象自动跳过);
|
||||
* - 已是最新:直接返回,不做任何写操作。
|
||||
*/
|
||||
export async function ensureSchema(): Promise<BootstrapResult> {
|
||||
if (env.databaseProvider !== 'sqlite') {
|
||||
@@ -107,27 +209,46 @@ export async function ensureSchema(): Promise<BootstrapResult> {
|
||||
};
|
||||
}
|
||||
|
||||
if (await schemaExists()) {
|
||||
return { applied: 0, skipped: true, detail: '数据库结构已存在,跳过初始化' };
|
||||
}
|
||||
|
||||
const files = listMigrationFiles();
|
||||
if (files.length === 0) {
|
||||
return { applied: 0, skipped: true, detail: '未找到迁移文件,请手动执行 pnpm db:migrate' };
|
||||
}
|
||||
|
||||
const freshInstall = !(await schemaExists());
|
||||
const client = createClient({ url: env.databaseUrl });
|
||||
let applied = 0;
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const sql = fs.readFileSync(file, 'utf8');
|
||||
await client.executeMultiple(sql);
|
||||
applied += 1;
|
||||
logger.info(`已应用迁移:${path.basename(path.dirname(file))}`);
|
||||
const applied = await loadAppliedMigrations(
|
||||
client as unknown as {
|
||||
execute: (sql: string) => Promise<unknown>;
|
||||
},
|
||||
);
|
||||
const pending = files.filter((file) => !applied.has(path.basename(path.dirname(file))));
|
||||
|
||||
if (pending.length === 0) {
|
||||
return { applied: 0, skipped: true, detail: '数据库结构已是最新,无需迁移' };
|
||||
}
|
||||
|
||||
let reused = 0;
|
||||
for (const file of pending) {
|
||||
const outcome = await applyMigration(
|
||||
client as unknown as { execute: (sql: string) => Promise<unknown> },
|
||||
file,
|
||||
);
|
||||
reused += outcome.skipped;
|
||||
logger.info(
|
||||
`已应用迁移:${path.basename(path.dirname(file))}` +
|
||||
(outcome.skipped > 0 ? `(跳过 ${outcome.skipped} 个已存在对象)` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
const mode = freshInstall ? '全新安装' : '升级安装';
|
||||
return {
|
||||
applied: pending.length,
|
||||
skipped: false,
|
||||
detail: `${mode}:已应用 ${pending.length} 个迁移文件${reused > 0 ? `,跳过 ${reused} 个已存在对象` : ''}`,
|
||||
};
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
|
||||
return { applied, skipped: false, detail: `已应用 ${applied} 个迁移文件` };
|
||||
}
|
||||
@@ -22,6 +22,12 @@ export type ApiErrorCode =
|
||||
| 'TOO_MANY_REQUESTS'
|
||||
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
|
||||
| 'URGENT_DURING_CLASS'
|
||||
/** 导入:空文件 / 文件过大 / 格式错误 / 内容不合法 / 没有有效数据 */
|
||||
| 'IMPORT_EMPTY_FILE'
|
||||
| 'IMPORT_TOO_LARGE'
|
||||
| 'IMPORT_FORMAT_INVALID'
|
||||
| 'IMPORT_INVALID'
|
||||
| 'IMPORT_EMPTY'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
||||
|
||||
@@ -28,6 +28,12 @@ export function validate<B = unknown, Q = unknown, P = unknown>(
|
||||
}
|
||||
|
||||
/** 读取已校验的 query(类型由调用方用 zod schema 保证) */
|
||||
/** 取出校验后的请求体(与 validatedQuery/validatedParams 同一套约定) */
|
||||
export function validatedBody<T>(req: Request): T {
|
||||
// validate() 会把解析结果写回 req.body
|
||||
return req.body as T;
|
||||
}
|
||||
|
||||
export function validatedQuery<T>(req: Request): T {
|
||||
return (req.validatedQuery ?? {}) as T;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,409 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
|
||||
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
|
||||
|
||||
/**
|
||||
* 表格导入(xlsx / xls / csv)。
|
||||
*
|
||||
* 流程:上传(base64) → 解析首个工作表 → 预览(列名/前 N 行/校验问题/建议映射)
|
||||
* → 前端确认字段映射与写入模式 → 提交(逐行校验,重复按模式处理)→ 结果统计。
|
||||
*
|
||||
* 异常处理:空文件、超大文件、格式错误、缺列、非法数值、重复数据、未知学生等
|
||||
* 都会给出明确原因与行号,绝不静默失败。
|
||||
*/
|
||||
const MAX_IMPORT_BYTES = 8 * 1024 * 1024; // 8MB
|
||||
const PREVIEW_ROWS = 20;
|
||||
|
||||
export type ImportKind = TableFileInput['kind'];
|
||||
|
||||
/** 规范字段定义(预览、模板、映射提示共用) */
|
||||
export const IMPORT_FIELDS: Record<
|
||||
ImportKind,
|
||||
{ key: string; label: string; required: boolean; synonyms: string[] }[]
|
||||
> = {
|
||||
grades: [
|
||||
{
|
||||
key: 'username',
|
||||
label: '学生用户名',
|
||||
required: false,
|
||||
synonyms: ['用户名', 'username', '学号', '账号'],
|
||||
},
|
||||
{ key: 'name', label: '学生姓名', required: false, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
|
||||
{
|
||||
key: 'examName',
|
||||
label: '考试名称',
|
||||
required: true,
|
||||
synonyms: ['考试', '考试名称', 'exam', 'examName', '测验'],
|
||||
},
|
||||
{ key: 'score', label: '分数', required: true, synonyms: ['分数', 'score', '成绩', '得分'] },
|
||||
{ key: 'totalScore', label: '总分', required: false, synonyms: ['总分', 'totalScore', '满分'] },
|
||||
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
|
||||
],
|
||||
students: [
|
||||
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
|
||||
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
|
||||
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
|
||||
],
|
||||
};
|
||||
|
||||
export interface TablePreview {
|
||||
kind: ImportKind;
|
||||
columns: string[];
|
||||
rows: string[][];
|
||||
totalRows: number;
|
||||
suggestedMapping: Record<string, string>;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
templateCsv: string;
|
||||
}
|
||||
|
||||
interface ParsedTable {
|
||||
columns: string[];
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
function decodeBase64(contentBase64: string): Buffer {
|
||||
const cleaned = contentBase64.includes(',')
|
||||
? contentBase64.slice(contentBase64.indexOf(',') + 1)
|
||||
: contentBase64;
|
||||
const buffer = Buffer.from(cleaned, 'base64');
|
||||
if (buffer.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '文件内容为空,请确认导出后重新上传');
|
||||
if (buffer.length > MAX_IMPORT_BYTES) {
|
||||
throw new ApiError(
|
||||
413,
|
||||
'IMPORT_TOO_LARGE',
|
||||
`文件过大(${(buffer.length / 1024 / 1024).toFixed(1)}MB),请拆分后分批导入(上限 ${MAX_IMPORT_BYTES / 1024 / 1024}MB)`,
|
||||
);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** 解析表格:xlsx/xls 走 SheetJS,csv/tsv 也走 SheetJS(自动识别分隔符) */
|
||||
function parseTable(buffer: Buffer, fileName: string): ParsedTable {
|
||||
let workbook: XLSX.WorkBook;
|
||||
try {
|
||||
workbook = XLSX.read(buffer, { type: 'buffer', raw: false, cellDates: false });
|
||||
} catch (error) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
'IMPORT_FORMAT_INVALID',
|
||||
`文件解析失败:${(error as Error).message}(支持 .xlsx/.xls/.csv)`,
|
||||
{
|
||||
fileName,
|
||||
},
|
||||
);
|
||||
}
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = sheetName ? workbook.Sheets[sheetName] : undefined;
|
||||
if (!sheet) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格里没有工作表,请确认文件内容');
|
||||
|
||||
const matrix = XLSX.utils.sheet_to_json<string[]>(sheet, {
|
||||
header: 1,
|
||||
blankrows: false,
|
||||
defval: '',
|
||||
raw: false,
|
||||
});
|
||||
const cleaned = matrix
|
||||
.map((row) => (Array.isArray(row) ? row.map((cell) => String(cell ?? '').trim()) : []))
|
||||
.filter((row) => row.some((cell) => cell !== ''));
|
||||
|
||||
if (cleaned.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格是空的(没有任何数据行)');
|
||||
|
||||
const headerRow = cleaned[0] ?? [];
|
||||
const columns = headerRow.map((cell, index) => (cell === '' ? `第 ${index + 1} 列` : cell));
|
||||
const rows = cleaned.slice(1).map((row) => {
|
||||
const values = [...row];
|
||||
while (values.length < columns.length) values.push('');
|
||||
return values.slice(0, columns.length);
|
||||
});
|
||||
if (rows.length === 0) throw new ApiError(400, 'IMPORT_EMPTY_FILE', '表格只有表头,没有数据行');
|
||||
return { columns, rows };
|
||||
}
|
||||
|
||||
/** 生成模板 CSV(同时用于预览弹窗的"下载模板") */
|
||||
export function buildTemplateCsv(kind: ImportKind): string {
|
||||
const fields = IMPORT_FIELDS[kind];
|
||||
const header = fields.map((field) => field.label).join(',');
|
||||
const sample =
|
||||
kind === 'grades'
|
||||
? ['student01', '王小明', '期中考试', '92', '100', '数学']
|
||||
: ['student01', '王小明', 'student123'];
|
||||
const sampleLine = fields
|
||||
.map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '')
|
||||
.join(',');
|
||||
return `\ufeff${header}\n${sampleLine}\n`;
|
||||
}
|
||||
|
||||
/** 生成模板 XLSX(Base64),让老师可以直接下载后填写 */
|
||||
export function buildTemplateXlsx(kind: ImportKind): string {
|
||||
const fields = IMPORT_FIELDS[kind];
|
||||
const header = fields.map((field) => field.label);
|
||||
const sample =
|
||||
kind === 'grades'
|
||||
? ['student01', '王小明', '期中考试', 92, 100, '数学']
|
||||
: ['student01', '王小明', 'student123'];
|
||||
const sheet = XLSX.utils.aoa_to_sheet([header, sample]);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, kind === 'grades' ? '成绩' : '学生名单');
|
||||
return XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
/** 按同义词自动建议列映射 */
|
||||
function suggestMapping(kind: ImportKind, columns: string[]): Record<string, string> {
|
||||
const mapping: Record<string, string> = {};
|
||||
for (const field of IMPORT_FIELDS[kind]) {
|
||||
const match = columns.find(
|
||||
(column) =>
|
||||
field.synonyms.some((synonym) => column.toLowerCase() === synonym.toLowerCase()) ||
|
||||
field.synonyms.some((synonym) => column.toLowerCase().includes(synonym.toLowerCase())),
|
||||
);
|
||||
if (match) mapping[field.key] = match;
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
/** 预览:解析 + 必填列校验 + 建议映射(不写库) */
|
||||
export function previewTable(input: TableFileInput): TablePreview {
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const suggestedMapping = suggestMapping(input.kind, parsed.columns);
|
||||
|
||||
for (const field of IMPORT_FIELDS[input.kind]) {
|
||||
if (field.required && !suggestedMapping[field.key]) {
|
||||
errors.push(`缺少必填列「${field.label}」:请在表格里补上该列,或在下一步手动映射到某一列`);
|
||||
}
|
||||
}
|
||||
if (parsed.rows.length > 2000) warnings.push(`数据行较多(${parsed.rows.length} 行),导入可能需要几秒钟`);
|
||||
|
||||
return {
|
||||
kind: input.kind,
|
||||
columns: parsed.columns,
|
||||
rows: parsed.rows.slice(0, PREVIEW_ROWS),
|
||||
totalRows: parsed.rows.length,
|
||||
suggestedMapping,
|
||||
errors,
|
||||
warnings,
|
||||
templateCsv: buildTemplateCsv(input.kind),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ImportRowError {
|
||||
row: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
kind: ImportKind;
|
||||
total: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
errors: ImportRowError[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function cellValue(row: string[], columns: string[], mapping: Record<string, string>, key: string): string {
|
||||
const column = mapping[key];
|
||||
if (!column) return '';
|
||||
const index = columns.indexOf(column);
|
||||
if (index < 0) return '';
|
||||
return (row[index] ?? '').trim();
|
||||
}
|
||||
|
||||
/** 导入成绩:学生按用户名/姓名匹配;重复(同班级+学生+考试+课程)按 mode 处理 */
|
||||
export async function commitGrades(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
assertCanManageGrades(user);
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
|
||||
const students = await prisma.user.findMany({
|
||||
where: { classId: input.classId, role: 'STUDENT' },
|
||||
select: { id: true, username: true, name: true },
|
||||
});
|
||||
const byUsername = new Map(students.map((item) => [item.username.toLowerCase(), item]));
|
||||
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
|
||||
const courses = await prisma.course.findMany({
|
||||
where: { classId: input.classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const [index, row] of parsed.rows.entries()) {
|
||||
const rowNumber = index + 2; // 含表头,从 Excel 视角的行号
|
||||
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
||||
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
||||
const examName = cellValue(row, parsed.columns, input.mapping, 'examName');
|
||||
const scoreText = cellValue(row, parsed.columns, input.mapping, 'score');
|
||||
const totalText = cellValue(row, parsed.columns, input.mapping, 'totalScore');
|
||||
const courseName = cellValue(row, parsed.columns, input.mapping, 'courseName');
|
||||
|
||||
if (!username && !name) {
|
||||
errors.push({ row: rowNumber, message: '缺少学生标识(用户名与姓名至少填写一个)' });
|
||||
continue;
|
||||
}
|
||||
const student =
|
||||
(username ? byUsername.get(username.toLowerCase()) : undefined) ??
|
||||
(name ? byName.get(name.toLowerCase()) : undefined);
|
||||
if (!student) {
|
||||
errors.push({ row: rowNumber, message: `找不到该班学生:${username || name}` });
|
||||
continue;
|
||||
}
|
||||
if (!examName) {
|
||||
errors.push({ row: rowNumber, message: '缺少考试名称' });
|
||||
continue;
|
||||
}
|
||||
const score = Number(scoreText);
|
||||
if (!Number.isFinite(score)) {
|
||||
errors.push({ row: rowNumber, message: `分数「${scoreText}」不是数字` });
|
||||
continue;
|
||||
}
|
||||
const totalScore = totalText ? Number(totalText) : 100;
|
||||
if (!Number.isFinite(totalScore) || totalScore <= 0) {
|
||||
errors.push({ row: rowNumber, message: `总分「${totalText || '100'}」不合法` });
|
||||
continue;
|
||||
}
|
||||
if (score < 0 || score > totalScore) {
|
||||
errors.push({ row: rowNumber, message: `分数 ${score} 超出 0~${totalScore} 范围` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const course = courseName ? courses.find((item) => item.name === courseName) : null;
|
||||
if (courseName && !course)
|
||||
warnings.push(`第 ${rowNumber} 行:课程「${courseName}」不存在,已按"不指定课程"导入`);
|
||||
|
||||
const existing = await prisma.grade.findFirst({
|
||||
where: { classId: input.classId, userId: student.id, examName, courseId: course?.id ?? null },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (input.mode === 'append') {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await prisma.grade.update({
|
||||
where: { id: existing.id },
|
||||
data: { score, totalScore, publishedAt: new Date() },
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
await prisma.grade.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
userId: student.id,
|
||||
courseId: course?.id ?? null,
|
||||
examName,
|
||||
score,
|
||||
totalScore,
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
kind: 'grades',
|
||||
total: parsed.rows.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
logger.info(
|
||||
`成绩导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 导入学生名单:按用户名去重(append 跳过 / upsert 更新姓名与班级),仅管理员可用 */
|
||||
export async function commitStudents(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
assertCanManageRoster(user);
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
|
||||
const cls = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
|
||||
if (!cls) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const [index, row] of parsed.rows.entries()) {
|
||||
const rowNumber = index + 2;
|
||||
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
||||
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
||||
const password = cellValue(row, parsed.columns, input.mapping, 'password');
|
||||
|
||||
if (!username || !name) {
|
||||
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
|
||||
continue;
|
||||
}
|
||||
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
|
||||
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
|
||||
if (existing) {
|
||||
if (existing.role !== 'STUDENT') {
|
||||
errors.push({ row: rowNumber, message: `用户名「${username}」已被教师/管理员账号占用` });
|
||||
continue;
|
||||
}
|
||||
if (input.mode === 'append') {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: input.classId } });
|
||||
updated += 1;
|
||||
} else {
|
||||
const initialPassword = password || env.defaultStudentPassword;
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
name,
|
||||
role: 'STUDENT',
|
||||
classId: input.classId,
|
||||
passwordHash: await bcrypt.hash(initialPassword, 10),
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
kind: 'students',
|
||||
total: parsed.rows.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
logger.info(
|
||||
`学生名单导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
return input.kind === 'grades' ? commitGrades(user, input) : commitStudents(user, input);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { assertCanManageSchedule } from '../../lib/access.js';
|
||||
import type { TimeLayoutImportInput } from './imports.schemas.js';
|
||||
|
||||
/**
|
||||
* ClassIsland 课表时间(TimeLayout)导入。
|
||||
*
|
||||
* ClassIsland 的导出结构在不同版本里略有差异,常见形态:
|
||||
* 1. 顶层就是数组:`[{ StartTime: "8:0:0", EndTime: "8:45:0", TimeType: 0 }, ...]`
|
||||
* 2. 包在对象里:`{ TimeLayouts: [...] }` / `{ TimeLayoutItems: [...] }` / `{ items: [...] }`
|
||||
* 3. 时间用秒表示:`{ StartSecond: 28800, EndSecond: 31500 }`
|
||||
* 4. 字段名大小写/命名不同:`StartTime` / `startTime` / `Start`;`Name` / `Title`
|
||||
*
|
||||
* 因此这里做**容错解析**:先把可能的数组抠出来,再逐条按候选字段名解析时间,
|
||||
* 解析失败的行进入 errors(不写库),成功解析但可疑的进入 warnings(可继续导入)。
|
||||
*/
|
||||
export type TimeLayoutItemType = 'class' | 'break' | 'divider';
|
||||
|
||||
export interface ParsedTimeLayoutItem {
|
||||
index: number;
|
||||
name: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
type: TimeLayoutItemType;
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
export interface TimeLayoutParseResult {
|
||||
items: ParsedTimeLayoutItem[];
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
const TIME_KEYS = ['StartTime', 'startTime', 'start', 'Start'] as const;
|
||||
const END_KEYS = ['EndTime', 'endTime', 'end', 'End'] as const;
|
||||
const START_SECOND_KEYS = ['StartSecond', 'startSecond', 'StartSeconds'] as const;
|
||||
const END_SECOND_KEYS = ['EndSecond', 'endSecond', 'EndSeconds'] as const;
|
||||
const NAME_KEYS = ['Name', 'name', 'Title', 'title', 'Subject'] as const;
|
||||
const TYPE_KEYS = ['TimeType', 'timeType', 'Type', 'type'] as const;
|
||||
const SKIP_KEYS = ['IsSkipped', 'isSkipped', 'Skipped', 'skip'] as const;
|
||||
|
||||
function pick(record: Record<string, unknown>, keys: readonly string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (record[key] !== undefined && record[key] !== null) return record[key];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** 把 ClassIsland 的时间写法统一成 HH:mm */
|
||||
function normalizeTime(value: unknown): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
// 数字:优先按"秒"解释(ClassIsland 的 StartSecond),小于 100 时按"分钟"
|
||||
const seconds = value > 100 ? value : value * 60;
|
||||
return secondsToHHmm(seconds);
|
||||
}
|
||||
if (typeof value !== 'string') return null;
|
||||
const text = value.trim();
|
||||
if (!text) return null;
|
||||
|
||||
// HH:mm / H:mm / HH:mm:ss / H:mm:ss
|
||||
const match = /^(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/.exec(text);
|
||||
if (match) {
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (hour > 23 || minute > 59) return null;
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ISO 字符串(ClassIsland 某些版本导出 "2024-01-01T08:00:00")
|
||||
const parsed = new Date(text);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
return `${String(parsed.getHours()).padStart(2, '0')}:${String(parsed.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function secondsToHHmm(seconds: number): string {
|
||||
const total = Math.max(0, Math.round(seconds));
|
||||
const hour = Math.floor(total / 3600) % 24;
|
||||
const minute = Math.floor((total % 3600) / 60);
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** 从任意形态里抠出"可能是时间表条目"的数组 */
|
||||
function extractItems(input: unknown): { items: unknown[]; shape: string } {
|
||||
if (Array.isArray(input)) return { items: input, shape: 'array' };
|
||||
if (input && typeof input === 'object') {
|
||||
const record = input as Record<string, unknown>;
|
||||
for (const key of ['TimeLayouts', 'TimeLayoutItems', 'Items', 'items', 'Layouts', 'TimeLayout']) {
|
||||
const value = record[key];
|
||||
if (Array.isArray(value)) return { items: value, shape: key };
|
||||
// 有些导出是 { TimeLayouts: { items: [...] } }
|
||||
if (value && typeof value === 'object' && Array.isArray((value as Record<string, unknown>).items)) {
|
||||
return { items: (value as Record<string, unknown>).items as unknown[], shape: `${key}.items` };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { items: [], shape: 'unknown' };
|
||||
}
|
||||
|
||||
function normalizeType(record: Record<string, unknown>): TimeLayoutItemType {
|
||||
const raw = pick(record, TYPE_KEYS);
|
||||
const numeric = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
|
||||
if (numeric === 1) return 'break';
|
||||
if (numeric === 2) return 'divider';
|
||||
return 'class';
|
||||
}
|
||||
|
||||
export function parseClassIslandTimeLayout(input: unknown): TimeLayoutParseResult {
|
||||
let payload = input;
|
||||
if (typeof payload === 'string') {
|
||||
try {
|
||||
payload = JSON.parse(payload);
|
||||
} catch (error) {
|
||||
return { items: [], errors: [`JSON 解析失败:${(error as Error).message}`], warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
const { items: rawItems, shape } = extractItems(payload);
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (rawItems.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
errors: [
|
||||
'未识别到时间表条目:请确认是 ClassIsland 导出的课表时间 JSON(数组或含 TimeLayouts/TimeLayoutItems 字段)',
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
if (shape === 'unknown') {
|
||||
warnings.push('未识别外层结构,已尝试按数组解析');
|
||||
}
|
||||
|
||||
const parsed: ParsedTimeLayoutItem[] = [];
|
||||
rawItems.forEach((raw, index) => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
errors.push(`第 ${index + 1} 条:不是对象,已跳过`);
|
||||
return;
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
|
||||
const start =
|
||||
normalizeTime(pick(record, TIME_KEYS)) ?? normalizeTime(pick(record, START_SECOND_KEYS)) ?? null;
|
||||
const end = normalizeTime(pick(record, END_KEYS)) ?? normalizeTime(pick(record, END_SECOND_KEYS)) ?? null;
|
||||
|
||||
const skipped = pick(record, SKIP_KEYS) === true || pick(record, SKIP_KEYS) === 'true';
|
||||
const type = normalizeType(record);
|
||||
const nameRaw = pick(record, NAME_KEYS);
|
||||
const name = typeof nameRaw === 'string' && nameRaw.trim() ? nameRaw.trim() : '';
|
||||
|
||||
if (!start || !end) {
|
||||
if (type === 'divider') {
|
||||
warnings.push(`第 ${index + 1} 条:分割线没有时间,已忽略`);
|
||||
return;
|
||||
}
|
||||
errors.push(
|
||||
`第 ${index + 1} 条(${name || '未命名'}):无法解析开始/结束时间` +
|
||||
`(支持 StartTime: "8:0:0"、StartSecond: 28800 等写法)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (start >= end) {
|
||||
errors.push(`第 ${index + 1} 条(${name || '未命名'}):结束时间 ${end} 不晚于开始时间 ${start}`);
|
||||
return;
|
||||
}
|
||||
if (skipped) warnings.push(`第 ${index + 1} 条(${name || '未命名'}):原配置标记为跳过`);
|
||||
|
||||
parsed.push({
|
||||
index: parsed.length + 1,
|
||||
name: name || `第 ${parsed.length + 1} 节`,
|
||||
startTime: start,
|
||||
endTime: end,
|
||||
type,
|
||||
skipped,
|
||||
});
|
||||
});
|
||||
|
||||
// 排序 + 重叠检测(不阻断导入,仅提示)
|
||||
parsed.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||
for (let index = 1; index < parsed.length; index += 1) {
|
||||
const previous = parsed[index - 1];
|
||||
const current = parsed[index];
|
||||
if (
|
||||
previous &&
|
||||
current &&
|
||||
previous.type === 'class' &&
|
||||
current.type === 'class' &&
|
||||
current.startTime < previous.endTime
|
||||
) {
|
||||
warnings.push(
|
||||
`时间重叠:${previous.startTime}-${previous.endTime} 与 ${current.startTime}-${current.endTime}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { items: parsed, errors, warnings };
|
||||
}
|
||||
|
||||
export interface TimeLayoutDto {
|
||||
id: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
source: string;
|
||||
items: ParsedTimeLayoutItem[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
function toDto(record: {
|
||||
id: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
source: string;
|
||||
items: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): TimeLayoutDto {
|
||||
let items: ParsedTimeLayoutItem[] = [];
|
||||
try {
|
||||
items = JSON.parse(record.items) as ParsedTimeLayoutItem[];
|
||||
} catch {
|
||||
items = [];
|
||||
}
|
||||
return {
|
||||
id: record.id,
|
||||
classId: record.classId,
|
||||
name: record.name,
|
||||
source: record.source,
|
||||
items,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 预览:只解析不落库(失败时原配置不受影响) */
|
||||
export function previewClassIslandTimeLayout(payload: unknown): TimeLayoutParseResult & { shape: string } {
|
||||
return {
|
||||
...parseClassIslandTimeLayout(payload),
|
||||
shape: extractItems(typeof payload === 'string' ? safeJson(payload) : payload).shape,
|
||||
};
|
||||
}
|
||||
|
||||
function safeJson(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTimeLayouts(user: TokenPayload, classId: string): Promise<TimeLayoutDto[]> {
|
||||
await assertCanManageSchedule(user, classId);
|
||||
const records = await prisma.timeLayout.findMany({ where: { classId }, orderBy: { updatedAt: 'desc' } });
|
||||
return records.map(toDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入(写入):解析 → 有错误直接 400(不写库)→ 按 replace/merge 落库。
|
||||
* 权限与"课表管理"一致:管理员或本班班主任。
|
||||
*/
|
||||
export async function importTimeLayout(
|
||||
user: TokenPayload,
|
||||
input: TimeLayoutImportInput,
|
||||
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
|
||||
await assertCanManageSchedule(user, input.classId);
|
||||
|
||||
const parsed = parseClassIslandTimeLayout(input.payload);
|
||||
if (parsed.errors.length > 0) {
|
||||
throw new ApiError(400, 'IMPORT_INVALID', '课表时间配置解析失败,已保持原有配置不变', {
|
||||
errors: parsed.errors,
|
||||
warnings: parsed.warnings,
|
||||
});
|
||||
}
|
||||
if (parsed.items.length === 0) {
|
||||
throw new ApiError(400, 'IMPORT_EMPTY', '没有解析到任何有效节次,已保持原有配置不变', {
|
||||
warnings: parsed.warnings,
|
||||
});
|
||||
}
|
||||
|
||||
const name = input.name?.trim() || '默认时间表';
|
||||
const existing = await prisma.timeLayout.findMany({
|
||||
where: { classId: input.classId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
const current = existing.find((item) => item.name === name) ?? existing[0] ?? null;
|
||||
|
||||
if (input.mode === 'merge' && current) {
|
||||
let previousItems: ParsedTimeLayoutItem[] = [];
|
||||
try {
|
||||
previousItems = JSON.parse(current.items) as ParsedTimeLayoutItem[];
|
||||
} catch {
|
||||
previousItems = [];
|
||||
}
|
||||
|
||||
// 以"开始时间"为槽位标识:
|
||||
// - 导入项与旧配置的开始时间相同 → 视为同一节次被覆盖(计入 merged)
|
||||
// - 开始时间不同 → 视为新增(计入 replaced),旧节次全部保留
|
||||
// 注意:旧配置里可能存在开始时间相同的多条(例如分割线/跳过的节次),
|
||||
// 只有被此次导入显式覆盖的那些才移除,避免合并时静默丢数据。
|
||||
const incomingStarts = new Set(parsed.items.map((item) => item.startTime));
|
||||
const previousStarts = new Set(previousItems.map((item) => item.startTime));
|
||||
const merged = parsed.items.filter((item) => previousStarts.has(item.startTime)).length;
|
||||
const replaced = parsed.items.length - merged;
|
||||
const preserved = previousItems.filter((item) => !incomingStarts.has(item.startTime));
|
||||
const combined = [...preserved, ...parsed.items].sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||
|
||||
const updated = await prisma.timeLayout.update({
|
||||
where: { id: current.id },
|
||||
data: {
|
||||
items: JSON.stringify(combined.map((item, index) => ({ ...item, index: index + 1 }))),
|
||||
source: 'classisland',
|
||||
},
|
||||
});
|
||||
logger.info(
|
||||
`课表时间配置合并导入:班级=${input.classId} 覆盖=${merged} 新增=${replaced} 共 ${combined.length} 节`,
|
||||
);
|
||||
return { layout: toDto(updated), mode: 'merge', warnings: parsed.warnings, replaced, merged };
|
||||
}
|
||||
|
||||
// replace:同一班级同名配置整体替换;没有则新建
|
||||
if (current) {
|
||||
const updated = await prisma.timeLayout.update({
|
||||
where: { id: current.id },
|
||||
data: { items: JSON.stringify(parsed.items), name, source: 'classisland' },
|
||||
});
|
||||
logger.info(`课表时间配置覆盖导入:班级=${input.classId} 共 ${parsed.items.length} 节`);
|
||||
return {
|
||||
layout: toDto(updated),
|
||||
mode: 'replace',
|
||||
warnings: parsed.warnings,
|
||||
replaced: parsed.items.length,
|
||||
merged: 0,
|
||||
};
|
||||
}
|
||||
const created = await prisma.timeLayout.create({
|
||||
data: { classId: input.classId, name, source: 'classisland', items: JSON.stringify(parsed.items) },
|
||||
});
|
||||
logger.info(`课表时间配置新建:班级=${input.classId} 共 ${parsed.items.length} 节`);
|
||||
return {
|
||||
layout: toDto(created),
|
||||
mode: 'replace',
|
||||
warnings: parsed.warnings,
|
||||
replaced: parsed.items.length,
|
||||
merged: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteTimeLayout(user: TokenPayload, layoutId: string): Promise<void> {
|
||||
const record = await prisma.timeLayout.findUnique({ where: { id: layoutId } });
|
||||
if (!record) throw ApiError.notFound('时间配置不存在');
|
||||
await assertCanManageSchedule(user, record.classId);
|
||||
await prisma.timeLayout.delete({ where: { id: layoutId } });
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { coursesModule } from './courses/courses.module.js';
|
||||
import { dashboardModule } from './dashboard/dashboard.module.js';
|
||||
import { gradesModule } from './grades/grades.module.js';
|
||||
import { homeworksModule } from './homeworks/homeworks.module.js';
|
||||
import { importsModule } from './imports/imports.module.js';
|
||||
import { notificationsModule } from './notifications/notifications.module.js';
|
||||
import { schedulesModule } from './schedules/schedules.module.js';
|
||||
import { studentsModule } from './students/students.module.js';
|
||||
@@ -26,6 +27,7 @@ export const apiModules: ApiModule[] = [
|
||||
homeworksModule,
|
||||
notificationsModule,
|
||||
callsModule,
|
||||
importsModule,
|
||||
gradesModule,
|
||||
studentsModule,
|
||||
teachersModule,
|
||||
|
||||
Reference in new issue
Block a user