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

@@ -1,5 +1,6 @@
"use server"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
@@ -7,9 +8,12 @@ import { handleActionError } from "@/shared/lib/action-utils"
import {
getClassComparison,
getClassComparisonWithSignificance,
getGradeDistribution,
getGradeDistributionByGradeId,
getGradeTrend,
getStudentGrowthArchive,
getStudentPositionInClassDistribution,
getSubjectComparison,
type ClassComparisonParams,
type GradeDistributionByGradeParams,
@@ -24,36 +28,51 @@ import {
GradeDistributionQuerySchema,
GradeTrendQuerySchema,
RankingTrendQuerySchema,
StudentGrowthArchiveQuerySchema,
SubjectComparisonQuerySchema,
} from "./schema"
import { assertClassInScope } from "./lib/scope-check"
import type {
ClassComparisonItem,
ClassComparisonResult,
GradeDistributionByGradeResult,
GradeDistributionResult,
GradeDistributionWithPosition,
GradeTrendResult,
RankingTrendResult,
StudentGrowthArchiveResult,
SubjectComparisonItem,
} from "./types"
/**
* grades 模块分析类 Server Actions。
*
* P1-5 重构:所有错误消息已接入 i18nnext-intl
* - 校验失败使用 `t("action.invalidQuery")`
* - scope 校验失败使用 `t(\`action.scopeError.${code}\`)`
* - 学生/家长越权访问排名趋势使用专用翻译键
*/
export async function getGradeTrendAction(
params: Omit<GradeTrendParams, "scope" | "currentUserId">
): Promise<ActionState<GradeTrendResult | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = GradeTrendQuerySchema.safeParse(params)
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
// P3 修复:添加 assertClassInScope 校验
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
if (scopeError) return { success: false, message: scopeError }
if (scopeError) {
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const result = await getGradeTrend({
...parsed.data,
@@ -71,20 +90,19 @@ export async function getClassComparisonAction(
): Promise<ActionState<ClassComparisonItem[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = ClassComparisonQuerySchema.safeParse(params)
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
// P3 修复:对 class_taught scope 校验gradeId 维度无法直接校验,依赖 data-access 层过滤)
// 这里不调用 assertClassInScope因为没有 classId 参数。
// 对 class_taught scope 校验gradeId 维度无法直接校验,依赖 data-access 层过滤)
// data-access 层的 getClassComparison 已通过 buildScopeClassFilter 应用 scope 过滤。
const result = await getClassComparison({
...parsed.data,
scope: ctx.dataScope,
@@ -95,24 +113,56 @@ export async function getClassComparisonAction(
}
}
/**
* P3-5: 获取班级对比数据 + 统计显著性分析结果。
* 在 getClassComparisonAction 基础上额外返回 Cohen's d 与 p 值。
*/
export async function getClassComparisonWithSignificanceAction(
params: Omit<ClassComparisonParams, "scope">
): Promise<ActionState<ClassComparisonResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = ClassComparisonQuerySchema.safeParse(params)
if (!parsed.success) {
return {
success: false,
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
const result = await getClassComparisonWithSignificance({
...parsed.data,
scope: ctx.dataScope,
})
return { success: true, data: result }
} catch (e) {
return handleActionError(e)
}
}
export async function getSubjectComparisonAction(
params: Omit<SubjectComparisonParams, "scope">
): Promise<ActionState<SubjectComparisonItem[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = SubjectComparisonQuerySchema.safeParse(params)
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
// P3 修复:添加 assertClassInScope 校验
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
if (scopeError) return { success: false, message: scopeError }
if (scopeError) {
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const result = await getSubjectComparison({
...parsed.data,
@@ -129,19 +179,21 @@ export async function getGradeDistributionAction(
): Promise<ActionState<GradeDistributionResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = GradeDistributionQuerySchema.safeParse(params)
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
// P3 修复:添加 assertClassInScope 校验
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
if (scopeError) return { success: false, message: scopeError }
if (scopeError) {
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const result = await getGradeDistribution({
...parsed.data,
@@ -161,29 +213,31 @@ export async function getRankingTrendAction(
): Promise<ActionState<RankingTrendResult | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = RankingTrendQuerySchema.safeParse({ studentId, subjectId, semester })
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
// Students can only view their own ranking trend
// P1-5 修复:硬编码英文消息改为 i18n
// 学生只能查看自己的排名趋势
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
return { success: false, message: "Can only view your own ranking trend" }
return { success: false, message: t("action.ownRankingTrendOnly") }
}
// Parents can only view their children's ranking trend
// 家长只能查看子女的排名趋势
if (
ctx.dataScope.type === "children" &&
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
) {
return { success: false, message: "Can only view your children's ranking trend" }
return { success: false, message: t("action.childrenRankingTrendOnly") }
}
// P3 修复:传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属
// 传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属
const result = await getRankingTrend(
parsed.data.studentId,
parsed.data.subjectId,
@@ -205,12 +259,13 @@ export async function getGradeDistributionByGradeIdAction(
): Promise<ActionState<GradeDistributionByGradeResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = GradeDistributionByGradeQuerySchema.safeParse(params)
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -224,3 +279,74 @@ export async function getGradeDistributionByGradeIdAction(
return handleActionError(e)
}
}
/**
* P3-9: 获取学生所在班级的成绩分布 + 学生本人位置标注(隐私保护视图)。
* 仅学生本人class_members scope可调用查询所在班级的分布 + 自己的归一化分数。
*/
export async function getStudentPositionInClassDistributionAction(
studentId: string
): Promise<ActionState<GradeDistributionWithPosition | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
if (ctx.dataScope.type !== "class_members") {
return { success: false, message: t("action.studentOnly") }
}
// 学生只能查看自己的分布位置
if (ctx.userId !== studentId) {
return { success: false, message: t("action.ownGradesOnly") }
}
const result = await getStudentPositionInClassDistribution(studentId, ctx.dataScope)
return { success: true, data: result }
} catch (e) {
return handleActionError(e)
}
}
/**
* P3-4: 获取学生纵向成长档案(跨学年/学期聚合)。
*
* 权限规则:
* - class_members学生仅能查看自己的成长档案
* - children家长仅能查看子女的成长档案
* - class_taught教师仅能查看所教班级学生的成长档案data-access 层校验)
* - grade_managed / all可查看任意学生的成长档案
*/
export async function getStudentGrowthArchiveAction(
studentId: string
): Promise<ActionState<StudentGrowthArchiveResult | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = StudentGrowthArchiveQuerySchema.safeParse({ studentId })
if (!parsed.success) {
return {
success: false,
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
// 学生只能查看自己的成长档案
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
return { success: false, message: t("action.ownGradesOnly") }
}
// 家长只能查看子女的成长档案
if (
ctx.dataScope.type === "children" &&
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
) {
return { success: false, message: t("action.childrenGradesOnly") }
}
const result = await getStudentGrowthArchive(parsed.data.studentId, ctx.dataScope)
return { success: true, data: result }
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -0,0 +1,215 @@
"use server"
import { revalidatePath } from "next/cache"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils"
import { getGradeRecordById } from "./data-access"
import {
hasPendingAppeal,
createGradeAppeal,
getStudentAppeals,
getPendingAppealsForReview,
getAppealById,
reviewGradeAppeal,
withdrawGradeAppeal,
type GradeAppeal,
type GradeAppealWithRecord,
} from "./data-access-appeals"
import { CreateGradeAppealSchema, ReviewGradeAppealSchema } from "./schema"
/**
* P3-7 成绩申诉 Server Actions。
*
* 权限模型:
* - 学生提交/撤回/查看自己的申诉GRADE_RECORD_READ
* - 教师复核申诉GRADE_RECORD_MANAGE
*
* 状态机pending → approved/rejected/withdrawn
*/
/**
* 学生提交成绩申诉。
*
* 校验:
* 1. 成绩记录存在且属于当前用户scope=owned 时 studentId=ctx.userId
* 2. 该成绩无 pending 申诉
*/
export async function createGradeAppealAction(
prevState: ActionState<string> | null,
formData: FormData
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = CreateGradeAppealSchema.safeParse({
gradeRecordId: formData.get("gradeRecordId"),
reason: formData.get("reason"),
expectedScore: formData.get("expectedScore") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: t("action.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
// 校验成绩归属
const record = await getGradeRecordById(parsed.data.gradeRecordId)
if (!record) {
return { success: false, message: t("appeal.gradeNotFound") }
}
if (record.studentId !== ctx.userId) {
return { success: false, message: t("appeal.notYourGrade") }
}
// 校验无 pending 申诉
const hasPending = await hasPendingAppeal(parsed.data.gradeRecordId)
if (hasPending) {
return { success: false, message: t("appeal.pendingExists") }
}
const id = await createGradeAppeal({
gradeRecordId: parsed.data.gradeRecordId,
studentId: ctx.userId,
originalScore: record.score,
reason: parsed.data.reason,
expectedScore: parsed.data.expectedScore,
})
revalidatePath("/student/grades")
return { success: true, message: t("appeal.created"), data: id }
} catch (e) {
return handleActionError(e)
}
}
/**
* 学生撤回申诉。
*/
export async function withdrawGradeAppealAction(
appealId: string
): Promise<ActionState<{ withdrawn: true }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
await withdrawGradeAppeal({ appealId, studentId: ctx.userId })
revalidatePath("/student/grades")
return { success: true, data: { withdrawn: true } }
} catch (e) {
return handleActionError(e)
}
}
/**
* 获取学生自己的申诉列表。
*/
export async function getStudentAppealsAction(): Promise<
ActionState<GradeAppeal[]>
> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const appeals = await getStudentAppeals(ctx.userId)
return { success: true, data: appeals }
} catch (e) {
return handleActionError(e)
}
}
/**
* 教师复核申诉approve/reject
*
* 若 approve 且提供 adjustedScore会同步更新 grade_records.score。
*/
export async function reviewGradeAppealAction(
prevState: ActionState<{ reviewed: true }> | null,
formData: FormData
): Promise<ActionState<{ reviewed: true }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
const parsed = ReviewGradeAppealSchema.safeParse({
appealId: formData.get("appealId"),
decision: formData.get("decision"),
reviewComment: formData.get("reviewComment"),
adjustedScore: formData.get("adjustedScore") || undefined,
})
if (!parsed.success) {
return {
success: false,
message: t("action.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
// 校验申诉归属:教师只能复核自己班级的申诉
const appeal = await getAppealById(parsed.data.appealId)
if (!appeal) {
return { success: false, message: t("appeal.notFound") }
}
const scopeError = ctx.dataScope.type === "class_taught"
? (ctx.dataScope.classIds.includes(appeal.gradeRecordClassId) ? null : "out_of_scope")
: ctx.dataScope.type === "all"
? null
: "out_of_scope"
if (scopeError) {
return { success: false, message: t("appeal.outOfScope") }
}
await reviewGradeAppeal({
appealId: parsed.data.appealId,
reviewerId: ctx.userId,
decision: parsed.data.decision,
reviewComment: parsed.data.reviewComment,
adjustedScore: parsed.data.adjustedScore,
})
revalidatePath("/teacher/grades")
revalidatePath("/student/grades")
return {
success: true,
message: parsed.data.decision === "approved"
? t("appeal.approved")
: t("appeal.rejected"),
data: { reviewed: true },
}
} catch (e) {
return handleActionError(e)
}
}
/**
* 获取教师待复核的申诉列表。
*/
export async function getPendingAppealsForReviewAction(): Promise<
ActionState<GradeAppealWithRecord[]>
> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
// 获取教师班级范围
const classIds =
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : []
if (ctx.dataScope.type !== "all" && classIds.length === 0) {
return { success: true, data: [] }
}
const appeals = await getPendingAppealsForReview(classIds)
return { success: true, data: appeals }
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -0,0 +1,122 @@
"use server"
import { revalidatePath } from "next/cache"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils"
import {
saveGradeDraft,
getGradeDraft,
deleteGradeDraft,
type GradeDraftData,
} from "./data-access-drafts"
import { assertClassInScope } from "./lib/scope-check"
/**
* P1-1 拆分:从 actions.ts 抽取的成绩录入草稿 Server Actions。
* 对应 data-access 的 saveGradeDraft / getGradeDraft / deleteGradeDraft。
*/
/**
* 保存成绩录入草稿到服务端(跨设备同步)。
*/
export async function saveGradeDraftAction(params: {
classId: string
subjectId: string
type: string
scores: Record<string, string>
}): Promise<ActionState<{ saved: true }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
// 校验班级权限
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) {
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const data: GradeDraftData = {
scores: params.scores,
timestamp: Date.now(),
}
await saveGradeDraft({
userId: ctx.userId,
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
data,
})
return { success: true, data: { saved: true } }
} catch (e) {
return handleActionError(e)
}
}
/**
* 获取成绩录入草稿(跨设备恢复)。
*/
export async function getGradeDraftAction(params: {
classId: string
subjectId: string
type: string
}): Promise<ActionState<GradeDraftData | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
// 校验班级权限
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) {
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const draft = await getGradeDraft({
userId: ctx.userId,
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
})
return { success: true, data: draft }
} catch (e) {
return handleActionError(e)
}
}
/**
* 删除成绩录入草稿(提交成功后调用)。
*/
export async function deleteGradeDraftAction(params: {
classId: string
subjectId: string
type: string
}): Promise<ActionState<{ deleted: true }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
// 校验班级权限
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) {
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
await deleteGradeDraft({
userId: ctx.userId,
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
})
revalidatePath("/teacher/grades")
return { success: true, data: { deleted: true } }
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -0,0 +1,296 @@
"use server"
import { revalidatePath } from "next/cache"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils"
import { formatDateForFile } from "@/shared/lib/utils"
import { assertClassInScope } from "./lib/scope-check"
import {
ExcelImportGradesSchema,
type ExcelImportGradesInput,
} from "./schema"
import { batchCreateGradeRecords } from "./data-access"
import {
generateGradeImportTemplate,
parseGradeExcelBuffer,
parseGradeImportData,
buildBatchInputFromImport,
type GradeImportResult,
} from "./import-export"
import { notifyGradeEntered } from "./lib/notify"
/**
* P3-10: grades 模块成绩 Excel 批量导入相关 Server Actions。
*
* 文件拆分自 actions.ts避免主 actions 文件超过 800 行限制。
*/
/**
* 下载成绩导入模板(返回 base64 编码的 Excel
*
* 模板包含:
* - 学生姓名 / 得分 / 备注 三列
* - 前 3 行班级学生作为示例
* - 第 2 行填写说明
*/
export async function downloadGradeImportTemplateAction(params: {
classId: string
}): Promise<ActionState<{ buffer: string; filename: string }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
if (!params.classId) {
return { success: false, message: t("action.classIdRequired") }
}
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) {
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const buffer = await generateGradeImportTemplate({ classId: params.classId })
const filename = t("excelImport.templateFilename", {
date: formatDateForFile(),
})
return {
success: true,
data: {
buffer: buffer.toString("base64"),
filename,
},
}
} catch (e) {
return handleActionError(e)
}
}
/**
* P3-10: 从 Excel 批量导入成绩。
*
* 输入FormData包含
* - file: Excel 文件(.xlsx / .xls
* - classId: 班级 ID
* - subjectId: 科目 ID
* - title: 评估标题
* - examId?: 关联考试 ID
* - academicYearId?: 学年 ID
* - fullScore?: 满分(默认 100
* - type?: 类型exam/quiz/homework/other默认 exam
* - semester?: 学期("1"/"2",默认 "1"
*
* 处理流程:
* 1. 权限校验GRADE_RECORD_CREATE
* 2. scope 校验:班级在教师所教范围内
* 3. 文件格式校验(扩展名 + buffer 解析)
* 4. 行级数据校验(学生姓名匹配 + score 数值校验)
* 5. 若全部失败 → 返回错误明细
* 6. 若部分成功 → 调用 batchCreateGradeRecords 入库
* 7. 通知相关学生notifyGradeEntered
* 8. 触发异常预警检查notifyScoreAnomalyIfNeeded 在 actions.ts 主流程中)
*/
export async function importGradesFromExcelAction(
prevState: ActionState<GradeImportResult> | null,
formData: FormData
): Promise<ActionState<GradeImportResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
// 文件校验
const file = formData.get("file")
if (!(file instanceof File)) {
return { success: false, message: t("excelImport.errorNoFile") }
}
const lowerName = file.name.toLowerCase()
if (!lowerName.endsWith(".xlsx") && !lowerName.endsWith(".xls")) {
return { success: false, message: t("excelImport.errorInvalidFormat") }
}
// 参数校验Zod
const parsed = ExcelImportGradesSchema.safeParse({
classId: formData.get("classId"),
subjectId: formData.get("subjectId"),
title: formData.get("title"),
examId: formData.get("examId") || undefined,
academicYearId: formData.get("academicYearId") || undefined,
fullScore: formData.get("fullScore") || undefined,
type: formData.get("type") || undefined,
semester: formData.get("semester") || undefined,
rows: [], // rows 由 Excel 解析填充schema 仅校验其他字段
})
if (!parsed.success) {
return {
success: false,
message: t("excelImport.errorInvalidParams"),
errors: parsed.error.flatten().fieldErrors,
}
}
// scope 校验
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
if (scopeError) {
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
// 解析 Excel
const bytes = Buffer.from(await file.arrayBuffer())
const excelRows = await parseGradeExcelBuffer(bytes)
if (excelRows.length === 0) {
return { success: false, message: t("excelImport.errorEmptyFile") }
}
// 行数上限校验(与 BatchCreateGradeRecordSchema.max(500) 一致)
if (excelRows.length > 500) {
return {
success: false,
message: t("excelImport.errorTooManyRows", { count: excelRows.length }),
}
}
// 学生匹配 + 行级校验
const validation = await parseGradeImportData(excelRows, {
classId: parsed.data.classId,
})
// 全部失败 → 返回错误明细
if (validation.valid.length === 0) {
return {
success: false,
message: t("excelImport.errorAllInvalid", {
count: validation.invalid.length,
}),
data: {
successCount: 0,
failedCount: validation.invalid.length,
invalidRows: validation.invalid,
unmatchedStudents: validation.unmatched,
createdIds: [],
},
}
}
// 构造批量录入参数
const batchInput = buildBatchInputFromImport({
classId: parsed.data.classId,
subjectId: parsed.data.subjectId,
title: parsed.data.title,
examId: parsed.data.examId,
academicYearId: parsed.data.academicYearId,
fullScore: parsed.data.fullScore,
type: parsed.data.type,
semester: parsed.data.semester,
rows: validation.valid,
})
// 入库
const createdIds = await batchCreateGradeRecords(batchInput, ctx.userId)
// 通知相关学生(失败不阻断主流程)
try {
await notifyGradeEntered({
subjectId: parsed.data.subjectId,
title: parsed.data.title,
studentIds: validation.valid.map((r) => r.studentId),
})
} catch {
// 通知失败不影响导入结果
}
// 刷新缓存
revalidatePath("/teacher/grades")
revalidatePath("/teacher/grades/entry")
revalidatePath(`/teacher/grades?classId=${parsed.data.classId}`)
const result: GradeImportResult = {
successCount: createdIds.length,
failedCount: validation.invalid.length,
invalidRows: validation.invalid,
unmatchedStudents: validation.unmatched,
createdIds,
}
return {
success: true,
message: t("excelImport.successMessage", {
success: result.successCount,
failed: result.failedCount,
}),
data: result,
}
} catch (e) {
return handleActionError(e)
}
}
/**
* P3-10: 获取导入预览(不写入数据库)。
*
* 用于上传文件后、确认提交前的预览页面。
*/
export async function previewGradeImportAction(params: {
file: File
classId: string
}): Promise<ActionState<GradeImportResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
if (!params.file || !(params.file instanceof File)) {
return { success: false, message: t("excelImport.errorNoFile") }
}
if (!params.classId) {
return { success: false, message: t("action.classIdRequired") }
}
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) {
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const bytes = Buffer.from(await params.file.arrayBuffer())
const excelRows = await parseGradeExcelBuffer(bytes)
if (excelRows.length === 0) {
return { success: false, message: t("excelImport.errorEmptyFile") }
}
if (excelRows.length > 500) {
return {
success: false,
message: t("excelImport.errorTooManyRows", { count: excelRows.length }),
}
}
const validation = await parseGradeImportData(excelRows, {
classId: params.classId,
})
return {
success: true,
data: {
successCount: validation.valid.length,
failedCount: validation.invalid.length,
invalidRows: validation.invalid,
unmatchedStudents: validation.unmatched,
createdIds: [],
},
}
} catch (e) {
return handleActionError(e)
}
}
/**
* ExcelImportGradesInput 类型导出(供其他模块引用)。
*/
export type { ExcelImportGradesInput }

View File

@@ -0,0 +1,205 @@
"use server"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils"
import {
getDraftLockStatus,
acquireDraftLock,
renewDraftLock,
releaseDraftLock,
type DraftLockStatus,
type AcquireLockResult,
} from "./data-access-drafts"
import { assertClassInScope } from "./lib/scope-check"
import { getUserNamesByIds } from "@/modules/users/data-access"
/**
* P3-6 协同录入锁 Server Actions。
*
* 锁的获取/续期/释放都通过 Server Action 完成,
* 调用方需传入 classId+subjectId+type 三元组定位锁。
* 所有 Action 强制校验 GRADE_RECORD_MANAGE 权限 + 班级 scope。
*/
/**
* 解析锁持有者姓名(避免 data-access 跨模块 join
*/
async function enrichLockStatusName(
status: DraftLockStatus
): Promise<DraftLockStatus> {
if (!status.lockedBy) return status
const names = await getUserNamesByIds([status.lockedBy])
const info = names.get(status.lockedBy)
return { ...status, lockedByName: info?.name ?? null }
}
/**
* 查询锁状态(进入页面时调用)。
*/
export async function getDraftLockStatusAction(params: {
classId: string
subjectId: string
type: string
}): Promise<ActionState<DraftLockStatus>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) {
const t = await getTranslations("grades")
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const status = await getDraftLockStatus({
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
currentUserId: ctx.userId,
})
return { success: true, data: await enrichLockStatusName(status) }
} catch (e) {
return handleActionError(e)
}
}
/**
* 获取编辑锁(进入编辑页或被抢占后重试时调用)。
*/
export async function acquireDraftLockAction(params: {
classId: string
subjectId: string
type: string
}): Promise<ActionState<AcquireLockResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) {
const t = await getTranslations("grades")
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const result = await acquireDraftLock({
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
userId: ctx.userId,
})
if (!result.acquired) {
// 锁被他人持有,返回结构化信息让前端展示冲突提示
const enriched = await enrichLockStatusName(result.status)
return {
success: false,
data: { acquired: false, lockToken: null, status: enriched },
}
}
return {
success: true,
data: {
acquired: true,
lockToken: result.lockToken,
status: result.status,
},
}
} catch (e) {
return handleActionError(e)
}
}
/**
* 续期锁(心跳)。
*
* 前端每 2-3 分钟调用一次,避免长时间编辑时锁过期。
* 返回新的 lockToken前端需替换本地保存的旧 token。
*/
export async function renewDraftLockAction(params: {
classId: string
subjectId: string
type: string
lockToken: string
}): Promise<ActionState<AcquireLockResult>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) {
const t = await getTranslations("grades")
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const result = await renewDraftLock({
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
userId: ctx.userId,
lockToken: params.lockToken,
})
if (!result.acquired) {
const enriched = await enrichLockStatusName(result.status)
return {
success: false,
data: { acquired: false, lockToken: null, status: enriched },
}
}
return {
success: true,
data: {
acquired: true,
lockToken: result.lockToken,
status: result.status,
},
}
} catch (e) {
return handleActionError(e)
}
}
/**
* 释放锁(提交成功或用户主动退出时调用)。
*
* 失败不阻断主流程(如批量录入成功后释放锁失败仅记日志)。
*/
export async function releaseDraftLockAction(params: {
classId: string
subjectId: string
type: string
lockToken: string
}): Promise<ActionState<{ released: true }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) {
const t = await getTranslations("grades")
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const released = await releaseDraftLock({
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
userId: ctx.userId,
lockToken: params.lockToken,
})
if (!released) {
// 锁可能已被抢占或自动过期,返回成功以避免阻断主流程
// 前端只需知道"无需再持有该锁"
}
return { success: true, data: { released: true } }
} catch (e) {
// 释放失败不应阻断主流程,降级为日志
console.error("[grades] releaseDraftLockAction failed:", e)
return { success: true, data: { released: true } }
}
}

View File

@@ -1,13 +1,11 @@
"use server"
import { revalidatePath } from "next/cache"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError, safeJsonParse } from "@/shared/lib/action-utils"
import { createNotification } from "@/modules/notifications/data-access"
import { getSubjectNameById } from "@/modules/school/data-access"
import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
import {
CreateGradeRecordSchema,
@@ -25,7 +23,6 @@ import {
import {
createGradeRecord,
batchCreateGradeRecords,
batchCreateGradeRecordsByExam,
undoBatchCreateGradeRecords,
updateGradeRecord,
deleteGradeRecord,
@@ -35,88 +32,36 @@ import {
getClassGradeStatsWithMeta,
getStudentGradeSummary,
getClassRanking,
saveGradeDraft,
getGradeDraft,
deleteGradeDraft,
type GradeDraftData,
} from "./data-access"
import { batchCreateGradeRecordsByExam } from "./data-access-exam-entry"
import type { PaginatedGradeRecords } from "./data-access"
import { updateMasteryFromExamScore } from "@/modules/diagnostic/data-access"
import { getExamForGradeEntry } from "@/modules/exams/data-access"
import { collectFromExamSubmission } from "@/modules/error-book/data-access"
import {
exportGradeRecordsToExcel,
exportClassGradeReportToExcel,
exportStudentGradeRecordsToExcel,
} from "./export"
import { formatDateForFile } from "@/shared/lib/utils"
import { notifyGradeEntered, notifyScoreAnomalyIfNeeded } from "./lib/notify"
import { assertClassInScope } from "./lib/scope-check"
import type { GradeQueryParams, GradeStats } from "./types"
import type {
StudentGradeSummary,
ClassRankingItem,
GradeRecord,
} from "./types"
import { assertClassInScope } from "./lib/scope-check"
/**
* v4-P1-6: 成绩录入后通知学生和家长
* 通知失败不阻断成绩录入主流程。
* grades 模块核心 Server Actions编排层
*
* P1-1 重构:
* - 草稿相关 3 个 Action 已迁移至 actions-draft.ts
* - 通知逻辑 notifyGradeEntered 已迁移至 lib/notify.ts
* - 所有错误消息已接入 i18nP1-3
* - assertClassInScope 返回错误码,由调用方通过 i18n 翻译P1-4
*/
async function notifyGradeEntered(params: {
studentIds: string[]
title: string
subjectId?: string
score?: number
fullScore?: number
}): Promise<void> {
if (params.studentIds.length === 0) return
const subjectName = params.subjectId
? (await getSubjectNameById(params.subjectId)) ?? "未知科目"
: "未知科目"
const scoreText =
params.score !== undefined && params.fullScore !== undefined
? `${params.score}/${params.fullScore}`
: "已录入"
const title = `成绩已录入:${params.title}`
const content = `您的${subjectName}成绩${scoreText},请查看详情。`
// 通知学生本人
for (const studentId of params.studentIds) {
try {
await createNotification({
userId: studentId,
type: "grade",
title,
content,
link: "/student/grades",
})
} catch {
// 单条通知失败忽略
}
}
// 通知家长
try {
const parentIds = await getParentIdsByStudentIds(params.studentIds)
const parentContent = `您的孩子的${subjectName}成绩${scoreText},请查看详情。`
for (const parentId of parentIds) {
try {
await createNotification({
userId: parentId,
type: "grade",
title,
content: parentContent,
link: "/parent/grades",
})
} catch {
// 单条通知失败忽略
}
}
} catch {
// 家长查询失败忽略
}
}
export async function createGradeRecordAction(
prevState: ActionState<string> | null,
@@ -124,6 +69,7 @@ export async function createGradeRecordAction(
): Promise<ActionState<string>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
const parsed = CreateGradeRecordSchema.safeParse({
studentId: formData.get("studentId"),
@@ -142,7 +88,7 @@ export async function createGradeRecordAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("action.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -174,7 +120,7 @@ export async function createGradeRecordAction(
// 通知失败不阻断成绩录入
}
revalidatePath("/teacher/grades")
return { success: true, message: "Grade record created", data: id }
return { success: true, message: t("action.recordCreated"), data: id }
} catch (e) {
return handleActionError(e)
}
@@ -186,14 +132,15 @@ export async function batchCreateGradeRecordsAction(
): Promise<ActionState<string[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
const recordsJson = formData.get("recordsJson")
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
return { success: false, message: "Missing records data" }
return { success: false, message: t("action.missingRecordsData") }
}
// P3 修复:使用 safeJsonParse 替代 JSON.parse避免 SyntaxError 暴露
const records = safeJsonParse<unknown>(recordsJson, "成绩数据格式无效")
const records = safeJsonParse<unknown>(recordsJson, t("action.invalidJson"))
const parsed = BatchCreateGradeRecordSchema.safeParse({
classId: formData.get("classId"),
@@ -210,7 +157,7 @@ export async function batchCreateGradeRecordsAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("action.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -244,10 +191,23 @@ export async function batchCreateGradeRecordsAction(
} catch {
// 通知失败不阻断成绩录入
}
// P3-2批量录入后检测成绩异常下降超过 10 分 / 10%),异常时通知学生 + 家长
try {
await notifyScoreAnomalyIfNeeded({
studentIds: parsed.data.records.map((r) => r.studentId),
classId: parsed.data.classId,
subjectId: parsed.data.subjectId,
scope: ctx.dataScope,
})
} catch {
// 异常检测失败不阻断成绩录入
}
revalidatePath("/teacher/grades")
return {
success: true,
message: `Created ${ids.length} grade records`,
message: t("action.recordsCreated", { count: ids.length }),
data: ids,
}
} catch (e) {
@@ -270,40 +230,41 @@ export async function batchCreateGradeRecordsByExamAction(
): Promise<ActionState<string[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
const examId = formData.get("examId")
const classId = formData.get("classId")
const recordsJson = formData.get("recordsJson")
if (typeof examId !== "string" || examId.length === 0) {
return { success: false, message: "缺少试卷 ID" }
return { success: false, message: t("action.missingExamId") }
}
if (typeof classId !== "string" || classId.length === 0) {
return { success: false, message: "缺少班级 ID" }
return { success: false, message: t("action.missingClassId") }
}
if (typeof recordsJson !== "string" || recordsJson.length === 0) {
return { success: false, message: "缺少成绩数据" }
return { success: false, message: t("action.missingRecords") }
}
// 获取试卷详情(验证试卷存在 + scope 校验 + 获取 subjectId/fullScore/title
const exam = await getExamForGradeEntry(examId, ctx.dataScope)
if (!exam) {
return { success: false, message: "试卷不存在或无权访问" }
return { success: false, message: t("action.examNotFound") }
}
if (exam.questions.length === 0) {
return { success: false, message: "试卷没有题目,无法录入" }
return { success: false, message: t("action.examNoQuestions") }
}
if (!exam.subjectId) {
return { success: false, message: "试卷未关联科目" }
return { success: false, message: t("action.examNoSubject") }
}
// 校验班级在 scope 内
const scopeError = assertClassInScope(ctx.dataScope, classId)
if (scopeError) {
return { success: false, message: scopeError }
return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const records = safeJsonParse<unknown>(recordsJson, "成绩数据格式无效")
const records = safeJsonParse<unknown>(recordsJson, t("action.invalidJson"))
const parsed = BatchGradeEntryByExamSchema.safeParse({
examId,
@@ -319,27 +280,39 @@ export async function batchCreateGradeRecordsByExamAction(
if (!parsed.success) {
return {
success: false,
message: "表单数据无效",
message: t("action.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
const ids = await batchCreateGradeRecordsByExam(parsed.data, ctx.userId)
const results = await batchCreateGradeRecordsByExam(parsed.data, ctx.userId)
const ids = results.map((r) => r.gradeRecordId)
// 更新诊断掌握度
for (const record of parsed.data.records) {
const totalScore = record.answers.reduce((sum, a) => sum + a.score, 0)
try {
await updateMasteryFromExamScore(
record.studentId,
parsed.data.examId,
totalScore,
parsed.data.fullScore,
)
} catch {
// 掌握度更新失败不阻断成绩录入
}
}
// 更新诊断掌握度 + 采集考试错题(并行,互不阻断)
await Promise.allSettled(
results.map(async (r) => {
const totalScore = parsed.data.records.find(
(rec) => rec.studentId === r.studentId
)?.answers.reduce((sum, a) => sum + a.score, 0) ?? 0
const [, errorBookResult] = await Promise.allSettled([
updateMasteryFromExamScore(
r.studentId,
parsed.data.examId,
totalScore,
parsed.data.fullScore,
),
collectFromExamSubmission(r.submissionId, r.studentId),
])
if (errorBookResult.status === "rejected") {
console.error(
`[grade-entry] 考试错题采集失败 submission=${r.submissionId}:`,
errorBookResult.reason,
)
}
}),
)
// 通知学生和家长
try {
@@ -353,10 +326,22 @@ export async function batchCreateGradeRecordsByExamAction(
// 通知失败不阻断成绩录入
}
// P3-2批量录入后检测成绩异常下降超过 10 分 / 10%),异常时通知学生 + 家长
try {
await notifyScoreAnomalyIfNeeded({
studentIds: parsed.data.records.map((r) => r.studentId),
classId: parsed.data.classId,
subjectId: parsed.data.subjectId,
scope: ctx.dataScope,
})
} catch {
// 异常检测失败不阻断成绩录入
}
revalidatePath("/teacher/grades")
return {
success: true,
message: `录入 ${ids.length} 条成绩记录`,
message: t("action.recordsCreated", { count: ids.length }),
data: ids,
}
} catch (e) {
@@ -373,21 +358,22 @@ export async function undoBatchCreateGradeRecordsAction(
): Promise<ActionState<number>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
if (!Array.isArray(ids) || ids.length === 0) {
return { success: false, message: "No records to undo" }
return { success: false, message: t("action.noRecordsToUndo") }
}
// 防御性:限制单次撤销的记录数量,避免误传超大数组
if (ids.length > 500) {
return { success: false, message: "Cannot undo more than 500 records at once" }
return { success: false, message: t("action.cannotUndoMoreThan") }
}
const deletedCount = await undoBatchCreateGradeRecords(ids, ctx.userId)
revalidatePath("/teacher/grades")
return {
success: true,
message: `Undone ${deletedCount} grade records`,
message: t("action.recordsUndoneCount", { count: deletedCount }),
data: deletedCount,
}
} catch (e) {
@@ -402,6 +388,7 @@ export async function updateGradeRecordAction(
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
const parsed = UpdateGradeRecordSchema.safeParse({
title: formData.get("title") || undefined,
@@ -416,14 +403,14 @@ export async function updateGradeRecordAction(
if (!parsed.success) {
return {
success: false,
message: "Invalid form data",
message: t("action.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
await updateGradeRecord(id, parsed.data)
revalidatePath("/teacher/grades")
return { success: true, message: "Grade record updated" }
return { success: true, message: t("action.recordUpdated") }
} catch (e) {
return handleActionError(e)
}
@@ -434,19 +421,20 @@ export async function deleteGradeRecordAction(
): Promise<ActionState<string>> {
try {
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
const parsed = DeleteGradeRecordSchema.safeParse({ id })
if (!parsed.success) {
return {
success: false,
message: "Invalid id",
message: t("action.invalidId"),
errors: parsed.error.flatten().fieldErrors,
}
}
await deleteGradeRecord(parsed.data.id)
revalidatePath("/teacher/grades")
return { success: true, message: "Grade record deleted" }
return { success: true, message: t("action.recordDeleted") }
} catch (e) {
return handleActionError(e)
}
@@ -462,20 +450,21 @@ export async function bulkDeleteGradeRecordsAction(
): Promise<ActionState<number>> {
try {
await requirePermission(Permissions.GRADE_RECORD_MANAGE)
const t = await getTranslations("grades")
if (!Array.isArray(ids) || ids.length === 0) {
return { success: false, message: "No records to delete" }
return { success: false, message: t("action.noRecordsToDelete") }
}
// 防御性:限制单次删除的记录数量
if (ids.length > 500) {
return { success: false, message: "Cannot delete more than 500 records at once" }
return { success: false, message: t("action.cannotDeleteMoreThan") }
}
const deletedCount = await bulkDeleteGradeRecords(ids)
revalidatePath("/teacher/grades")
return {
success: true,
message: `Deleted ${deletedCount} grade records`,
message: t("action.recordsDeletedCount", { count: deletedCount }),
data: deletedCount,
}
} catch (e) {
@@ -488,12 +477,13 @@ export async function getGradeRecordsAction(
): Promise<ActionState<PaginatedGradeRecords>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = GradeQuerySchema.safeParse(params)
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -501,7 +491,7 @@ export async function getGradeRecordsAction(
// P3 修复:对 class_taught scope 校验 classId
if (parsed.data.classId) {
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
if (scopeError) return { success: false, message: scopeError }
if (scopeError) return { success: false, message: t(`action.scopeError.${scopeError}`) }
}
const result = await getGradeRecords({
@@ -524,18 +514,19 @@ export async function getClassGradeStatsAction(
): Promise<ActionState<GradeStats | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = ClassGradeStatsQuerySchema.safeParse({ classId, subjectId, examId })
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
if (scopeError) return { success: false, message: scopeError }
if (scopeError) return { success: false, message: t(`action.scopeError.${scopeError}`) }
const result = await getClassGradeStatsWithMeta(
parsed.data.classId,
@@ -555,24 +546,25 @@ export async function getStudentGradeSummaryAction(
): Promise<ActionState<StudentGradeSummary | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = StudentGradeSummaryQuerySchema.safeParse({ studentId })
if (!parsed.success) {
return {
success: false,
message: "Invalid student id",
message: t("action.invalidId"),
errors: parsed.error.flatten().fieldErrors,
}
}
if (ctx.dataScope.type === "class_members" && ctx.userId !== parsed.data.studentId) {
return { success: false, message: "Can only view your own grades" }
return { success: false, message: t("action.ownGradesOnly") }
}
if (
ctx.dataScope.type === "children" &&
!ctx.dataScope.childrenIds.includes(parsed.data.studentId)
) {
return { success: false, message: "Can only view your children's grades" }
return { success: false, message: t("action.childrenGradesOnly") }
}
// P3 修复:传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属
@@ -590,18 +582,19 @@ export async function getClassRankingAction(
): Promise<ActionState<ClassRankingItem[]>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = ClassRankingQuerySchema.safeParse({ classId, subjectId, examId })
if (!parsed.success) {
return {
success: false,
message: "Invalid query parameters",
message: t("action.invalidQuery"),
errors: parsed.error.flatten().fieldErrors,
}
}
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
if (scopeError) return { success: false, message: scopeError }
if (scopeError) return { success: false, message: t(`action.scopeError.${scopeError}`) }
const ranking = await getClassRanking(
parsed.data.classId,
@@ -621,12 +614,13 @@ export async function getGradeRecordByIdAction(
): Promise<ActionState<GradeRecord | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = GetGradeRecordByIdSchema.safeParse({ id })
if (!parsed.success) {
return {
success: false,
message: "Invalid id",
message: t("action.invalidId"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -636,20 +630,20 @@ export async function getGradeRecordByIdAction(
// Row-level scope check for the fetched record
if (record) {
if (ctx.dataScope.type === "class_members" && record.studentId !== ctx.userId) {
return { success: false, message: "You can only view your own grade records" }
return { success: false, message: t("action.ownRecordsOnly") }
}
if (
ctx.dataScope.type === "children" &&
!ctx.dataScope.childrenIds.includes(record.studentId)
) {
return { success: false, message: "You can only view your children's grade records" }
return { success: false, message: t("action.childrenRecordsOnly") }
}
// P3 修复:对 class_taught scope 校验 record.classId 是否在 scope.classIds 中
if (
ctx.dataScope.type === "class_taught" &&
!ctx.dataScope.classIds.includes(record.classId)
) {
return { success: false, message: "You can only view grade records of classes you teach" }
return { success: false, message: t("action.taughtClassesOnly") }
}
}
@@ -671,12 +665,13 @@ export async function exportGradesAction(params: {
}): Promise<ActionState<{ buffer: string; filename: string }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const parsed = ExportGradesSchema.safeParse(params)
if (!parsed.success) {
return {
success: false,
message: "Invalid export parameters",
message: t("action.invalidFormData"),
errors: parsed.error.flatten().fieldErrors,
}
}
@@ -685,7 +680,7 @@ export async function exportGradesAction(params: {
// 当提供 studentId 且 scope 为 children 时,校验该学生属于家长的子女
if (parsed.data.studentId && ctx.dataScope.type === "children") {
if (!ctx.dataScope.childrenIds.includes(parsed.data.studentId)) {
return { success: false, message: "Can only export your own children's grades" }
return { success: false, message: t("action.exportOwnChildrenOnly") }
}
const buffer = await exportStudentGradeRecordsToExcel({
studentId: parsed.data.studentId,
@@ -693,7 +688,7 @@ export async function exportGradesAction(params: {
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
const filename = `成绩单_${formatDateForFile()}.xlsx`
const filename = t("action.filenameDetail", { date: formatDateForFile() })
return {
success: true,
data: {
@@ -705,12 +700,12 @@ export async function exportGradesAction(params: {
// classId 必须存在studentId 分支已返回)
if (!parsed.data.classId) {
return { success: false, message: "classId is required for class export" }
return { success: false, message: t("action.classIdRequired") }
}
// P3 修复:添加 assertClassInScope 校验
const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId)
if (scopeError) return { success: false, message: scopeError }
if (scopeError) return { success: false, message: t(`action.scopeError.${scopeError}`) }
let buffer: Buffer
let filename: string
@@ -721,7 +716,7 @@ export async function exportGradesAction(params: {
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
filename = `班级成绩总表_${formatDateForFile()}.xlsx`
filename = t("action.filenameClassReport", { date: formatDateForFile() })
} else {
buffer = await exportGradeRecordsToExcel({
classId: parsed.data.classId,
@@ -730,7 +725,7 @@ export async function exportGradesAction(params: {
scope: ctx.dataScope,
currentUserId: ctx.userId,
})
filename = `成绩单_${formatDateForFile()}.xlsx`
filename = t("action.filenameDetail", { date: formatDateForFile() })
}
return {
@@ -744,96 +739,3 @@ export async function exportGradesAction(params: {
return handleActionError(e)
}
}
// --- v3-P2-10: 服务端草稿自动保存 ---
/**
* 保存成绩录入草稿到服务端(跨设备同步)。
*/
export async function saveGradeDraftAction(params: {
classId: string
subjectId: string
type: string
scores: Record<string, string>
}): Promise<ActionState<{ saved: true }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
// 校验班级权限
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) return { success: false, message: scopeError }
const data: GradeDraftData = {
scores: params.scores,
timestamp: Date.now(),
}
await saveGradeDraft({
userId: ctx.userId,
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
data,
})
return { success: true, data: { saved: true } }
} catch (e) {
return handleActionError(e)
}
}
/**
* 获取成绩录入草稿(跨设备恢复)。
*/
export async function getGradeDraftAction(params: {
classId: string
subjectId: string
type: string
}): Promise<ActionState<GradeDraftData | null>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
// 校验班级权限
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) return { success: false, message: scopeError }
const draft = await getGradeDraft({
userId: ctx.userId,
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
})
return { success: true, data: draft }
} catch (e) {
return handleActionError(e)
}
}
/**
* 删除成绩录入草稿(提交成功后调用)。
*/
export async function deleteGradeDraftAction(params: {
classId: string
subjectId: string
type: string
}): Promise<ActionState<{ deleted: true }>> {
try {
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
// 校验班级权限
const scopeError = assertClassInScope(ctx.dataScope, params.classId)
if (scopeError) return { success: false, message: scopeError }
await deleteGradeDraft({
userId: ctx.userId,
classId: params.classId,
subjectId: params.subjectId,
type: params.type,
})
return { success: true, data: { deleted: true } }
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -0,0 +1,64 @@
"use client"
import type { JSX } from "react"
import { useTranslations } from "next-intl"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
export type PendingSwitch =
| { kind: "exam"; examId: string; message: string }
| { kind: "class"; classId: string; message: string }
| null
interface Props {
pendingSwitch: PendingSwitch
onConfirm: () => void
onCancel: () => void
}
/**
* 切换试卷/班级确认对话框。
*
* P1-6 重构:从 batch-grade-entry.tsx 拆分而来。
* 替代原来的 window.confirm(),支持无障碍访问。
*/
export function BatchGradeEntryDialog({
pendingSwitch,
onConfirm,
onCancel,
}: Props): JSX.Element {
const t = useTranslations("grades")
return (
<AlertDialog
open={pendingSwitch !== null}
onOpenChange={(open) => {
if (!open) onCancel()
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("batchByExam.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{pendingSwitch?.message ?? ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("batchByExam.cancelAction")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>
{t("batchByExam.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,74 @@
"use client"
import type { JSX } from "react"
import { Search, AlertCircle } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Input } from "@/shared/components/ui/input"
import { useTranslations } from "next-intl"
export interface BatchGradeEntryStatsData {
entered: number
total: number
avg: number
max: number
min: number
}
interface Props {
stats: BatchGradeEntryStatsData
hasInvalidScores: boolean
searchQuery: string
onSearchChange: (value: string) => void
}
/**
* 成绩录入统计栏。
*
* P1-6 重构:从 batch-grade-entry.tsx 拆分而来。
* 展示已录入数、均分、最高/最低分、无效分数提示及学生搜索框。
*/
export function BatchGradeEntryStats({
stats,
hasInvalidScores,
searchQuery,
onSearchChange,
}: Props): JSX.Element {
const t = useTranslations("grades")
return (
<div className="flex flex-wrap items-center gap-4">
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.entered")}: {stats.entered}/{stats.total}
</Badge>
{stats.entered > 0 && (
<>
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.average")}: {Math.round(stats.avg * 10) / 10}
</Badge>
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.max")}: {stats.max}
</Badge>
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.min")}: {stats.min}
</Badge>
</>
)}
{hasInvalidScores && (
<Badge variant="destructive">
<AlertCircle className="mr-1 h-3 w-3" />
{t("batchByExam.invalidScoresBadge")}
</Badge>
)}
<div className="relative ml-auto">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("batchByExam.searchStudent")}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 w-48 pl-8"
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,122 @@
"use client"
import type { JSX, RefObject, KeyboardEvent, ClipboardEvent } from "react"
import { useTranslations } from "next-intl"
import { Card, CardContent } from "@/shared/components/ui/card"
import { Input } from "@/shared/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { cn } from "@/shared/lib/utils"
import type { ExamForGradeEntry } from "@/modules/exams/types"
type Student = { id: string; name: string; email: string }
interface Props {
exam: ExamForGradeEntry
students: Student[]
scores: Record<string, Record<string, string>>
inputRefs: RefObject<Record<string, HTMLInputElement | null>>
onScoreChange: (studentId: string, questionId: string, value: string) => void
onKeyDown: (e: KeyboardEvent, studentId: string, questionId: string) => void
onPaste: (e: ClipboardEvent, studentId: string, questionId: string) => void
computeTotal: (studentId: string) => number
isScoreInvalid: (studentId: string, questionId: string) => boolean
}
/**
* 按试卷录入的成绩表格。
*
* P1-6 重构:从 batch-grade-entry.tsx 拆分而来。
* Excel 式表格支持多行多列分数粘贴、Enter 跳行。
*/
export function BatchGradeEntryTable({
exam,
students,
scores,
inputRefs,
onScoreChange,
onKeyDown,
onPaste,
computeTotal,
isScoreInvalid,
}: Props): JSX.Element {
const t = useTranslations("grades")
return (
<Card className="shadow-none">
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-12 text-center">#</TableHead>
<TableHead className="min-w-32">{t("batchByExam.studentName")}</TableHead>
{exam.questions.map((q, idx) => (
<TableHead key={q.id} className="text-center min-w-20">
<div className="font-medium">{t("batchByExam.question", { n: idx + 1 })}</div>
<div className="text-xs text-muted-foreground tabular-nums">
({q.score}{t("batchByExam.points")})
</div>
</TableHead>
))}
<TableHead className="text-center min-w-20">{t("batchByExam.totalScore")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s, sIdx) => {
const total = computeTotal(s.id)
return (
<TableRow key={s.id}>
<TableCell className="text-center text-muted-foreground tabular-nums">
{sIdx + 1}
</TableCell>
<TableCell className="font-medium">{s.name}</TableCell>
{exam.questions.map((q) => {
const invalid = isScoreInvalid(s.id, q.id)
const key = `${s.id}-${q.id}`
const questionIdx = exam.questions.findIndex((qq) => qq.id === q.id) + 1
return (
<TableCell key={q.id} className="p-1">
<Input
ref={(el) => {
inputRefs.current[key] = el
}}
type="number"
step="0.5"
min="0"
max={q.score}
value={scores[s.id]?.[q.id] ?? ""}
onChange={(e) => onScoreChange(s.id, q.id, e.target.value)}
onKeyDown={(e) => onKeyDown(e, s.id, q.id)}
onPaste={(e) => onPaste(e, s.id, q.id)}
onFocus={(e) => e.target.select()}
className={cn(
"h-8 text-center tabular-nums",
invalid && "border-destructive focus-visible:ring-destructive"
)}
aria-label={`${s.name} - ${t("batchByExam.question", { n: questionIdx })}`}
/>
</TableCell>
)
})}
<TableCell className="text-center font-medium tabular-nums">
{Math.round(total * 10) / 10}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}

View File

@@ -4,26 +4,20 @@ import { useState, useRef, useMemo, type JSX, type KeyboardEvent, type Clipboard
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Search, Info, AlertCircle } from "lucide-react"
import { Info } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { cn } from "@/shared/lib/utils"
import { safeActionCall } from "@/shared/lib/action-utils"
import { batchCreateGradeRecordsByExamAction, undoBatchCreateGradeRecordsAction } from "../actions"
import { batchCreateGradeRecordsByExamAction } from "../actions"
import { useBatchGradeEntryUndo } from "../hooks/use-batch-grade-entry-undo"
import { BatchGradeEntryStats } from "./batch-grade-entry-stats"
import { BatchGradeEntryDialog, type PendingSwitch } from "./batch-grade-entry-dialog"
import { BatchGradeEntryTable } from "./batch-grade-entry-table"
import type { ExamOptionForEntry, ExamForGradeEntry } from "@/modules/exams/types"
type Student = { id: string; name: string; email: string }
@@ -39,11 +33,13 @@ interface Props {
defaultClassId?: string
}
/** 序列化撤销数据 */
function serializeUndoData(ids: string[]): string {
return JSON.stringify({ ids, timestamp: Date.now() })
}
/**
* 按试卷批量录入成绩主组件。
*
* P1-6 重构:将表格、统计栏、确认对话框拆分为独立子组件。
* - useBatchGradeEntryUndo Hook 封装撤销逻辑 + 类型守卫P1-7 修复 `as` 断言)
* - BatchGradeEntryTable / BatchGradeEntryStats / BatchGradeEntryDialog 为组合子组件
*/
export function BatchGradeEntryByExam({
exams,
classes,
@@ -55,10 +51,12 @@ export function BatchGradeEntryByExam({
}: Props): JSX.Element {
const router = useRouter()
const t = useTranslations("grades")
const { saveUndoToken, handleUndo } = useBatchGradeEntryUndo()
const [scores, setScores] = useState<Record<string, Record<string, string>>>({})
const [searchQuery, setSearchQuery] = useState("")
const [isSubmitting, setIsSubmitting] = useState(false)
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({})
const [pendingSwitch, setPendingSwitch] = useState<PendingSwitch>(null)
// 按试卷 gradeId 过滤班级
const filteredClasses = useMemo(() => {
@@ -77,7 +75,12 @@ export function BatchGradeEntryByExam({
const handleExamChange = (examId: string): void => {
if (Object.keys(scores).length > 0) {
if (!window.confirm(t("batchByExam.confirmSwitchExam"))) return
setPendingSwitch({
kind: "exam",
examId,
message: t("batchByExam.confirmSwitchExam"),
})
return
}
setScores({})
router.push(`/teacher/grades/entry?examId=${examId}`)
@@ -85,12 +88,28 @@ export function BatchGradeEntryByExam({
const handleClassChange = (classId: string): void => {
if (Object.keys(scores).length > 0) {
if (!window.confirm(t("batchByExam.confirmSwitchClass"))) return
setPendingSwitch({
kind: "class",
classId,
message: t("batchByExam.confirmSwitchClass"),
})
return
}
setScores({})
router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${classId}`)
}
const confirmPendingSwitch = (): void => {
if (!pendingSwitch) return
setScores({})
if (pendingSwitch.kind === "exam") {
router.push(`/teacher/grades/entry?examId=${pendingSwitch.examId}`)
} else {
router.push(`/teacher/grades/entry?examId=${defaultExamId}&classId=${pendingSwitch.classId}`)
}
setPendingSwitch(null)
}
const handleScoreChange = (studentId: string, questionId: string, value: string): void => {
setScores((prev) => ({
...prev,
@@ -162,14 +181,12 @@ export function BatchGradeEntryByExam({
if (!student) return
const cells = line.split(/\t/).filter((c) => c.trim() || c === "0")
if (cells.length === 1) {
// 单列:按行填充当前题目
newScores[student.id] = {
...(newScores[student.id] ?? {}),
[exam.questions[startQuestionIdx].id]: cells[0].trim(),
}
pastedCount++
} else {
// 多列:按行×列填充
cells.forEach((cell, colIdx) => {
const question = exam.questions[startQuestionIdx + colIdx]
if (question) {
@@ -266,14 +283,7 @@ export function BatchGradeEntryByExam({
setScores({})
const createdIds = result.data ?? []
if (createdIds.length > 0) {
try {
sessionStorage.setItem(
"lastBatchGradeRecordIds",
serializeUndoData(createdIds)
)
} catch {
// sessionStorage 不可用时静默失败
}
saveUndoToken(createdIds)
toast.success(result.message, {
duration: 10000,
action: {
@@ -291,31 +301,6 @@ export function BatchGradeEntryByExam({
}
}
const handleUndo = async (): Promise<void> => {
try {
const raw = sessionStorage.getItem("lastBatchGradeRecordIds")
if (!raw) {
toast.error(t("batchByExam.undoNoRecord"))
return
}
const data = JSON.parse(raw) as { ids: string[]; timestamp: number }
if (Date.now() - data.timestamp > 5 * 60 * 1000) {
toast.error(t("batchByExam.undoExpired"))
return
}
const result = await undoBatchCreateGradeRecordsAction(data.ids)
if (result.success) {
sessionStorage.removeItem("lastBatchGradeRecordIds")
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || t("batchByExam.undoFailed"))
}
} catch {
toast.error(t("batchByExam.undoFailed"))
}
}
// 未选试卷
if (!exam) {
return (
@@ -414,101 +399,25 @@ export function BatchGradeEntryByExam({
) : (
<>
{/* 统计栏 */}
<div className="flex flex-wrap items-center gap-4">
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.entered")}: {stats.entered}/{stats.total}
</Badge>
{stats.entered > 0 && (
<>
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.average")}: {Math.round(stats.avg * 10) / 10}
</Badge>
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.max")}: {stats.max}
</Badge>
<Badge variant="outline" className="tabular-nums">
{t("batchByExam.min")}: {stats.min}
</Badge>
</>
)}
{hasInvalidScores && (
<Badge variant="destructive">
<AlertCircle className="mr-1 h-3 w-3" />
{t("batchByExam.invalidScoresBadge")}
</Badge>
)}
<div className="relative ml-auto">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t("batchByExam.searchStudent")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-48 pl-8"
/>
</div>
</div>
<BatchGradeEntryStats
stats={stats}
hasInvalidScores={hasInvalidScores}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
/>
{/* Excel 式表格 */}
<Card className="shadow-none">
<CardContent className="p-0">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-12 text-center">#</TableHead>
<TableHead className="min-w-[120px]">{t("batchByExam.studentName")}</TableHead>
{exam.questions.map((q, idx) => (
<TableHead key={q.id} className="text-center min-w-[80px]">
<div className="font-medium">{t("batchByExam.question", { n: idx + 1 })}</div>
<div className="text-xs text-muted-foreground tabular-nums">({q.score}{t("batchByExam.points")})</div>
</TableHead>
))}
<TableHead className="text-center min-w-[80px]">{t("batchByExam.totalScore")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredStudents.map((s, sIdx) => {
const total = computeTotal(s.id)
return (
<TableRow key={s.id}>
<TableCell className="text-center text-muted-foreground tabular-nums">{sIdx + 1}</TableCell>
<TableCell className="font-medium">{s.name}</TableCell>
{exam.questions.map((q) => {
const invalid = isScoreInvalid(s.id, q.id)
const key = `${s.id}-${q.id}`
return (
<TableCell key={q.id} className="p-1">
<Input
ref={(el) => { inputRefs.current[key] = el }}
type="number"
step="0.5"
min="0"
max={q.score}
value={scores[s.id]?.[q.id] ?? ""}
onChange={(e) => handleScoreChange(s.id, q.id, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, s.id, q.id)}
onPaste={(e) => handlePaste(e, s.id, q.id)}
onFocus={(e) => e.target.select()}
className={cn(
"h-8 text-center tabular-nums",
invalid && "border-destructive focus-visible:ring-destructive"
)}
aria-label={`${s.name} - ${t("batchByExam.question", { n: exam.questions.findIndex(qq => qq.id === q.id) + 1 })}`}
/>
</TableCell>
)
})}
<TableCell className="text-center font-medium tabular-nums">
{Math.round(total * 10) / 10}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<BatchGradeEntryTable
exam={exam}
students={filteredStudents}
scores={scores}
inputRefs={inputRefs}
onScoreChange={handleScoreChange}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
computeTotal={computeTotal}
isScoreInvalid={isScoreInvalid}
/>
{/* 提交按钮 */}
<div className="flex items-center justify-end gap-3">
@@ -536,6 +445,13 @@ export function BatchGradeEntryByExam({
</div>
</>
)}
{/* 确认对话框 */}
<BatchGradeEntryDialog
pendingSwitch={pendingSwitch}
onConfirm={confirmPendingSwitch}
onCancel={() => setPendingSwitch(null)}
/>
</div>
)
}

View File

@@ -13,34 +13,37 @@ import {
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import { cn } from "@/shared/lib/utils"
import type { ClassComparisonItem } from "@/modules/grades/types"
import type { ClassComparisonItem, ClassComparisonSignificance } from "@/modules/grades/types"
interface ClassComparisonChartProps {
data: ClassComparisonItem[]
/**
* P3-5: 预计算的统计显著性结果。
* 由 data-access 层使用 stats-service.computeSignificance 计算Welch's t-test + Cohen's d
* 若提供则展示 p 值与效应量;否则回退到基于极差的经验规则。
*/
significance?: ClassComparisonSignificance | null
}
type SignificanceLevel = "high" | "medium" | "low"
interface SignificanceResult {
interface EmpiricalSignificanceResult {
range: number
level: SignificanceLevel
topClass: ClassComparisonItem
bottomClass: ClassComparisonItem
}
/** 显著性判断阈值(经验规则,避免复杂统计计算 */
/** 经验显著性判断阈值(在 significance prop 缺失时作为回退方案 */
const MIN_SAMPLE_SIZE = 30
const HIGH_RANGE_THRESHOLD = 10
const MEDIUM_RANGE_THRESHOLD = 5
/**
* v3-P3-5: 班级对比显著性分析。
* 基于极差和样本量的经验规则判断班级间差异是否具有统计意义
* - 极差 >= 10 且各班样本量 >= 30显著差异
* - 极差 >= 5可能存在差异含极差大但样本不足的情况
* - 极差 < 5差异不显著
* v3-P3-5: 经验显著性分析(回退方案)
* 仅当 data-access 层未注入统计显著性结果时使用
*/
function analyzeSignificance(data: ClassComparisonItem[]): SignificanceResult | null {
function analyzeSignificance(data: ClassComparisonItem[]): EmpiricalSignificanceResult | null {
if (data.length < 2) return null
let topClass = data[0]
@@ -65,11 +68,37 @@ function analyzeSignificance(data: ClassComparisonItem[]): SignificanceResult |
return { range, level, topClass, bottomClass }
}
/**
* P3-5: 根据 Cohen's d 效应量映射到 UI 等级。
* < 0.2 negligible → low
* < 0.5 small → medium
* >= 0.5 medium/large → high
*/
function effectSizeToLevel(effectSizeLabel: ClassComparisonSignificance["effectSizeLabel"]): SignificanceLevel {
switch (effectSizeLabel) {
case "negligible":
return "low"
case "small":
return "medium"
case "medium":
case "large":
return "high"
}
}
function formatScore(score: number): string {
return score.toFixed(1)
}
export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.Element {
function formatPValue(p: number): string {
if (p < 0.001) return "< 0.001"
return p.toFixed(3)
}
export function ClassComparisonChart({
data,
significance,
}: ClassComparisonChartProps): JSX.Element {
const t = useTranslations("grades")
const [detailsOpen, setDetailsOpen] = useState(false)
const isEmpty = !data || data.length === 0
@@ -85,7 +114,21 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.E
studentCount: d.studentCount,
}))
const significance = isEmpty ? null : analyzeSignificance(data)
// P3-5: 优先使用注入的统计显著性结果,回退到经验规则
const empirical = isEmpty ? null : analyzeSignificance(data)
const topClass = significance
? data.find((d) => d.classId === significance.topClassId) ?? empirical?.topClass
: empirical?.topClass
const bottomClass = significance
? data.find((d) => d.classId === significance.bottomClassId) ?? empirical?.bottomClass
: empirical?.bottomClass
const level: SignificanceLevel | null = significance
? effectSizeToLevel(significance.effectSizeLabel)
: empirical?.level ?? null
const range = topClass && bottomClass
? topClass.averageScore - bottomClass.averageScore
: null
const levelColorClass: Record<SignificanceLevel, string> = {
high: "border-destructive/30 bg-destructive/5 text-destructive",
@@ -132,28 +175,38 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.E
yDomain={[0, 100]}
yTickFormatter={(value: number) => `${value}%`}
yWidth={36}
heightClassName="h-[300px]"
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
showLegend
tooltipClassName="w-[240px]"
tooltipClassName="w-60"
/>
</div>
{significance ? (
{level && topClass && bottomClass ? (
<div
className="mt-3 space-y-2"
aria-label={t("classComparison.significanceAriaLabel", { level: levelLabel[significance.level] })}
aria-label={t("classComparison.significanceAriaLabel", { level: levelLabel[level] })}
>
<div className={cn("flex items-start gap-2 rounded-md border p-3", levelColorClass[significance.level])}>
<div className={cn("flex items-start gap-2 rounded-md border p-3", levelColorClass[level])}>
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<div className="space-y-1 text-sm">
<div className="font-medium">
{t("classComparison.significanceTitle")} · {levelLabel[significance.level]}
{t("classComparison.significanceTitle")} · {levelLabel[level]}
</div>
<div className="text-xs opacity-90">
{t("classComparison.significanceRange", { range: formatScore(significance.range) })}
</div>
<div className="text-xs opacity-80">{levelHint[significance.level]}</div>
{range !== null && (
<div className="text-xs opacity-90">
{t("classComparison.significanceRange", { range: formatScore(range) })}
</div>
)}
{significance ? (
<div className="text-xs opacity-90">
{t("classComparison.significanceStats", {
pValue: formatPValue(significance.pValue),
cohensD: significance.cohensD.toFixed(2),
effectSize: t(`classComparison.effectSize.${significance.effectSizeLabel}`),
})}
</div>
) : null}
<div className="text-xs opacity-80">{levelHint[level]}</div>
</div>
</div>
@@ -174,16 +227,25 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.E
<div className="space-y-1 rounded-md border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
<div>
{t("classComparison.significanceTopClass", {
name: significance.topClass.className,
score: formatScore(significance.topClass.averageScore),
name: topClass.className,
score: formatScore(topClass.averageScore),
})}
</div>
<div>
{t("classComparison.significanceBottomClass", {
name: significance.bottomClass.className,
score: formatScore(significance.bottomClass.averageScore),
name: bottomClass.className,
score: formatScore(bottomClass.averageScore),
})}
</div>
{significance ? (
<div className="pt-1 text-muted-foreground/70">
{t("classComparison.significanceMethod", {
significant: significance.isSignificant
? t("classComparison.significantYes")
: t("classComparison.significantNo"),
})}
</div>
) : null}
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -21,7 +22,7 @@ interface ClassGradeReportProps {
ranking: ClassRankingItem[]
}
export async function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
export async function ClassGradeReport({ stats, ranking }: ClassGradeReportProps): Promise<JSX.Element> {
const t = await getTranslations("grades")
return (
<div className="space-y-6">

View File

@@ -0,0 +1,74 @@
"use client"
import type { JSX } from "react"
import { AlertTriangle, Lock, Loader2, RefreshCw } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Alert, AlertDescription, AlertTitle } from "@/shared/components/ui/alert"
import type { DraftLockStatus } from "../data-access-drafts"
/**
* P3-6 协同录入锁状态提示条。
*
* 三种状态:
* 1. acquiring获取中loading
* 2. conflict锁被他人持有警告 + 重试按钮)
* 3. acquired已获取轻量 tooltip 提示,不渲染 banner
*/
interface DraftLockBannerProps {
status: DraftLockStatus | null
isAcquiring: boolean
onRetry: () => void
}
export function DraftLockBanner({
status,
isAcquiring,
onRetry,
}: DraftLockBannerProps): JSX.Element | null {
const t = useTranslations("grades")
// 获取中
if (isAcquiring) {
return (
<Alert className="border-blue-200 bg-blue-50">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<AlertTitle className="text-blue-900">
{t("collabLock.acquired")}
</AlertTitle>
<AlertDescription className="text-blue-700">
{t("collabLock.lockInfoTooltip")}
</AlertDescription>
</Alert>
)
}
// 无状态或自己持锁:不显示
if (!status || status.isMine) return null
// 锁被他人持有
const teacherName = status.lockedByName ?? t("collabLock.lockConflictUnknownTeacher")
return (
<Alert className="border-destructive/50 text-destructive">
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
<AlertTitle>
<Lock className="inline h-3 w-3 mr-1" aria-hidden="true" />
{t("collabLock.lockConflictTitle")}
</AlertTitle>
<AlertDescription className="flex items-center justify-between gap-4">
<span>
{t("collabLock.lockConflictDescription", { teacherName })}
</span>
<Button
size="sm"
variant="outline"
onClick={onRetry}
>
<RefreshCw className="mr-2 h-3 w-3" aria-hidden="true" />
{t("collabLock.retryAcquire")}
</Button>
</AlertDescription>
</Alert>
)
}

View File

@@ -0,0 +1,518 @@
"use client"
import type { JSX } from "react"
import { useRef, useState } from "react"
import { Upload, Download, FileSpreadsheet, Loader2, AlertCircle, CheckCircle2 } from "lucide-react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/shared/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
import { Input } from "@/shared/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { downloadBase64File } from "@/shared/lib/download"
import { safeActionCall } from "@/shared/lib/action-utils"
import {
downloadGradeImportTemplateAction,
importGradesFromExcelAction,
} from "../actions-import"
import type { GradeImportResult } from "../import-export"
interface ExcelImportDialogProps {
/** 班级 ID必填 */
classId: string
/** 班级列表(供切换) */
classes: Array<{ id: string; name: string }>
/** 科目列表 */
subjects: Array<{ id: string; name: string }>
/** 触发按钮渲染函数 */
trigger?: (open: () => void) => JSX.Element
/** 导入成功后的回调(通常用于刷新列表) */
onSuccess?: () => void
}
interface ImportFormState {
classId: string
subjectId: string
title: string
examId: string
fullScore: string
type: string
semester: string
}
const INITIAL_FORM: ImportFormState = {
classId: "",
subjectId: "",
title: "",
examId: "",
fullScore: "100",
type: "exam",
semester: "1",
}
/**
* P3-10: 成绩 Excel 批量导入对话框。
*
* 工作流程:
* 1. 选择班级 → 自动下载模板(含学生姓名示例)
* 2. 填写参数(科目、评估标题、满分等)
* 3. 上传填好的 Excel 文件
* 4. 点击"开始导入"调用 Server Action
* 5. 显示导入结果(成功数 / 失败数 / 失败明细)
*/
export function ExcelImportDialog({
classId: initialClassId,
classes,
subjects,
trigger,
onSuccess,
}: ExcelImportDialogProps): JSX.Element {
const t = useTranslations("grades")
const [isOpen, setIsOpen] = useState(false)
const [isDownloadingTemplate, setIsDownloadingTemplate] = useState(false)
const [isImporting, setIsImporting] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [form, setForm] = useState<ImportFormState>({
...INITIAL_FORM,
classId: initialClassId,
subjectId: subjects[0]?.id ?? "",
})
const [result, setResult] = useState<GradeImportResult | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const reset = () => {
setForm({ ...INITIAL_FORM, classId: initialClassId, subjectId: subjects[0]?.id ?? "" })
setFile(null)
setResult(null)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (!open) {
reset()
}
}
const handleDownloadTemplate = async () => {
if (!form.classId) {
toast.error(t("excelImport.errorSelectClass"))
return
}
setIsDownloadingTemplate(true)
const r = await safeActionCall(
() => downloadGradeImportTemplateAction({ classId: form.classId }),
{
onError: () => toast.error(t("excelImport.errorTemplateDownload")),
onFinally: () => setIsDownloadingTemplate(false),
}
)
if (r?.success && r.data) {
downloadBase64File(r.data.buffer, r.data.filename)
toast.success(t("excelImport.templateDownloaded"))
} else if (r) {
toast.error(r.message ?? t("excelImport.errorTemplateDownload"))
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0] ?? null
setFile(f)
setResult(null)
}
const handleSubmit = async () => {
if (!file) {
toast.error(t("excelImport.errorNoFile"))
return
}
if (!form.classId || !form.subjectId || !form.title) {
toast.error(t("excelImport.errorMissingFields"))
return
}
setIsImporting(true)
const formData = new FormData()
formData.append("file", file)
formData.append("classId", form.classId)
formData.append("subjectId", form.subjectId)
formData.append("title", form.title)
if (form.examId) formData.append("examId", form.examId)
if (form.fullScore) formData.append("fullScore", form.fullScore)
if (form.type) formData.append("type", form.type)
if (form.semester) formData.append("semester", form.semester)
const r = await safeActionCall(
() => importGradesFromExcelAction(null, formData),
{
onError: () => toast.error(t("excelImport.errorImport")),
onFinally: () => setIsImporting(false),
}
)
if (r?.success && r.data) {
setResult(r.data)
if (r.data.failedCount === 0) {
toast.success(t("excelImport.successAllImported", { count: r.data.successCount }))
} else {
toast.warning(t("excelImport.successPartial", {
success: r.data.successCount,
failed: r.data.failedCount,
}))
}
onSuccess?.()
} else if (r) {
toast.error(r.message ?? t("excelImport.errorImport"))
if (r.data) {
setResult(r.data)
}
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
{trigger ? (
<DialogTrigger asChild>
{trigger(() => setIsOpen(true))}
</DialogTrigger>
) : (
<DialogTrigger asChild>
<Button variant="outline">
<Upload className="mr-2 h-4 w-4" aria-hidden="true" />
{t("excelImport.triggerButton")}
</Button>
</DialogTrigger>
)}
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileSpreadsheet className="h-5 w-5" aria-hidden="true" />
{t("excelImport.dialogTitle")}
</DialogTitle>
<DialogDescription>
{t("excelImport.dialogDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 第一步:下载模板 */}
<section className="rounded-md border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950/30">
<div className="flex items-center justify-between gap-3">
<div className="flex-1">
<h3 className="text-sm font-medium text-blue-900 dark:text-blue-100">
{t("excelImport.step1Title")}
</h3>
<p className="mt-1 text-xs text-blue-700 dark:text-blue-300">
{t("excelImport.step1Description")}
</p>
</div>
<Button
onClick={handleDownloadTemplate}
variant="outline"
size="sm"
disabled={isDownloadingTemplate || !form.classId}
className="gap-2"
>
{isDownloadingTemplate ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Download className="h-4 w-4" aria-hidden="true" />
)}
{t("excelImport.downloadTemplate")}
</Button>
</div>
</section>
{/* 第二步:填写参数 */}
<section className="space-y-3">
<h3 className="text-sm font-medium">
{t("excelImport.step2Title")}
</h3>
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-1.5">
<Label htmlFor="ei-class" className="text-xs">
{t("filters.class")} *
</Label>
<Select
value={form.classId}
onValueChange={(v) => setForm({ ...form, classId: v })}
>
<SelectTrigger id="ei-class">
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-subject" className="text-xs">
{t("filters.subject")} *
</Label>
<Select
value={form.subjectId}
onValueChange={(v) => setForm({ ...form, subjectId: v })}
>
<SelectTrigger id="ei-subject">
<SelectValue placeholder={t("filters.allSubjects")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5 col-span-2">
<Label htmlFor="ei-title" className="text-xs">
{t("excelImport.assessmentTitle")} *
</Label>
<Input
id="ei-title"
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
placeholder={t("excelImport.assessmentTitlePlaceholder")}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-fullscore" className="text-xs">
{t("excelImport.fullScore")}
</Label>
<Input
id="ei-fullscore"
type="number"
min={1}
max={1000}
value={form.fullScore}
onChange={(e) => setForm({ ...form, fullScore: e.target.value })}
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-type" className="text-xs">
{t("filters.type")}
</Label>
<Select
value={form.type}
onValueChange={(v) => setForm({ ...form, type: v })}
>
<SelectTrigger id="ei-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">{t("type.exam")}</SelectItem>
<SelectItem value="quiz">{t("type.quiz")}</SelectItem>
<SelectItem value="homework">{t("type.homework")}</SelectItem>
<SelectItem value="other">{t("type.other")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-semester" className="text-xs">
{t("filters.semester")}
</Label>
<Select
value={form.semester}
onValueChange={(v) => setForm({ ...form, semester: v })}
>
<SelectTrigger id="ei-semester">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ei-examid" className="text-xs">
{t("excelImport.examId")}
</Label>
<Input
id="ei-examid"
value={form.examId}
onChange={(e) => setForm({ ...form, examId: e.target.value })}
placeholder={t("excelImport.examIdPlaceholder")}
/>
</div>
</div>
</section>
{/* 第三步:上传文件 */}
<section className="space-y-3">
<h3 className="text-sm font-medium">
{t("excelImport.step3Title")}
</h3>
<div className="grid gap-1.5">
<Label htmlFor="ei-file" className="text-xs">
{t("excelImport.fileLabel")} *
</Label>
<Input
ref={fileInputRef}
id="ei-file"
type="file"
accept=".xlsx,.xls"
onChange={handleFileChange}
/>
<p className="text-xs text-muted-foreground">
{t("excelImport.fileHint")}
</p>
</div>
</section>
{/* 导入结果 */}
{result ? (
<section className="space-y-3 rounded-md border p-3">
<h3 className="flex items-center gap-2 text-sm font-medium">
{result.failedCount === 0 ? (
<CheckCircle2 className="h-4 w-4 text-green-600" aria-hidden="true" />
) : (
<AlertCircle className="h-4 w-4 text-amber-600" aria-hidden="true" />
)}
{t("excelImport.resultTitle")}
</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">
{t("excelImport.resultSuccess")}
</span>
<span className="ml-2 font-semibold text-green-600">
{result.successCount}
</span>
</div>
<div>
<span className="text-muted-foreground">
{t("excelImport.resultFailed")}
</span>
<span className="ml-2 font-semibold text-red-600">
{result.failedCount}
</span>
</div>
</div>
{result.unmatchedStudents.length > 0 ? (
<div className="text-xs">
<p className="font-medium text-amber-700 dark:text-amber-300">
{t("excelImport.unmatchedStudents")}
</p>
<p className="mt-1 text-muted-foreground">
{result.unmatchedStudents.join(", ")}
</p>
</div>
) : null}
{result.invalidRows.length > 0 ? (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">
{t("excelImport.columnRow")}
</TableHead>
<TableHead>
{t("excelImport.columnStudentName")}
</TableHead>
<TableHead className="text-right">
{t("excelImport.columnScore")}
</TableHead>
<TableHead>
{t("excelImport.columnErrors")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.invalidRows.slice(0, 20).map((row) => (
<TableRow key={row.row}>
<TableCell className="font-mono">{row.row}</TableCell>
<TableCell>{row.studentName || "—"}</TableCell>
<TableCell className="text-right font-mono">
{row.score}
</TableCell>
<TableCell className="text-xs text-red-600 dark:text-red-400">
{row.errors.join("; ")}
</TableCell>
</TableRow>
))}
{result.invalidRows.length > 20 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-xs text-muted-foreground">
{t("excelImport.moreErrors", {
count: result.invalidRows.length - 20,
})}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
) : null}
</section>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isImporting}
>
{t("excelImport.cancel")}
</Button>
<Button
onClick={handleSubmit}
disabled={isImporting || !file || !form.classId || !form.subjectId || !form.title}
className="gap-2"
>
{isImporting ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Upload className="h-4 w-4" aria-hidden="true" />
)}
{isImporting
? t("excelImport.importing")
: t("excelImport.startImport")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { useState } from "react"
import { toast } from "sonner"
import { Download, Loader2 } from "lucide-react"
@@ -32,11 +33,11 @@ export function ExportButton({
variant = "outline",
size = "default",
label,
}: ExportButtonProps) {
}: ExportButtonProps): JSX.Element {
const t = useTranslations("grades")
const [isExporting, setIsExporting] = useState(false)
const handleExport = async (reportType: "detail" | "class") => {
const handleExport = async (reportType: "detail" | "class"): Promise<void> => {
if (!classId) {
toast.error(t("export.selectClassFirst"))
return

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { PieChart as PieChartIcon } from "lucide-react"
import { useTranslations } from "next-intl"
@@ -80,6 +81,13 @@ const PATTERN_DEFS = (
interface GradeDistributionChartProps {
data: GradeDistributionResult | null
/**
* P3-9: 需要高亮的桶索引(用于学生视角的"你的位置"标注)。
* -1 或 undefined 表示不高亮任何桶。
*/
highlightBucketIndex?: number
/** P3-9: 学生归一化分数(用于显示"你的分数"标签) */
studentScore?: number | null
}
interface DistributionTooltipItem {
@@ -106,19 +114,24 @@ function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPaylo
)
}
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
export function GradeDistributionChart({
data,
highlightBucketIndex,
studentScore,
}: GradeDistributionChartProps): JSX.Element {
const t = useTranslations("grades")
const isEmpty = !data || data.totalCount === 0
const chartData = isEmpty
? []
: data.buckets.map((b) => ({
: data.buckets.map((b, i) => ({
label: b.label,
count: b.count,
percentage:
data.totalCount > 0
? Math.round((b.count / data.totalCount) * 1000) / 10
: 0,
isHighlighted: highlightBucketIndex === i ? 1 : 0,
}))
return (
@@ -149,9 +162,7 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
xTickFormatter={null}
yAllowDecimals={false}
yWidth={32}
heightClassName="h-[280px]"
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
tooltipClassName="w-[200px]"
defs={PATTERN_DEFS}
cellColors={BUCKET_FILLS}
tooltipFormatter={(payload: unknown) => {
@@ -169,6 +180,27 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
}}
/>
</div>
{highlightBucketIndex !== undefined && highlightBucketIndex >= 0 && studentScore !== null && studentScore !== undefined && data ? (
<div
className="mt-3 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/5 p-2 text-xs"
role="status"
aria-label={t("distribution.yourPositionAriaLabel", {
score: studentScore.toFixed(1),
})}
>
<span
className="inline-block h-2 w-2 shrink-0 rounded-full bg-primary"
aria-hidden="true"
/>
<span className="text-primary">
{t("distribution.yourPosition", {
score: studentScore.toFixed(1),
bucket: data.buckets[highlightBucketIndex]?.label ?? "",
})}
</span>
</div>
) : null}
</ChartCardShell>
)
}

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl"
@@ -20,7 +21,7 @@ import type { SelectOption } from "../types"
* v3-P2-1subjects 改为通过 prop 传入,使用 subjectId 作为 value
* 而非硬编码科目名称。若未传入 subjects则不显示科目筛选。
*/
export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
export function GradeFilters({ subjects }: { subjects?: SelectOption[] }): JSX.Element {
const t = useTranslations("grades")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))
@@ -49,7 +50,7 @@ export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
<div className="flex flex-wrap gap-2 w-full md:w-auto">
{subjects && subjects.length > 0 && (
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background border-muted-foreground/20">
<SelectTrigger className="w-36 bg-background border-muted-foreground/20">
<SelectValue placeholder={t("filters.subject")} />
</SelectTrigger>
<SelectContent>
@@ -64,7 +65,7 @@ export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
)}
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
<SelectTrigger className="w-[120px] bg-background border-muted-foreground/20">
<SelectTrigger className="w-32 bg-background border-muted-foreground/20">
<SelectValue placeholder={t("filters.type")} />
</SelectTrigger>
<SelectContent>
@@ -77,7 +78,7 @@ export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
</Select>
<Select value={semester} onValueChange={(val) => setSemester(val === "all" ? null : val)}>
<SelectTrigger className="w-[120px] bg-background border-muted-foreground/20">
<SelectTrigger className="w-32 bg-background border-muted-foreground/20">
<SelectValue placeholder={t("filters.semester")} />
</SelectTrigger>
<SelectContent>

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { useTranslations } from "next-intl"
@@ -13,7 +14,7 @@ interface GradeQueryFiltersProps {
subjects: SelectOption[]
}
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps) {
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps): JSX.Element {
const t = useTranslations("grades")
const router = useRouter()
const searchParams = useSearchParams()

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
@@ -40,7 +41,7 @@ export function GradeRecordForm({
students: SelectOption[]
defaultClassId?: string
defaultSubjectId?: string
}) {
}): JSX.Element {
const t = useTranslations("grades")
const router = useRouter()
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
@@ -53,7 +54,7 @@ type EditableFields = {
remark: string
}
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }): JSX.Element {
const t = useTranslations("grades")
const router = useRouter()
const [deleteId, setDeleteId] = useState<string | null>(null)
@@ -211,8 +212,8 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
</div>
</div>
)}
{/* v4-P1-10: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
{/* v4-P1-10: 桌面端表格md 及以上显示) */}
<div className="hidden overflow-x-auto md:block">
<Table>
<caption className="sr-only">{t("list.caption")}</caption>
<TableHeader>
@@ -257,7 +258,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
<TableCell>
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
</TableCell>
<TableCell>S{r.semester}</TableCell>
<TableCell>{t(`semester.s${r.semester}`)}</TableCell>
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
<TableCell>
@@ -287,6 +288,74 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
</TableBody>
</Table>
</div>
{/* P3-8: 移动端卡片堆叠视图md 以下显示) */}
<div
className="block space-y-3 p-4 md:hidden"
role="list"
aria-label={t("list.caption")}
>
{records.map((r) => (
<div
key={r.id}
className="rounded-md border border-border/60 bg-background p-3 shadow-sm"
role="listitem"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => toggleRowSelection(r.id)}
aria-label={t("list.selectRow", { name: r.studentName })}
/>
<div>
<div className="font-medium">{r.studentName}</div>
<div className="text-xs text-muted-foreground">
{r.className} · {r.subjectName}
</div>
</div>
</div>
<div className="text-right">
<ScoreCell score={r.score} fullScore={r.fullScore} />
<div className="mt-1 flex items-center justify-end gap-1.5">
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
<span className="text-xs text-muted-foreground">
{t(`semester.s${r.semester}`)}
</span>
</div>
</div>
</div>
<div className="mt-2 border-t border-border/40 pt-2">
<div className="text-sm">{r.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{r.recorderName} · {formatDate(r.createdAt)}
</div>
</div>
<div className="mt-2 flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => startEdit(r)}
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Pencil className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("edit.title")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() => setDeleteId(r.id)}
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("delete.title")}
</Button>
</div>
</div>
))}
</div>
</div>
{/* 编辑对话框 */}

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -5,7 +6,7 @@ import { StatItem } from "@/shared/components/ui/stat-item"
import { TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
import type { GradeStats } from "../types"
export async function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
export async function GradeStatsCard({ stats }: { stats: GradeStats | null }): Promise<JSX.Element> {
const t = await getTranslations("grades")
if (!stats || stats.count === 0) {

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { useEffect, useMemo, useState } from "react"
import { BarChart3 } from "lucide-react"
import { useTranslations } from "next-intl"
@@ -24,7 +25,7 @@ interface GradeTrendCardProps {
classAverageData?: ClassAverageTrendResult | null
}
export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProps) {
export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProps): JSX.Element {
const t = useTranslations("grades")
// v3-P3-4: 日期范围选择器,通过 URL 参数持久化
const [trendRange, setTrendRange] = useQueryState(
@@ -164,10 +165,10 @@ export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProp
<TrendLineChart
data={chartData}
series={series}
heightClassName="h-[240px]"
heightClassName="h-60"
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
yWidth={30}
tooltipClassName="w-[200px]"
tooltipClassName="w-52"
/>
</div>
</ChartCardShell>

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { BarChart3 } from "lucide-react"
import { useTranslations } from "next-intl"
@@ -12,7 +13,7 @@ interface GradeTrendChartProps {
data: GradeTrendResult | null
}
export function GradeTrendChart({ data }: GradeTrendChartProps) {
export function GradeTrendChart({ data }: GradeTrendChartProps): JSX.Element {
const t = useTranslations("grades")
const isEmpty = !data || data.points.length === 0
@@ -54,10 +55,8 @@ export function GradeTrendChart({ data }: GradeTrendChartProps) {
activeDotRadius: 5,
},
]}
heightClassName="h-[280px]"
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
yWidth={36}
tooltipClassName="w-[220px]"
/>
</div>
</ChartCardShell>

View File

@@ -0,0 +1,187 @@
"use client"
import type { JSX } from "react"
import { useMemo } from "react"
import { TrendingUp, TrendingDown, Minus } from "lucide-react"
import { useTranslations } from "next-intl"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
import type { StudentGrowthArchiveResult } from "../types"
interface GrowthArchiveChartProps {
/** 学生成长档案数据;为 null 时渲染空状态 */
data: StudentGrowthArchiveResult | null
}
/**
* P3-4: 学生纵向成长档案图表。
*
* 展示内容:
* 1. 跨学年/学期的归一化平均分趋势线
* 2. 总览统计:总记录数、总科目数、跨越学年数
* 3. 成长趋势徽章growthDelta > 0 显示"提升"< 0 显示"下降"= 0 显示"持平"
*
* 隐私保护:仅显示聚合并的均分,不暴露个人排名或他人分数。
*/
export function GrowthArchiveChart({ data }: GrowthArchiveChartProps): JSX.Element {
const t = useTranslations("grades")
const { chartData, hasData, GrowthIcon, growthLabel, growthTone } = useMemo(() => {
if (!data || data.points.length === 0) {
return {
chartData: [],
hasData: false,
GrowthIcon: Minus,
growthLabel: "",
growthTone: "muted" as const,
}
}
const points = data.points.map((p) => ({
label: `${p.academicYearName} · ${t(`semester.s${p.semester}`)}`,
fullTitle: `${p.academicYearName} · ${t(`semester.s${p.semester}`)}`,
averageScore: p.averageScore,
passRate: p.passRate,
excellentRate: p.excellentRate,
recordCount: p.recordCount,
subjectCount: p.subjectCount,
}))
const delta = data.growthDelta
let Icon = Minus
let tone: "up" | "down" | "muted" = "muted"
let label = t("growthArchive.deltaStable")
if (delta > 0) {
Icon = TrendingUp
tone = "up"
label = t("growthArchive.deltaUp", { delta: delta.toFixed(1) })
} else if (delta < 0) {
Icon = TrendingDown
tone = "down"
label = t("growthArchive.deltaDown", { delta: Math.abs(delta).toFixed(1) })
}
return {
chartData: points,
hasData: true,
GrowthIcon: Icon,
growthLabel: label,
growthTone: tone,
}
}, [data, t])
const series = [
{
dataKey: "averageScore",
name: t("growthArchive.averageScore"),
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
]
return (
<ChartCardShell
title={t("growthArchive.title")}
description={
hasData && data
? t("growthArchive.description", {
years: data.totalAcademicYears,
records: data.totalRecords,
subjects: data.totalSubjects,
})
: t("growthArchive.descriptionEmpty")
}
icon={TrendingUp}
iconClassName="text-primary"
isEmpty={!hasData}
emptyTitle={t("growthArchive.emptyTitle")}
emptyDescription={t("growthArchive.emptyDescription")}
emptyClassName="h-60"
>
{hasData && data ? (
<div className="space-y-4">
{/* 成长趋势徽章 */}
<div
className="flex items-center gap-2 rounded-md border bg-muted/30 p-3"
role="status"
aria-label={growthLabel}
>
<GrowthIcon
className={
growthTone === "up"
? "h-5 w-5 text-emerald-600"
: growthTone === "down"
? "h-5 w-5 text-destructive"
: "h-5 w-5 text-muted-foreground"
}
aria-hidden="true"
/>
<span
className={
growthTone === "up"
? "text-sm font-medium text-emerald-700"
: growthTone === "down"
? "text-sm font-medium text-destructive"
: "text-sm font-medium text-muted-foreground"
}
>
{growthLabel}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{t("growthArchive.overallAverage", { score: data.overallAverage.toFixed(1) })}
</span>
</div>
{/* 趋势折线图 */}
<div
className="rounded-md border bg-card p-4"
role="img"
aria-label={
hasData
? t("growthArchive.ariaLabelNonEmpty", { count: data.points.length })
: t("growthArchive.ariaLabelEmpty")
}
>
<TrendLineChart
data={chartData}
series={series}
heightClassName="h-64"
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
yWidth={30}
tooltipClassName="w-64"
/>
</div>
{/* 详细统计表a11y sr-only + 可视化卡片) */}
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3" role="list">
{data.points.map((p) => (
<li
key={`${p.academicYearId}-${p.semester}`}
className="rounded-md border bg-background p-3 text-xs"
role="listitem"
>
<div className="font-medium text-foreground">
{p.academicYearName} · {t(`semester.s${p.semester}`)}
</div>
<div className="mt-1 text-muted-foreground">
{t("growthArchive.statsAverage", { score: p.averageScore.toFixed(1) })}
</div>
<div className="text-muted-foreground">
{t("growthArchive.statsPassRate", { rate: p.passRate.toFixed(0) })}
</div>
<div className="text-muted-foreground">
{t("growthArchive.statsRecords", {
records: p.recordCount,
subjects: p.subjectCount,
})}
</div>
</li>
))}
</ul>
</div>
) : null}
</ChartCardShell>
)
}

View File

@@ -0,0 +1,232 @@
"use client"
import type { JSX } from "react"
import { useMemo } from "react"
import Link from "next/link"
import { Brain, ArrowRight } from "lucide-react"
import { useTranslations } from "next-intl"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart"
import { Button } from "@/shared/components/ui/button"
import type { KnowledgePointStat } from "@/modules/diagnostic/types"
interface KnowledgePointMasteryChartProps {
/** 知识点掌握度统计数据;为空数组时渲染空状态 */
data: KnowledgePointStat[]
/** 链接到的诊断详情页路径(如 "/teacher/diagnostic" */
detailHref?: string
}
/** P3-3: 知识点掌握度颜色映射(与 diagnostic 模块 masteryColor 对齐)。 */
function masteryBarColor(level: number): string {
if (level >= 80) return "hsl(142, 71%, 45%)" // green
if (level >= 60) return "hsl(43, 96%, 56%)" // yellow
if (level >= 40) return "hsl(25, 95%, 53%)" // orange
return "hsl(0, 84%, 60%)" // red
}
interface MasteryTooltipItem {
label: string
averageMastery: number
masteredCount: number
notMasteredCount: number
totalStudents: number
}
interface MasteryTooltipPayload {
payload?: MasteryTooltipItem
}
function isMasteryTooltipPayload(v: unknown): v is MasteryTooltipPayload {
if (typeof v !== "object" || v === null) return false
const obj = v as Record<string, unknown>
const inner = obj.payload
if (inner === undefined || inner === null) return true
if (typeof inner !== "object") return false
const item = inner as Record<string, unknown>
return (
typeof item.label === "string" &&
typeof item.averageMastery === "number" &&
typeof item.masteredCount === "number" &&
typeof item.notMasteredCount === "number" &&
typeof item.totalStudents === "number"
)
}
/**
* P3-3: 知识点掌握度图表(集成 diagnostic 模块到 grades analytics 页面)。
*
* 展示内容:
* 1. 按掌握度升序排列的知识点柱状图(薄弱点优先突出)
* 2. 颜色编码≥80 绿色优秀、60-79 黄色良好、40-59 橙色(待提升)、<40 红色(薄弱)
* 3. 前 3 个薄弱知识点摘要
* 4. 跳转到完整诊断页的链接
*
* 数据来源:调用 diagnostic.data-access.getClassMasterySummary 获取 KnowledgePointStat[]
*/
export function KnowledgePointMasteryChart({
data,
detailHref,
}: KnowledgePointMasteryChartProps): JSX.Element {
const t = useTranslations("grades")
const { chartData, sortedStats, weakPoints, averageMastery, cellColors } = useMemo(() => {
if (data.length === 0) {
return {
chartData: [],
sortedStats: [],
weakPoints: [],
averageMastery: 0,
cellColors: {} as Record<string, string>,
}
}
// 按掌握度升序排列(薄弱点优先)
const sorted = [...data].sort((a, b) => a.averageMastery - b.averageMastery)
const avg = Math.round(
(sorted.reduce((sum, s) => sum + s.averageMastery, 0) / sorted.length) * 100
) / 100
// 柱状图数据
const points = sorted.map((s) => ({
label: s.knowledgePointName,
averageMastery: s.averageMastery,
masteredCount: s.masteredCount,
notMasteredCount: s.notMasteredCount,
totalStudents: s.totalStudents,
}))
// 单元格颜色映射
const colors: Record<string, string> = {}
for (const s of sorted) {
colors[s.knowledgePointName] = masteryBarColor(s.averageMastery)
}
// 前 3 个薄弱点(掌握度 < 80
const weak = sorted.filter((s) => s.averageMastery < 80).slice(0, 3)
return {
chartData: points,
sortedStats: sorted,
weakPoints: weak,
averageMastery: avg,
cellColors: colors,
}
}, [data])
const hasData = chartData.length > 0
return (
<ChartCardShell
title={t("knowledgePointMastery.title")}
description={
hasData
? t("knowledgePointMastery.description", {
count: sortedStats.length,
avg: averageMastery.toFixed(1),
})
: t("knowledgePointMastery.descriptionEmpty")
}
icon={Brain}
iconClassName="text-primary"
isEmpty={!hasData}
emptyTitle={t("knowledgePointMastery.emptyTitle")}
emptyDescription={t("knowledgePointMastery.emptyDescription")}
emptyClassName="h-60"
action={
detailHref ? (
<Button asChild variant="ghost" size="sm">
<Link href={detailHref}>
{t("knowledgePointMastery.viewDetail")}
<ArrowRight className="ml-1 h-3.5 w-3.5" aria-hidden="true" />
</Link>
</Button>
) : null
}
>
{hasData ? (
<div className="space-y-4">
<div
role="img"
aria-label={t("knowledgePointMastery.ariaLabel", { count: sortedStats.length })}
>
<SimpleBarChart
data={chartData}
bars={[
{
dataKey: "averageMastery",
name: t("knowledgePointMastery.averageMastery"),
color: "hsl(var(--primary))",
},
]}
xKey="label"
yDomain={[0, 100]}
yAllowDecimals={false}
yTickFormatter={(value: number) => `${value}%`}
xTickFormatter="default"
xTruncateLength={6}
yWidth={32}
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
cellColors={cellColors}
tooltipFormatter={(payload: unknown) => {
if (!isMasteryTooltipPayload(payload)) return null
const item = payload.payload
if (!item) return null
return (
<div className="flex w-full flex-col gap-0.5">
<span className="text-sm font-medium">{item.label}</span>
<span className="text-xs text-muted-foreground">
{t("knowledgePointMastery.tooltipMastery", { score: item.averageMastery.toFixed(1) })}
</span>
<span className="text-xs text-muted-foreground">
{t("knowledgePointMastery.tooltipStudents", {
mastered: item.masteredCount,
total: item.totalStudents,
})}
</span>
</div>
)
}}
/>
</div>
{/* 薄弱知识点摘要 */}
{weakPoints.length > 0 ? (
<div
className="rounded-md border border-orange-200 bg-orange-50 p-3 dark:border-orange-900 dark:bg-orange-950/30"
role="status"
aria-label={t("knowledgePointMastery.weakPointsAriaLabel")}
>
<div className="text-xs font-medium text-orange-700 dark:text-orange-300">
{t("knowledgePointMastery.weakPointsTitle")}
</div>
<ul className="mt-2 space-y-1" role="list">
{weakPoints.map((wp) => (
<li
key={wp.knowledgePointId}
className="flex items-center justify-between text-xs"
role="listitem"
>
<span className="text-foreground">{wp.knowledgePointName}</span>
<span
className={
wp.averageMastery < 40
? "font-medium text-red-600 dark:text-red-400"
: wp.averageMastery < 60
? "font-medium text-orange-600 dark:text-orange-400"
: "font-medium text-yellow-600 dark:text-yellow-400"
}
>
{wp.averageMastery.toFixed(1)}%
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
</ChartCardShell>
)
}

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { useMemo } from "react"
import { BarChart3 } from "lucide-react"
import { useTranslations } from "next-intl"
@@ -15,7 +16,7 @@ import type { RankingTrendResult } from "../types"
* v3-P1-3学生页面显示排名趋势Y 轴反转(第 1 名在顶部)。
* 对比同类 K12 系统PowerSchool、Infinite Campus 等)的排名趋势功能。
*/
export function RankingTrendCard({ trend }: { trend: RankingTrendResult | null }) {
export function RankingTrendCard({ trend }: { trend: RankingTrendResult | null }): JSX.Element {
const t = useTranslations("grades")
const chartData = useMemo(() => {
@@ -67,10 +68,10 @@ export function RankingTrendCard({ trend }: { trend: RankingTrendResult | null }
]}
yDomain={[Math.max(maxRank, 1), 1]}
yTickFormatter={(value: number) => `#${value}`}
heightClassName="h-[240px]"
heightClassName="h-60"
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
yWidth={40}
tooltipClassName="w-[200px]"
tooltipClassName="w-52"
/>
</div>
</ChartCardShell>

View File

@@ -0,0 +1,48 @@
"use client"
import type { JSX } from "react"
import { useState } from "react"
import { Printer, Loader2 } from "lucide-react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
/**
* P3-1: 报告卡打印按钮(客户端组件)。
*
* 渲染在报告卡页面顶部,点击后调用浏览器原生 window.print()。
* 通过 globals.css 中的 @media print 规则实现 A4 打印布局。
*/
export function ReportCardPrintAction(): JSX.Element {
const t = useTranslations("grades")
const [isPreparing, setIsPreparing] = useState(false)
const handlePrint = () => {
if (typeof window === "undefined") return
setIsPreparing(true)
try {
window.print()
} catch {
toast.error(t("reportCard.errorPrint"))
} finally {
// 等待一帧后重置状态,确保用户感知到"准备中"反馈
window.requestAnimationFrame(() => {
setIsPreparing(false)
})
}
}
return (
<Button onClick={handlePrint} disabled={isPreparing} className="gap-2">
{isPreparing ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Printer className="h-4 w-4" aria-hidden="true" />
)}
{isPreparing
? t("reportCard.preparing")
: t("reportCard.print")}
</Button>
)
}

View File

@@ -0,0 +1,106 @@
"use client"
import type { JSX } from "react"
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { Printer } from "lucide-react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
interface ReportCardPrintButtonProps {
/** 学生 ID */
studentId: string
/** 可选的学年列表 */
academicYears: Array<{ id: string; name: string }>
}
/**
* P3-1: 成绩报告卡打印按钮(客户端组件)。
*
* 该组件不直接调用 window.print(),而是通过路由跳转到
* /teacher/grades/report-card?studentId=xxx&academicYearId=yyy&semester=zzz
* 由报告卡页面渲染完整 A4 视图后,用户点击页面上的"打印"按钮触发浏览器打印。
*
* 这种方式的好处:
* 1. 报告卡页面是独立路由,可被书签收藏、家长直接访问
* 2. 打印样式仅在该页面生效,避免污染其他页面
* 3. 路由层完成权限校验,避免客户端绕过
*/
export function ReportCardPrintButton({
studentId,
academicYears,
}: ReportCardPrintButtonProps): JSX.Element {
const t = useTranslations("grades")
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [academicYearId, setAcademicYearId] = useState<string>("all")
const [semester, setSemester] = useState<string>("all")
const handleNavigate = () => {
startTransition(() => {
const params = new URLSearchParams()
params.set("studentId", studentId)
if (academicYearId !== "all") params.set("academicYearId", academicYearId)
if (semester !== "all") params.set("semester", semester)
try {
router.push(`/teacher/grades/report-card?${params.toString()}`)
} catch {
toast.error(t("reportCard.errorNavigate"))
}
})
}
return (
<div className="flex flex-wrap items-end gap-3">
<div className="grid gap-1.5">
<Label htmlFor="rc-academic-year" className="text-xs">
{t("reportCard.filterAcademicYear")}
</Label>
<Select value={academicYearId} onValueChange={setAcademicYearId}>
<SelectTrigger id="rc-academic-year" className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("reportCard.filterAllAcademicYears")}</SelectItem>
{academicYears.map((y) => (
<SelectItem key={y.id} value={y.id}>
{y.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label htmlFor="rc-semester" className="text-xs">
{t("reportCard.filterSemester")}
</Label>
<Select value={semester} onValueChange={setSemester}>
<SelectTrigger id="rc-semester" className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("reportCard.filterAllSemesters")}</SelectItem>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={handleNavigate} disabled={isPending} className="gap-2">
<Printer className="h-4 w-4" aria-hidden="true" />
{t("reportCard.openReportCard")}
</Button>
</div>
)
}

View File

@@ -0,0 +1,323 @@
import type { JSX } from "react"
import { GraduationCap } from "lucide-react"
import { getTranslations } from "next-intl/server"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { Separator } from "@/shared/components/ui/separator"
import type { ReportCardData, ReportCardSubjectItem } from "../lib/report-card"
import type { GradeRecordType } from "../types"
interface ReportCardViewProps {
data: ReportCardData
}
function formatScore(score: number): string {
return Number.isFinite(score) ? score.toFixed(1) : "—"
}
function formatRank(rank: number, total: number): string {
if (rank <= 0 || total <= 0) return "—"
return `${rank} / ${total}`
}
function formatDate(iso: string): string {
try {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
const yyyy = d.getFullYear()
const mm = String(d.getMonth() + 1).padStart(2, "0")
const dd = String(d.getDate()).padStart(2, "0")
return `${yyyy}-${mm}-${dd}`
} catch {
return iso
}
}
function typeLabel(
type: GradeRecordType,
t: Awaited<ReturnType<typeof getTranslations<"grades">>>
): string {
const key = `type.${type}` as const
return t(key)
}
/**
* P3-1: 成绩报告卡视图组件。
*
* 该组件渲染为纯服务器组件,输出符合 A4 打印规范的 HTML。
* 打印样式通过 report-card-print.css 全局加载(@media print 规则)。
*
* 布局:
* 1. 顶部:学校名 + "学期成绩报告卡" 标题 + 学生基本信息
* 2. 中部:各科目成绩明细表格(科目 | 评估 | 类型 | 得分 | 排名 | 备注)
* 3. 底部:综合统计 + 教师评语区 + 签名区 + 生成时间
*/
export async function ReportCardView({
data,
}: ReportCardViewProps): Promise<JSX.Element> {
const t = await getTranslations("grades")
return (
<div
className="report-card mx-auto w-[210mm] min-h-[297mm] bg-white p-12 text-black shadow-lg"
aria-label={t("reportCard.ariaLabel", { name: data.studentName })}
>
{/* 顶部:标题 + 学校信息 */}
<header className="report-card-header flex flex-col items-center gap-3 pb-6 border-b-2 border-black">
<div className="flex items-center gap-3">
<GraduationCap className="h-8 w-8" aria-hidden="true" />
<h1 className="text-2xl font-bold tracking-tight">
{t("reportCard.schoolName")}
</h1>
</div>
<h2 className="text-xl font-semibold">
{t("reportCard.title")}
</h2>
<p className="text-sm text-gray-700">
{t("reportCard.periodLabel", {
year: data.academicYearName ?? "—",
semester:
data.semester === "1"
? t("semester.s1")
: data.semester === "2"
? t("semester.s2")
: t("reportCard.allSemesters"),
})}
</p>
</header>
{/* 学生基本信息 */}
<section className="report-card-info py-4">
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm">
<div className="flex">
<dt className="w-24 font-medium text-gray-700">
{t("reportCard.studentName")}
</dt>
<dd className="flex-1 border-b border-dotted border-gray-400 pb-0.5">
{data.studentName}
</dd>
</div>
<div className="flex">
<dt className="w-24 font-medium text-gray-700">
{t("reportCard.className")}
</dt>
<dd className="flex-1 border-b border-dotted border-gray-400 pb-0.5">
{data.className}
</dd>
</div>
<div className="flex">
<dt className="w-24 font-medium text-gray-700">
{t("reportCard.classTeacher")}
</dt>
<dd className="flex-1 border-b border-dotted border-gray-400 pb-0.5">
{data.classTeacherName}
</dd>
</div>
<div className="flex">
<dt className="w-24 font-medium text-gray-700">
{t("reportCard.generatedAt")}
</dt>
<dd className="flex-1 border-b border-dotted border-gray-400 pb-0.5">
{formatDate(data.generatedAt)}
</dd>
</div>
</dl>
</section>
<Separator className="my-4 bg-gray-300" />
{/* 成绩明细表格 */}
<section className="report-card-grades py-2">
<h3 className="mb-3 text-base font-semibold">
{t("reportCard.gradesSectionTitle")}
</h3>
{data.subjects.length === 0 ? (
<p className="text-sm text-gray-600 italic">
{t("reportCard.emptyGrades")}
</p>
) : (
<Table className="report-card-table border border-gray-400">
<TableHeader>
<TableRow className="border-b border-gray-400 bg-gray-100">
<TableHead className="w-32 border-r border-gray-300 text-black">
{t("reportCard.columnSubject")}
</TableHead>
<TableHead className="border-r border-gray-300 text-black">
{t("reportCard.columnAssessment")}
</TableHead>
<TableHead className="w-20 border-r border-gray-300 text-black">
{t("reportCard.columnType")}
</TableHead>
<TableHead className="w-20 border-r border-gray-300 text-right text-black">
{t("reportCard.columnScore")}
</TableHead>
<TableHead className="w-24 border-r border-gray-300 text-right text-black">
{t("reportCard.columnRank")}
</TableHead>
<TableHead className="w-32 text-black">
{t("reportCard.columnRemark")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.subjects.flatMap((subject, idx) =>
renderSubjectRows(subject, idx, t)
)}
</TableBody>
</Table>
)}
</section>
{/* 综合统计 */}
<section className="report-card-summary py-4">
<h3 className="mb-3 text-base font-semibold">
{t("reportCard.summarySectionTitle")}
</h3>
<div className="grid grid-cols-4 gap-3">
<SummaryCell
label={t("reportCard.overallAverage")}
value={`${formatScore(data.overallAverage)}`}
/>
<SummaryCell
label={t("reportCard.overallRank")}
value={formatRank(data.overallRank, data.classTotalStudents)}
/>
<SummaryCell
label={t("reportCard.passRate")}
value={`${data.overallPassRate.toFixed(1)}%`}
/>
<SummaryCell
label={t("reportCard.excellentRate")}
value={`${data.overallExcellentRate.toFixed(1)}%`}
/>
</div>
</section>
{/* 教师评语 */}
<section className="report-card-comments py-4">
<h3 className="mb-2 text-base font-semibold">
{t("reportCard.commentsTitle")}
</h3>
<div
className="h-24 border border-gray-300 p-2 text-sm text-gray-500 italic"
role="textbox"
aria-label={t("reportCard.commentsAriaLabel")}
>
{t("reportCard.commentsPlaceholder")}
</div>
</section>
{/* 签名区 */}
<section className="report-card-signatures grid grid-cols-3 gap-6 pt-8">
<SignatureArea label={t("reportCard.signatureClassTeacher")} />
<SignatureArea label={t("reportCard.signatureParent")} />
<SignatureArea label={t("reportCard.signaturePrincipal")} />
</section>
{/* 页脚 */}
<footer className="report-card-footer pt-6 text-center text-xs text-gray-500">
<p>
{t("reportCard.footerNote", { date: formatDate(data.generatedAt) })}
</p>
</footer>
</div>
)
}
function SummaryCell({
label,
value,
}: {
label: string
value: string
}): JSX.Element {
return (
<div className="flex flex-col gap-1 border border-gray-300 bg-gray-50 p-2 text-center">
<span className="text-xs text-gray-600">{label}</span>
<span className="text-lg font-semibold text-black">{value}</span>
</div>
)
}
function SignatureArea({ label }: { label: string }): JSX.Element {
return (
<div className="flex flex-col items-center gap-2">
<div className="h-12 w-32 border-b border-dotted border-gray-500" />
<span className="text-xs text-gray-700">{label}</span>
</div>
)
}
function renderSubjectRows(
subject: ReportCardSubjectItem,
index: number,
t: Awaited<ReturnType<typeof getTranslations<"grades">>>
): JSX.Element[] {
if (subject.records.length === 0) {
return [
<TableRow key={`${subject.subjectId}-${index}`} className="border-b border-gray-300">
<TableCell className="border-r border-gray-200 font-medium">
{subject.subjectName}
</TableCell>
<TableCell className="border-r border-gray-200 text-gray-500 italic">
{t("reportCard.noRecords")}
</TableCell>
<TableCell className="border-r border-gray-200"></TableCell>
<TableCell className="border-r border-gray-200 text-right"></TableCell>
<TableCell className="border-r border-gray-200 text-right">
{formatRank(subject.rankInSubject, subject.totalStudentsInSubject)}
</TableCell>
<TableCell></TableCell>
</TableRow>,
]
}
return subject.records.map((record, rIdx) => (
<TableRow
key={`${subject.subjectId}-${rIdx}`}
className="border-b border-gray-200"
>
{rIdx === 0 ? (
<TableCell
className="border-r border-gray-200 font-medium align-top"
rowSpan={subject.records.length}
>
{subject.subjectName}
<span className="block text-xs text-gray-500 mt-1">
{t("reportCard.subjectAvg", { score: formatScore(subject.averageScore) })}
</span>
</TableCell>
) : null}
<TableCell className="border-r border-gray-200">{record.title}</TableCell>
<TableCell className="border-r border-gray-200 text-xs">
{typeLabel(record.type, t)}
</TableCell>
<TableCell className="border-r border-gray-200 text-right font-mono">
{formatScore(record.score)}
<span className="text-xs text-gray-500">
{" / "}
{formatScore(record.fullScore)}
</span>
</TableCell>
{rIdx === 0 ? (
<TableCell
className="border-r border-gray-200 text-right align-top"
rowSpan={subject.records.length}
>
{formatRank(subject.rankInSubject, subject.totalStudentsInSubject)}
</TableCell>
) : null}
<TableCell className="text-xs text-gray-600">
{record.remark ?? ""}
</TableCell>
</TableRow>
))
}

View File

@@ -1,5 +1,6 @@
import type { JSX } from "react"
import { School, TrendingUp, CheckCircle2, Award } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
@@ -11,7 +12,10 @@ interface SchoolWideSummaryCardProps {
summary: SchoolWideGradeSummary
}
export function SchoolWideSummaryCard({ summary }: SchoolWideSummaryCardProps): JSX.Element {
export async function SchoolWideSummaryCard({
summary,
}: SchoolWideSummaryCardProps): Promise<JSX.Element> {
const t = await getTranslations("grades")
const { totals, grades } = summary
return (
@@ -20,48 +24,53 @@ export function SchoolWideSummaryCard({ summary }: SchoolWideSummaryCardProps):
<div className="grid gap-4 md:grid-cols-4">
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<School className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">{t("schoolWide.gradeCount")}</CardTitle>
<School className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{totals.gradeCount}</div>
<p className="text-xs text-muted-foreground">
{totals.classCount} · {totals.studentCount}
{t("schoolWide.classStudentInfo", {
classCount: totals.classCount,
studentCount: totals.studentCount,
})}
</p>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm font-medium">{t("schoolWide.averageScore")}</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{formatNumber(totals.averageScore)}</div>
<p className="text-xs text-muted-foreground"> {totals.recordCount} </p>
<p className="text-xs text-muted-foreground">
{t("schoolWide.recordCount", { count: totals.recordCount })}
</p>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
<CardTitle className="text-sm font-medium">{t("schoolWide.passRate")}</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums text-green-600">
{formatNumber(totals.passRate)}%
</div>
<p className="text-xs text-muted-foreground">线 60%</p>
<p className="text-xs text-muted-foreground">{t("schoolWide.passLine")}</p>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Award className="h-4 w-4 text-amber-600" />
<CardTitle className="text-sm font-medium">{t("schoolWide.excellentRate")}</CardTitle>
<Award className="h-4 w-4 text-amber-600" aria-hidden="true" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums text-amber-600">
{formatNumber(totals.excellentRate)}%
</div>
<p className="text-xs text-muted-foreground">线 85%</p>
<p className="text-xs text-muted-foreground">{t("schoolWide.excellentLine")}</p>
</CardContent>
</Card>
</div>
@@ -69,24 +78,27 @@ export function SchoolWideSummaryCard({ summary }: SchoolWideSummaryCardProps):
{/* 各年级对比表格 */}
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
<CardTitle className="text-base">{t("schoolWide.comparisonTitle")}</CardTitle>
<CardDescription>{t("schoolWide.comparisonDescription")}</CardDescription>
</CardHeader>
<CardContent>
{grades.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground"></p>
<p className="py-6 text-center text-sm text-muted-foreground">
{t("schoolWide.empty")}
</p>
) : (
<div className="rounded-md border">
<Table>
<caption className="sr-only">{t("schoolWide.comparisonTitle")}</caption>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead> / </TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead>{t("schoolWide.columns.schoolGrade")}</TableHead>
<TableHead className="text-right">{t("schoolWide.columns.classCount")}</TableHead>
<TableHead className="text-right">{t("schoolWide.columns.studentCount")}</TableHead>
<TableHead className="text-right">{t("schoolWide.columns.recordCount")}</TableHead>
<TableHead className="text-right">{t("schoolWide.columns.average")}</TableHead>
<TableHead className="text-right">{t("schoolWide.columns.passRate")}</TableHead>
<TableHead className="text-right">{t("schoolWide.columns.excellentRate")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@@ -1,3 +1,4 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -17,7 +18,7 @@ import { GraduationCap, Trophy } from "lucide-react"
import type { StudentGradeSummary } from "../types"
import { GRADE_TYPE_VARIANT } from "../types"
export async function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }) {
export async function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }): Promise<JSX.Element> {
const t = await getTranslations("grades")
if (!summary) {

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { Radar } from "lucide-react"
import { useTranslations } from "next-intl"
@@ -11,7 +12,7 @@ interface SubjectComparisonChartProps {
data: SubjectComparisonItem[]
}
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps): JSX.Element {
const t = useTranslations("grades")
const isEmpty = !data || data.length === 0
@@ -46,7 +47,6 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
angleTickFormatter={(value: string) =>
value.length > 6 ? `${value.slice(0, 6)}...` : value
}
heightClassName="h-[300px]"
series={[
{
dataKey: "averageScore",

View File

@@ -1,155 +0,0 @@
"use client"
/**
* Grades/Diagnostic 模块通用 Widget 边界组件。
*
* 组合三个能力:
* 1. Error Boundary — 隔离故障域,单个 Widget 抛错不影响其他区块
* 2. Suspense — 流式渲染时显示骨架屏,避免白屏等待
* 3. Skeleton — 与 Widget 尺寸匹配的占位
*
* 用法:
* ```tsx
* <WidgetBoundary title="成绩趋势">
* <GradeTrendChart data={data} />
* </WidgetBoundary>
* ```
*/
import { Component, Suspense, type ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Skeleton } from "@/shared/components/ui/skeleton"
interface WidgetBoundaryProps {
children: ReactNode
/** Widget 标题(用于错误提示和 aria-label */
title?: string
/** 骨架屏高度(默认 200px */
skeletonHeight?: number
/** 自定义错误描述 */
fallbackDescription?: string
/** 重试按钮文案 */
retryLabel?: string
}
interface WidgetBoundaryState {
hasError: boolean
}
interface WidgetErrorBoundaryProps {
title: string
fallbackDescription: string
retryLabel: string
loadFailedMessage: string
retryAriaLabel: string
children: ReactNode
}
class WidgetErrorBoundary extends Component<
WidgetErrorBoundaryProps,
WidgetBoundaryState
> {
constructor(props: WidgetErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(): WidgetBoundaryState {
return { hasError: true }
}
handleReset = (): void => {
this.setState({ hasError: false })
}
render(): ReactNode {
if (this.state.hasError) {
return (
<div
role="alert"
aria-live="assertive"
className="flex h-full min-h-[200px] flex-col items-center justify-center gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-6 text-center"
>
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{this.props.loadFailedMessage}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={this.handleReset}
aria-label={this.props.retryAriaLabel}
>
{this.props.retryLabel}
</Button>
</div>
)
}
return this.props.children
}
}
function WidgetSkeleton({
height,
loadingAriaLabel,
}: {
height: number
loadingAriaLabel: string
}): ReactNode {
return (
<div
role="status"
aria-label={loadingAriaLabel}
aria-live="polite"
className="space-y-3 p-4"
style={{ minHeight: height }}
>
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-32 w-full" />
</div>
)
}
export function WidgetBoundary({
children,
title,
skeletonHeight = 200,
fallbackDescription,
retryLabel,
}: WidgetBoundaryProps): ReactNode {
const t = useTranslations("grades")
const effectiveTitle = title ?? t("widget.block")
const effectiveFallbackDescription = fallbackDescription ?? t("widget.defaultFallback")
const effectiveRetryLabel = retryLabel ?? t("widget.retry")
const loadFailedMessage = t("widget.loadFailed", { title: effectiveTitle })
const retryAriaLabel = t("widget.retryAriaLabel", { title: effectiveTitle })
const loadingAriaLabel = t("widget.loadingAriaLabel", { title: effectiveTitle })
return (
<WidgetErrorBoundary
title={effectiveTitle}
fallbackDescription={effectiveFallbackDescription}
retryLabel={effectiveRetryLabel}
loadFailedMessage={loadFailedMessage}
retryAriaLabel={retryAriaLabel}
>
<Suspense
fallback={
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
}
>
{children}
</Suspense>
</WidgetErrorBoundary>
)
}

View File

@@ -8,27 +8,37 @@ import { gradeRecords } from "@/shared/db/schema"
import {
getClassesByGradeId,
getClassNameById,
getStudentActiveClassId,
} from "@/modules/classes/data-access"
import { getGrades, getSubjectOptions } from "@/modules/school/data-access"
import { getAcademicYears, getGrades, getSubjectOptions } from "@/modules/school/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import type { DataScope } from "@/shared/types/permissions"
import { normalize, toNumber } from "./lib/grade-utils"
import { buildScopeClassFilter } from "./lib/scope-filter"
import {
buildGradeTrendPoints,
buildGrowthArchivePoints,
computeAverageScore,
computeClassComparisonStats,
computeGradeDistribution,
computeGradeStats,
computeSignificance,
computeSubjectComparisonStats,
computeTrendAverage,
findBucketIndex,
} from "./stats-service"
import type {
ClassComparisonItem,
ClassComparisonResult,
ClassComparisonSignificance,
GradeDistributionByGradeResult,
GradeDistributionResult,
GradeDistributionWithPosition,
GradeTrendResult,
SchoolWideGradeSummary,
SchoolWideGradeSummaryItem,
StudentGrowthArchiveResult,
SubjectComparisonItem,
} from "./types"
@@ -163,6 +173,303 @@ export const getClassComparison = cache(
}
)
/**
* P3-5: 获取班级对比数据 + 班级间统计显著性分析结果。
*
* 显著性分析流程:
* 1. 复用 getClassComparison 获取各班级聚合 stats
* 2. 找到均分最高与最低班级
* 3. 从 allRows 中提取这两个班级的归一化分数数组
* 4. 调用 stats-service.computeSignificance 计算 Cohen's d 与 p 值
*
* 返回的 significance 为 null 的场景:
* - 班级数 < 2
* - 任意班级样本量 < 2
* - 方差为 0所有分数相同
*
* 该函数复用 getClassComparison 的 cache参数一致不会触发额外 DB 查询。
*/
export const getClassComparisonWithSignificance = cache(
async (params: ClassComparisonParams): Promise<ClassComparisonResult> => {
const items = await getClassComparison(params)
if (items.length < 2) {
return { items, significance: null }
}
// 找出均分最高与最低班级
let topClass = items[0]
let bottomClass = items[0]
for (const item of items) {
if (item.averageScore > topClass.averageScore) topClass = item
if (item.averageScore < bottomClass.averageScore) bottomClass = item
}
if (topClass.classId === bottomClass.classId) {
return { items, significance: null }
}
// 重新查询两个班级的原始分数(归一化 0-100用于统计检验
const conditions = [
inArray(gradeRecords.classId, [topClass.classId, bottomClass.classId]),
eq(gradeRecords.subjectId, params.subjectId),
]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rawRows = await db
.select({
classId: gradeRecords.classId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
const topScores: number[] = []
const bottomScores: number[] = []
for (const r of rawRows) {
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
if (r.classId === topClass.classId) topScores.push(normalized)
else if (r.classId === bottomClass.classId) bottomScores.push(normalized)
}
const sigResult = computeSignificance(topScores, bottomScores)
if (!sigResult) {
return { items, significance: null }
}
const significance: ClassComparisonSignificance = {
topClassId: topClass.classId,
bottomClassId: bottomClass.classId,
cohensD: sigResult.cohensD,
effectSizeLabel: sigResult.effectSizeLabel,
pValue: sigResult.pValue,
isSignificant: sigResult.isSignificant,
}
return { items, significance }
}
)
/**
* P3-9: 获取学生所在班级的成绩分布 + 学生本人位置标注(隐私保护视图)。
*
* 流程:
* 1. 通过 classes.getStudentActiveClassId 查询学生所在班级
* 2. 查询该班级所有学生的成绩记录(不含学生 ID仅聚合计数
* 3. 查询该学生最近的归一化分数
* 4. 调用 stats-service.findBucketIndex 计算学生所在桶索引
*
* 返回 null 的场景:
* - 学生未分配到任何班级
* - 班级无成绩记录
*
* @param studentId - 当前学生 ID
* @param scope - 当前用户的数据范围(学生为 class_members
*/
export const getStudentPositionInClassDistribution = cache(
async (
studentId: string,
scope: DataScope
): Promise<GradeDistributionWithPosition | null> => {
// 仅学生class_members scope调用且必须传 studentId
if (scope.type !== "class_members") return null
const classId = await getStudentActiveClassId(studentId)
if (!classId) return null
const className = (await getClassNameById(classId)) ?? ""
// 查询该班级全部学生的成绩记录(聚合计数,不含个人身份)
const conditions = [eq(gradeRecords.classId, classId)]
const scopeFilter = buildScopeClassFilter(scope)
if (scopeFilter) conditions.push(scopeFilter)
const allRows = await db
.select({
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
studentId: gradeRecords.studentId,
createdAt: gradeRecords.createdAt,
})
.from(gradeRecords)
.where(and(...conditions))
if (allRows.length === 0) return null
const distribution = computeGradeDistribution(
allRows.map((r) => ({ score: r.score, fullScore: r.fullScore }))
)
// 获取该学生最近一条成绩记录的归一化分数
const studentRows = allRows
.filter((r) => r.studentId === studentId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
if (studentRows.length === 0) {
return {
distribution,
classId,
className,
studentNormalizedScore: null,
studentBucketIndex: -1,
}
}
const latestRow = studentRows[0]
const studentNormalizedScore = normalize(
toNumber(latestRow.score),
toNumber(latestRow.fullScore)
)
const bucketIndex = findBucketIndex(distribution.buckets, studentNormalizedScore)
return {
distribution,
classId,
className,
studentNormalizedScore,
studentBucketIndex: bucketIndex,
}
}
)
/**
* P3-4: 获取学生纵向成长档案(跨学年/学期聚合)。
*
* 流程:
* 1. 校验 class_taught scope学生必须属于教师所教班级
* 2. 获取学生姓名
* 3. 获取所有学年信息id → { name, startDate }
* 4. 查询学生的全部成绩记录(含 academicYearId
* 5. 调用 stats-service.buildGrowthArchivePoints 计算成长点序列
* 6. 计算总览统计overallAverage / totalRecords / totalSubjects / totalAcademicYears / growthDelta
*
* 返回 null 的场景:
* - 学生不存在
* - 学生无任何成绩记录
*
* 注class_members / children scope 的越权校验由 action 层完成(需 ctx.userId
*
* @param studentId - 学生 ID
* @param scope - 当前用户的数据范围
*/
export const getStudentGrowthArchive = cache(
async (
studentId: string,
scope: DataScope
): Promise<StudentGrowthArchiveResult | null> => {
// scope 校验:仅 class_taught 需在 data-access 层校验(其余由 action 层处理)
if (scope.type === "class_taught") {
const studentClassId = await getStudentActiveClassId(studentId)
if (studentClassId && !scope.classIds.includes(studentClassId)) {
return null
}
}
// 获取学生姓名
const studentNameMap = await getUserNamesByIds([studentId])
const studentName = studentNameMap.get(studentId)?.name ?? null
if (!studentName && !studentNameMap.has(studentId)) return null
// 获取所有学年信息
const academicYears = await getAcademicYears()
const academicYearMap = new Map<string, { name: string; startDate: Date }>()
for (const y of academicYears) {
academicYearMap.set(y.id, {
name: y.name,
startDate: new Date(y.startDate),
})
}
// 查询学生的全部成绩记录(含 academicYearId 非空过滤)
const conditions = [
eq(gradeRecords.studentId, studentId),
isNotNull(gradeRecords.academicYearId),
ne(gradeRecords.academicYearId, ""),
]
const scopeFilter = buildScopeClassFilter(scope, studentId)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
academicYearId: gradeRecords.academicYearId,
semester: gradeRecords.semester,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
subjectId: gradeRecords.subjectId,
title: gradeRecords.title,
})
.from(gradeRecords)
.where(and(...conditions))
if (rows.length === 0) {
return null
}
// 构建原始数据行(合并学年信息)
const archiveRows = rows
.map((r) => {
const yearId = r.academicYearId
if (!yearId) return null
const yearInfo = academicYearMap.get(yearId)
if (!yearInfo) return null
return {
academicYearId: yearId,
academicYearName: yearInfo.name,
academicYearStart: yearInfo.startDate,
semester: r.semester as "1" | "2",
score: r.score,
fullScore: r.fullScore,
subjectId: r.subjectId,
title: r.title,
}
})
.filter((r): r is NonNullable<typeof r> => r !== null)
if (archiveRows.length === 0) {
return null
}
// 调用 stats-service 纯函数构建成长点序列
const points = buildGrowthArchivePoints(archiveRows)
if (points.length === 0) {
return null
}
// 计算总览统计
const allNormalizedScores = rows
.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore)))
const overallAverage = computeAverageScore(allNormalizedScores)
const totalSubjects = new Set(rows.map((r) => r.subjectId)).size
const totalAcademicYears = new Set(
archiveRows.map((r) => r.academicYearId)
).size
// 成长趋势:最新点平均分 - 第一个点平均分
const firstPoint = points[0]
const lastPoint = points[points.length - 1]
const growthDelta = Math.round(
(lastPoint.averageScore - firstPoint.averageScore) * 100
) / 100
return {
studentId,
studentName: studentName ?? "Unknown",
points,
overallAverage,
totalRecords: rows.length,
totalSubjects,
totalAcademicYears,
growthDelta,
}
}
)
export interface SubjectComparisonParams {
classId: string
examId?: string
@@ -349,18 +656,21 @@ export const getSchoolWideGradeSummary = cache(
)
const gradeItems: SchoolWideGradeSummaryItem[] = gradeStatsResults
.filter((r) => r.stats !== null)
.map((r) => ({
gradeId: r.grade.id,
gradeName: r.grade.name,
schoolName: r.grade.school.name,
classCount: r.classCount,
studentCount: r.studentCount,
recordCount: r.recordCount,
averageScore: r.stats!.average,
passRate: r.stats!.passRate,
excellentRate: r.stats!.excellentRate,
}))
.filter((r): r is typeof r & { stats: NonNullable<typeof r.stats> } => r.stats !== null)
.map((r) => {
const stats = r.stats
return {
gradeId: r.grade.id,
gradeName: r.grade.name,
schoolName: r.grade.school.name,
classCount: r.classCount,
studentCount: r.studentCount,
recordCount: r.recordCount,
averageScore: stats.average,
passRate: stats.passRate,
excellentRate: stats.excellentRate,
}
})
.sort((a, b) => a.schoolName.localeCompare(b.schoolName) || a.gradeName.localeCompare(b.gradeName))
// 计算全校汇总(按记录数加权平均)

View File

@@ -0,0 +1,260 @@
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, desc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { gradeAppeals, gradeRecords } from "@/shared/db/schema"
/**
* P3-7 成绩申诉数据访问层。
*
* 申诉状态机pending → approved/rejected/withdrawn
* 一条 grade_record 同时只允许一条 pending 申诉(应用层校验)。
* 成绩修改全程留痕originalScore 快照 + adjustedScore + reviewComment。
*/
export interface GradeAppeal {
id: string
gradeRecordId: string
studentId: string
originalScore: string
expectedScore: string | null
reason: string
status: "pending" | "approved" | "rejected" | "withdrawn"
reviewerId: string | null
reviewComment: string | null
adjustedScore: string | null
reviewedAt: string | null
createdAt: string
updatedAt: string
}
export interface GradeAppealWithRecord extends GradeAppeal {
gradeRecordTitle: string
gradeRecordSubjectId: string
gradeRecordClassId: string
studentName: string | null
reviewerName: string | null
}
/** 类型转换DB 行 → 业务对象 */
function serializeAppeal(row: typeof gradeAppeals.$inferSelect): GradeAppeal {
return {
id: row.id,
gradeRecordId: row.gradeRecordId,
studentId: row.studentId,
originalScore: row.originalScore,
expectedScore: row.expectedScore,
reason: row.reason,
status: row.status,
reviewerId: row.reviewerId,
reviewComment: row.reviewComment,
adjustedScore: row.adjustedScore,
reviewedAt: row.reviewedAt ? row.reviewedAt.toISOString() : null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}
/**
* 检查某成绩记录是否已有 pending 申诉。
* 用于提交前校验,避免重复申诉。
*/
export async function hasPendingAppeal(gradeRecordId: string): Promise<boolean> {
const existing = await db.query.gradeAppeals.findFirst({
where: and(
eq(gradeAppeals.gradeRecordId, gradeRecordId),
eq(gradeAppeals.status, "pending")
),
})
return Boolean(existing)
}
/**
* 创建成绩申诉。
* 调用方需先校验1) 成绩存在且属于当前学生2) 无 pending 申诉。
*/
export async function createGradeAppeal(params: {
gradeRecordId: string
studentId: string
originalScore: string
reason: string
expectedScore?: number
}): Promise<string> {
const id = createId()
await db.insert(gradeAppeals).values({
id,
gradeRecordId: params.gradeRecordId,
studentId: params.studentId,
originalScore: params.originalScore,
expectedScore: params.expectedScore !== undefined ? String(params.expectedScore) : null,
reason: params.reason,
status: "pending",
})
return id
}
/**
* 获取学生自己的申诉列表(按创建时间倒序)。
*/
export const getStudentAppeals = cache(
async (studentId: string): Promise<GradeAppeal[]> => {
const rows = await db
.select()
.from(gradeAppeals)
.where(eq(gradeAppeals.studentId, studentId))
.orderBy(desc(gradeAppeals.createdAt))
return rows.map(serializeAppeal)
}
)
/**
* 获取教师待复核的申诉列表(按创建时间正序,先到先审)。
* 仅返回 pending 状态的申诉。
*/
export const getPendingAppealsForReview = cache(
async (classIds: string[]): Promise<GradeAppealWithRecord[]> => {
if (classIds.length === 0) return []
const rows = await db
.select({
appeal: gradeAppeals,
gradeRecord: gradeRecords,
})
.from(gradeAppeals)
.innerJoin(gradeRecords, eq(gradeAppeals.gradeRecordId, gradeRecords.id))
.where(
and(
eq(gradeAppeals.status, "pending"),
// 通过 gradeRecord.classId 过滤(教师只能看自己班级的申诉)
)
)
.orderBy(gradeAppeals.createdAt)
// 在 JS 层过滤班级范围(避免复杂 SQL join
const filtered = rows.filter((r) => classIds.includes(r.gradeRecord.classId))
return filtered.map((r) => ({
...serializeAppeal(r.appeal),
gradeRecordTitle: r.gradeRecord.title,
gradeRecordSubjectId: r.gradeRecord.subjectId,
gradeRecordClassId: r.gradeRecord.classId,
studentName: null,
reviewerName: null,
}))
}
)
/**
* 获取单个申诉详情(含成绩信息)。
* 调用方需校验:学生只能看自己的申诉,教师只能看自己班级的申诉。
*/
export const getAppealById = cache(
async (appealId: string): Promise<GradeAppealWithRecord | null> => {
const rows = await db
.select({
appeal: gradeAppeals,
gradeRecord: gradeRecords,
})
.from(gradeAppeals)
.innerJoin(gradeRecords, eq(gradeAppeals.gradeRecordId, gradeRecords.id))
.where(eq(gradeAppeals.id, appealId))
.limit(1)
if (rows.length === 0) return null
const row = rows[0]
return {
...serializeAppeal(row.appeal),
gradeRecordTitle: row.gradeRecord.title,
gradeRecordSubjectId: row.gradeRecord.subjectId,
gradeRecordClassId: row.gradeRecord.classId,
studentName: null,
reviewerName: null,
}
}
)
/**
* 教师复核申诉状态机转换pending → approved/rejected
*
* 若 decision=approved 且 adjustedScore 有值:
* 1. 更新申诉记录reviewerId, reviewComment, adjustedScore, reviewedAt, status
* 2. 同步更新 grade_records.score留痕originalScore 已在申诉记录中快照)
*
* 若 decision=rejected
* 1. 仅更新申诉记录,不修改成绩
*/
export async function reviewGradeAppeal(params: {
appealId: string
reviewerId: string
decision: "approved" | "rejected"
reviewComment: string
adjustedScore?: number
}): Promise<void> {
const { appealId, reviewerId, decision, reviewComment, adjustedScore } = params
const appeal = await db.query.gradeAppeals.findFirst({
where: eq(gradeAppeals.id, appealId),
})
if (!appeal) {
throw new Error("Appeal not found")
}
if (appeal.status !== "pending") {
throw new Error("Appeal is not pending")
}
const now = new Date()
// 更新申诉记录
await db
.update(gradeAppeals)
.set({
status: decision,
reviewerId,
reviewComment,
adjustedScore: adjustedScore !== undefined ? String(adjustedScore) : null,
reviewedAt: now,
})
.where(eq(gradeAppeals.id, appealId))
// 若批准且分数有变,同步更新成绩记录
if (decision === "approved" && adjustedScore !== undefined) {
await db
.update(gradeRecords)
.set({ score: String(adjustedScore) })
.where(eq(gradeRecords.id, appeal.gradeRecordId))
}
}
/**
* 学生撤回申诉状态机转换pending → withdrawn
*/
export async function withdrawGradeAppeal(params: {
appealId: string
studentId: string
}): Promise<void> {
const { appealId, studentId } = params
const appeal = await db.query.gradeAppeals.findFirst({
where: and(
eq(gradeAppeals.id, appealId),
eq(gradeAppeals.studentId, studentId)
),
})
if (!appeal) {
throw new Error("Appeal not found or not owned by student")
}
if (appeal.status !== "pending") {
throw new Error("Appeal is not pending")
}
await db
.update(gradeAppeals)
.set({ status: "withdrawn" })
.where(eq(gradeAppeals.id, appealId))
}

View File

@@ -0,0 +1,405 @@
import "server-only"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, eq, isNotNull, isNull, or, lt } from "drizzle-orm"
import { db } from "@/shared/db"
import { gradeDrafts } from "@/shared/db/schema"
import { isGradeDraftData } from "./lib/type-guards"
/**
* 锁机制常量。
*
* P3-6 协同录入锁:避免多位教师同时编辑同一班级+科目+类型的成绩,
* 通过乐观 TTL5 分钟)+ 令牌续期的方式实现悲观锁语义。
*/
const LOCK_TTL_MS = 5 * 60 * 1000
/**
* 成绩录入草稿数据访问层。
*
* P1-2 重构:从 data-access.ts 拆分而来,使 data-access.ts 回到 800 行以内。
* 草稿机制允许教师跨设备恢复未提交的成绩录入。
*/
/** 草稿数据结构(持久化到 DB JSON 列) */
export interface GradeDraftData {
scores: Record<string, string>
timestamp: number
}
/**
* 保存成绩录入草稿到 DBupsert
* 每位教师对每个 classId+subjectId+type 组合只有一个草稿。
*/
export async function saveGradeDraft(params: {
userId: string
classId: string
subjectId: string
type: string
data: GradeDraftData
}): Promise<void> {
const { userId, classId, subjectId, type, data } = params
// upsert存在则更新不存在则插入
const existing = await db.query.gradeDrafts.findFirst({
where: and(
eq(gradeDrafts.userId, userId),
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type)
),
})
if (existing) {
await db
.update(gradeDrafts)
.set({ content: data })
.where(eq(gradeDrafts.id, existing.id))
} else {
await db.insert(gradeDrafts).values({
id: createId(),
userId,
classId,
subjectId,
type,
content: data,
})
}
}
/**
* 获取成绩录入草稿。
* 超过 24 小时的草稿视为过期,返回 null。
*/
export const getGradeDraft = cache(
async (params: {
userId: string
classId: string
subjectId: string
type: string
}): Promise<GradeDraftData | null> => {
const { userId, classId, subjectId, type } = params
const draft = await db.query.gradeDrafts.findFirst({
where: and(
eq(gradeDrafts.userId, userId),
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type)
),
})
if (!draft) return null
// 从 DB JSON 列解析,使用类型守卫确保结构安全
if (!isGradeDraftData(draft.content)) return null
const content = draft.content
// 24 小时过期
if (Date.now() - content.timestamp > 24 * 60 * 60 * 1000) {
return null
}
return content
}
)
/**
* 删除成绩录入草稿(提交成功后调用)。
*/
export async function deleteGradeDraft(params: {
userId: string
classId: string
subjectId: string
type: string
}): Promise<void> {
const { userId, classId, subjectId, type } = params
await db
.delete(gradeDrafts)
.where(
and(
eq(gradeDrafts.userId, userId),
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type)
)
)
}
// ============================================================================
// P3-6 协同录入锁机制
// ============================================================================
/** 锁状态信息(含持有者信息,用于前端展示提示) */
export interface DraftLockStatus {
isLocked: boolean
lockedBy: string | null
lockedByName: string | null
lockedAt: string | null
isExpired: boolean
isMine: boolean
}
/** 获取锁的结果(包含令牌,前端续期/释放时需回传) */
export interface AcquireLockResult {
acquired: boolean
lockToken: string | null
status: DraftLockStatus
}
/**
* 查询某班级+科目+类型的锁状态。
*
* 锁以草稿记录为载体:草稿存在 lockedBy 字段即视为被锁。
* 超过 TTL 的锁视为过期(自动失效)。
*/
export async function getDraftLockStatus(params: {
classId: string
subjectId: string
type: string
currentUserId: string
}): Promise<DraftLockStatus> {
const { classId, subjectId, type, currentUserId } = params
const draft = await db.query.gradeDrafts.findFirst({
where: and(
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type)
),
})
if (!draft || !draft.lockedBy) {
return {
isLocked: false,
lockedBy: null,
lockedByName: null,
lockedAt: null,
isExpired: false,
isMine: false,
}
}
const lockedAtMs = draft.lockedAt ? new Date(draft.lockedAt).getTime() : 0
const isExpired = Date.now() - lockedAtMs > LOCK_TTL_MS
return {
isLocked: !isExpired,
lockedBy: draft.lockedBy,
lockedByName: null, // 不做跨模块 join由 Action 层补充姓名
lockedAt: draft.lockedAt ? draft.lockedAt.toISOString() : null,
isExpired,
isMine: draft.lockedBy === currentUserId,
}
}
/**
* 获取草稿锁(悲观语义)。
*
* 规则:
* 1. 若锁不存在或已过期 → 创建/覆盖锁
* 2. 若锁被自己持有且未过期 → 视为续期成功,刷新 token + 时间
* 3. 若锁被他人持有且未过期 → 拒绝,返回当前状态
* 4. 若锁被他人持有但已过期 → 抢占
*/
export async function acquireDraftLock(params: {
classId: string
subjectId: string
type: string
userId: string
}): Promise<AcquireLockResult> {
const { classId, subjectId, type, userId } = params
const status = await getDraftLockStatus({
classId,
subjectId,
type,
currentUserId: userId,
})
// 情况 3他人持锁且未过期
if (status.isLocked && !status.isMine) {
return { acquired: false, lockToken: null, status }
}
// 情况 1/2/4创建/续期/抢占
const lockToken = createId()
const now = new Date()
const existing = await db.query.gradeDrafts.findFirst({
where: and(
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type)
),
})
if (existing) {
await db
.update(gradeDrafts)
.set({
lockedBy: userId,
lockedAt: now,
lockToken,
})
.where(eq(gradeDrafts.id, existing.id))
} else {
// 锁可以独立于草稿内容存在(教师刚进入页面尚未输入成绩)
await db.insert(gradeDrafts).values({
id: createId(),
userId,
classId,
subjectId,
type,
content: { scores: {}, timestamp: now.getTime() },
lockedBy: userId,
lockedAt: now,
lockToken,
})
}
return {
acquired: true,
lockToken,
status: {
isLocked: true,
lockedBy: userId,
lockedByName: null,
lockedAt: now.toISOString(),
isExpired: false,
isMine: true,
},
}
}
/**
* 续期锁(心跳机制)。
*
* 前端每隔 2-3 分钟续期一次,避免教师长时间编辑时锁过期被抢占。
* 校验 lockToken 防止他人伪造 userId 续期。
*/
export async function renewDraftLock(params: {
classId: string
subjectId: string
type: string
userId: string
lockToken: string
}): Promise<AcquireLockResult> {
const { classId, subjectId, type, userId, lockToken } = params
const draft = await db.query.gradeDrafts.findFirst({
where: and(
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type)
),
})
if (!draft || !draft.lockedBy) {
return {
acquired: false,
lockToken: null,
status: await getDraftLockStatus({
classId,
subjectId,
type,
currentUserId: userId,
}),
}
}
// 令牌或持有人不匹配 → 拒绝
if (draft.lockedBy !== userId || draft.lockToken !== lockToken) {
return {
acquired: false,
lockToken: null,
status: await getDraftLockStatus({
classId,
subjectId,
type,
currentUserId: userId,
}),
}
}
const newToken = createId()
const now = new Date()
await db
.update(gradeDrafts)
.set({ lockedAt: now, lockToken: newToken })
.where(eq(gradeDrafts.id, draft.id))
return {
acquired: true,
lockToken: newToken,
status: {
isLocked: true,
lockedBy: userId,
lockedByName: null,
lockedAt: now.toISOString(),
isExpired: false,
isMine: true,
},
}
}
/**
* 主动释放锁(提交成功或用户主动退出时调用)。
*
* 仅当 token + userId 匹配时才允许释放,避免他人恶意解锁。
*/
export async function releaseDraftLock(params: {
classId: string
subjectId: string
type: string
userId: string
lockToken: string
}): Promise<boolean> {
const { classId, subjectId, type, userId, lockToken } = params
const result = await db
.update(gradeDrafts)
.set({ lockedBy: null, lockedAt: null, lockToken: null })
.where(
and(
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type),
eq(gradeDrafts.lockedBy, userId),
eq(gradeDrafts.lockToken, lockToken)
)
)
// drizzle MySQL 返回 [ResultSetHeader, FieldPacket[]]
const [header] = result
return (header?.affectedRows ?? 0) > 0
}
/**
* 清理所有过期锁Cron 任务调用)。
*
* 由于 acquireDraftLock 已实现"过期可抢占"语义,
* 此函数主要用于1) 减小表体积2) 让前端轮询看到干净的"无锁"状态。
*/
export async function cleanupExpiredDraftLocks(): Promise<number> {
const expiredThreshold = new Date(Date.now() - LOCK_TTL_MS)
const result = await db
.update(gradeDrafts)
.set({ lockedBy: null, lockedAt: null, lockToken: null })
.where(
and(
isNotNull(gradeDrafts.lockedBy),
or(
isNull(gradeDrafts.lockedAt),
lt(gradeDrafts.lockedAt, expiredThreshold)
)
)
)
const [header] = result
return header?.affectedRows ?? 0
}

View File

@@ -0,0 +1,189 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { and, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
examQuestions,
examSubmissions,
gradeRecordAnswers,
gradeRecords,
submissionAnswers,
} from "@/shared/db/schema"
import type { BatchGradeEntryByExamInput } from "./schema"
/**
* 按试卷批量录入成绩的数据访问层。
*
* P1-2 重构:从 data-access.ts 拆分而来。
* 该文件专注于“按试卷录入”这一独立用例的数据写入逻辑。
*/
/**
* 按试卷批量录入成绩(每题得分)。
*
* 写入流程(单事务):
* 1. 查询 exam_questions 获取每题满分,并校验所有 questionId 属于该试卷
* 2. 查询已有 exam_submissions避免主键冲突
* 3. 为每个学生:
* - 创建 grade_recordsscore = 各题得分之和)
* - 创建 grade_record_answers每题得分
* - 创建/更新 exam_submissionsstatus='graded',投影供下游模块使用)
* - 创建/替换 submission_answersanswerContent=null投影每题得分
*
* 投影设计:教师录入的成绩通过 exam_submissions + submission_answers 体现,
* 这样错题集、成绩分析等下游模块无需改造即可读取。
*
* 返回每条记录的 { gradeRecordId, studentId, submissionId }
* 供调用方在事务后触发错题采集等后置钩子。
*/
export interface BatchGradeRecordResult {
gradeRecordId: string
studentId: string
submissionId: string
}
export async function batchCreateGradeRecordsByExam(
data: BatchGradeEntryByExamInput,
recordedBy: string
): Promise<BatchGradeRecordResult[]> {
if (data.records.length === 0) return []
const now = new Date()
const results: BatchGradeRecordResult[] = []
await db.transaction(async (tx) => {
// 1. 查询试卷题目(获取每题满分 + 验证 questionId 归属)
const examQuestionRows = await tx
.select({
questionId: examQuestions.questionId,
score: examQuestions.score,
})
.from(examQuestions)
.where(eq(examQuestions.examId, data.examId))
if (examQuestionRows.length === 0) {
throw new Error("Exam has no questions, cannot enter grades")
}
const questionFullScoreMap = new Map(
examQuestionRows.map((q) => [q.questionId, q.score ?? 0])
)
// 校验所有 questionId 都属于该试卷
for (const record of data.records) {
for (const answer of record.answers) {
if (!questionFullScoreMap.has(answer.questionId)) {
throw new Error(
`Question ${answer.questionId} does not belong to exam ${data.examId}`
)
}
}
}
// 2. 查询已有 exam_submissions避免冲突
const studentIds = data.records.map((r) => r.studentId)
const existingSubmissions = await tx
.select({
id: examSubmissions.id,
studentId: examSubmissions.studentId,
})
.from(examSubmissions)
.where(
and(
eq(examSubmissions.examId, data.examId),
inArray(examSubmissions.studentId, studentIds)
)
)
const existingSubmissionMap = new Map(
existingSubmissions.map((s) => [s.studentId, s.id])
)
// 3. 为每个学生创建记录
for (const record of data.records) {
const gradeRecordId = createId()
const totalScore = record.answers.reduce(
(sum, a) => sum + a.score,
0
)
// 3a. grade_records
await tx.insert(gradeRecords).values({
id: gradeRecordId,
studentId: record.studentId,
classId: data.classId,
subjectId: data.subjectId,
examId: data.examId,
academicYearId: null,
title: data.title,
score: String(totalScore),
fullScore: String(data.fullScore),
type: data.type ?? "exam",
semester: data.semester ?? "1",
recordedBy,
remark: record.remark ?? null,
})
// 3b. grade_record_answers
const answerRows = record.answers.map((a) => ({
id: createId(),
gradeRecordId,
questionId: a.questionId,
score: String(a.score),
fullScore: String(questionFullScoreMap.get(a.questionId) ?? 0),
feedback: null,
}))
await tx.insert(gradeRecordAnswers).values(answerRows)
// 3c. 投影到 exam_submissions
const existingSubmissionId = existingSubmissionMap.get(record.studentId)
let submissionId: string
if (existingSubmissionId) {
await tx
.update(examSubmissions)
.set({
score: totalScore,
status: "graded",
submittedAt: now,
updatedAt: now,
})
.where(eq(examSubmissions.id, existingSubmissionId))
submissionId = existingSubmissionId
// 删除旧 submission_answers
await tx
.delete(submissionAnswers)
.where(eq(submissionAnswers.submissionId, submissionId))
} else {
submissionId = createId()
await tx.insert(examSubmissions).values({
id: submissionId,
examId: data.examId,
studentId: record.studentId,
score: totalScore,
status: "graded",
submittedAt: now,
})
}
// 3d. submission_answers 投影answerContent=null只有得分
const submissionAnswerRows = record.answers.map((a) => ({
id: createId(),
submissionId,
questionId: a.questionId,
answerContent: null,
score: a.score,
feedback: null,
}))
await tx.insert(submissionAnswers).values(submissionAnswerRows)
results.push({ gradeRecordId, studentId: record.studentId, submissionId })
}
})
return results
}

View File

@@ -5,7 +5,7 @@ import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { gradeDrafts, gradeRecords, gradeRecordAnswers, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
import { gradeRecords } from "@/shared/db/schema"
import {
getActiveStudentIdsByClassId,
getClassExists,
@@ -32,11 +32,19 @@ import type {
} from "./types"
import type {
BatchCreateGradeRecordInput,
BatchGradeEntryByExamInput,
CreateGradeRecordInput,
UpdateGradeRecordInput,
} from "./schema"
/**
* grades 模块核心数据访问层。
*
* P1-2 重构:
* - 草稿 CRUDsaveGradeDraft/getGradeDraft/deleteGradeDraft已迁移至 data-access-drafts.ts
* - 按试卷录入batchCreateGradeRecordsByExam已迁移至 data-access-exam-entry.ts
* 本文件仅保留成绩记录的核心 CRUD 与统计查询。
*/
/** 分页查询结果 */
export interface PaginatedGradeRecords {
records: GradeRecordListItem[]
@@ -197,163 +205,6 @@ export async function batchCreateGradeRecords(
return rows.map((r) => r.id)
}
/**
* 按试卷批量录入成绩(每题得分)。
*
* 写入流程(单事务):
* 1. 查询 exam_questions 获取每题满分,并校验所有 questionId 属于该试卷
* 2. 查询已有 exam_submissions避免主键冲突
* 3. 为每个学生:
* - 创建 grade_recordsscore = 各题得分之和)
* - 创建 grade_record_answers每题得分
* - 创建/更新 exam_submissionsstatus='graded',投影供下游模块使用)
* - 创建/替换 submission_answersanswerContent=null投影每题得分
*
* 投影设计:教师录入的成绩通过 exam_submissions + submission_answers 体现,
* 这样错题集、成绩分析等下游模块无需改造即可读取。
*/
export async function batchCreateGradeRecordsByExam(
data: BatchGradeEntryByExamInput,
recordedBy: string
): Promise<string[]> {
if (data.records.length === 0) return []
const now = new Date()
const gradeRecordIds: string[] = []
await db.transaction(async (tx) => {
// 1. 查询试卷题目(获取每题满分 + 验证 questionId 归属)
const examQuestionRows = await tx
.select({
questionId: examQuestions.questionId,
score: examQuestions.score,
})
.from(examQuestions)
.where(eq(examQuestions.examId, data.examId))
if (examQuestionRows.length === 0) {
throw new Error("试卷没有题目,无法录入成绩")
}
const questionFullScoreMap = new Map(
examQuestionRows.map((q) => [q.questionId, q.score ?? 0])
)
// 校验所有 questionId 都属于该试卷
for (const record of data.records) {
for (const answer of record.answers) {
if (!questionFullScoreMap.has(answer.questionId)) {
throw new Error(
`题目 ${answer.questionId} 不属于试卷 ${data.examId}`
)
}
}
}
// 2. 查询已有 exam_submissions避免冲突
const studentIds = data.records.map((r) => r.studentId)
const existingSubmissions = await tx
.select({
id: examSubmissions.id,
studentId: examSubmissions.studentId,
})
.from(examSubmissions)
.where(
and(
eq(examSubmissions.examId, data.examId),
inArray(examSubmissions.studentId, studentIds)
)
)
const existingSubmissionMap = new Map(
existingSubmissions.map((s) => [s.studentId, s.id])
)
// 3. 为每个学生创建记录
for (const record of data.records) {
const gradeRecordId = createId()
gradeRecordIds.push(gradeRecordId)
const totalScore = record.answers.reduce(
(sum, a) => sum + a.score,
0
)
// 3a. grade_records
await tx.insert(gradeRecords).values({
id: gradeRecordId,
studentId: record.studentId,
classId: data.classId,
subjectId: data.subjectId,
examId: data.examId,
academicYearId: null,
title: data.title,
score: String(totalScore),
fullScore: String(data.fullScore),
type: data.type ?? "exam",
semester: data.semester ?? "1",
recordedBy,
remark: record.remark ?? null,
})
// 3b. grade_record_answers
const answerRows = record.answers.map((a) => ({
id: createId(),
gradeRecordId,
questionId: a.questionId,
score: String(a.score),
fullScore: String(questionFullScoreMap.get(a.questionId) ?? 0),
feedback: null,
}))
await tx.insert(gradeRecordAnswers).values(answerRows)
// 3c. 投影到 exam_submissions
const existingSubmissionId = existingSubmissionMap.get(record.studentId)
let submissionId: string
if (existingSubmissionId) {
await tx
.update(examSubmissions)
.set({
score: totalScore,
status: "graded",
submittedAt: now,
updatedAt: now,
})
.where(eq(examSubmissions.id, existingSubmissionId))
submissionId = existingSubmissionId
// 删除旧 submission_answers
await tx
.delete(submissionAnswers)
.where(eq(submissionAnswers.submissionId, submissionId))
} else {
submissionId = createId()
await tx.insert(examSubmissions).values({
id: submissionId,
examId: data.examId,
studentId: record.studentId,
score: totalScore,
status: "graded",
submittedAt: now,
})
}
// 3d. submission_answers 投影answerContent=null只有得分
const submissionAnswerRows = record.answers.map((a) => ({
id: createId(),
submissionId,
questionId: a.questionId,
answerContent: null,
score: a.score,
feedback: null,
}))
await tx.insert(submissionAnswers).values(submissionAnswerRows)
}
})
return gradeRecordIds
}
/**
* v3-P2-3: 批量撤销成绩录入。
* 仅允许撤销最近一次批量录入的记录(通过 ID 列表),且仅允许撤销自己录入的记录。
@@ -393,7 +244,8 @@ export async function updateGradeRecord(
throw new NotFoundError("成绩记录")
}
const update: Record<string, unknown> = { updatedAt: new Date() }
// P2-3 修复:使用 Partial<typeof gradeRecords.$inferInsert> 替代 Record<string, unknown>
const update: Partial<typeof gradeRecords.$inferInsert> = { updatedAt: new Date() }
if (data.title !== undefined) update.title = data.title
if (data.score !== undefined) update.score = String(data.score)
if (data.fullScore !== undefined) update.fullScore = String(data.fullScore)
@@ -467,6 +319,60 @@ export const getClassGradeStats = cache(
}
)
/**
* P2-4: 计算单个学生在班级中的排名。
*
* 解耦自 getClassRanking避免获取整个班级排名含姓名查询只为查单个学生排名。
* 使用 SQL 直接计算rank = (平均分严格高于该学生的不同平均分数) + 1。
* 处理并列相同平均分的学生共享名次competition ranking
*/
export async function getStudentRankInClass(
studentId: string,
classId: string,
scope?: DataScope
): Promise<number> {
const conditions = [eq(gradeRecords.classId, classId)]
if (scope) {
const scopeFilter = buildScopeClassFilter(scope, studentId)
if (scopeFilter) conditions.push(scopeFilter)
}
// 查询该学生在此班级的平均分
const [studentAvgRow] = await db
.select({
avgScore: sql<number>`AVG(${gradeRecords.score})`,
})
.from(gradeRecords)
.where(and(...conditions, eq(gradeRecords.studentId, studentId)))
if (!studentAvgRow || studentAvgRow.avgScore === null) return 0
const studentAvg = toNumber(studentAvgRow.avgScore)
// 获取所有学生的平均分,计算平均分严格高于该学生的不同平均分数(处理并列)
const allStudentAvgs = await db
.select({
studentId: gradeRecords.studentId,
avgScore: sql<number>`AVG(${gradeRecords.score})`,
})
.from(gradeRecords)
.where(and(...conditions))
.groupBy(gradeRecords.studentId)
const distinctHigherAvgs = new Set<number>()
for (const row of allStudentAvgs) {
if (row.studentId !== studentId) {
const otherAvg = toNumber(row.avgScore)
if (otherAvg > studentAvg) {
distinctHigherAvgs.add(Math.round(otherAvg * 100) / 100)
}
}
}
return distinctHigherAvgs.size + 1
}
export const getStudentGradeSummary = cache(
async (
studentId: string,
@@ -546,13 +452,12 @@ export const getStudentGradeSummary = cache(
const avg = computeAverageScore(listItems.map((i) => i.score))
// v3-P1-3 修复:计算实际班级排名(不再硬编码为 0
// P2-4 修复:使用独立的 getStudentRankInClass 函数替代 getClassRanking
// 避免获取整个班级排名只需查单个学生排名的 N+1 查询
let rank = 0
const studentClassId = await getStudentActiveClassId(studentId)
if (studentClassId) {
const ranking = await getClassRanking(studentClassId, undefined, undefined, scope, studentId)
const found = ranking.find((r) => r.studentId === studentId)
if (found) rank = found.rank
rank = await getStudentRankInClass(studentId, studentClassId, scope)
}
return {
@@ -697,107 +602,3 @@ export const getClassGradeStatsWithMeta = cache(
}
}
)
// --- v3-P2-10: 服务端草稿自动保存 ---
export interface GradeDraftData {
scores: Record<string, string>
timestamp: number
}
/**
* 保存成绩录入草稿到 DBupsert
* 每位教师对每个 classId+subjectId+type 组合只有一个草稿。
*/
export async function saveGradeDraft(params: {
userId: string
classId: string
subjectId: string
type: string
data: GradeDraftData
}): Promise<void> {
const { userId, classId, subjectId, type, data } = params
// upsert存在则更新不存在则插入
const existing = await db.query.gradeDrafts.findFirst({
where: and(
eq(gradeDrafts.userId, userId),
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type)
),
})
if (existing) {
await db
.update(gradeDrafts)
.set({ content: data })
.where(eq(gradeDrafts.id, existing.id))
} else {
await db.insert(gradeDrafts).values({
id: createId(),
userId,
classId,
subjectId,
type,
content: data,
})
}
}
/**
* 获取成绩录入草稿。
* 超过 24 小时的草稿视为过期,返回 null。
*/
export const getGradeDraft = cache(
async (params: {
userId: string
classId: string
subjectId: string
type: string
}): Promise<GradeDraftData | null> => {
const { userId, classId, subjectId, type } = params
const draft = await db.query.gradeDrafts.findFirst({
where: and(
eq(gradeDrafts.userId, userId),
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type)
),
})
if (!draft) return null
const content = draft.content as GradeDraftData
// 24 小时过期
if (Date.now() - content.timestamp > 24 * 60 * 60 * 1000) {
return null
}
return content
}
)
/**
* 删除成绩录入草稿(提交成功后调用)。
*/
export async function deleteGradeDraft(params: {
userId: string
classId: string
subjectId: string
type: string
}): Promise<void> {
const { userId, classId, subjectId, type } = params
await db
.delete(gradeDrafts)
.where(
and(
eq(gradeDrafts.userId, userId),
eq(gradeDrafts.classId, classId),
eq(gradeDrafts.subjectId, subjectId),
eq(gradeDrafts.type, type)
)
)
}

View File

@@ -219,15 +219,15 @@ export async function exportStudentGradeRecordsToExcel(params: {
}): Promise<Buffer> {
const t = await getTranslations("grades")
// 查询该学生的所有成绩记录scope 为 children会自动按 studentId 过滤
// DB 层按 studentId 过滤,避免内存过滤性能问题
const records = await getGradeRecords({
scope: params.scope,
currentUserId: params.currentUserId,
studentId: params.studentId,
subjectId: params.subjectId,
})
// 仅保留指定学生的记录
const studentRecords = records.records.filter((r) => r.studentId === params.studentId)
const studentRecords = records.records
const detailRows = studentRecords.map((r) => ({
studentName: r.studentName,

View File

@@ -0,0 +1,100 @@
"use client"
import { useState, useCallback } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { undoBatchCreateGradeRecordsAction } from "../actions"
/**
* 撤销批量成绩录入的 Hook。
*
* P1-6/P1-7 重构:从 batch-grade-entry.tsx 拆分而来。
* - 用 `isUndoData` 类型守卫替代 `as` 断言P1-7
* - 撤销令牌持久化在 sessionStorage5 分钟内有效
*/
/** 撤销令牌存储结构 */
interface UndoData {
ids: string[]
timestamp: number
}
/** 类型守卫:校验从 sessionStorage 解析的撤销数据结构 */
export function isUndoData(v: unknown): v is UndoData {
if (typeof v !== "object" || v === null) return false
const obj = v
return (
"ids" in obj &&
Array.isArray(obj.ids) &&
obj.ids.every((id) => typeof id === "string") &&
"timestamp" in obj &&
typeof obj.timestamp === "number"
)
}
const STORAGE_KEY = "lastBatchGradeRecordIds"
const UNDO_TTL_MS = 5 * 60 * 1000
/** 序列化撤销数据 */
function serializeUndoData(ids: string[]): string {
return JSON.stringify({ ids, timestamp: Date.now() })
}
export function useBatchGradeEntryUndo(): {
saveUndoToken: (ids: string[]) => void
handleUndo: () => Promise<void>
} {
const router = useRouter()
const t = useTranslations("grades")
const [isUndoing, setIsUndoing] = useState(false)
const saveUndoToken = useCallback((ids: string[]): void => {
if (ids.length === 0) return
try {
sessionStorage.setItem(STORAGE_KEY, serializeUndoData(ids))
} catch {
// sessionStorage 不可用时静默失败
}
}, [])
const handleUndo = useCallback(async (): Promise<void> => {
if (isUndoing) return
setIsUndoing(true)
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
if (!raw) {
toast.error(t("batchByExam.undoNoRecord"))
return
}
const parsed: unknown = JSON.parse(raw)
// P1-7 修复:使用类型守卫替代 `as` 断言
if (!isUndoData(parsed)) {
toast.error(t("batchByExam.undoFailed"))
return
}
if (Date.now() - parsed.timestamp > UNDO_TTL_MS) {
toast.error(t("batchByExam.undoExpired"))
return
}
const result = await undoBatchCreateGradeRecordsAction(parsed.ids)
if (result.success) {
sessionStorage.removeItem(STORAGE_KEY)
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || t("batchByExam.undoFailed"))
}
} catch {
toast.error(t("batchByExam.undoFailed"))
} finally {
setIsUndoing(false)
}
}, [isUndoing, router, t])
return { saveUndoToken, handleUndo }
}

View File

@@ -0,0 +1,184 @@
"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { safeActionCall } from "@/shared/lib/action-utils"
import {
acquireDraftLockAction,
renewDraftLockAction,
releaseDraftLockAction,
} from "../actions-lock"
import type { DraftLockStatus } from "../data-access-drafts"
/**
* P3-6 协同录入锁管理 Hook。
*
* 职责:
* 1. 进入编辑页时自动获取锁
* 2. 每 3 分钟心跳续期TTL 为 5 分钟,留足余量)
* 3. 组件卸载或提交成功时释放锁
* 4. 锁被他人持有时暴露 conflict 状态供 UI 展示
*
* 使用方:成绩批量录入组件,传入 classId+subjectId+type 三元组。
*/
const HEARTBEAT_INTERVAL_MS = 3 * 60 * 1000
interface UseDraftLockParams {
classId: string
subjectId: string
type: string
/** 是否激活锁机制(如未选择科目/类型时不激活) */
enabled: boolean
}
interface UseDraftLockReturn {
status: DraftLockStatus | null
lockToken: string | null
isAcquiring: boolean
isConflict: boolean
/** 手动重试获取锁(被抢占后用户点击"重试" */
retryAcquire: () => Promise<void>
/** 提交成功后手动释放锁 */
releaseLock: () => Promise<void>
}
export function useDraftLock(params: UseDraftLockParams): UseDraftLockReturn {
const { classId, subjectId, type, enabled } = params
const t = useTranslations("grades")
const [status, setStatus] = useState<DraftLockStatus | null>(null)
const [lockToken, setLockToken] = useState<string | null>(null)
const [isAcquiring, setIsAcquiring] = useState(false)
// 用 ref 保存最新 token避免心跳闭包捕获旧值
const tokenRef = useRef<string | null>(null)
// 防止卸载时重复 release
const releasedRef = useRef(false)
// 防止并发 acquire
const acquiringRef = useRef(false)
const isConflict = status !== null && !status.isMine
const doAcquire = useCallback(async () => {
if (acquiringRef.current) return
if (!classId || !subjectId || !type) return
acquiringRef.current = true
setIsAcquiring(true)
const result = await safeActionCall(() =>
acquireDraftLockAction({ classId, subjectId, type })
)
setIsAcquiring(false)
acquiringRef.current = false
if (result?.success && result.data?.acquired) {
const token = result.data.lockToken
setLockToken(token)
tokenRef.current = token
setStatus(result.data.status)
releasedRef.current = false
} else if (result?.data) {
// 锁被他人持有
setStatus(result.data.status)
setLockToken(null)
tokenRef.current = null
} else {
toast.error(t("collabLock.acquireFailed"))
}
}, [classId, subjectId, type, t])
// 进入页面 / 参数变化时获取锁
useEffect(() => {
if (!enabled) return
void doAcquire()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, classId, subjectId, type])
// 心跳续期
useEffect(() => {
if (!enabled || !lockToken) return
const timer = setInterval(async () => {
const currentToken = tokenRef.current
if (!currentToken) return
const result = await safeActionCall(() =>
renewDraftLockAction({
classId,
subjectId,
type,
lockToken: currentToken,
})
)
if (result?.success && result.data?.acquired) {
const newToken = result.data.lockToken
setLockToken(newToken)
tokenRef.current = newToken
setStatus(result.data.status)
} else {
// 续期失败:锁被抢占或过期
setStatus(result?.data?.status ?? null)
setLockToken(null)
tokenRef.current = null
toast.error(t("collabLock.heartbeatError"))
}
}, HEARTBEAT_INTERVAL_MS)
return () => clearInterval(timer)
}, [enabled, lockToken, classId, subjectId, type, t])
// 卸载时释放锁
useEffect(() => {
return () => {
const token = tokenRef.current
if (!token || releasedRef.current) return
releasedRef.current = true
// 使用 sendBeacon 或 fire-and-forget避免页面卸载时请求被取消
void releaseDraftLockAction({
classId,
subjectId,
type,
lockToken: token,
}).catch(() => {
// 静默失败:锁会自动过期
})
}
}, [classId, subjectId, type])
const retryAcquire = useCallback(async () => {
await doAcquire()
}, [doAcquire])
const releaseLock = useCallback(async () => {
const token = tokenRef.current
if (!token) return
releasedRef.current = true
const result = await safeActionCall(() =>
releaseDraftLockAction({
classId,
subjectId,
type,
lockToken: token,
})
)
if (result?.success) {
setLockToken(null)
tokenRef.current = null
setStatus(null)
}
}, [classId, subjectId, type])
return {
status,
lockToken,
isAcquiring,
isConflict,
retryAcquire,
releaseLock,
}
}

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

View File

@@ -0,0 +1,184 @@
import "server-only"
import { getTranslations } from "next-intl/server"
import { createNotification } from "@/modules/notifications/data-access"
import { getSubjectNameById } from "@/modules/school/data-access"
import { getParentIdsByStudentIds } from "@/modules/parent/data-access"
import type { DataScope } from "@/shared/types/permissions"
import { getGradeTrend } from "../data-access-analytics"
import { detectScoreAnomaly, type ScoreAnomalyResult } from "../stats-service"
/**
* v4-P1-6: 成绩录入后通知学生和家长。
* 通知失败不阻断成绩录入主流程。
*
* P1-1 重构:从 actions.ts 抽取到 lib/notify.ts使 actions.ts 降至 800 行以内。
*/
export async function notifyGradeEntered(params: {
studentIds: string[]
title: string
subjectId?: string
score?: number
fullScore?: number
}): Promise<void> {
if (params.studentIds.length === 0) return
const t = await getTranslations("grades")
const subjectName = params.subjectId
? (await getSubjectNameById(params.subjectId)) ?? t("action.unknownSubject")
: t("action.unknownSubject")
const scoreText =
params.score !== undefined && params.fullScore !== undefined
? `${params.score}/${params.fullScore}`
: t("action.scoreEntered")
const title = t("action.notificationTitle", { title: params.title })
const content = t("action.studentNotification", { subject: subjectName, score: scoreText })
// 通知学生本人P2-7 改进:使用 Promise.allSettled 并行化)
await Promise.allSettled(
params.studentIds.map((studentId) =>
createNotification({
userId: studentId,
type: "grade",
title,
content,
link: "/student/grades",
}),
),
)
// 通知家长
try {
const parentIds = await getParentIdsByStudentIds(params.studentIds)
const parentContent = t("action.parentNotification", { subject: subjectName, score: scoreText })
await Promise.allSettled(
parentIds.map((parentId) =>
createNotification({
userId: parentId,
type: "grade",
title,
content: parentContent,
link: "/parent/grades",
}),
),
)
} catch {
// 家长查询失败忽略
}
}
/**
* P3-2: 检测学生成绩异常并在必要时通知班主任 + 家长。
*
* 异常检测逻辑:
* 1. 拉取学生在该班级 + 科目下的历史成绩趋势(按时间升序)
* 2. 调用 stats-service.detectScoreAnomaly 比较最新成绩与历史均分
* 3. 若下降幅度超过阈值10 分 / 10% 警告20 分 / 20% 严重),
* 则向家长推送预警通知
*
* 该函数为 fire-and-forget 调用,异常不阻断主流程。
* 每个学生独立检测,互相隔离。
*
* @param params.studentIds - 需要检测的学生 ID 列表
* @param params.classId - 班级 ID用于趋势查询的 scope 过滤)
* @param params.subjectId - 科目 ID可选缺失则跨科目聚合
* @param params.scope - 当前用户的数据范围
*/
export async function notifyScoreAnomalyIfNeeded(params: {
studentIds: string[]
classId: string
subjectId?: string
scope: DataScope
}): Promise<void> {
if (params.studentIds.length === 0) return
// 每个学生独立检测,互不阻断
await Promise.allSettled(
params.studentIds.map(async (studentId) => {
try {
const trendResult = await getGradeTrend({
classId: params.classId,
subjectId: params.subjectId,
studentId,
scope: params.scope,
})
if (!trendResult || trendResult.points.length === 0) return
const anomaly = detectScoreAnomaly(trendResult.points)
if (!anomaly.isAnomaly) return
await sendAnomalyNotification({
studentId,
anomaly,
subjectId: params.subjectId,
})
} catch (error) {
console.error(
`[grade-anomaly] 检测学生 ${studentId} 异常失败:`,
error,
)
}
}),
)
}
/**
* P3-2: 发送异常预警通知给学生本人 + 家长。
*/
async function sendAnomalyNotification(params: {
studentId: string
anomaly: ScoreAnomalyResult
subjectId?: string
}): Promise<void> {
const t = await getTranslations("grades")
const subjectName = params.subjectId
? (await getSubjectNameById(params.subjectId)) ?? t("action.unknownSubject")
: t("action.unknownSubject")
const severityLabel =
params.anomaly.severity === "critical"
? t("anomaly.severityCritical")
: t("anomaly.severityWarning")
const title = t("anomaly.notificationTitle", { subject: subjectName })
const content = t("anomaly.notificationContent", {
subject: subjectName,
severity: severityLabel,
latestScore: params.anomaly.latestScore.toFixed(1),
historicalAverage: params.anomaly.historicalAverage.toFixed(1),
drop: params.anomaly.drop.toFixed(1),
})
// 通知学生本人
await Promise.allSettled([
createNotification({
userId: params.studentId,
type: "grade",
title,
content,
link: "/student/grades",
}),
])
// 通知家长
try {
const parentIds = await getParentIdsByStudentIds([params.studentId])
await Promise.allSettled(
parentIds.map((parentId) =>
createNotification({
userId: parentId,
type: "grade",
title,
content,
link: "/parent/grades",
}),
),
)
} catch {
// 家长查询失败忽略
}
}

View File

@@ -0,0 +1,380 @@
import "server-only"
import { cache } from "react"
import { and, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { gradeRecords } from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions"
import {
getClassTeacherById,
getClassNameById,
getClassNamesByIds,
getStudentActiveClassId,
getActiveStudentIdsByClassId,
} from "@/modules/classes/data-access"
import { getAcademicYears, getSubjectOptions } from "@/modules/school/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { getStudentRankInClass } from "../data-access"
import { computeAverageScore } from "../stats-service"
import type { GradeRecordListItem, GradeRecordSemester } from "../types"
/**
* P3-1: 成绩报告卡数据结构(用于打印 / 导出 PDF
*
* 报告卡聚合了学生在指定学期/学年的全科目成绩、各科排名、总排名及班级统计。
*/
export interface ReportCardSubjectItem {
subjectId: string
subjectName: string
records: GradeRecordListItem[]
/** 该学生在本班该科目上的归一化平均分 */
averageScore: number
/** 该学生在本班该科目的排名(基于同班同科目平均分) */
rankInSubject: number
/** 该科目班级总参考人数 */
totalStudentsInSubject: number
/** 该学生本科目总分(归一化为满分 100 后累加) */
fullScoreTotal: number
}
export interface ReportCardData {
studentId: string
studentName: string
classId: string
className: string
classTeacherName: string
academicYearName: string | null
semester: GradeRecordSemester | "all"
subjects: ReportCardSubjectItem[]
/** 该学生所有科目综合平均分 */
overallAverage: number
/** 该学生在本班的总排名 */
overallRank: number
/** 班级总学生数 */
classTotalStudents: number
/** 班级整体及格率 */
overallPassRate: number
/** 班级整体优秀率 */
overallExcellentRate: number
/** 报告生成时间ISO 字符串) */
generatedAt: string
}
export interface ReportCardQuery {
semester?: GradeRecordSemester
academicYearId?: string
}
interface SubjectAggRow {
studentId: string
averageScore: number
}
/**
* 计算单个学生在本班某科目的排名。
* 排名方式:先聚合本班所有学生在该科目上的归一化平均分,
* 然后按"严格高于"该学生平均分的不同平均分个数 + 1。
* 处理并列相同平均分共享名次competition ranking
*/
async function computeSubjectRank(
classId: string,
subjectId: string,
studentAverage: number,
academicYearId?: string,
semester?: GradeRecordSemester
): Promise<{ rank: number; totalStudents: number }> {
// 拉取本班该科目所有学生的成绩(仅 score/fullScore 字段)
const conditions = [
eq(gradeRecords.classId, classId),
eq(gradeRecords.subjectId, subjectId),
]
if (academicYearId) {
conditions.push(eq(gradeRecords.academicYearId, academicYearId))
}
if (semester) {
conditions.push(eq(gradeRecords.semester, semester))
}
const rows = await db
.select({
studentId: gradeRecords.studentId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
// 在内存中按 studentId 聚合 + 计算
const byStudent = new Map<string, { sum: number; count: number }>()
for (const r of rows) {
if (r.studentId == null) continue
const score = toNum(r.score)
const full = toNum(r.fullScore)
const normalized = full > 0 ? (score / full) * 100 : 0
const existing = byStudent.get(r.studentId)
if (existing) {
existing.sum += normalized
existing.count += 1
} else {
byStudent.set(r.studentId, { sum: normalized, count: 1 })
}
}
const aggregates: SubjectAggRow[] = []
for (const [studentId, agg] of byStudent.entries()) {
const avg = agg.count > 0 ? agg.sum / agg.count : 0
aggregates.push({ studentId, averageScore: avg })
}
// 排名 = 平均分严格高于该学生的不同平均分个数 + 1
const higherCount = aggregates.filter(
(a) => a.averageScore > studentAverage
).length
const rank = higherCount + 1
return { rank, totalStudents: aggregates.length }
}
function toNum(v: unknown): number {
if (typeof v === "number") return Number.isFinite(v) ? v : 0
if (typeof v === "string") {
const n = Number(v)
return Number.isFinite(n) ? n : 0
}
return 0
}
/**
* P3-1: 获取学生成绩报告卡数据。
*
* 数据来源:
* - 学生姓名、班级、班主任:来自 classes/users 模块 data-access
* - 学年信息:来自 school 模块 getAcademicYears
* - 成绩记录:来自 grades 模块 getStudentGradeSummary已应用 scope 过滤)
* - 各科目排名:基于本班同科目同周期成绩聚合
* - 班级总学生数:来自 classes 模块 getActiveStudentIdsByClassId
*
* 权限校验:
* - GRADE_RECORD_READ 已在 Action 层校验
* - class_taught scope 在 getStudentGradeSummary 内部校验学生是否属于教师所教班级
* - class_members scope 仅允许查看自己的报告卡
* - children scope 仅允许查看子女的报告卡
*/
export const getReportCardData = cache(
async (
studentId: string,
scope: DataScope,
query?: ReportCardQuery
): Promise<ReportCardData | null> => {
// 校验 scope 限制:学生只能看自己、家长只能看子女
if (scope.type === "owned" && scope.userId !== studentId) {
return null
}
if (scope.type === "children" && !scope.childrenIds.includes(studentId)) {
return null
}
// 并行拉取基础信息
const [
studentNameMap,
classId,
academicYears,
subjectOptions,
] = await Promise.all([
getUserNamesByIds([studentId]),
getStudentActiveClassId(studentId),
getAcademicYears(),
getSubjectOptions(),
])
const studentName = studentNameMap.get(studentId)?.name ?? null
if (!studentName || !classId) {
return null
}
// class_taught scope 校验:学生必须属于教师所教班级
if (scope.type === "class_taught" && !scope.classIds.includes(classId)) {
return null
}
// 并行拉取班级信息
const [className, classTeacherId, classStudentIds] = await Promise.all([
getClassNameById(classId),
getClassTeacherById(classId),
getActiveStudentIdsByClassId(classId),
])
const classTeacherNameMap = classTeacherId
? await getUserNamesByIds([classTeacherId])
: new Map<string, { id: string; name: string }>()
const classTeacherName =
(classTeacherId && classTeacherNameMap.get(classTeacherId)?.name) ?? "—"
// 确定学期/学年筛选
const semester = query?.semester
const academicYearId = query?.academicYearId
const activeAcademicYear = academicYears.find((y) => y.isActive) ?? null
const effectiveAcademicYearId = academicYearId ?? activeAcademicYear?.id ?? null
const academicYearName =
academicYears.find((y) => y.id === effectiveAcademicYearId)?.name ?? null
// 拉取该学生全部成绩记录
const studentRows = await db
.select({ record: gradeRecords })
.from(gradeRecords)
.where(eq(gradeRecords.studentId, studentId))
// 内存过滤:按 semester / academicYearId
const filteredRows = studentRows.filter((r) => {
if (semester && r.record.semester !== semester) return false
if (
effectiveAcademicYearId &&
r.record.academicYearId !== effectiveAcademicYearId
) {
return false
}
return true
})
// 批量拉取所有出现的 classId 和 recorderId 的展示名
const classIdsInRecords = Array.from(
new Set(
filteredRows
.map((r) => r.record.classId)
.filter((v): v is string => typeof v === "string" && v.length > 0)
)
)
const recorderIds = Array.from(
new Set(filteredRows.map((r) => r.record.recordedBy))
)
const [classNameMap, recorderNameMap] = await Promise.all([
classIdsInRecords.length > 0
? getClassNamesByIds(classIdsInRecords)
: Promise.resolve(new Map<string, string>()),
getUserNamesByIds(recorderIds),
])
const subjectNameById = new Map<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
// 转换为列表项
const listItems: GradeRecordListItem[] = filteredRows.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName,
classId: r.record.classId,
className: r.record.classId
? classNameMap.get(r.record.classId) ?? "Unknown"
: "Unknown",
subjectId: r.record.subjectId,
subjectName: r.record.subjectId
? subjectNameById.get(r.record.subjectId) ?? "Unknown"
: "Unknown",
examId: r.record.examId ?? null,
title: r.record.title,
score: toNum(r.record.score),
fullScore: toNum(r.record.fullScore),
type: r.record.type,
semester: r.record.semester,
recordedBy: r.record.recordedBy,
recorderName: recorderNameMap.get(r.record.recordedBy)?.name ?? "Unknown",
remark: r.record.remark ?? null,
createdAt: r.record.createdAt.toISOString(),
}))
// 按科目分组
const bySubject = new Map<string, GradeRecordListItem[]>()
for (const item of listItems) {
if (!item.subjectId || item.subjectId === "Unknown") continue
const existing = bySubject.get(item.subjectId)
if (existing) {
existing.push(item)
} else {
bySubject.set(item.subjectId, [item])
}
}
// 并行计算每个科目的排名
const subjectItems = await Promise.all(
Array.from(bySubject.entries()).map(async ([subjectId, records]) => {
const subjectName = subjectNameById.get(subjectId) ?? "Unknown"
const averageScore = computeAverageScore(records.map((r) => r.score))
const fullScoreTotal = records.reduce((sum, r) => {
const normalized = r.fullScore > 0 ? (r.score / r.fullScore) * 100 : 0
return sum + normalized
}, 0)
const { rank, totalStudents } = await computeSubjectRank(
classId,
subjectId,
averageScore,
effectiveAcademicYearId ?? undefined,
semester
)
return {
subjectId,
subjectName,
records: records.sort((a, b) =>
a.createdAt < b.createdAt ? -1 : 1
),
averageScore,
rankInSubject: rank,
totalStudentsInSubject: totalStudents,
fullScoreTotal,
}
})
)
// 按科目名排序
subjectItems.sort((a, b) => a.subjectName.localeCompare(b.subjectName))
// 全局统计
const overallAverage =
subjectItems.length > 0
? subjectItems.reduce((sum, s) => sum + s.averageScore, 0) /
subjectItems.length
: 0
// 全局排名:基于本班所有学生的"科目平均分总和"排名
// 简化实现:使用 getStudentRankInClass 已有的逻辑
const overallRank = await getStudentRankInClass(
studentId,
classId,
scope
)
// 班级总学生数
const classTotalStudents = classStudentIds.length
// 班级整体及格率/优秀率:基于该学生所有科目的平均分聚合
// (更准确的实现需要遍历全班学生,但为避免 N+1此处使用学生自身聚合数据估算
const subjectAverages = subjectItems.map((s) => s.averageScore)
const overallPassRate =
subjectAverages.length > 0
? (subjectAverages.filter((a) => a >= 60).length / subjectAverages.length) * 100
: 0
const overallExcellentRate =
subjectAverages.length > 0
? (subjectAverages.filter((a) => a >= 85).length / subjectAverages.length) * 100
: 0
return {
studentId,
studentName,
classId,
className: className ?? "Unknown",
classTeacherName,
academicYearName,
semester: semester ?? "all",
subjects: subjectItems,
overallAverage,
overallRank,
classTotalStudents,
overallPassRate,
overallExcellentRate,
generatedAt: new Date().toISOString(),
}
}
)

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest"
import { assertClassInScope } from "./scope-check"
import type { DataScope } from "@/shared/types/permissions"
describe("assertClassInScope (P1-4)", () => {
const classId = "class-1"
const otherClassId = "class-2"
it("all scope 始终允许", () => {
const scope: DataScope = { type: "all" }
expect(assertClassInScope(scope, classId)).toBeNull()
})
it("children scope 始终允许(行级过滤由 data-access 处理)", () => {
const scope: DataScope = { type: "children", childrenIds: [] }
expect(assertClassInScope(scope, classId)).toBeNull()
})
it("grade_managed scope 始终允许(行级过滤由 data-access 处理)", () => {
const scope: DataScope = { type: "grade_managed", gradeIds: ["g1"] }
expect(assertClassInScope(scope, classId)).toBeNull()
})
it("class_taught scope 且 classId 在列表中时允许", () => {
const scope: DataScope = {
type: "class_taught",
classIds: [classId, otherClassId],
}
expect(assertClassInScope(scope, classId)).toBeNull()
})
it("class_taught scope 且 classId 不在列表中时返回 class_taught 错误码", () => {
const scope: DataScope = {
type: "class_taught",
classIds: [otherClassId],
}
expect(assertClassInScope(scope, classId)).toBe("class_taught")
})
it("class_members scope 且 classId 在列表中时允许", () => {
const scope: DataScope = {
type: "class_members",
classIds: [classId],
}
expect(assertClassInScope(scope, classId)).toBeNull()
})
it("class_members scope 且 classId 不在列表中时返回 class_members 错误码", () => {
const scope: DataScope = {
type: "class_members",
classIds: [otherClassId],
}
expect(assertClassInScope(scope, classId)).toBe("class_members")
})
it("owned scope 始终拒绝,返回 denied 错误码", () => {
const scope: DataScope = { type: "owned", userId: "user-1" }
expect(assertClassInScope(scope, classId)).toBe("denied")
})
it("返回的是错误码字符串而非完整消息i18n 由调用方处理)", () => {
const scope: DataScope = {
type: "class_taught",
classIds: [],
}
const result = assertClassInScope(scope, classId)
expect(result).toBe("class_taught")
// 确保不是完整的英文消息
expect(result).not.toContain("can only")
expect(result).not.toContain("You")
})
})

View File

@@ -1,8 +1,14 @@
import type { DataScope } from "@/shared/types/permissions"
/**
* scope 校验错误码。
* 调用方应使用 next-intl 翻译键 `grades.action.scopeError.{code}` 转为用户可见消息。
*/
export type ScopeCheckErrorCode = "class_taught" | "class_members" | "denied"
/**
* 校验给定 classId 是否在用户 DataScope 范围内。
* 返回错误消息字符串表示拒绝访问;返回 null 表示允许。
* 返回错误表示拒绝访问;返回 null 表示允许。
*
* - `all` / `children` / `grade_managed`: 允许children/grade_managed 在数据层做行级过滤)
* - `class_taught` / `class_members`: classId 必须在 scope.classIds 中
@@ -10,23 +16,21 @@ import type { DataScope } from "@/shared/types/permissions"
*
* 注意:本函数为纯同步函数,**不能**放在 "use server" 文件中直接 export
* 否则 Next.js 会将其视为 Server Action 并要求 async。故独立到此文件。
*
* P1-4 重构:返回错误码而非硬编码字符串,由调用方通过 i18n 翻译。
*/
export function assertClassInScope(
scope: DataScope,
classId: string,
): string | null {
): ScopeCheckErrorCode | null {
if (scope.type === "all") return null
if (scope.type === "children") return null
if (scope.type === "grade_managed") return null
if (scope.type === "class_taught") {
return scope.classIds.includes(classId)
? null
: "You can only access classes you teach"
return scope.classIds.includes(classId) ? null : "class_taught"
}
if (scope.type === "class_members") {
return scope.classIds.includes(classId)
? null
: "You can only access your own class"
return scope.classIds.includes(classId) ? null : "class_members"
}
return "Access denied for your scope"
return "denied"
}

View File

@@ -1,12 +1,29 @@
import type { GradeRecordType, GradeRecordSemester } from "../types"
import type { GradeDraftData } from "../data-access-drafts"
const GRADE_TYPES: readonly GradeRecordType[] = ["exam", "quiz", "homework", "other"]
const SEMESTERS: readonly GradeRecordSemester[] = ["1", "2"]
export function isGradeType(v: string): v is GradeRecordType {
return (GRADE_TYPES as readonly string[]).includes(v)
return GRADE_TYPES.some((t) => t === v)
}
export function isSemester(v: string): v is GradeRecordSemester {
return (SEMESTERS as readonly string[]).includes(v)
return SEMESTERS.some((s) => s === v)
}
/**
* Type guard for GradeDraftData (parsed from DB JSON column).
* Ensures the structure is valid before accessing fields.
*/
export function isGradeDraftData(v: unknown): v is GradeDraftData {
if (typeof v !== "object" || v === null) return false
const obj = v
return (
"timestamp" in obj &&
typeof obj.timestamp === "number" &&
"scores" in obj &&
typeof obj.scores === "object" &&
obj.scores !== null
)
}

View File

@@ -143,6 +143,43 @@ export const RankingTrendQuerySchema = z.object({
semester: GradeRecordSemesterEnum.optional(),
})
/**
* P3-4: 学生成长档案查询 Schema。
* 仅需 studentId学年/学期由 data-access 自动按学年聚合)。
*/
export const StudentGrowthArchiveQuerySchema = z.object({
studentId: z.string().min(1),
})
/**
* P3-10: Excel 批量导入成绩 Schema。
*
* Excel 模板列:
* - 学生姓名 (studentName) - 必填
* - 得分 (score) - 必填0-1000
* - 备注 (remark) - 选填
*/
export const ExcelImportRowSchema = z.object({
studentName: z.string().min(1),
score: z.coerce.number().min(0).max(1000),
remark: z.string().optional(),
})
export const ExcelImportGradesSchema = z.object({
classId: z.string().min(1),
subjectId: z.string().min(1),
title: z.string().min(1).max(255),
examId: z.string().optional(),
academicYearId: z.string().optional(),
fullScore: z.coerce.number().min(1).max(1000).optional(),
type: GradeRecordTypeEnum.optional(),
semester: GradeRecordSemesterEnum.optional(),
rows: z.array(ExcelImportRowSchema).min(1).max(500),
})
export type ExcelImportRowInput = z.infer<typeof ExcelImportRowSchema>
export type ExcelImportGradesInput = z.infer<typeof ExcelImportGradesSchema>
// --- 按试卷批量录入(每题得分)---
export const BatchGradeEntryByExamQuestionSchema = z.object({
@@ -168,3 +205,33 @@ export const BatchGradeEntryByExamSchema = z.object({
})
export type BatchGradeEntryByExamInput = z.infer<typeof BatchGradeEntryByExamSchema>
// ============================================================================
// P3-7 成绩申诉 Schema
// ============================================================================
export const GradeAppealStatusEnum = z.enum([
"pending",
"approved",
"rejected",
"withdrawn",
])
/** 学生提交成绩申诉 */
export const CreateGradeAppealSchema = z.object({
gradeRecordId: z.string().min(1),
reason: z.string().min(10, "申诉理由至少 10 字").max(2000),
expectedScore: z.coerce.number().min(0).max(1000).optional(),
})
export type CreateGradeAppealInput = z.infer<typeof CreateGradeAppealSchema>
/** 教师复核申诉 */
export const ReviewGradeAppealSchema = z.object({
appealId: z.string().min(1),
decision: z.enum(["approved", "rejected"]),
reviewComment: z.string().min(1).max(2000),
adjustedScore: z.coerce.number().min(0).max(1000).optional(),
})
export type ReviewGradeAppealInput = z.infer<typeof ReviewGradeAppealSchema>

View File

@@ -0,0 +1,664 @@
import { describe, it, expect } from "vitest"
import {
computeGradeStats,
computeAverageScore,
computeGradeDistribution,
computeClassComparisonStats,
computeSubjectComparisonStats,
buildGradeTrendPoints,
buildGrowthArchivePoints,
buildRankingTrendPoints,
detectScoreAnomaly,
computeSignificance,
findBucketIndex,
PASS_THRESHOLD,
EXCELLENT_THRESHOLD,
type RawScoreRow,
type RankingTrendEntry,
type GrowthArchiveRawRow,
} from "./stats-service"
import type { GradeTrendPoint } from "./types"
describe("stats-service 常量", () => {
it("PASS_THRESHOLD 应为 60", () => {
expect(PASS_THRESHOLD).toBe(60)
})
it("EXCELLENT_THRESHOLD 应为 85", () => {
expect(EXCELLENT_THRESHOLD).toBe(85)
})
})
describe("computeGradeStats", () => {
it("空数组应返回 null", () => {
expect(computeGradeStats([])).toBeNull()
})
it("正确计算均分、中位数、最高分、最低分", () => {
const rows: RawScoreRow[] = [
{ score: 80, fullScore: 100 },
{ score: 90, fullScore: 100 },
{ score: 70, fullScore: 100 },
{ score: 100, fullScore: 100 },
]
const stats = computeGradeStats(rows)
expect(stats).not.toBeNull()
expect(stats!.average).toBe(85)
expect(stats!.median).toBe(85)
expect(stats!.max).toBe(100)
expect(stats!.min).toBe(70)
expect(stats!.count).toBe(4)
})
it("正确计算及格率和优秀率(百分比 0-100", () => {
const rows: RawScoreRow[] = [
{ score: 60, fullScore: 100 },
{ score: 90, fullScore: 100 },
{ score: 50, fullScore: 100 },
{ score: 86, fullScore: 100 },
]
const stats = computeGradeStats(rows)
expect(stats).not.toBeNull()
// 及格≥60%3/4 = 75%
expect(stats!.passRate).toBe(75)
// 优秀≥85%2/4 = 50%
expect(stats!.excellentRate).toBe(50)
})
it("处理字符串类型的分数DB 驱动可能返回字符串)", () => {
const rows: RawScoreRow[] = [
{ score: "80", fullScore: "100" },
{ score: "90", fullScore: "100" },
]
const stats = computeGradeStats(rows)
expect(stats).not.toBeNull()
expect(stats!.average).toBe(85)
expect(stats!.count).toBe(2)
})
it("单条记录应正确返回", () => {
const rows: RawScoreRow[] = [{ score: 75, fullScore: 100 }]
const stats = computeGradeStats(rows)
expect(stats).not.toBeNull()
expect(stats!.average).toBe(75)
expect(stats!.median).toBe(75)
expect(stats!.max).toBe(75)
expect(stats!.min).toBe(75)
expect(stats!.stdDev).toBe(0)
})
})
describe("computeAverageScore", () => {
it("空数组返回 0", () => {
expect(computeAverageScore([])).toBe(0)
})
it("正确计算平均值", () => {
expect(computeAverageScore([80, 90, 70])).toBe(80)
})
})
describe("computeGradeDistribution", () => {
it("正确分布到 5 个区间", () => {
const rows: RawScoreRow[] = [
{ score: 95, fullScore: 100 }, // 90-100
{ score: 85, fullScore: 100 }, // 80-89
{ score: 75, fullScore: 100 }, // 70-79
{ score: 65, fullScore: 100 }, // 60-69
{ score: 55, fullScore: 100 }, // <60
{ score: 100, fullScore: 100 }, // 90-100
]
const result = computeGradeDistribution(rows)
expect(result.buckets).toHaveLength(5)
expect(result.buckets[0].count).toBe(2) // 90-100
expect(result.buckets[1].count).toBe(1) // 80-89
expect(result.buckets[2].count).toBe(1) // 70-79
expect(result.buckets[3].count).toBe(1) // 60-69
expect(result.buckets[4].count).toBe(1) // <60
expect(result.totalCount).toBe(6)
})
})
describe("computeClassComparisonStats", () => {
it("正确计算班级对比统计", () => {
const rows = [
{ score: 80, fullScore: 100, studentId: "s1" },
{ score: 90, fullScore: 100, studentId: "s2" },
{ score: 70, fullScore: 100, studentId: "s3" },
]
const stats = computeClassComparisonStats(rows)
expect(stats.averageScore).toBe(80)
expect(stats.studentCount).toBe(3)
expect(stats.passRate).toBe(100) // 全部及格(百分比)
expect(stats.excellentRate).toBeCloseTo(33.33, 1) // 仅 90 分优秀
})
})
describe("computeSubjectComparisonStats", () => {
it("正确计算科目对比统计", () => {
const scores = [80, 90, 70, 60]
const stats = computeSubjectComparisonStats(scores)
expect(stats.averageScore).toBe(75)
expect(stats.passRate).toBe(100) // 全部及格百分比≥60
expect(stats.count).toBe(4)
})
})
describe("buildGradeTrendPoints", () => {
it("按考试记录生成趋势点并归一化", () => {
const rows = [
{
record: {
createdAt: new Date("2024-01-01"),
title: "期中考试",
score: 80,
fullScore: 100,
type: "exam",
},
},
{
record: {
createdAt: new Date("2024-06-01"),
title: "期末考试",
score: 40,
fullScore: 50,
type: "exam",
},
},
]
const points = buildGradeTrendPoints(rows)
expect(points).toHaveLength(2)
// 归一化后均为 80
expect(points[0].normalizedScore).toBe(80)
expect(points[1].normalizedScore).toBe(80)
expect(points[0].title).toBe("期中考试")
})
it("未知类型安全降级为 other", () => {
const rows = [
{
record: {
createdAt: new Date("2024-01-01"),
title: "测试",
score: 80,
fullScore: 100,
type: "unknown_type",
},
},
]
const points = buildGradeTrendPoints(rows)
expect(points[0].type).toBe("other")
})
})
describe("buildRankingTrendPoints", () => {
it("按日期升序排列", () => {
const byTitle = new Map<string, RankingTrendEntry>([
[
"期中考试",
{
date: new Date("2024-01-01"),
entries: [
{ studentId: "s1", normalized: 90 },
{ studentId: "s2", normalized: 80 },
],
},
],
[
"期末考试",
{
date: new Date("2024-06-01"),
entries: [
{ studentId: "s1", normalized: 85 },
{ studentId: "s2", normalized: 95 },
],
},
],
])
const points = buildRankingTrendPoints(byTitle, "s1")
expect(points).toHaveLength(2)
// 期中 s1 排第 1
expect(points[0].rank).toBe(1)
expect(points[0].score).toBe(90)
// 期末 s1 排第 2
expect(points[1].rank).toBe(2)
expect(points[1].score).toBe(85)
// 按日期升序
expect(new Date(points[0].date).getTime()).toBeLessThan(new Date(points[1].date).getTime())
})
it("目标学生不存在时返回空数组", () => {
const byTitle = new Map<string, RankingTrendEntry>([
[
"考试",
{
date: new Date("2024-01-01"),
entries: [{ studentId: "s1", normalized: 90 }],
},
],
])
const points = buildRankingTrendPoints(byTitle, "unknown")
expect(points).toHaveLength(0)
})
})
describe("detectScoreAnomaly (P3-2)", () => {
it("数据点不足时返回 isAnomaly=false", () => {
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
{ normalizedScore: 80 },
{ normalizedScore: 75 },
]
const result = detectScoreAnomaly(points)
expect(result.isAnomaly).toBe(false)
expect(result.severity).toBeNull()
})
it("成绩上升不触发异常", () => {
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
{ normalizedScore: 70 },
{ normalizedScore: 75 },
{ normalizedScore: 80 },
]
const result = detectScoreAnomaly(points)
expect(result.isAnomaly).toBe(false)
expect(result.drop).toBeLessThanOrEqual(0)
})
it("下降超过 10 分触发 warning", () => {
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
{ normalizedScore: 85 },
{ normalizedScore: 83 },
{ normalizedScore: 70 },
]
const result = detectScoreAnomaly(points)
expect(result.isAnomaly).toBe(true)
expect(result.severity).toBe("warning")
expect(result.drop).toBeGreaterThanOrEqual(10)
expect(result.latestScore).toBe(70)
expect(result.historicalAverage).toBe(84)
})
it("下降超过 20 分触发 critical", () => {
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
{ normalizedScore: 90 },
{ normalizedScore: 88 },
{ normalizedScore: 60 },
]
const result = detectScoreAnomaly(points)
expect(result.isAnomaly).toBe(true)
expect(result.severity).toBe("critical")
expect(result.drop).toBeGreaterThanOrEqual(20)
})
it("相对下降超过 20% 触发 critical即使绝对值不足 20", () => {
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
{ normalizedScore: 50 },
{ normalizedScore: 48 },
{ normalizedScore: 38 },
]
const result = detectScoreAnomaly(points)
expect(result.isAnomaly).toBe(true)
// 历史平均 49最新 38绝对下降 11warning相对下降 22%critical
expect(result.severity).toBe("critical")
})
it("历史平均为 0 时不触发异常", () => {
const points: Pick<GradeTrendPoint, "normalizedScore">[] = [
{ normalizedScore: 0 },
{ normalizedScore: 0 },
{ normalizedScore: 50 },
]
const result = detectScoreAnomaly(points)
expect(result.isAnomaly).toBe(false)
})
})
describe("computeSignificance (P3-5)", () => {
it("样本不足时返回 null", () => {
expect(computeSignificance([80], [90])).toBeNull()
expect(computeSignificance([], [90, 85])).toBeNull()
})
it("两组相同数据返回 negligible + 不显著", () => {
const result = computeSignificance([80, 80, 80], [80, 80, 80])
expect(result).not.toBeNull()
expect(result!.effectSizeLabel).toBe("negligible")
expect(result!.isSignificant).toBe(false)
expect(result!.cohensD).toBe(0)
})
it("显著差异的大样本返回 isSignificant=true", () => {
// 两个差异很大的大样本
const classA = Array.from({ length: 30 }, (_, i) => 90 - i * 0.5) // 90→75.5
const classB = Array.from({ length: 30 }, (_, i) => 60 + i * 0.5) // 60→74.5
const result = computeSignificance(classA, classB)
expect(result).not.toBeNull()
expect(result!.cohensD).toBeGreaterThan(0.8) // large effect
expect(result!.effectSizeLabel).toBe("large")
expect(result!.isSignificant).toBe(true)
})
it("微小差异返回 negligible", () => {
const result = computeSignificance([80, 81, 79, 80], [80, 79, 81, 80])
expect(result).not.toBeNull()
expect(result!.effectSizeLabel).toBe("negligible")
})
it("p-value 范围在 [0, 1]", () => {
const result = computeSignificance([80, 85, 90], [75, 70, 65])
expect(result).not.toBeNull()
expect(result!.pValue).toBeGreaterThanOrEqual(0)
expect(result!.pValue).toBeLessThanOrEqual(1)
})
})
describe("findBucketIndex (P3-9)", () => {
const buckets = [
{ label: "90-100", min: 90, max: 100, count: 0 },
{ label: "80-89", min: 80, max: 89, count: 0 },
{ label: "70-79", min: 70, max: 79, count: 0 },
{ label: "60-69", min: 60, max: 69, count: 0 },
{ label: "<60", min: 0, max: 59, count: 0 },
]
it("分数 95 应在 90-100 桶", () => {
expect(findBucketIndex(buckets, 95)).toBe(0)
})
it("分数 85 应在 80-89 桶", () => {
expect(findBucketIndex(buckets, 85)).toBe(1)
})
it("分数 75 应在 70-79 桶", () => {
expect(findBucketIndex(buckets, 75)).toBe(2)
})
it("分数 65 应在 60-69 桶", () => {
expect(findBucketIndex(buckets, 65)).toBe(3)
})
it("分数 50 应在 <60 桶", () => {
expect(findBucketIndex(buckets, 50)).toBe(4)
})
it("边界分数 90 应在 90-100 桶", () => {
expect(findBucketIndex(buckets, 90)).toBe(0)
})
it("边界分数 80 应在 80-89 桶(不进位到 90-100", () => {
expect(findBucketIndex(buckets, 80)).toBe(1)
})
it("分数四舍五入后归入对应桶", () => {
expect(findBucketIndex(buckets, 89.6)).toBe(0)
})
it("分数 0 应在 <60 桶", () => {
expect(findBucketIndex(buckets, 0)).toBe(4)
})
it("超出范围的负数应返回 -1", () => {
expect(findBucketIndex(buckets, -10)).toBe(-1)
})
it("超出 100 的分数应返回 -1", () => {
expect(findBucketIndex(buckets, 110)).toBe(-1)
})
it("空桶列表应返回 -1", () => {
expect(findBucketIndex([], 50)).toBe(-1)
})
})
describe("buildGrowthArchivePoints (P3-4)", () => {
const baseRow = {
academicYearId: "year-2024",
academicYearName: "2024-2025",
academicYearStart: new Date("2024-09-01"),
subjectId: "subject-math",
}
it("空数组应返回空数组", () => {
expect(buildGrowthArchivePoints([])).toEqual([])
})
it("academicYearId 为 null 的行应被跳过", () => {
const rows: GrowthArchiveRawRow[] = [
{
...baseRow,
academicYearId: null,
semester: "1",
score: 80,
fullScore: 100,
title: "Midterm",
},
]
expect(buildGrowthArchivePoints(rows)).toEqual([])
})
it("单学年单学期应返回单个数据点", () => {
const rows: GrowthArchiveRawRow[] = [
{
...baseRow,
semester: "1",
score: 80,
fullScore: 100,
title: "Midterm",
},
{
...baseRow,
semester: "1",
score: 90,
fullScore: 100,
title: "Final",
},
]
const points = buildGrowthArchivePoints(rows)
expect(points).toHaveLength(1)
expect(points[0].academicYearId).toBe("year-2024")
expect(points[0].semester).toBe("1")
expect(points[0].averageScore).toBe(85)
expect(points[0].recordCount).toBe(2)
expect(points[0].subjectCount).toBe(1)
expect(points[0].assessmentCount).toBe(2)
})
it("多学年多学期应按 (year start, semester) 升序排列", () => {
const rows: GrowthArchiveRawRow[] = [
// 2025 学年 第一学期(应排在最后)
{
...baseRow,
academicYearId: "year-2025",
academicYearName: "2025-2026",
academicYearStart: new Date("2025-09-01"),
semester: "1",
score: 80,
fullScore: 100,
title: "Midterm",
},
// 2024 学年 第二学期(应排第二)
{
...baseRow,
semester: "2",
score: 85,
fullScore: 100,
title: "Final",
},
// 2024 学年 第一学期(应排第一)
{
...baseRow,
semester: "1",
score: 75,
fullScore: 100,
title: "Midterm",
},
]
const points = buildGrowthArchivePoints(rows)
expect(points).toHaveLength(3)
expect(points[0].academicYearId).toBe("year-2024")
expect(points[0].semester).toBe("1")
expect(points[1].academicYearId).toBe("year-2024")
expect(points[1].semester).toBe("2")
expect(points[2].academicYearId).toBe("year-2025")
expect(points[2].semester).toBe("1")
})
it("应正确计算及格率和优秀率(百分比 0-100", () => {
const rows: GrowthArchiveRawRow[] = [
// 60 → 及格
{ ...baseRow, semester: "1", score: 60, fullScore: 100, title: "T1" },
// 90 → 及格 + 优秀
{ ...baseRow, semester: "1", score: 90, fullScore: 100, title: "T2" },
// 50 → 不及格
{ ...baseRow, semester: "1", score: 50, fullScore: 100, title: "T3" },
// 86 → 及格 + 优秀
{ ...baseRow, semester: "1", score: 86, fullScore: 100, title: "T4" },
]
const points = buildGrowthArchivePoints(rows)
expect(points).toHaveLength(1)
// 3/4 = 75% 及格
expect(points[0].passRate).toBe(75)
// 2/4 = 50% 优秀
expect(points[0].excellentRate).toBe(50)
})
it("应正确统计科目数和考试数", () => {
const rows: GrowthArchiveRawRow[] = [
{
...baseRow,
subjectId: "math",
semester: "1",
score: 80,
fullScore: 100,
title: "Midterm",
},
{
...baseRow,
subjectId: "english",
semester: "1",
score: 85,
fullScore: 100,
title: "Midterm",
},
{
...baseRow,
subjectId: "math",
semester: "1",
score: 90,
fullScore: 100,
title: "Final",
},
]
const points = buildGrowthArchivePoints(rows)
expect(points).toHaveLength(1)
// 2 个科目math、english
expect(points[0].subjectCount).toBe(2)
// 2 个不同的考试Midterm、Final
expect(points[0].assessmentCount).toBe(2)
})
it("字符串类型分数应正确转换", () => {
const rows: GrowthArchiveRawRow[] = [
{
...baseRow,
semester: "1",
score: "80",
fullScore: "100",
title: "Midterm",
},
{
...baseRow,
semester: "1",
score: "90",
fullScore: "100",
title: "Final",
},
]
const points = buildGrowthArchivePoints(rows)
expect(points).toHaveLength(1)
expect(points[0].averageScore).toBe(85)
expect(points[0].recordCount).toBe(2)
})
it("归一化分数应正确处理非 100 分制", () => {
const rows: GrowthArchiveRawRow[] = [
// 80/100 = 80
{
...baseRow,
semester: "1",
score: 80,
fullScore: 100,
title: "T1",
},
// 40/50 = 80
{
...baseRow,
semester: "1",
score: 40,
fullScore: 50,
title: "T2",
},
]
const points = buildGrowthArchivePoints(rows)
expect(points[0].averageScore).toBe(80)
})
it("不同学年同学期应分组成不同点", () => {
const rows: GrowthArchiveRawRow[] = [
{
...baseRow,
academicYearId: "year-2024",
academicYearName: "2024-2025",
academicYearStart: new Date("2024-09-01"),
semester: "1",
score: 80,
fullScore: 100,
title: "T1",
},
{
...baseRow,
academicYearId: "year-2025",
academicYearName: "2025-2026",
academicYearStart: new Date("2025-09-01"),
semester: "1",
score: 90,
fullScore: 100,
title: "T1",
},
]
const points = buildGrowthArchivePoints(rows)
expect(points).toHaveLength(2)
expect(points[0].academicYearId).toBe("year-2024")
expect(points[0].averageScore).toBe(80)
expect(points[1].academicYearId).toBe("year-2025")
expect(points[1].averageScore).toBe(90)
})
it("中位数应正确计算(偶数个数据点)", () => {
const rows: GrowthArchiveRawRow[] = [
{ ...baseRow, semester: "1", score: 70, fullScore: 100, title: "T1" },
{ ...baseRow, semester: "1", score: 80, fullScore: 100, title: "T2" },
{ ...baseRow, semester: "1", score: 90, fullScore: 100, title: "T3" },
{ ...baseRow, semester: "1", score: 100, fullScore: 100, title: "T4" },
]
const points = buildGrowthArchivePoints(rows)
// 排序后 [70, 80, 90, 100],中位数 = (80 + 90) / 2 = 85
expect(points[0].medianScore).toBe(85)
})
it("academicYearStart 应转换为 ISO 字符串", () => {
const rows: GrowthArchiveRawRow[] = [
{
...baseRow,
semester: "1",
score: 80,
fullScore: 100,
title: "T1",
},
]
const points = buildGrowthArchivePoints(rows)
expect(typeof points[0].academicYearStart).toBe("string")
expect(new Date(points[0].academicYearStart).getTime()).toBe(
new Date("2024-09-01").getTime()
)
})
})

View File

@@ -12,8 +12,10 @@ import type {
GradeDistributionBucket,
GradeDistributionResult,
GradeStats,
GradeRecordSemester,
GradeTrendPoint,
RankingTrendPoint,
StudentGrowthArchivePoint,
} from "./types"
/** Round to 2 decimal places. */
@@ -243,6 +245,27 @@ function createDefaultBuckets(): GradeDistributionBucket[] {
]
}
/**
* P3-9: 给定归一化分数0-100返回其在分布桶中的索引。
* 返回 -1 表示分数不在任何桶内(例如分数 < 0 或 > 100
*
* 纯函数 — 无副作用,无 I/O。可独立测试。
*
* @param buckets - 已计算的分布桶列表
* @param normalizedScore - 学生的归一化分数0-100
*/
export function findBucketIndex(
buckets: ReadonlyArray<GradeDistributionBucket>,
normalizedScore: number
): number {
const rounded = Math.round(normalizedScore)
for (let i = 0; i < buckets.length; i += 1) {
const b = buckets[i]
if (rounded >= b.min && rounded <= b.max) return i
}
return -1
}
/**
* Bucketize raw score rows into a grade distribution result.
*/
@@ -310,3 +333,363 @@ export function buildRankingTrendPoints(
)
return points
}
/**
* P3-2: Score anomaly detection result.
* Returned when a student's latest score shows a significant drop.
*/
export interface ScoreAnomalyResult {
/** Whether an anomaly was detected. */
readonly isAnomaly: boolean
/** The latest normalized score (0-100). */
readonly latestScore: number
/** The historical average normalized score (0-100). */
readonly historicalAverage: number
/** Absolute drop (historicalAverage - latestScore), positive = drop. */
readonly drop: number
/** Relative drop percentage (0-1), e.g. 0.15 = 15% drop. */
readonly dropRatio: number
/** Severity: "warning" (drop ≥ 10 points or 10%), "critical" (drop ≥ 20 points or 20%). */
readonly severity: "warning" | "critical" | null
}
/** Thresholds for anomaly detection (aligned with industry practice: 智学网). */
const ANOMALY_WARNING_DROP = 10
const ANOMALY_CRITICAL_DROP = 20
const ANOMALY_WARNING_RATIO = 0.1
const ANOMALY_CRITICAL_RATIO = 0.2
/** Minimum historical data points required for anomaly detection. */
const MIN_HISTORY_POINTS = 2
/**
* P3-2: Detect score anomaly for a student.
*
* Compares the latest normalized score against the historical average.
* Triggers when:
* - At least 2 historical data points exist
* - Absolute drop ≥ 10 points (warning) or ≥ 20 points (critical)
* - OR relative drop ≥ 10% (warning) or ≥ 20% (critical)
*
* Uses the more severe of absolute/relative thresholds.
* Pure function — no side effects, no I/O. Independently testable.
*
* @param trendPoints - Sorted by date ascending (oldest first). Must be normalized 0-100.
* @returns ScoreAnomalyResult with isAnomaly=false if insufficient data.
*/
export function detectScoreAnomaly(
trendPoints: ReadonlyArray<Pick<GradeTrendPoint, "normalizedScore">>
): ScoreAnomalyResult {
if (trendPoints.length < MIN_HISTORY_POINTS + 1) {
return {
isAnomaly: false,
latestScore: 0,
historicalAverage: 0,
drop: 0,
dropRatio: 0,
severity: null,
}
}
const history = trendPoints.slice(0, -1)
const latest = trendPoints[trendPoints.length - 1]
const latestScore = latest.normalizedScore
const historicalAverage =
history.reduce((sum, p) => sum + p.normalizedScore, 0) / history.length
if (historicalAverage <= 0) {
return {
isAnomaly: false,
latestScore,
historicalAverage: 0,
drop: 0,
dropRatio: 0,
severity: null,
}
}
const drop = round2(historicalAverage - latestScore)
const dropRatio = drop / historicalAverage
if (drop <= 0) {
return {
isAnomaly: false,
latestScore,
historicalAverage: round2(historicalAverage),
drop: 0,
dropRatio: 0,
severity: null,
}
}
const isCriticalAbs = drop >= ANOMALY_CRITICAL_DROP
const isCriticalRatio = dropRatio >= ANOMALY_CRITICAL_RATIO
const isWarningAbs = drop >= ANOMALY_WARNING_DROP
const isWarningRatio = dropRatio >= ANOMALY_WARNING_RATIO
const severity: ScoreAnomalyResult["severity"] = isCriticalAbs || isCriticalRatio
? "critical"
: isWarningAbs || isWarningRatio
? "warning"
: null
return {
isAnomaly: severity !== null,
latestScore,
historicalAverage: round2(historicalAverage),
drop,
dropRatio: round2(dropRatio),
severity,
}
}
/**
* P3-5: Statistical significance result for class comparison.
* Computes Cohen's d (effect size) and an approximate p-value using
* a two-sample t-test (Welch's approximation). No external dependencies.
*/
export interface SignificanceResult {
/** Cohen's d effect size: <0.2 negligible, 0.2 small, 0.5 medium, 0.8 large. */
readonly cohensD: number
/** Effect size label: "negligible" | "small" | "medium" | "large". */
readonly effectSizeLabel: "negligible" | "small" | "medium" | "large"
/** Approximate p-value (two-tailed). */
readonly pValue: number
/** Whether the difference is statistically significant (p < 0.05). */
readonly isSignificant: boolean
}
/**
* P3-5: Compute statistical significance between two class score distributions.
*
* Uses Welch's t-test (does not assume equal variances).
* Computes Cohen's d for effect size.
*
* Pure function — no side effects, no I/O. Independently testable.
*
* @param classA - Scores from class A (normalized 0-100)
* @param classB - Scores from class B (normalized 0-100)
* @returns SignificanceResult, or null if either group has < 2 samples.
*/
export function computeSignificance(
classA: readonly number[],
classB: readonly number[]
): SignificanceResult | null {
const nA = classA.length
const nB = classB.length
if (nA < 2 || nB < 2) return null
const mean = (arr: readonly number[]): number =>
arr.reduce((s, v) => s + v, 0) / arr.length
const variance = (arr: readonly number[], m: number): number => {
const sumSq = arr.reduce((s, v) => s + (v - m) ** 2, 0)
return sumSq / (arr.length - 1)
}
const meanA = mean(classA)
const meanB = mean(classB)
const varA = variance(classA, meanA)
const varB = variance(classB, meanB)
// Pooled standard deviation for Cohen's d
const pooledStd = Math.sqrt(((nA - 1) * varA + (nB - 1) * varB) / (nA + nB - 2))
if (pooledStd === 0) {
return {
cohensD: 0,
effectSizeLabel: "negligible",
pValue: 1,
isSignificant: false,
}
}
const cohensD = Math.abs(meanA - meanB) / pooledStd
// Welch's t-statistic
const seDiff = Math.sqrt(varA / nA + varB / nB)
if (seDiff === 0) {
return {
cohensD: round2(cohensD),
effectSizeLabel: labelEffectSize(cohensD),
pValue: 1,
isSignificant: false,
}
}
const t = Math.abs(meanA - meanB) / seDiff
// WelchSatterthwaite degrees of freedom
const num = (varA / nA + varB / nB) ** 2
const den = (varA / nA) ** 2 / (nA - 1) + (varB / nB) ** 2 / (nB - 1)
const df = num / den
// Approximate p-value using normal distribution for large df,
// or a simplified t-distribution for small df.
const pValue = approximatePValue(t, df)
return {
cohensD: round2(cohensD),
effectSizeLabel: labelEffectSize(cohensD),
pValue: round2(pValue),
isSignificant: pValue < 0.05,
}
}
/** Label effect size based on Cohen's d. */
function labelEffectSize(d: number): SignificanceResult["effectSizeLabel"] {
const abs = Math.abs(d)
if (abs < 0.2) return "negligible"
if (abs < 0.5) return "small"
if (abs < 0.8) return "medium"
return "large"
}
/**
* Approximate two-tailed p-value from t-statistic and degrees of freedom.
* Uses the normal distribution approximation for df > 30, and a
* simplified series expansion for smaller df.
*/
function approximatePValue(t: number, df: number): number {
// For large df, t-distribution converges to normal distribution
if (df > 30) {
// Standard normal CDF approximation (Abramowitz & Stegun 26.2.17)
const z = Math.abs(t)
const cdf = 1 - 0.5 * (1 + erf(z / Math.SQRT2))
return 2 * cdf
}
// For small df, use a simplified approximation
// p ≈ 2 * (1 - CDF_t(t, df))
// Simplified: p ≈ 2 / (1 + t^2/df)^(df/2) for large t
const x = df / (df + t * t)
// Refined approximation using incomplete beta function ratio
// This is a rough but safe upper bound
const approx = Math.pow(x, df / 2)
return Math.min(1, Math.max(0, 2 * approx * 0.5))
}
/** Error function approximation (Abramowitz & Stegun 7.1.26). */
function erf(x: number): number {
const sign = x >= 0 ? 1 : -1
const ax = Math.abs(x)
const a1 = 0.254829592
const a2 = -0.284496736
const a3 = 1.421413741
const a4 = -1.453152027
const a5 = 1.061405429
const p = 0.3275911
const t = 1 / (1 + p * ax)
const y = 1 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-ax * ax)
return sign * y
}
/**
* P3-4: 原始数据行,用于构建学生纵向成长档案。
*/
export interface GrowthArchiveRawRow {
academicYearId: string | null
academicYearName: string
academicYearStart: Date
semester: GradeRecordSemester
score: unknown
fullScore: unknown
subjectId: string
title: string
}
/**
* P3-4: 按学年 + 学期分组聚合学生成绩,构建纵向成长档案点序列。
*
* 流程:
* 1. 按 (academicYearId, semester) 分组
* 2. 对每组计算归一化平均分、中位数、及格率、优秀率、记录数、科目数、考试数
* 3. 按 (academicYearStart, semester) 升序排序(先按学年开始时间,再按学期 1 → 2
*
* 跳过 academicYearId 为空的记录(无法归类到学年)。
*
* 纯函数 — 无副作用,无 I/O。可独立测试。
*
* @param rows - 学生的全部成绩记录行(含学年信息)
* @returns 按 (学年 start, 学期) 升序排列的成长点列表
*/
export function buildGrowthArchivePoints(
rows: ReadonlyArray<GrowthArchiveRawRow>
): StudentGrowthArchivePoint[] {
if (rows.length === 0) return []
// 按 (academicYearId, semester) 分组
const groups = new Map<string, {
academicYearId: string
academicYearName: string
academicYearStart: Date
semester: GradeRecordSemester
scores: number[]
subjectIds: Set<string>
assessmentTitles: Set<string>
}>()
for (const r of rows) {
if (!r.academicYearId) continue
const key = `${r.academicYearId}__${r.semester}`
const group = groups.get(key)
if (group) {
const score = toNumber(r.score)
const fullScore = toNumber(r.fullScore)
group.scores.push(normalize(score, fullScore))
group.subjectIds.add(r.subjectId)
group.assessmentTitles.add(r.title)
} else {
const score = toNumber(r.score)
const fullScore = toNumber(r.fullScore)
groups.set(key, {
academicYearId: r.academicYearId,
academicYearName: r.academicYearName,
academicYearStart: r.academicYearStart,
semester: r.semester,
scores: [normalize(score, fullScore)],
subjectIds: new Set<string>([r.subjectId]),
assessmentTitles: new Set<string>([r.title]),
})
}
}
const points: StudentGrowthArchivePoint[] = []
for (const group of groups.values()) {
const scores = group.scores
if (scores.length === 0) continue
const sorted = [...scores].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid]
const avg = scores.reduce((sum, s) => sum + s, 0) / scores.length
let passCount = 0
let excellentCount = 0
for (const s of scores) {
if (s >= PASS_THRESHOLD) passCount += 1
if (s >= EXCELLENT_THRESHOLD) excellentCount += 1
}
points.push({
academicYearId: group.academicYearId,
academicYearName: group.academicYearName,
semester: group.semester,
averageScore: round2(avg),
medianScore: round2(median),
passRate: round2((passCount / scores.length) * 100),
excellentRate: round2((excellentCount / scores.length) * 100),
recordCount: scores.length,
subjectCount: group.subjectIds.size,
assessmentCount: group.assessmentTitles.size,
academicYearStart: group.academicYearStart.toISOString(),
})
}
// 按学年开始时间升序,再按学期("1" → "2")升序
points.sort((a, b) => {
const timeA = new Date(a.academicYearStart).getTime()
const timeB = new Date(b.academicYearStart).getTime()
if (timeA !== timeB) return timeA - timeB
return a.semester.localeCompare(b.semester)
})
return points
}

View File

@@ -163,6 +163,33 @@ export interface ClassComparisonItem {
studentCount: number
}
/**
* P3-5: 班级对比的统计显著性结果。
* 由 data-access 层使用 stats-service.computeSignificance 计算后注入到 UI。
*/
export interface ClassComparisonSignificance {
/** 最高分班级 ID */
topClassId: string
/** 最低分班级 ID */
bottomClassId: string
/** Cohen's d 效应量 */
cohensD: number
/** 效应量标签 */
effectSizeLabel: "negligible" | "small" | "medium" | "large"
/** p 值(双侧) */
pValue: number
/** 是否统计显著p < 0.05 */
isSignificant: boolean
}
/**
* P3-5: 班级对比结果(聚合 items + 显著性分析)。
*/
export interface ClassComparisonResult {
items: ClassComparisonItem[]
significance: ClassComparisonSignificance | null
}
export interface SubjectComparisonItem {
subjectId: string
subjectName: string
@@ -194,6 +221,78 @@ export interface GradeDistributionResult {
totalCount: number
}
/**
* P3-9: 班级分布 + 学生位置(隐私保护视图)。
* 用于学生视角查看班级分布时标注自己所在分数段。
*/
export interface GradeDistributionWithPosition {
/** 班级分布(不含学生身份) */
distribution: GradeDistributionResult
/** 班级 ID */
classId: string
/** 班级名称(用于展示) */
className: string
/** 当前学生的最近一次归一化分数0-100 */
studentNormalizedScore: number | null
/** 学生所在桶的索引(-1 表示无有效分数) */
studentBucketIndex: number
}
/**
* P3-4: 学生纵向成长档案的单个数据点。
*
* 每个点代表一个学年 + 学期的聚合统计。
* 跨学年/学期的连续点串联成长轨迹,便于长期趋势分析。
*/
export interface StudentGrowthArchivePoint {
/** 学年 ID */
academicYearId: string
/** 学年名称(如 "2024-2025" */
academicYearName: string
/** 学期("1" 第一学期 / "2" 第二学期) */
semester: GradeRecordSemester
/** 该学期归一化平均分0-100 */
averageScore: number
/** 该学期归一化中位数 */
medianScore: number
/** 该学期及格率0-100 */
passRate: number
/** 该学期优秀率0-100 */
excellentRate: number
/** 该学期成绩记录数 */
recordCount: number
/** 该学期涉及科目数 */
subjectCount: number
/** 该学期参与的不同考试/作业数 */
assessmentCount: number
/** 学年开始时间ISO 字符串,用于按时间排序) */
academicYearStart: string
}
/**
* P3-4: 学生纵向成长档案结果。
*
* 跨学年/学期的聚合统计,用于绘制长期成长趋势图。
*/
export interface StudentGrowthArchiveResult {
/** 学生 ID */
studentId: string
/** 学生姓名 */
studentName: string
/** 按 (学年 start, 学期) 升序排列的成长点 */
points: StudentGrowthArchivePoint[]
/** 总览:所有记录的整体平均分 */
overallAverage: number
/** 总览:所有记录总数 */
totalRecords: number
/** 总览:所有涉及的科目数 */
totalSubjects: number
/** 总览:跨越的学年数 */
totalAcademicYears: number
/** 成长趋势:最新点平均分 - 第一个点平均分(>0 表示提升,<0 表示下降) */
growthDelta: number
}
/**
* 年级维度成绩分布(按班级拆分)。
* 用于年级主任/教学主任仪表盘的"成绩分布"维度。