From 95145cd03b74535769c53a85c94091748a7f1f02 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:37:32 +0800 Subject: [PATCH] feat(grades): add ranking trend, school-wide summary, score cell, and scope filter - Add ranking-trend-card and school-wide-summary-card for broader analytics - Add score-cell and grade-filters components for table rendering - Add scope-filter and type-guards lib utilities for grade data filtering - Update actions, data-access (analytics, ranking, main), stats-service, export - Update schema, types, and grade-utils lib - Update all grade chart and report components (distribution, trend, comparison, query) --- src/modules/grades/actions-analytics.ts | 54 +- src/modules/grades/actions.ts | 501 ++++++++++++++--- .../grades/components/analytics-filters.tsx | 72 ++- .../grades/components/batch-grade-entry.tsx | 519 +++++++++++++++--- .../components/class-comparison-chart.tsx | 154 +++++- .../grades/components/class-grade-report.tsx | 22 +- .../grades/components/export-button.tsx | 44 +- .../components/grade-distribution-chart.tsx | 98 +++- .../grades/components/grade-filters.tsx | 92 ++++ .../grades/components/grade-query-filters.tsx | 52 +- .../grades/components/grade-record-form.tsx | 100 ++-- .../grades/components/grade-record-list.tsx | 419 ++++++++++++-- .../grades/components/grade-stats-card.tsx | 34 +- .../grades/components/grade-trend-card.tsx | 158 +++++- .../grades/components/grade-trend-chart.tsx | 16 +- .../grades/components/ranking-trend-card.tsx | 78 +++ .../components/school-wide-summary-card.tsx | 131 +++++ src/modules/grades/components/score-cell.tsx | 41 ++ .../components/stats-class-selector.tsx | 8 +- .../components/student-grade-summary.tsx | 70 ++- .../components/subject-comparison-chart.tsx | 18 +- .../grades/components/widget-boundary.tsx | 50 +- src/modules/grades/data-access-analytics.ts | 164 +++++- src/modules/grades/data-access-ranking.ts | 114 +++- src/modules/grades/data-access.ts | 484 ++++++++++++---- src/modules/grades/export.ts | 182 ++++-- src/modules/grades/lib/grade-utils.ts | 41 -- src/modules/grades/lib/scope-filter.ts | 51 ++ src/modules/grades/lib/type-guards.ts | 12 + src/modules/grades/schema.ts | 30 +- src/modules/grades/stats-service.ts | 11 +- src/modules/grades/types.ts | 64 +++ 32 files changed, 3202 insertions(+), 682 deletions(-) create mode 100644 src/modules/grades/components/grade-filters.tsx create mode 100644 src/modules/grades/components/ranking-trend-card.tsx create mode 100644 src/modules/grades/components/school-wide-summary-card.tsx create mode 100644 src/modules/grades/components/score-cell.tsx create mode 100644 src/modules/grades/lib/scope-filter.ts create mode 100644 src/modules/grades/lib/type-guards.ts diff --git a/src/modules/grades/actions-analytics.ts b/src/modules/grades/actions-analytics.ts index d597f32..e12d2f8 100644 --- a/src/modules/grades/actions-analytics.ts +++ b/src/modules/grades/actions-analytics.ts @@ -1,8 +1,9 @@ "use server" -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +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 { getClassComparison, @@ -22,6 +23,7 @@ import { RankingTrendQuerySchema, SubjectComparisonQuerySchema, } from "./schema" +import { assertClassInScope } from "./actions" import type { ClassComparisonItem, GradeDistributionResult, @@ -45,6 +47,10 @@ export async function getGradeTrendAction( } } + // P3 修复:添加 assertClassInScope 校验 + const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId) + if (scopeError) return { success: false, message: scopeError } + const result = await getGradeTrend({ ...parsed.data, scope: ctx.dataScope, @@ -52,11 +58,7 @@ export async function getGradeTrendAction( }) return { success: true, data: result } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -75,17 +77,17 @@ export async function getClassComparisonAction( } } + // P3 修复:对 class_taught scope 校验(gradeId 维度无法直接校验,依赖 data-access 层过滤) + // 这里不调用 assertClassInScope,因为没有 classId 参数。 + // data-access 层的 getClassComparison 已通过 buildScopeClassFilter 应用 scope 过滤。 + const result = await getClassComparison({ ...parsed.data, scope: ctx.dataScope, }) return { success: true, data: result } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -104,17 +106,17 @@ export async function getSubjectComparisonAction( } } + // P3 修复:添加 assertClassInScope 校验 + const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId) + if (scopeError) return { success: false, message: scopeError } + const result = await getSubjectComparison({ ...parsed.data, scope: ctx.dataScope, }) return { success: true, data: result } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -133,6 +135,10 @@ export async function getGradeDistributionAction( } } + // P3 修复:添加 assertClassInScope 校验 + const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId) + if (scopeError) return { success: false, message: scopeError } + const result = await getGradeDistribution({ ...parsed.data, scope: ctx.dataScope, @@ -140,11 +146,7 @@ export async function getGradeDistributionAction( }) return { success: true, data: result } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -177,17 +179,15 @@ export async function getRankingTrendAction( return { success: false, message: "Can only view your children's ranking trend" } } + // P3 修复:传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属 const result = await getRankingTrend( parsed.data.studentId, parsed.data.subjectId, - parsed.data.semester + parsed.data.semester, + ctx.dataScope ) return { success: true, data: result } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } diff --git a/src/modules/grades/actions.ts b/src/modules/grades/actions.ts index da752e2..09a94bc 100644 --- a/src/modules/grades/actions.ts +++ b/src/modules/grades/actions.ts @@ -1,9 +1,13 @@ "use server" import { revalidatePath } from "next/cache" -import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" +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, @@ -20,20 +24,126 @@ import { import { createGradeRecord, batchCreateGradeRecords, + undoBatchCreateGradeRecords, updateGradeRecord, deleteGradeRecord, + bulkDeleteGradeRecords, getGradeRecords, getGradeRecordById, getClassGradeStatsWithMeta, getStudentGradeSummary, getClassRanking, + saveGradeDraft, + getGradeDraft, + deleteGradeDraft, + type GradeDraftData, } from "./data-access" +import type { PaginatedGradeRecords } from "./data-access" +import { updateMasteryFromExamScore } from "@/modules/diagnostic/data-access" import { exportGradeRecordsToExcel, exportClassGradeReportToExcel, + exportStudentGradeRecordsToExcel, } from "./export" import { formatDateForFile } from "@/shared/lib/utils" -import type { GradeQueryParams, GradeRecordListItem, GradeStats } from "./types" +import type { GradeQueryParams, GradeStats } from "./types" +import type { + StudentGradeSummary, + ClassRankingItem, + GradeRecord, +} from "./types" +import type { DataScope } from "@/shared/types/permissions" + +/** + * Validate that the given classId is within the user's DataScope. + * Returns an error message string when access is denied, otherwise null. + * + * - `all` / `children` / `grade_managed`: allowed (children/grade_managed are + * validated at the data layer via row-level filters; classId scoping would + * require extra cross-module lookups and is intentionally permissive here). + * - `class_taught` / `class_members`: classId must be in scope.classIds. + * - `owned`: never allowed (owned scope has no class context). + */ +export function assertClassInScope( + scope: DataScope, + classId: string +): string | 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" + } + if (scope.type === "class_members") { + return scope.classIds.includes(classId) + ? null + : "You can only access your own class" + } + return "Access denied for your scope" +} + +/** + * v4-P1-6: 成绩录入后通知学生和家长。 + * 通知失败不阻断成绩录入主流程。 + */ +async function notifyGradeEntered(params: { + studentIds: string[] + title: string + subjectId?: string + score?: number + fullScore?: number +}): Promise { + 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 | null, @@ -65,21 +175,42 @@ export async function createGradeRecordAction( } const id = await createGradeRecord(parsed.data, ctx.userId) + // v3-P1-5:成绩录入后更新诊断掌握度 + if (parsed.data.examId) { + try { + await updateMasteryFromExamScore( + parsed.data.studentId, + parsed.data.examId, + parsed.data.score, + parsed.data.fullScore ?? 100, + ) + } catch { + // 掌握度更新失败不应阻断成绩录入 + } + } + // v4-P1-6:成绩录入后通知学生和家长 + try { + await notifyGradeEntered({ + studentIds: [parsed.data.studentId], + title: parsed.data.title, + subjectId: parsed.data.subjectId, + score: parsed.data.score, + fullScore: parsed.data.fullScore ?? 100, + }) + } catch { + // 通知失败不阻断成绩录入 + } revalidatePath("/teacher/grades") return { success: true, message: "Grade record created", data: id } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } export async function batchCreateGradeRecordsAction( - prevState: ActionState | null, + prevState: ActionState | null, formData: FormData -): Promise> { +): Promise> { try { const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE) @@ -88,6 +219,9 @@ export async function batchCreateGradeRecordsAction( return { success: false, message: "Missing records data" } } + // P3 修复:使用 safeJsonParse 替代 JSON.parse,避免 SyntaxError 暴露 + const records = safeJsonParse(recordsJson, "成绩数据格式无效") + const parsed = BatchCreateGradeRecordSchema.safeParse({ classId: formData.get("classId"), subjectId: formData.get("subjectId"), @@ -97,7 +231,7 @@ export async function batchCreateGradeRecordsAction( fullScore: formData.get("fullScore") || undefined, type: formData.get("type") || undefined, semester: formData.get("semester") || undefined, - records: JSON.parse(recordsJson), + records, }) if (!parsed.success) { @@ -108,15 +242,74 @@ export async function batchCreateGradeRecordsAction( } } - const count = await batchCreateGradeRecords(parsed.data, ctx.userId) - revalidatePath("/teacher/grades") - return { success: true, message: `Created ${count} grade records`, data: count } - } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } + // v3-P2-3:返回创建的记录 ID 列表,供前端撤销使用 + const ids = await batchCreateGradeRecords(parsed.data, ctx.userId) + // v3-P1-5:批量成绩录入后更新诊断掌握度 + if (parsed.data.examId) { + const fullScore = parsed.data.fullScore ?? 100 + for (const record of parsed.data.records) { + try { + await updateMasteryFromExamScore( + record.studentId, + parsed.data.examId, + record.score, + fullScore, + ) + } catch { + // 掌握度更新失败不应阻断成绩录入 + } + } } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + // v4-P1-6:批量成绩录入后通知学生和家长 + try { + await notifyGradeEntered({ + studentIds: parsed.data.records.map((r) => r.studentId), + title: parsed.data.title, + subjectId: parsed.data.subjectId, + fullScore: parsed.data.fullScore ?? 100, + }) + } catch { + // 通知失败不阻断成绩录入 + } + revalidatePath("/teacher/grades") + return { + success: true, + message: `Created ${ids.length} grade records`, + data: ids, + } + } catch (e) { + return handleActionError(e) + } +} + +/** + * v3-P2-3: 撤销最近一次批量成绩录入。 + * 接收记录 ID 列表,仅允许撤销当前用户自己录入的记录。 + */ +export async function undoBatchCreateGradeRecordsAction( + ids: string[] +): Promise> { + try { + const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE) + + if (!Array.isArray(ids) || ids.length === 0) { + return { success: false, message: "No records to undo" } + } + + // 防御性:限制单次撤销的记录数量,避免误传超大数组 + if (ids.length > 500) { + return { success: false, message: "Cannot undo more than 500 records at once" } + } + + const deletedCount = await undoBatchCreateGradeRecords(ids, ctx.userId) + revalidatePath("/teacher/grades") + return { + success: true, + message: `Undone ${deletedCount} grade records`, + data: deletedCount, + } + } catch (e) { + return handleActionError(e) } } @@ -150,11 +343,7 @@ export async function updateGradeRecordAction( revalidatePath("/teacher/grades") return { success: true, message: "Grade record updated" } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -177,17 +366,44 @@ export async function deleteGradeRecordAction( revalidatePath("/teacher/grades") return { success: true, message: "Grade record deleted" } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } + return handleActionError(e) + } +} + +/** + * v3-P3-2: 批量删除成绩记录。 + * 使用 inArray 一次性删除,避免 N+1 查询。 + * 限制单次最多删除 500 条记录。 + */ +export async function bulkDeleteGradeRecordsAction( + ids: string[] +): Promise> { + try { + await requirePermission(Permissions.GRADE_RECORD_MANAGE) + + if (!Array.isArray(ids) || ids.length === 0) { + return { success: false, message: "No records to delete" } } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + // 防御性:限制单次删除的记录数量 + if (ids.length > 500) { + return { success: false, message: "Cannot delete more than 500 records at once" } + } + + const deletedCount = await bulkDeleteGradeRecords(ids) + revalidatePath("/teacher/grades") + return { + success: true, + message: `Deleted ${deletedCount} grade records`, + data: deletedCount, + } + } catch (e) { + return handleActionError(e) } } export async function getGradeRecordsAction( - params: GradeQueryParams -): Promise> { + params: GradeQueryParams & { limit?: number; offset?: number } +): Promise> { try { const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) @@ -200,18 +416,22 @@ export async function getGradeRecordsAction( } } - const records = await getGradeRecords({ + // P3 修复:对 class_taught scope 校验 classId + if (parsed.data.classId) { + const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId) + if (scopeError) return { success: false, message: scopeError } + } + + const result = await getGradeRecords({ ...parsed.data, scope: ctx.dataScope, currentUserId: ctx.userId, + limit: params.limit, + offset: params.offset, }) - return { success: true, data: records } + return { success: true, data: result } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -221,7 +441,7 @@ export async function getClassGradeStatsAction( examId?: string ): Promise> { try { - await requirePermission(Permissions.GRADE_RECORD_READ) + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) const parsed = ClassGradeStatsQuerySchema.safeParse({ classId, subjectId, examId }) if (!parsed.success) { @@ -232,24 +452,25 @@ export async function getClassGradeStatsAction( } } + const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId) + if (scopeError) return { success: false, message: scopeError } + const result = await getClassGradeStatsWithMeta( parsed.data.classId, parsed.data.subjectId, - parsed.data.examId + parsed.data.examId, + ctx.dataScope, + ctx.userId ) return { success: true, data: result?.stats ?? null } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } export async function getStudentGradeSummaryAction( studentId: string -): Promise>>> { +): Promise> { try { const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) @@ -272,14 +493,11 @@ export async function getStudentGradeSummaryAction( return { success: false, message: "Can only view your children's grades" } } - const summary = await getStudentGradeSummary(parsed.data.studentId) + // P3 修复:传递 scope 到 data-access 层,对 class_taught scope 在数据层校验学生归属 + const summary = await getStudentGradeSummary(parsed.data.studentId, ctx.dataScope) return { success: true, data: summary } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -287,9 +505,9 @@ export async function getClassRankingAction( classId: string, subjectId?: string, examId?: string -): Promise>>> { +): Promise> { try { - await requirePermission(Permissions.GRADE_RECORD_READ) + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) const parsed = ClassRankingQuerySchema.safeParse({ classId, subjectId, examId }) if (!parsed.success) { @@ -300,26 +518,27 @@ export async function getClassRankingAction( } } + const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId) + if (scopeError) return { success: false, message: scopeError } + const ranking = await getClassRanking( parsed.data.classId, parsed.data.subjectId, - parsed.data.examId + parsed.data.examId, + ctx.dataScope, + ctx.userId ) return { success: true, data: ranking } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } export async function getGradeRecordByIdAction( id: string -): Promise>>> { +): Promise> { try { - await requirePermission(Permissions.GRADE_RECORD_READ) + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) const parsed = GetGradeRecordByIdSchema.safeParse({ id }) if (!parsed.success) { @@ -331,13 +550,30 @@ export async function getGradeRecordByIdAction( } const record = await getGradeRecordById(parsed.data.id) + + // 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" } + } + if ( + ctx.dataScope.type === "children" && + !ctx.dataScope.childrenIds.includes(record.studentId) + ) { + return { success: false, message: "You can only view your children's grade records" } + } + // 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: true, data: record } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } + return handleActionError(e) } } @@ -345,7 +581,8 @@ export async function getGradeRecordByIdAction( * 导出成绩单(返回 base64 编码的 Excel) */ export async function exportGradesAction(params: { - classId: string + classId?: string + studentId?: string subjectId?: string examId?: string reportType?: "detail" | "class" @@ -362,6 +599,37 @@ export async function exportGradesAction(params: { } } + // v4-P1-12: 支持按 studentId 导出(家长视角) + // 当提供 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" } + } + const buffer = await exportStudentGradeRecordsToExcel({ + studentId: parsed.data.studentId, + subjectId: parsed.data.subjectId, + scope: ctx.dataScope, + currentUserId: ctx.userId, + }) + const filename = `成绩单_${formatDateForFile()}.xlsx` + return { + success: true, + data: { + buffer: buffer.toString("base64"), + filename, + }, + } + } + + // classId 必须存在(studentId 分支已返回) + if (!parsed.data.classId) { + return { success: false, message: "classId is required for class export" } + } + + // P3 修复:添加 assertClassInScope 校验 + const scopeError = assertClassInScope(ctx.dataScope, parsed.data.classId) + if (scopeError) return { success: false, message: scopeError } + let buffer: Buffer let filename: string @@ -369,6 +637,7 @@ export async function exportGradesAction(params: { buffer = await exportClassGradeReportToExcel({ classId: parsed.data.classId, scope: ctx.dataScope, + currentUserId: ctx.userId, }) filename = `班级成绩总表_${formatDateForFile()}.xlsx` } else { @@ -377,6 +646,7 @@ export async function exportGradesAction(params: { subjectId: parsed.data.subjectId, examId: parsed.data.examId, scope: ctx.dataScope, + currentUserId: ctx.userId, }) filename = `成绩单_${formatDateForFile()}.xlsx` } @@ -389,10 +659,99 @@ export async function exportGradesAction(params: { }, } } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "导出失败" } + return handleActionError(e) + } +} + +// --- v3-P2-10: 服务端草稿自动保存 --- + +/** + * 保存成绩录入草稿到服务端(跨设备同步)。 + */ +export async function saveGradeDraftAction(params: { + classId: string + subjectId: string + type: string + scores: Record +}): Promise> { + 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> { + 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> { + 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) } } diff --git a/src/modules/grades/components/analytics-filters.tsx b/src/modules/grades/components/analytics-filters.tsx index d105913..6982c83 100644 --- a/src/modules/grades/components/analytics-filters.tsx +++ b/src/modules/grades/components/analytics-filters.tsx @@ -1,4 +1,5 @@ import type { JSX } from "react" +import { getTranslations } from "next-intl/server" import { ChipNav } from "@/shared/components/ui/chip-nav" @@ -6,23 +7,32 @@ interface AnalyticsFiltersProps { classes: Array<{ id: string; name: string }> grades: Array<{ id: string; name: string }> subjects: Array<{ id: string; name: string }> + exams: Array<{ id: string; title: string }> currentClassId: string currentSubjectId: string currentGradeId: string + currentExamId: string + currentSemester: string } -export function AnalyticsFilters({ +export async function AnalyticsFilters({ classes, grades, subjects, + exams, currentClassId, currentSubjectId, currentGradeId, -}: AnalyticsFiltersProps): JSX.Element { + currentExamId, + currentSemester, +}: AnalyticsFiltersProps): Promise { + const t = await getTranslations("grades") const buildHref = (overrides: { classId?: string subjectId?: string gradeId?: string + examId?: string + semester?: string }): string => { const params = new URLSearchParams() params.set( @@ -43,14 +53,35 @@ export function AnalyticsFilters({ overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId ) } + // v3-P2-7: examId 筛选 + const examId = overrides.examId !== undefined ? overrides.examId : currentExamId + if (examId && examId !== "all") { + params.set("examId", examId) + } + // v3-P2-7: semester 筛选 + const semester = overrides.semester !== undefined ? overrides.semester : currentSemester + if (semester && semester !== "all") { + params.set("semester", semester) + } return `/teacher/grades/analytics?${params.toString()}` } + const semesterOptions = [ + { id: "all", name: t("analytics.semesterAll") }, + { id: "1", name: t("analytics.semester1") }, + { id: "2", name: t("analytics.semester2") }, + ] + + const examOptions = [ + { id: "all", name: t("analytics.examAll") }, + ...exams.map((e) => ({ id: e.id, name: e.title })), + ] + return (
-
Class
+
{t("analytics.class")}
-
Subject
+
{t("analytics.subject")}
buildHref({ subjectId: id })} size="xs" - allOption={{ id: "all", label: "All" }} + allOption={{ id: "all", label: t("analytics.allOption") }} className="gap-1.5" />
- Grade (for class comparison) + {t("analytics.classComparisonLabel")}
+ + {/* v3-P2-7: 学期和考试筛选 */} +
+
+
{t("analytics.semester")}
+ buildHref({ semester: id })} + size="xs" + className="gap-1.5" + /> +
+ +
+
{t("analytics.exam")}
+ {examOptions.length > 1 ? ( + buildHref({ examId: id })} + size="xs" + className="gap-1.5" + /> + ) : ( +

{t("analytics.noExams")}

+ )} +
+
) } diff --git a/src/modules/grades/components/batch-grade-entry.tsx b/src/modules/grades/components/batch-grade-entry.tsx index 5bea06a..0076902 100644 --- a/src/modules/grades/components/batch-grade-entry.tsx +++ b/src/modules/grades/components/batch-grade-entry.tsx @@ -4,7 +4,8 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react" import { useFormStatus } from "react-dom" import { toast } from "sonner" import { useRouter } from "next/navigation" -import { Search, TrendingUp, Trophy, AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" +import { Search, TrendingUp, Trophy, AlertCircle, Download, Info, X } from "lucide-react" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" @@ -20,30 +21,60 @@ import { TableRow, } from "@/shared/components/ui/table" import { cn } from "@/shared/lib/utils" +import { safeActionCall } from "@/shared/lib/action-utils" -import { batchCreateGradeRecordsAction } from "../actions" +import { batchCreateGradeRecordsAction, undoBatchCreateGradeRecordsAction, saveGradeDraftAction, getGradeDraftAction, deleteGradeDraftAction } from "../actions" +import type { SelectOption, GradeRecordType, GradeRecordSemester } from "../types" +import { isGradeType, isSemester } from "../lib/type-guards" -type Option = { id: string; name: string } type Student = { id: string; name: string; email: string } -type GradeType = "exam" | "quiz" | "homework" | "other" -type Semester = "1" | "2" -function isGradeType(v: string): v is GradeType { - return v === "exam" || v === "quiz" || v === "homework" || v === "other" +interface DraftData { + scores: Record + timestamp: number } -function isSemester(v: string): v is Semester { - return v === "1" || v === "2" +interface UndoData { + ids: string[] + timestamp: number +} + +/** 类型守卫:验证 localStorage 草稿数据结构 */ +function isDraftData(v: unknown): v is DraftData { + if (typeof v !== "object" || v === null) return false + // 从 unknown 转换为 Record 以访问属性(已通过对象检查) + const obj = v as Record + if (typeof obj.timestamp !== "number") return false + if (typeof obj.scores !== "object" || obj.scores === null) return false + // 从 unknown 转换:已通过对象检查 + const scores = obj.scores as Record + return Object.values(scores).every((val) => typeof val === "string") +} + +/** 类型守卫:验证 sessionStorage 撤销数据结构 */ +function isUndoData(v: unknown): v is UndoData { + if (typeof v !== "object" || v === null) return false + // 从 unknown 转换为 Record 以访问属性(已通过对象检查) + const obj = v as Record + if (typeof obj.timestamp !== "number") return false + if (!Array.isArray(obj.ids)) return false + return obj.ids.every((id) => typeof id === "string") } const MAX_SCORE = 100 const DRAFT_KEY_PREFIX = "grade-draft" +/** 将创建的记录 ID 序列化为可撤销的 sessionStorage 数据(提取到组件外部避免 purity lint) */ +function serializeUndoData(ids: string[]): string { + return JSON.stringify({ ids, timestamp: Date.now() }) +} + function SubmitButton() { + const t = useTranslations("grades") const { pending } = useFormStatus() return ( ) } @@ -55,26 +86,33 @@ export function BatchGradeEntry({ defaultClassId, defaultSubjectId, }: { - classes: Option[] - subjects: Option[] + classes: SelectOption[] + subjects: SelectOption[] students: Student[] defaultClassId?: string defaultSubjectId?: string }) { + const t = useTranslations("grades") const router = useRouter() const initialDraftKey = `${DRAFT_KEY_PREFIX}-${defaultClassId ?? classes[0]?.id ?? ""}-${defaultSubjectId ?? subjects[0]?.id ?? ""}-exam` const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "") const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "") - const [type, setType] = useState("exam") - const [semester, setSemester] = useState("1") + const [type, setType] = useState("exam") + const [semester, setSemester] = useState("1") const [scores, setScores] = useState>(() => { // 惰性初始化:从 localStorage 恢复草稿(避免 useEffect 中 setState 导致级联渲染) + // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 + if (typeof window === "undefined") return {} try { const raw = localStorage.getItem(initialDraftKey) if (raw) { - const data = JSON.parse(raw) as { scores: Record; timestamp: number } - if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) { - return data.scores + const parsed: unknown = JSON.parse(raw) + if ( + isDraftData(parsed) && + Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 && + Object.keys(parsed.scores).length > 0 + ) { + return parsed.scores } } } catch { @@ -84,11 +122,18 @@ export function BatchGradeEntry({ }) const [draftRestored] = useState(() => { // 检查是否恢复了草稿(用于显示 toast) + // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 + if (typeof window === "undefined") return false try { const raw = localStorage.getItem(initialDraftKey) if (raw) { - const data = JSON.parse(raw) as { scores: Record; timestamp: number } - return Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0 + const parsed: unknown = JSON.parse(raw) + if (isDraftData(parsed)) { + return ( + Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 && + Object.keys(parsed.scores).length > 0 + ) + } } } catch { // 解析失败,忽略 @@ -101,12 +146,42 @@ export function BatchGradeEntry({ const draftKey = `${DRAFT_KEY_PREFIX}-${classId}-${subjectId}-${type}` + // P3-12 修复:使用 ref 保存最新 scores,避免草稿保存 useEffect 依赖 scores 导致每次按键触发重建定时器 + const scoresRef = useRef(scores) + useEffect(() => { + scoresRef.current = scores + }, [scores]) + // 草稿恢复提示(仅在首次挂载时显示一次) useEffect(() => { if (draftRestored) { - toast.info("已恢复未保存的成绩草稿") + toast.info(t("batch.restored")) } - }, [draftRestored]) + }, [draftRestored, t]) + + // v3-P2-10: 服务端草稿恢复(localStorage 无草稿时,尝试从服务端恢复) + useEffect(() => { + if (draftRestored) return // 本地已恢复,跳过 + if (typeof window === "undefined") return + + let cancelled = false + void (async () => { + const result = await safeActionCall(() => + getGradeDraftAction({ classId, subjectId, type }) + ) + if (cancelled || !result || !result.success || !result.data) return + + const draft = result.data + // 24 小时过期检查(服务端已检查,这里双重校验) + if (Date.now() - draft.timestamp > 24 * 60 * 60 * 1000) return + if (Object.keys(draft.scores).length === 0) return + + setScores(draft.scores) + toast.info(t("batch.restoredFromServer")) + })() + + return () => { cancelled = true } + }, [classId, subjectId, type, draftRestored, t]) const handleScoreChange = useCallback((studentId: string, value: string) => { if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) { @@ -114,19 +189,25 @@ export function BatchGradeEntry({ } }, []) - const validateScore = (value: string): boolean => { + const validateScore = useCallback((value: string): boolean => { if (value === "") return true const num = Number(value) return !isNaN(num) && num >= 0 && num <= MAX_SCORE - } + }, []) const restoreDraft = useCallback((key: string): boolean => { + // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 + if (typeof window === "undefined") return false try { const raw = localStorage.getItem(key) if (raw) { - const data = JSON.parse(raw) as { scores: Record; timestamp: number } - if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) { - setScores(data.scores) + const parsed: unknown = JSON.parse(raw) + if ( + isDraftData(parsed) && + Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 && + Object.keys(parsed.scores).length > 0 + ) { + setScores(parsed.scores) return true } } @@ -139,7 +220,7 @@ export function BatchGradeEntry({ const handleClassChange = (newClassId: string) => { const hasUnsaved = Object.keys(scores).length > 0 if (hasUnsaved && newClassId !== classId) { - if (!window.confirm("当前班级有未保存的成绩记录,确认切换班级?")) { + if (!window.confirm(t("batch.confirmSwitchClass"))) { return } } @@ -148,7 +229,7 @@ export function BatchGradeEntry({ // 切换班级后尝试恢复该班级的草稿 const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}` if (restoreDraft(newDraftKey)) { - toast.info("已恢复未保存的成绩草稿") + toast.info(t("batch.restored")) } const newUrl = newClassId ? `/teacher/grades/entry?classId=${encodeURIComponent(newClassId)}` : "/teacher/grades/entry" router.push(newUrl) @@ -159,6 +240,69 @@ export function BatchGradeEntry({ [students, searchQuery] ) + /** + * Excel 粘贴处理器:从剪贴板解析一列分数,按当前学生列表顺序填充。 + * 支持格式: + * - 单列分数(每行一个数字) + * - 多列(取第一列或第一个可解析为数字的列) + * - Tab/逗号/空格分隔 + */ + const handlePaste = useCallback((e: React.ClipboardEvent, startIndex: number) => { + const text = e.clipboardData.getData("text") + if (!text) return + + // 按行分割(兼容 \r\n 和 \n) + const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "") + if (lines.length === 0) return + + // 尝试从每行提取数字(支持单列和多列格式) + const values: string[] = [] + for (const line of lines) { + const trimmed = line.trim() + // 尝试 Tab/逗号分隔,取第一个可解析为数字的部分 + const parts = trimmed.split(/[\t,]/).map((p) => p.trim()) + let found: string | null = null + for (const part of parts) { + if (part !== "" && /^\d*\.?\d+$/.test(part)) { + found = part + break + } + } + // 如果多列没匹配到,尝试整行作为数字 + if (found === null && /^\d*\.?\d+$/.test(trimmed)) { + found = trimmed + } + if (found !== null) values.push(found) + } + + if (values.length === 0) { + toast.error(t("batch.pasteNoMatch")) + e.preventDefault() + return + } + + // 按当前过滤后的学生列表顺序填充 + const targetStudents = filteredStudents.slice(startIndex) + const newScores: Record = {} + let appliedCount = 0 + for (let i = 0; i < Math.min(values.length, targetStudents.length); i += 1) { + const student = targetStudents[i] + if (!student) break + const val = values[i] + // 校验分数格式(与 handleScoreChange 一致) + if (/^\d*\.?\d{0,2}$/.test(val)) { + newScores[student.id] = val + appliedCount += 1 + } + } + + if (appliedCount > 0) { + setScores((prev) => ({ ...prev, ...newScores })) + toast.success(t("batch.pasteApplied", { count: appliedCount })) + e.preventDefault() + } + }, [filteredStudents, t]) + const stats = useMemo(() => { const validScores = students .map((s) => scores[s.id]) @@ -174,32 +318,101 @@ export function BatchGradeEntry({ min: Math.min(...validScores), total: students.length, } - }, [scores, students]) + }, [scores, students, validateScore]) + + // v3-P3-1: 下载成绩录入模板(CSV,含学生姓名列表,支持 Excel 粘贴) + const handleDownloadTemplate = useCallback(() => { + const headers = [t("batch.templateStudentName"), t("batch.templateScore"), t("batch.templateRemark")] + const rows = students.map((s) => [s.name, "", ""]) + const csv = [headers, ...rows] + .map((r) => r.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(",")) + .join("\n") + // 添加 BOM 以支持 Excel 正确识别 UTF-8 + const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = t("batch.templateFilename") + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + }, [students, t]) + + // v4-P3-2: 新手引导提示框,使用 localStorage 记住用户是否已关闭 + const [guideVisible, setGuideVisible] = useState(() => { + if (typeof window === "undefined") return true + try { + return localStorage.getItem("grade-entry-guide-dismissed") !== "true" + } catch { + return true + } + }) + + const handleDismissGuide = useCallback(() => { + setGuideVisible(false) + try { + localStorage.setItem("grade-entry-guide-dismissed", "true") + } catch { + // localStorage 不可用时静默失败 + } + }, []) const hasInvalidScores = Object.values(scores).some((v) => v !== "" && v !== undefined && !validateScore(v)) - // 草稿保存到 localStorage(30秒间隔) + // v3-P2-10: 草稿保存到 localStorage(30秒间隔)+ 服务端同步(60秒间隔) + // P3-12 修复:使用 scoresRef 读取最新 scores,定时器不再依赖 scores,避免每次按键重建定时器 useEffect(() => { - const interval = setInterval(() => { - if (Object.keys(scores).length > 0) { + // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 + if (typeof window === "undefined") return + + // 本地草稿:30 秒间隔,快速恢复 + const localInterval = setInterval(() => { + const currentScores = scoresRef.current + if (Object.keys(currentScores).length > 0) { try { - localStorage.setItem(draftKey, JSON.stringify({ scores, timestamp: Date.now() })) + localStorage.setItem(draftKey, JSON.stringify({ scores: currentScores, timestamp: Date.now() })) } catch { // localStorage 可能已满或不可用,静默失败 } } }, 30000) - return () => clearInterval(interval) - }, [scores, draftKey]) - // 清除草稿 + // 服务端草稿:60 秒间隔,跨设备同步 + const serverInterval = setInterval(() => { + const currentScores = scoresRef.current + if (Object.keys(currentScores).length > 0) { + void safeActionCall(() => + saveGradeDraftAction({ + classId, + subjectId, + type, + scores: currentScores, + }) + ) + } + }, 60000) + + return () => { + clearInterval(localInterval) + clearInterval(serverInterval) + } + }, [draftKey, classId, subjectId, type]) + + // 清除草稿(本地 + 服务端) const clearDraft = useCallback(() => { + // P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错 + if (typeof window === "undefined") return try { localStorage.removeItem(draftKey) } catch { // 忽略 } - }, [draftKey]) + // v3-P2-10: 同步清除服务端草稿 + void safeActionCall(() => + deleteGradeDraftAction({ classId, subjectId, type }) + ) + }, [draftKey, classId, subjectId, type]) const handleKeyDown = (e: React.KeyboardEvent, studentId: string) => { if (e.key === "Enter") { @@ -215,23 +428,31 @@ export function BatchGradeEntry({ const handleSubmit = async (formData: FormData) => { if (!classId || !subjectId) { - toast.error("Please select class and subject") + toast.error(t("batch.selectClassAndSubject")) return } if (hasInvalidScores) { - toast.error("存在无效分数(超过满分或格式错误),请检查后重试") + toast.error(t("batch.invalidScoresError")) return } + // P3 修复:区分"未输入"和"输入 0" + // 只有当 scores[studentId] 有值且非空字符串时才视为已输入 + // 这样输入 0 会被正确提交,而未输入会被跳过 const records = students - .map((s) => ({ - studentId: s.id, - score: Number(scores[s.id] ?? 0), - })) - .filter((r) => r.score > 0 || scores[r.studentId] !== undefined) + .map((s) => { + const raw = scores[s.id] + if (raw === undefined || raw === "") return null + return { + studentId: s.id, + score: Number(raw), + } + }) + .filter((r): r is { studentId: string; score: number } => r !== null) + if (records.length === 0) { - toast.error("Please enter at least one score") + toast.error(t("batch.enterAtLeastOne")) return } @@ -242,42 +463,156 @@ export function BatchGradeEntry({ formData.set("semester", semester) formData.set("recordsJson", JSON.stringify(records)) - const result = await batchCreateGradeRecordsAction(null, formData) - setIsSubmitting(false) - if (result.success) { + // P3 修复:使用 safeActionCall 包装,确保异常时也能重置 loading 状态 + const result = await safeActionCall( + () => batchCreateGradeRecordsAction(null, formData), + { + onError: () => toast.error(t("error.saveFailed")), + onFinally: () => setIsSubmitting(false), + } + ) + if (result?.success) { clearDraft() - toast.success(result.message) + // v3-P2-3:保存创建的记录 ID 到 sessionStorage,供撤销使用 + const createdIds = result.data ?? [] + if (createdIds.length > 0) { + try { + sessionStorage.setItem( + "lastBatchGradeRecordIds", + serializeUndoData(createdIds) + ) + } catch { + // sessionStorage 不可用时静默失败(不影响主流程) + } + // 显示带撤销按钮的 toast,10 秒内可撤销 + toast.success(result.message, { + duration: 10000, + action: { + label: t("batch.undo"), + onClick: () => handleUndo(), + }, + }) + } else { + toast.success(result.message) + } router.push("/teacher/grades") router.refresh() - } else { - toast.error(result.message || "Failed to save") + } else if (result) { + toast.error(result.message || t("error.saveFailed")) } } + /** + * v3-P2-3: 撤销最近一次批量录入。 + * 从 sessionStorage 读取上次创建的记录 ID 列表,调用 undo action 删除。 + */ + const handleUndo = useCallback(async () => { + if (typeof window === "undefined") return + let ids: string[] = [] + try { + const raw = sessionStorage.getItem("lastBatchGradeRecordIds") + if (!raw) { + toast.error(t("batch.undoNoRecord")) + return + } + const parsed: unknown = JSON.parse(raw) + if (!isUndoData(parsed)) { + toast.error(t("batch.undoNoRecord")) + return + } + // 仅允许 5 分钟内的撤销 + if (Date.now() - parsed.timestamp > 5 * 60 * 1000) { + sessionStorage.removeItem("lastBatchGradeRecordIds") + toast.error(t("batch.undoExpired")) + return + } + ids = parsed.ids + } catch { + toast.error(t("batch.undoNoRecord")) + return + } + + if (ids.length === 0) { + toast.error(t("batch.undoNoRecord")) + return + } + + const result = await safeActionCall( + () => undoBatchCreateGradeRecordsAction(ids), + { + onError: () => toast.error(t("batch.undoFailed")), + } + ) + if (result?.success) { + try { + sessionStorage.removeItem("lastBatchGradeRecordIds") + } catch { + // 忽略 + } + toast.success(result.message) + router.refresh() + } else if (result) { + toast.error(result.message || t("batch.undoFailed")) + } + }, [router, t]) + return ( {isSubmitting && (
- Saving grades... + {t("batch.savingGrades")}
)} - Batch Grade Entry + {t("batch.title")}

- 满分 {MAX_SCORE} 分。输入分数后按 Enter 跳到下一位学生。草稿每 30 秒自动保存,2 小时内有效。 + {t("batch.fullScoreHint", { max: MAX_SCORE })}

+ {/* v4-P3-2: 新手引导提示框 */} + {guideVisible && ( +
+
+
+
+ )}
- + - - + + {subjects.map((s) => ( @@ -306,53 +641,53 @@ export function BatchGradeEntry({
- - + +
- +
- +
- +
{students.length === 0 ? ( -

No students in this class.

+

{t("batch.noStudentsInClass")}

) : ( <> {/* 实时统计栏 */}
- 已录入 + {t("batch.entered")} {stats.entered} / {stats.total} @@ -360,33 +695,47 @@ export function BatchGradeEntry({ <> - 最低 + {t("batch.min")} {stats.min} )} + {t("batch.pasteHint")}
{hasInvalidScores && ( )} + {/* v3-P3-1: 下载成绩录入模板 */} +
setSearchQuery(e.target.value)} className="h-8 w-40 pl-8 text-sm" @@ -396,12 +745,13 @@ export function BatchGradeEntry({
+ # - Student - Email - Score + {t("list.columns.student")} + {t("batch.emailColumn")} + {t("batch.score")} @@ -424,6 +774,7 @@ export function BatchGradeEntry({ value={scoreValue} onChange={(e) => handleScoreChange(s.id, e.target.value)} onKeyDown={(e) => handleKeyDown(e, s.id)} + onPaste={(e) => handlePaste(e, idx)} className={cn("h-8", isInvalid && "border-destructive focus-visible:ring-destructive")} aria-invalid={isInvalid} /> @@ -439,7 +790,7 @@ export function BatchGradeEntry({ diff --git a/src/modules/grades/components/class-comparison-chart.tsx b/src/modules/grades/components/class-comparison-chart.tsx index 1ac7a87..42be8e8 100644 --- a/src/modules/grades/components/class-comparison-chart.tsx +++ b/src/modules/grades/components/class-comparison-chart.tsx @@ -1,16 +1,77 @@ "use client" -import { BarChart3 } from "lucide-react" +import { useState } from "react" +import type { JSX } from "react" +import { BarChart3, ChevronDown, Info } 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 { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/shared/components/ui/collapsible" +import { cn } from "@/shared/lib/utils" import type { ClassComparisonItem } from "@/modules/grades/types" interface ClassComparisonChartProps { data: ClassComparisonItem[] } -export function ClassComparisonChart({ data }: ClassComparisonChartProps) { +type SignificanceLevel = "high" | "medium" | "low" + +interface SignificanceResult { + range: number + level: SignificanceLevel + topClass: ClassComparisonItem + bottomClass: ClassComparisonItem +} + +/** 显著性判断阈值(经验规则,避免复杂统计计算) */ +const MIN_SAMPLE_SIZE = 30 +const HIGH_RANGE_THRESHOLD = 10 +const MEDIUM_RANGE_THRESHOLD = 5 + +/** + * v3-P3-5: 班级对比显著性分析。 + * 基于极差和样本量的经验规则判断班级间差异是否具有统计意义。 + * - 极差 >= 10 且各班样本量 >= 30:显著差异 + * - 极差 >= 5:可能存在差异(含极差大但样本不足的情况) + * - 极差 < 5:差异不显著 + */ +function analyzeSignificance(data: ClassComparisonItem[]): SignificanceResult | null { + if (data.length < 2) return null + + let topClass = data[0] + let bottomClass = data[0] + for (const item of data) { + if (item.averageScore > topClass.averageScore) topClass = item + if (item.averageScore < bottomClass.averageScore) bottomClass = item + } + + const range = topClass.averageScore - bottomClass.averageScore + const allSufficient = data.every((d) => d.studentCount >= MIN_SAMPLE_SIZE) + + let level: SignificanceLevel + if (range >= HIGH_RANGE_THRESHOLD && allSufficient) { + level = "high" + } else if (range >= MEDIUM_RANGE_THRESHOLD) { + level = "medium" + } else { + level = "low" + } + + return { range, level, topClass, bottomClass } +} + +function formatScore(score: number): string { + return score.toFixed(1) +} + +export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.Element { + const t = useTranslations("grades") + const [detailsOpen, setDetailsOpen] = useState(false) const isEmpty = !data || data.length === 0 const chartData = isEmpty @@ -24,27 +85,47 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps) { studentCount: d.studentCount, })) + const significance = isEmpty ? null : analyzeSignificance(data) + + const levelColorClass: Record = { + high: "border-destructive/30 bg-destructive/5 text-destructive", + medium: "border-yellow-500/30 bg-yellow-500/5 text-yellow-700 dark:text-yellow-400", + low: "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400", + } + + const levelLabel: Record = { + high: t("classComparison.significanceHigh"), + medium: t("classComparison.significanceMedium"), + low: t("classComparison.significanceLow"), + } + + const levelHint: Record = { + high: t("classComparison.significanceHighHint"), + medium: t("classComparison.significanceMediumHint"), + low: t("classComparison.significanceLowHint"), + } + return ( -
+
+ + {significance ? ( +
+
+
+ + + + + + +
+
+ {t("classComparison.significanceTopClass", { + name: significance.topClass.className, + score: formatScore(significance.topClass.averageScore), + })} +
+
+ {t("classComparison.significanceBottomClass", { + name: significance.bottomClass.className, + score: formatScore(significance.bottomClass.averageScore), + })} +
+
+
+
+
+ ) : null} ) } diff --git a/src/modules/grades/components/class-grade-report.tsx b/src/modules/grades/components/class-grade-report.tsx index 5b2dd6f..f45f111 100644 --- a/src/modules/grades/components/class-grade-report.tsx +++ b/src/modules/grades/components/class-grade-report.tsx @@ -1,3 +1,5 @@ +import { getTranslations } from "next-intl/server" + import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { @@ -19,7 +21,8 @@ interface ClassGradeReportProps { ranking: ClassRankingItem[] } -export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) { +export async function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) { + const t = await getTranslations("grades") return (
{stats ? ( @@ -28,7 +31,7 @@ export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {

{stats.className}

- {stats.studentCount} students · {stats.stats.count} grade records + {t("classReport.studentCountInfo", { studentCount: stats.studentCount, recordCount: stats.stats.count })}

@@ -36,8 +39,8 @@ export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
) : ( @@ -46,17 +49,18 @@ export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) { {ranking.length > 0 ? ( - Class Ranking + {t("classReport.classRanking")}
{t("batch.caption")}
+ - Rank - Student - Average Score - Records + {t("classReport.rankColumn")} + {t("list.columns.student")} + {t("summary.averageScore")} + {t("classReport.recordsColumn")} diff --git a/src/modules/grades/components/export-button.tsx b/src/modules/grades/components/export-button.tsx index e894beb..81c46e3 100644 --- a/src/modules/grades/components/export-button.tsx +++ b/src/modules/grades/components/export-button.tsx @@ -3,6 +3,7 @@ import { useState } from "react" import { toast } from "sonner" import { Download, Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" import { Button } from "@/shared/components/ui/button" import { @@ -12,6 +13,7 @@ import { DropdownMenuTrigger, } from "@/shared/components/ui/dropdown-menu" import { downloadBase64File } from "@/shared/lib/download" +import { safeActionCall } from "@/shared/lib/action-utils" import { exportGradesAction } from "../actions" type ExportButtonProps = { @@ -29,29 +31,37 @@ export function ExportButton({ examId, variant = "outline", size = "default", - label = "导出", + label, }: ExportButtonProps) { + const t = useTranslations("grades") const [isExporting, setIsExporting] = useState(false) const handleExport = async (reportType: "detail" | "class") => { if (!classId) { - toast.error("请先选择班级") + toast.error(t("export.selectClassFirst")) return } setIsExporting(true) - const result = await exportGradesAction({ - classId, - subjectId, - examId, - reportType, - }) - setIsExporting(false) + // P3 修复:使用 safeActionCall 包装,确保异常时也能重置 loading 状态 + const result = await safeActionCall( + () => + exportGradesAction({ + classId, + subjectId, + examId, + reportType, + }), + { + onError: () => toast.error(t("export.failed")), + onFinally: () => setIsExporting(false), + } + ) - if (result.success && result.data) { + if (result?.success && result.data) { downloadBase64File(result.data.buffer, result.data.filename) - toast.success("导出成功") - } else { - toast.error(result.message ?? "导出失败") + toast.success(t("export.success")) + } else if (result) { + toast.error(result.message ?? t("export.failed")) } } @@ -59,7 +69,7 @@ export function ExportButton({ return ( ) } @@ -69,15 +79,15 @@ export function ExportButton({ handleExport("detail")}> - 成绩明细 + {t("export.detailItem")} handleExport("class")}> - 班级成绩总表 + {t("export.classReportItem")} diff --git a/src/modules/grades/components/grade-distribution-chart.tsx b/src/modules/grades/components/grade-distribution-chart.tsx index b98aee4..6376682 100644 --- a/src/modules/grades/components/grade-distribution-chart.tsx +++ b/src/modules/grades/components/grade-distribution-chart.tsx @@ -1,19 +1,83 @@ "use client" import { PieChart as PieChartIcon } 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 type { GradeDistributionResult } from "@/modules/grades/types" -const BUCKET_COLORS: Record = { - "90-100": "hsl(142, 71%, 45%)", - "80-89": "hsl(217, 91%, 60%)", - "70-79": "hsl(43, 96%, 56%)", - "60-69": "hsl(25, 95%, 53%)", - "<60": "hsl(0, 84%, 60%)", +/** + * v4-P3-4: 色盲友好的双重编码。 + * 每个分数段使用不同的 SVG pattern(条纹/点状/交叉线等)+ 颜色, + * 确保色觉障碍用户能通过纹理区分各分数段。 + */ +const BUCKET_FILLS: Record = { + "90-100": "url(#grade-pattern-90-100)", + "80-89": "url(#grade-pattern-80-89)", + "70-79": "url(#grade-pattern-70-79)", + "60-69": "url(#grade-pattern-60-69)", + "<60": "url(#grade-pattern-lt60)", } +/** v4-P3-4: SVG 图案定义,为每个分数段提供独特的纹理 */ +const PATTERN_DEFS = ( + + {/* 90-100: 正向斜条纹 */} + + + + + {/* 80-89: 圆点 */} + + + + + {/* 70-79: 交叉线 */} + + + + + {/* 60-69: 反向斜条纹 */} + + + + + {/* <60: 网格 */} + + + + + +) + interface GradeDistributionChartProps { data: GradeDistributionResult | null } @@ -43,6 +107,7 @@ function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPaylo } export function GradeDistributionChart({ data }: GradeDistributionChartProps) { + const t = useTranslations("grades") const isEmpty = !data || data.totalCount === 0 const chartData = isEmpty @@ -58,25 +123,25 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) { return ( -
+
{ if (!isDistributionTooltipPayload(payload)) return null const item = payload.payload @@ -95,9 +161,9 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) { return (
- {item.label}: {item.count} student{item.count === 1 ? "" : "s"} + {item.label}: {t("distribution.tooltipStudents", { count: item.count })} - {item.percentage}% of total + {t("distribution.tooltipOfTotal", { percentage: item.percentage })}
) }} diff --git a/src/modules/grades/components/grade-filters.tsx b/src/modules/grades/components/grade-filters.tsx new file mode 100644 index 0000000..410d473 --- /dev/null +++ b/src/modules/grades/components/grade-filters.tsx @@ -0,0 +1,92 @@ +"use client" + +import { useQueryState, parseAsString } from "nuqs" +import { useTranslations } from "next-intl" + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/shared/components/ui/select" +import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" + +import type { SelectOption } from "../types" + +/** + * 学生端成绩过滤器。 + * + * v3-P2-1:subjects 改为通过 prop 传入,使用 subjectId 作为 value, + * 而非硬编码科目名称。若未传入 subjects,则不显示科目筛选。 + */ +export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) { + const t = useTranslations("grades") + const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) + const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all")) + const [type, setType] = useQueryState("type", parseAsString.withDefault("all")) + const [semester, setSemester] = useQueryState("semester", parseAsString.withDefault("all")) + + const hasFilters = Boolean(search || subject !== "all" || type !== "all" || semester !== "all") + + return ( + { + setSearch(null) + setSubject(null) + setType(null) + setSemester(null) + }} + > + setSearch(v || null)} + placeholder={t("filters.searchPlaceholder")} + /> + +
+ {subjects && subjects.length > 0 && ( + + )} + + + + +
+
+ ) +} diff --git a/src/modules/grades/components/grade-query-filters.tsx b/src/modules/grades/components/grade-query-filters.tsx index d1001e9..096dbcc 100644 --- a/src/modules/grades/components/grade-query-filters.tsx +++ b/src/modules/grades/components/grade-query-filters.tsx @@ -2,17 +2,19 @@ import { useRouter, useSearchParams } from "next/navigation" import { useCallback } from "react" +import { useTranslations } from "next-intl" import { Label } from "@/shared/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" -type Option = { id: string; name: string } +import type { SelectOption } from "../types" interface GradeQueryFiltersProps { - classes: Option[] - subjects: Option[] + classes: SelectOption[] + subjects: SelectOption[] } export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps) { + const t = useTranslations("grades") const router = useRouter() const searchParams = useSearchParams() @@ -37,13 +39,13 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps) return (
- + updateParam("subjectId", v)}> - - + + - All subjects + {t("filters.allSubjects")} {subjects.map((s) => ( {s.name} @@ -71,31 +73,31 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
- +
- +
diff --git a/src/modules/grades/components/grade-record-form.tsx b/src/modules/grades/components/grade-record-form.tsx index e84fd45..8902590 100644 --- a/src/modules/grades/components/grade-record-form.tsx +++ b/src/modules/grades/components/grade-record-form.tsx @@ -4,6 +4,7 @@ import { useState } from "react" import { useFormStatus } from "react-dom" import { toast } from "sonner" import { useRouter } from "next/navigation" +import { useTranslations } from "next-intl" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Button } from "@/shared/components/ui/button" @@ -11,26 +12,18 @@ 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 { Textarea } from "@/shared/components/ui/textarea" +import { safeActionCall } from "@/shared/lib/action-utils" import { createGradeRecordAction } from "../actions" - -type Option = { id: string; name: string } -type GradeType = "exam" | "quiz" | "homework" | "other" -type Semester = "1" | "2" - -function isGradeType(v: string): v is GradeType { - return v === "exam" || v === "quiz" || v === "homework" || v === "other" -} - -function isSemester(v: string): v is Semester { - return v === "1" || v === "2" -} +import type { SelectOption, GradeRecordType, GradeRecordSemester } from "../types" +import { isGradeType, isSemester } from "../lib/type-guards" function SubmitButton() { + const t = useTranslations("grades") const { pending } = useFormStatus() return ( ) } @@ -42,22 +35,23 @@ export function GradeRecordForm({ defaultClassId, defaultSubjectId, }: { - classes: Option[] - subjects: Option[] - students: Option[] + classes: SelectOption[] + subjects: SelectOption[] + students: SelectOption[] defaultClassId?: string defaultSubjectId?: string }) { + const t = useTranslations("grades") const router = useRouter() const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "") const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "") const [studentId, setStudentId] = useState(students[0]?.id ?? "") - const [type, setType] = useState("exam") - const [semester, setSemester] = useState("1") + const [type, setType] = useState("exam") + const [semester, setSemester] = useState("1") const handleSubmit = async (formData: FormData) => { if (!classId || !subjectId || !studentId) { - toast.error("Please select class, subject and student") + toast.error(t("form.selectPrompt")) return } formData.set("classId", classId) @@ -66,29 +60,35 @@ export function GradeRecordForm({ formData.set("type", type) formData.set("semester", semester) - const result = await createGradeRecordAction(null, formData) - if (result.success) { + // P3 修复:使用 safeActionCall 包装,确保异常时也能正确处理 + const result = await safeActionCall( + () => createGradeRecordAction(null, formData), + { + onError: () => toast.error(t("error.failedToCreate")), + } + ) + if (result?.success) { toast.success(result.message) router.push("/teacher/grades") router.refresh() - } else { - toast.error(result.message || "Failed to create") + } else if (result) { + toast.error(result.message || t("error.failedToCreate")) } } return ( - Record Grade + {t("form.title")}
- + - - + + {subjects.map((s) => ( @@ -117,10 +117,10 @@ export function GradeRecordForm({
- + + +
- +
- +
- +
- +
- -
{t("classReport.caption")}