feat(grades): add appeals, drafts, import, report card, and growth archive

- Add actions-appeal, actions-draft, actions-import, actions-lock for grade workflow

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

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

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

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

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

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

- Add hooks directory
This commit is contained in:
SpecialX
2026-07-03 10:25:01 +08:00
parent 2b95fd668b
commit e85a5f05dd
51 changed files with 6871 additions and 916 deletions

View File

@@ -0,0 +1,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>
)
}