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:
64
src/modules/grades/components/batch-grade-entry-dialog.tsx
Normal file
64
src/modules/grades/components/batch-grade-entry-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
src/modules/grades/components/batch-grade-entry-stats.tsx
Normal file
74
src/modules/grades/components/batch-grade-entry-stats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
122
src/modules/grades/components/batch-grade-entry-table.tsx
Normal file
122
src/modules/grades/components/batch-grade-entry-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
74
src/modules/grades/components/draft-lock-banner.tsx
Normal file
74
src/modules/grades/components/draft-lock-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
518
src/modules/grades/components/excel-import-dialog.tsx
Normal file
518
src/modules/grades/components/excel-import-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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-1:subjects 改为通过 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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ?? "")
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 编辑对话框 */}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
187
src/modules/grades/components/growth-archive-chart.tsx
Normal file
187
src/modules/grades/components/growth-archive-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
232
src/modules/grades/components/knowledge-point-mastery-chart.tsx
Normal file
232
src/modules/grades/components/knowledge-point-mastery-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
48
src/modules/grades/components/report-card-print-action.tsx
Normal file
48
src/modules/grades/components/report-card-print-action.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
src/modules/grades/components/report-card-print-button.tsx
Normal file
106
src/modules/grades/components/report-card-print-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
323
src/modules/grades/components/report-card-view.tsx
Normal file
323
src/modules/grades/components/report-card-view.tsx
Normal 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>
|
||||
))
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user