Files
NextEdu/src/modules/grades/import-export.ts
SpecialX e85a5f05dd feat(grades): add appeals, drafts, import, report card, and growth archive
- Add actions-appeal, actions-draft, actions-import, actions-lock for grade workflow

- Add data-access-appeals, data-access-drafts, data-access-exam-entry for data layer

- Add batch-grade-entry-dialog, batch-grade-entry-stats, batch-grade-entry-table

- Add draft-lock-banner, excel-import-dialog for import and draft management

- Add growth-archive-chart, knowledge-point-mastery-chart for analytics

- Add report-card-view, report-card-print-action, report-card-print-button

- Add import-export, lib/notify, lib/report-card, scope-check test, stats-service test

- Add hooks directory
2026-07-03 10:25:01 +08:00

278 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Buffer> {
// 拉取班级学生姓名作为示例
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<GradeImportRow[]> {
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<GradeImportValidation> {
const students = await getClassActiveStudentsWithInfo(params.classId)
// 按姓名构建索引(同一班级可能存在重名学生,需提醒)
const nameToStudents = new Map<string, typeof students>()
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<string>()
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: [],
}
}