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:
SpecialX
2026-07-03 10:25:01 +08:00
parent 2b95fd668b
commit e85a5f05dd
51 changed files with 6871 additions and 916 deletions

View 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: [],
}
}