import "server-only" import { generateTemplate, parseExcel } from "@/shared/lib/excel" import { getClassActiveStudentsWithInfo } from "@/modules/classes/data-access" import type { BatchCreateGradeRecordInput, ExcelImportRowInput, } from "./schema" import type { GradeRecordSemester, GradeRecordType } from "./types" /** * P3-10: 成绩 Excel 导入相关类型。 */ export interface GradeImportRow { /** 行号(从 2 开始,第 1 行为表头) */ row: number /** 学生姓名(必填) */ studentName: string /** 学生 ID(解析时为空,匹配班级学生后填充) */ studentId?: string /** 得分(必填,0-1000) */ score: number /** 备注(选填) */ remark?: string /** 该行的错误列表 */ errors: string[] } export interface GradeImportValidation { /** 校验通过的行:studentId 已填充 */ valid: Array<{ studentId: string studentName: string score: number remark?: string }> /** 校验失败的行(含错误原因) */ invalid: GradeImportRow[] /** 文件中检测到的学生姓名与班级学生匹配情况 */ unmatched: string[] } export interface GradeImportParams { classId: string } /** * P3-10: 生成成绩导入模板。 * * 模板列: * - 学生姓名(必填,须与班级学生姓名一致) * - 得分(必填,0-1000) * - 备注(选填) * * 该模板可由教师下载后填写,再上传以批量录入成绩。 */ export async function generateGradeImportTemplate(params: { classId: string }): Promise { // 拉取班级学生姓名作为示例 const students = await getClassActiveStudentsWithInfo(params.classId) const sampleRows = students.slice(0, 3).map((s, idx) => ({ 学生姓名: s.name, 得分: String(80 + idx * 5), 备注: "", })) return generateTemplate({ sheets: [ { name: "成绩录入", columns: [ { header: "学生姓名", key: "studentName", width: 24, note: "必填,须与班级学生姓名一致", }, { header: "得分", key: "score", width: 14, note: "必填,数值,范围 0-1000", }, { header: "备注", key: "remark", width: 30, note: "选填", }, ], sampleRows, }, ], }) } /** * P3-10: 解析 Excel 文件 Buffer 为成绩导入行。 * * 该函数仅做格式解析与基本类型转换(score → number), * 不做学生姓名匹配(匹配需要班级信息,由 parseGradeImportData 完成)。 */ export async function parseGradeExcelBuffer( buffer: Buffer ): Promise { const sheets = await parseExcel(buffer) if (sheets.length === 0) { return [] } const rows = sheets[0].rows const result: GradeImportRow[] = [] for (let i = 0; i < rows.length; i++) { const row = rows[i] const rowNum = i + 2 // Excel 行号从 2 开始(第 1 行为表头) // 支持中英文表头 const studentNameRaw = row["学生姓名"] ?? row["studentName"] ?? "" const scoreRaw = row["得分"] ?? row["score"] ?? "" const remarkRaw = row["备注"] ?? row["remark"] ?? "" const studentName = String(studentNameRaw).trim() const remark = remarkRaw === "" || remarkRaw == null ? undefined : String(remarkRaw).trim() // 解析 score let score: number | undefined if (scoreRaw !== "" && scoreRaw != null) { const num = Number(scoreRaw) if (Number.isFinite(num)) { score = num } } const errors: string[] = [] if (!studentName) errors.push("学生姓名不能为空") if (score === undefined) errors.push("得分不能为空且必须为数值") else if (score < 0 || score > 1000) errors.push("得分必须在 0-1000 之间") result.push({ row: rowNum, studentName, score: score ?? 0, remark, errors, }) } return result } /** * P3-10: 校验成绩导入数据,匹配学生 ID。 * * 步骤: * 1. 拉取班级所有学生信息 * 2. 按姓名匹配学生 ID * 3. 校验行级数据完整性 * 4. 返回 valid / invalid / unmatched */ export async function parseGradeImportData( rows: GradeImportRow[], params: GradeImportParams ): Promise { const students = await getClassActiveStudentsWithInfo(params.classId) // 按姓名构建索引(同一班级可能存在重名学生,需提醒) const nameToStudents = new Map() for (const s of students) { const existing = nameToStudents.get(s.name) if (existing) { existing.push(s) } else { nameToStudents.set(s.name, [s]) } } const valid: GradeImportValidation["valid"] = [] const invalid: GradeImportRow[] = [] const unmatchedSet = new Set() for (const row of rows) { const errors = [...row.errors] // 学生姓名匹配 if (row.studentName) { const matched = nameToStudents.get(row.studentName) if (!matched || matched.length === 0) { errors.push(`未在班级中找到学生"${row.studentName}"`) unmatchedSet.add(row.studentName) } else if (matched.length > 1) { errors.push( `班级中存在 ${matched.length} 个名为"${row.studentName}"的学生,无法自动匹配` ) } else { row.studentId = matched[0].id } } if (errors.length > 0 || !row.studentId) { invalid.push({ ...row, errors: errors.length > 0 ? errors : ["学生匹配失败"] }) } else { valid.push({ studentId: row.studentId, studentName: row.studentName, score: row.score, remark: row.remark, }) } } return { valid, invalid, unmatched: Array.from(unmatchedSet), } } /** * P3-10: 将校验通过的导入行转换为 batchCreateGradeRecords 的输入格式。 */ export function buildBatchInputFromImport(params: { classId: string subjectId: string title: string examId?: string academicYearId?: string fullScore?: number type?: GradeRecordType semester?: GradeRecordSemester rows: Array<{ studentId: string score: number remark?: string }> }): BatchCreateGradeRecordInput { return { classId: params.classId, subjectId: params.subjectId, title: params.title, examId: params.examId, academicYearId: params.academicYearId, fullScore: params.fullScore, type: params.type, semester: params.semester, records: params.rows.map((r) => ({ studentId: r.studentId, score: r.score, remark: r.remark, })), } } export interface GradeImportResult { successCount: number failedCount: number invalidRows: GradeImportRow[] unmatchedStudents: string[] /** 创建的记录 ID 列表(用于撤销) */ createdIds: string[] } /** * 兼容类型:从 ExcelImportRowInput 转换到 GradeImportRow(仅类型层使用)。 */ export function fromSchemaRow(row: ExcelImportRowInput, rowNum: number): GradeImportRow { return { row: rowNum, studentName: row.studentName, score: row.score, remark: row.remark, errors: [], } }