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
This commit is contained in:
277
src/modules/grades/import-export.ts
Normal file
277
src/modules/grades/import-export.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
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: [],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user