feat(grades): add ranking trend, school-wide summary, score cell, and scope filter

- Add ranking-trend-card and school-wide-summary-card for broader analytics

- Add score-cell and grade-filters components for table rendering

- Add scope-filter and type-guards lib utilities for grade data filtering

- Update actions, data-access (analytics, ranking, main), stats-service, export

- Update schema, types, and grade-utils lib

- Update all grade chart and report components (distribution, trend, comparison, query)
This commit is contained in:
SpecialX
2026-06-23 17:37:32 +08:00
parent 2197e68069
commit 95145cd03b
32 changed files with 3202 additions and 682 deletions

View File

@@ -1,4 +1,5 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { ChipNav } from "@/shared/components/ui/chip-nav"
@@ -6,23 +7,32 @@ interface AnalyticsFiltersProps {
classes: Array<{ id: string; name: string }>
grades: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
exams: Array<{ id: string; title: string }>
currentClassId: string
currentSubjectId: string
currentGradeId: string
currentExamId: string
currentSemester: string
}
export function AnalyticsFilters({
export async function AnalyticsFilters({
classes,
grades,
subjects,
exams,
currentClassId,
currentSubjectId,
currentGradeId,
}: AnalyticsFiltersProps): JSX.Element {
currentExamId,
currentSemester,
}: AnalyticsFiltersProps): Promise<JSX.Element> {
const t = await getTranslations("grades")
const buildHref = (overrides: {
classId?: string
subjectId?: string
gradeId?: string
examId?: string
semester?: string
}): string => {
const params = new URLSearchParams()
params.set(
@@ -43,14 +53,35 @@ export function AnalyticsFilters({
overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId
)
}
// v3-P2-7: examId 筛选
const examId = overrides.examId !== undefined ? overrides.examId : currentExamId
if (examId && examId !== "all") {
params.set("examId", examId)
}
// v3-P2-7: semester 筛选
const semester = overrides.semester !== undefined ? overrides.semester : currentSemester
if (semester && semester !== "all") {
params.set("semester", semester)
}
return `/teacher/grades/analytics?${params.toString()}`
}
const semesterOptions = [
{ id: "all", name: t("analytics.semesterAll") },
{ id: "1", name: t("analytics.semester1") },
{ id: "2", name: t("analytics.semester2") },
]
const examOptions = [
{ id: "all", name: t("analytics.examAll") },
...exams.map((e) => ({ id: e.id, name: e.title })),
]
return (
<div className="rounded-lg border bg-card p-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Class</div>
<div className="text-xs font-medium text-muted-foreground">{t("analytics.class")}</div>
<ChipNav
options={classes}
currentId={currentClassId}
@@ -61,20 +92,20 @@ export function AnalyticsFilters({
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">Subject</div>
<div className="text-xs font-medium text-muted-foreground">{t("analytics.subject")}</div>
<ChipNav
options={subjects}
currentId={currentSubjectId}
buildHref={(id) => buildHref({ subjectId: id })}
size="xs"
allOption={{ id: "all", label: "All" }}
allOption={{ id: "all", label: t("analytics.allOption") }}
className="gap-1.5"
/>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
Grade (for class comparison)
{t("analytics.classComparisonLabel")}
</div>
<ChipNav
options={grades}
@@ -85,6 +116,35 @@ export function AnalyticsFilters({
/>
</div>
</div>
{/* v3-P2-7: 学期和考试筛选 */}
<div className="mt-4 grid grid-cols-1 gap-4 border-t pt-4 md:grid-cols-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">{t("analytics.semester")}</div>
<ChipNav
options={semesterOptions}
currentId={currentSemester}
buildHref={(id) => buildHref({ semester: id })}
size="xs"
className="gap-1.5"
/>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">{t("analytics.exam")}</div>
{examOptions.length > 1 ? (
<ChipNav
options={examOptions}
currentId={currentExamId}
buildHref={(id) => buildHref({ examId: id })}
size="xs"
className="gap-1.5"
/>
) : (
<p className="text-xs text-muted-foreground">{t("analytics.noExams")}</p>
)}
</div>
</div>
</div>
)
}

View File

@@ -4,7 +4,8 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { Search, TrendingUp, Trophy, AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { Search, TrendingUp, Trophy, AlertCircle, Download, Info, X } from "lucide-react"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
@@ -20,30 +21,60 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { cn } from "@/shared/lib/utils"
import { safeActionCall } from "@/shared/lib/action-utils"
import { batchCreateGradeRecordsAction } from "../actions"
import { batchCreateGradeRecordsAction, undoBatchCreateGradeRecordsAction, saveGradeDraftAction, getGradeDraftAction, deleteGradeDraftAction } from "../actions"
import type { SelectOption, GradeRecordType, GradeRecordSemester } from "../types"
import { isGradeType, isSemester } from "../lib/type-guards"
type Option = { id: string; name: string }
type Student = { id: string; name: string; email: string }
type GradeType = "exam" | "quiz" | "homework" | "other"
type Semester = "1" | "2"
function isGradeType(v: string): v is GradeType {
return v === "exam" || v === "quiz" || v === "homework" || v === "other"
interface DraftData {
scores: Record<string, string>
timestamp: number
}
function isSemester(v: string): v is Semester {
return v === "1" || v === "2"
interface UndoData {
ids: string[]
timestamp: number
}
/** 类型守卫:验证 localStorage 草稿数据结构 */
function isDraftData(v: unknown): v is DraftData {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换为 Record 以访问属性(已通过对象检查)
const obj = v as Record<string, unknown>
if (typeof obj.timestamp !== "number") return false
if (typeof obj.scores !== "object" || obj.scores === null) return false
// 从 unknown 转换:已通过对象检查
const scores = obj.scores as Record<string, unknown>
return Object.values(scores).every((val) => typeof val === "string")
}
/** 类型守卫:验证 sessionStorage 撤销数据结构 */
function isUndoData(v: unknown): v is UndoData {
if (typeof v !== "object" || v === null) return false
// 从 unknown 转换为 Record 以访问属性(已通过对象检查)
const obj = v as Record<string, unknown>
if (typeof obj.timestamp !== "number") return false
if (!Array.isArray(obj.ids)) return false
return obj.ids.every((id) => typeof id === "string")
}
const MAX_SCORE = 100
const DRAFT_KEY_PREFIX = "grade-draft"
/** 将创建的记录 ID 序列化为可撤销的 sessionStorage 数据(提取到组件外部避免 purity lint) */
function serializeUndoData(ids: string[]): string {
return JSON.stringify({ ids, timestamp: Date.now() })
}
function SubmitButton() {
const t = useTranslations("grades")
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save All Grades"}
{pending ? t("batch.saving") : t("batch.saveAllGrades")}
</Button>
)
}
@@ -55,26 +86,33 @@ export function BatchGradeEntry({
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
classes: SelectOption[]
subjects: SelectOption[]
students: Student[]
defaultClassId?: string
defaultSubjectId?: string
}) {
const t = useTranslations("grades")
const router = useRouter()
const initialDraftKey = `${DRAFT_KEY_PREFIX}-${defaultClassId ?? classes[0]?.id ?? ""}-${defaultSubjectId ?? subjects[0]?.id ?? ""}-exam`
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
const [type, setType] = useState<GradeType>("exam")
const [semester, setSemester] = useState<Semester>("1")
const [type, setType] = useState<GradeRecordType>("exam")
const [semester, setSemester] = useState<GradeRecordSemester>("1")
const [scores, setScores] = useState<Record<string, string>>(() => {
// 惰性初始化:从 localStorage 恢复草稿(避免 useEffect 中 setState 导致级联渲染)
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
if (typeof window === "undefined") return {}
try {
const raw = localStorage.getItem(initialDraftKey)
if (raw) {
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) {
return data.scores
const parsed: unknown = JSON.parse(raw)
if (
isDraftData(parsed) &&
Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 &&
Object.keys(parsed.scores).length > 0
) {
return parsed.scores
}
}
} catch {
@@ -84,11 +122,18 @@ export function BatchGradeEntry({
})
const [draftRestored] = useState(() => {
// 检查是否恢复了草稿(用于显示 toast
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
if (typeof window === "undefined") return false
try {
const raw = localStorage.getItem(initialDraftKey)
if (raw) {
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
return Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0
const parsed: unknown = JSON.parse(raw)
if (isDraftData(parsed)) {
return (
Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 &&
Object.keys(parsed.scores).length > 0
)
}
}
} catch {
// 解析失败,忽略
@@ -101,12 +146,42 @@ export function BatchGradeEntry({
const draftKey = `${DRAFT_KEY_PREFIX}-${classId}-${subjectId}-${type}`
// P3-12 修复:使用 ref 保存最新 scores避免草稿保存 useEffect 依赖 scores 导致每次按键触发重建定时器
const scoresRef = useRef(scores)
useEffect(() => {
scoresRef.current = scores
}, [scores])
// 草稿恢复提示(仅在首次挂载时显示一次)
useEffect(() => {
if (draftRestored) {
toast.info("已恢复未保存的成绩草稿")
toast.info(t("batch.restored"))
}
}, [draftRestored])
}, [draftRestored, t])
// v3-P2-10: 服务端草稿恢复localStorage 无草稿时,尝试从服务端恢复)
useEffect(() => {
if (draftRestored) return // 本地已恢复,跳过
if (typeof window === "undefined") return
let cancelled = false
void (async () => {
const result = await safeActionCall(() =>
getGradeDraftAction({ classId, subjectId, type })
)
if (cancelled || !result || !result.success || !result.data) return
const draft = result.data
// 24 小时过期检查(服务端已检查,这里双重校验)
if (Date.now() - draft.timestamp > 24 * 60 * 60 * 1000) return
if (Object.keys(draft.scores).length === 0) return
setScores(draft.scores)
toast.info(t("batch.restoredFromServer"))
})()
return () => { cancelled = true }
}, [classId, subjectId, type, draftRestored, t])
const handleScoreChange = useCallback((studentId: string, value: string) => {
if (value === "" || /^\d*\.?\d{0,2}$/.test(value)) {
@@ -114,19 +189,25 @@ export function BatchGradeEntry({
}
}, [])
const validateScore = (value: string): boolean => {
const validateScore = useCallback((value: string): boolean => {
if (value === "") return true
const num = Number(value)
return !isNaN(num) && num >= 0 && num <= MAX_SCORE
}
}, [])
const restoreDraft = useCallback((key: string): boolean => {
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
if (typeof window === "undefined") return false
try {
const raw = localStorage.getItem(key)
if (raw) {
const data = JSON.parse(raw) as { scores: Record<string, string>; timestamp: number }
if (Date.now() - data.timestamp < 2 * 60 * 60 * 1000 && Object.keys(data.scores).length > 0) {
setScores(data.scores)
const parsed: unknown = JSON.parse(raw)
if (
isDraftData(parsed) &&
Date.now() - parsed.timestamp < 2 * 60 * 60 * 1000 &&
Object.keys(parsed.scores).length > 0
) {
setScores(parsed.scores)
return true
}
}
@@ -139,7 +220,7 @@ export function BatchGradeEntry({
const handleClassChange = (newClassId: string) => {
const hasUnsaved = Object.keys(scores).length > 0
if (hasUnsaved && newClassId !== classId) {
if (!window.confirm("当前班级有未保存的成绩记录,确认切换班级?")) {
if (!window.confirm(t("batch.confirmSwitchClass"))) {
return
}
}
@@ -148,7 +229,7 @@ export function BatchGradeEntry({
// 切换班级后尝试恢复该班级的草稿
const newDraftKey = `${DRAFT_KEY_PREFIX}-${newClassId}-${subjectId}-${type}`
if (restoreDraft(newDraftKey)) {
toast.info("已恢复未保存的成绩草稿")
toast.info(t("batch.restored"))
}
const newUrl = newClassId ? `/teacher/grades/entry?classId=${encodeURIComponent(newClassId)}` : "/teacher/grades/entry"
router.push(newUrl)
@@ -159,6 +240,69 @@ export function BatchGradeEntry({
[students, searchQuery]
)
/**
* Excel 粘贴处理器:从剪贴板解析一列分数,按当前学生列表顺序填充。
* 支持格式:
* - 单列分数(每行一个数字)
* - 多列(取第一列或第一个可解析为数字的列)
* - Tab/逗号/空格分隔
*/
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement>, startIndex: number) => {
const text = e.clipboardData.getData("text")
if (!text) return
// 按行分割(兼容 \r\n 和 \n
const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
if (lines.length === 0) return
// 尝试从每行提取数字(支持单列和多列格式)
const values: string[] = []
for (const line of lines) {
const trimmed = line.trim()
// 尝试 Tab/逗号分隔,取第一个可解析为数字的部分
const parts = trimmed.split(/[\t,]/).map((p) => p.trim())
let found: string | null = null
for (const part of parts) {
if (part !== "" && /^\d*\.?\d+$/.test(part)) {
found = part
break
}
}
// 如果多列没匹配到,尝试整行作为数字
if (found === null && /^\d*\.?\d+$/.test(trimmed)) {
found = trimmed
}
if (found !== null) values.push(found)
}
if (values.length === 0) {
toast.error(t("batch.pasteNoMatch"))
e.preventDefault()
return
}
// 按当前过滤后的学生列表顺序填充
const targetStudents = filteredStudents.slice(startIndex)
const newScores: Record<string, string> = {}
let appliedCount = 0
for (let i = 0; i < Math.min(values.length, targetStudents.length); i += 1) {
const student = targetStudents[i]
if (!student) break
const val = values[i]
// 校验分数格式(与 handleScoreChange 一致)
if (/^\d*\.?\d{0,2}$/.test(val)) {
newScores[student.id] = val
appliedCount += 1
}
}
if (appliedCount > 0) {
setScores((prev) => ({ ...prev, ...newScores }))
toast.success(t("batch.pasteApplied", { count: appliedCount }))
e.preventDefault()
}
}, [filteredStudents, t])
const stats = useMemo(() => {
const validScores = students
.map((s) => scores[s.id])
@@ -174,32 +318,101 @@ export function BatchGradeEntry({
min: Math.min(...validScores),
total: students.length,
}
}, [scores, students])
}, [scores, students, validateScore])
// v3-P3-1: 下载成绩录入模板CSV含学生姓名列表支持 Excel 粘贴)
const handleDownloadTemplate = useCallback(() => {
const headers = [t("batch.templateStudentName"), t("batch.templateScore"), t("batch.templateRemark")]
const rows = students.map((s) => [s.name, "", ""])
const csv = [headers, ...rows]
.map((r) => r.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(","))
.join("\n")
// 添加 BOM 以支持 Excel 正确识别 UTF-8
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = t("batch.templateFilename")
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, [students, t])
// v4-P3-2: 新手引导提示框,使用 localStorage 记住用户是否已关闭
const [guideVisible, setGuideVisible] = useState<boolean>(() => {
if (typeof window === "undefined") return true
try {
return localStorage.getItem("grade-entry-guide-dismissed") !== "true"
} catch {
return true
}
})
const handleDismissGuide = useCallback(() => {
setGuideVisible(false)
try {
localStorage.setItem("grade-entry-guide-dismissed", "true")
} catch {
// localStorage 不可用时静默失败
}
}, [])
const hasInvalidScores = Object.values(scores).some((v) => v !== "" && v !== undefined && !validateScore(v))
// 草稿保存到 localStorage30秒间隔
// v3-P2-10: 草稿保存到 localStorage30秒间隔+ 服务端同步60秒间隔
// P3-12 修复:使用 scoresRef 读取最新 scores定时器不再依赖 scores避免每次按键重建定时器
useEffect(() => {
const interval = setInterval(() => {
if (Object.keys(scores).length > 0) {
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
if (typeof window === "undefined") return
// 本地草稿30 秒间隔,快速恢复
const localInterval = setInterval(() => {
const currentScores = scoresRef.current
if (Object.keys(currentScores).length > 0) {
try {
localStorage.setItem(draftKey, JSON.stringify({ scores, timestamp: Date.now() }))
localStorage.setItem(draftKey, JSON.stringify({ scores: currentScores, timestamp: Date.now() }))
} catch {
// localStorage 可能已满或不可用,静默失败
}
}
}, 30000)
return () => clearInterval(interval)
}, [scores, draftKey])
// 清除草稿
// 服务端草稿60 秒间隔,跨设备同步
const serverInterval = setInterval(() => {
const currentScores = scoresRef.current
if (Object.keys(currentScores).length > 0) {
void safeActionCall(() =>
saveGradeDraftAction({
classId,
subjectId,
type,
scores: currentScores,
})
)
}
}, 60000)
return () => {
clearInterval(localInterval)
clearInterval(serverInterval)
}
}, [draftKey, classId, subjectId, type])
// 清除草稿(本地 + 服务端)
const clearDraft = useCallback(() => {
// P3 修复:添加 typeof window !== "undefined" 检查,防止 SSR 报错
if (typeof window === "undefined") return
try {
localStorage.removeItem(draftKey)
} catch {
// 忽略
}
}, [draftKey])
// v3-P2-10: 同步清除服务端草稿
void safeActionCall(() =>
deleteGradeDraftAction({ classId, subjectId, type })
)
}, [draftKey, classId, subjectId, type])
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, studentId: string) => {
if (e.key === "Enter") {
@@ -215,23 +428,31 @@ export function BatchGradeEntry({
const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId) {
toast.error("Please select class and subject")
toast.error(t("batch.selectClassAndSubject"))
return
}
if (hasInvalidScores) {
toast.error("存在无效分数(超过满分或格式错误),请检查后重试")
toast.error(t("batch.invalidScoresError"))
return
}
// P3 修复:区分"未输入"和"输入 0"
// 只有当 scores[studentId] 有值且非空字符串时才视为已输入
// 这样输入 0 会被正确提交,而未输入会被跳过
const records = students
.map((s) => ({
studentId: s.id,
score: Number(scores[s.id] ?? 0),
}))
.filter((r) => r.score > 0 || scores[r.studentId] !== undefined)
.map((s) => {
const raw = scores[s.id]
if (raw === undefined || raw === "") return null
return {
studentId: s.id,
score: Number(raw),
}
})
.filter((r): r is { studentId: string; score: number } => r !== null)
if (records.length === 0) {
toast.error("Please enter at least one score")
toast.error(t("batch.enterAtLeastOne"))
return
}
@@ -242,42 +463,156 @@ export function BatchGradeEntry({
formData.set("semester", semester)
formData.set("recordsJson", JSON.stringify(records))
const result = await batchCreateGradeRecordsAction(null, formData)
setIsSubmitting(false)
if (result.success) {
// P3 修复:使用 safeActionCall 包装,确保异常时也能重置 loading 状态
const result = await safeActionCall(
() => batchCreateGradeRecordsAction(null, formData),
{
onError: () => toast.error(t("error.saveFailed")),
onFinally: () => setIsSubmitting(false),
}
)
if (result?.success) {
clearDraft()
toast.success(result.message)
// v3-P2-3保存创建的记录 ID 到 sessionStorage供撤销使用
const createdIds = result.data ?? []
if (createdIds.length > 0) {
try {
sessionStorage.setItem(
"lastBatchGradeRecordIds",
serializeUndoData(createdIds)
)
} catch {
// sessionStorage 不可用时静默失败(不影响主流程)
}
// 显示带撤销按钮的 toast10 秒内可撤销
toast.success(result.message, {
duration: 10000,
action: {
label: t("batch.undo"),
onClick: () => handleUndo(),
},
})
} else {
toast.success(result.message)
}
router.push("/teacher/grades")
router.refresh()
} else {
toast.error(result.message || "Failed to save")
} else if (result) {
toast.error(result.message || t("error.saveFailed"))
}
}
/**
* v3-P2-3: 撤销最近一次批量录入。
* 从 sessionStorage 读取上次创建的记录 ID 列表,调用 undo action 删除。
*/
const handleUndo = useCallback(async () => {
if (typeof window === "undefined") return
let ids: string[] = []
try {
const raw = sessionStorage.getItem("lastBatchGradeRecordIds")
if (!raw) {
toast.error(t("batch.undoNoRecord"))
return
}
const parsed: unknown = JSON.parse(raw)
if (!isUndoData(parsed)) {
toast.error(t("batch.undoNoRecord"))
return
}
// 仅允许 5 分钟内的撤销
if (Date.now() - parsed.timestamp > 5 * 60 * 1000) {
sessionStorage.removeItem("lastBatchGradeRecordIds")
toast.error(t("batch.undoExpired"))
return
}
ids = parsed.ids
} catch {
toast.error(t("batch.undoNoRecord"))
return
}
if (ids.length === 0) {
toast.error(t("batch.undoNoRecord"))
return
}
const result = await safeActionCall(
() => undoBatchCreateGradeRecordsAction(ids),
{
onError: () => toast.error(t("batch.undoFailed")),
}
)
if (result?.success) {
try {
sessionStorage.removeItem("lastBatchGradeRecordIds")
} catch {
// 忽略
}
toast.success(result.message)
router.refresh()
} else if (result) {
toast.error(result.message || t("batch.undoFailed"))
}
}, [router, t])
return (
<Card className="relative">
{isSubmitting && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-background/60 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
Saving grades...
{t("batch.savingGrades")}
</div>
</div>
)}
<CardHeader>
<CardTitle>Batch Grade Entry</CardTitle>
<CardTitle>{t("batch.title")}</CardTitle>
<p className="text-sm text-muted-foreground">
{MAX_SCORE} Enter 稿 30 2
{t("batch.fullScoreHint", { max: MAX_SCORE })}
</p>
</CardHeader>
<CardContent>
{/* v4-P3-2: 新手引导提示框 */}
{guideVisible && (
<div
role="region"
aria-label={t("batch.guide.title")}
className="mb-6 rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-900 dark:bg-blue-950/40"
>
<div className="flex items-start gap-3">
<Info className="mt-0.5 h-5 w-5 shrink-0 text-blue-600 dark:text-blue-400" aria-hidden="true" />
<div className="flex-1 space-y-2">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100">
{t("batch.guide.title")}
</h3>
<ol className="list-decimal space-y-1 pl-4 text-sm text-blue-800 dark:text-blue-200">
<li>{t("batch.guide.step1")}</li>
<li>{t("batch.guide.step2")}</li>
<li>{t("batch.guide.step3")}</li>
<li>{t("batch.guide.step4")}</li>
</ol>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-blue-600 hover:bg-blue-100 hover:text-blue-700 dark:text-blue-400 dark:hover:bg-blue-900"
onClick={handleDismissGuide}
aria-label={t("batch.guide.dismissAriaLabel")}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
)}
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Class</Label>
<Label htmlFor="batch-class">{t("filters.class")}</Label>
<Select value={classId} onValueChange={handleClassChange}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
<SelectTrigger id="batch-class">
<SelectValue placeholder={t("form.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
@@ -290,10 +625,10 @@ export function BatchGradeEntry({
</div>
<div className="grid gap-2">
<Label>Subject</Label>
<Label htmlFor="batch-subject">{t("filters.subject")}</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
<SelectTrigger id="batch-subject">
<SelectValue placeholder={t("form.selectSubject")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
@@ -306,53 +641,53 @@ export function BatchGradeEntry({
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Exam / Quiz Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
<Label htmlFor="title">{t("batch.examQuizTitle")}</Label>
<Input id="title" name="title" placeholder={t("form.titlePlaceholder")} required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">Full Score</Label>
<Label htmlFor="fullScore">{t("form.fullScore")}</Label>
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue={String(MAX_SCORE)} />
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Label htmlFor="batch-type">{t("filters.type")}</Label>
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
<SelectTrigger>
<SelectTrigger id="batch-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
<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-2">
<Label>Semester</Label>
<Label htmlFor="batch-semester">{t("filters.semester")}</Label>
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
<SelectTrigger>
<SelectTrigger id="batch-semester">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{students.length === 0 ? (
<p className="text-sm text-muted-foreground">No students in this class.</p>
<p className="text-sm text-muted-foreground">{t("batch.noStudentsInClass")}</p>
) : (
<>
{/* 实时统计栏 */}
<div className="flex flex-col gap-3 rounded-md border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("batch.entered")}</span>
<span className="font-semibold tabular-nums">{stats.entered}</span>
<span className="text-muted-foreground">/ {stats.total}</span>
</span>
@@ -360,33 +695,47 @@ export function BatchGradeEntry({
<>
<span className="inline-flex items-center gap-1.5">
<TrendingUp className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("batch.average")}</span>
<span className="font-semibold tabular-nums">{stats.average}</span>
</span>
<span className="inline-flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5 text-amber-500" aria-hidden="true" />
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("batch.max")}</span>
<span className="font-semibold tabular-nums">{stats.max}</span>
</span>
<span className="inline-flex items-center gap-1.5">
<span className="text-muted-foreground"></span>
<span className="text-muted-foreground">{t("batch.min")}</span>
<span className="font-semibold tabular-nums">{stats.min}</span>
</span>
</>
)}
<span className="text-xs text-muted-foreground">{t("batch.pasteHint")}</span>
</div>
<div className="flex items-center gap-2">
{hasInvalidScores && (
<span className="inline-flex items-center gap-1 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5" aria-hidden="true" />
{t("batch.invalidScoresBadge")}
</span>
)}
{/* v3-P3-1: 下载成绩录入模板 */}
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={handleDownloadTemplate}
aria-label={t("batch.templateAriaLabel")}
disabled={students.length === 0}
>
<Download className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
{t("batch.downloadTemplate")}
</Button>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search student..."
placeholder={t("batch.searchStudent")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 w-40 pl-8 text-sm"
@@ -396,12 +745,13 @@ export function BatchGradeEntry({
</div>
<div className="rounded-md border">
<Table>
<caption className="sr-only">{t("batch.caption")}</caption>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Student</TableHead>
<TableHead className="hidden md:table-cell">Email</TableHead>
<TableHead className="w-32">Score</TableHead>
<TableHead>{t("list.columns.student")}</TableHead>
<TableHead className="hidden md:table-cell">{t("batch.emailColumn")}</TableHead>
<TableHead className="w-32">{t("batch.score")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -424,6 +774,7 @@ export function BatchGradeEntry({
value={scoreValue}
onChange={(e) => handleScoreChange(s.id, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, s.id)}
onPaste={(e) => handlePaste(e, idx)}
className={cn("h-8", isInvalid && "border-destructive focus-visible:ring-destructive")}
aria-invalid={isInvalid}
/>
@@ -439,7 +790,7 @@ export function BatchGradeEntry({
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
{t("batch.cancel")}
</Button>
<SubmitButton />
</CardFooter>

View File

@@ -1,16 +1,77 @@
"use client"
import { BarChart3 } from "lucide-react"
import { useState } from "react"
import type { JSX } from "react"
import { BarChart3, ChevronDown, Info } from "lucide-react"
import { useTranslations } from "next-intl"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/shared/components/ui/collapsible"
import { cn } from "@/shared/lib/utils"
import type { ClassComparisonItem } from "@/modules/grades/types"
interface ClassComparisonChartProps {
data: ClassComparisonItem[]
}
export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
type SignificanceLevel = "high" | "medium" | "low"
interface SignificanceResult {
range: number
level: SignificanceLevel
topClass: ClassComparisonItem
bottomClass: ClassComparisonItem
}
/** 显著性判断阈值(经验规则,避免复杂统计计算) */
const MIN_SAMPLE_SIZE = 30
const HIGH_RANGE_THRESHOLD = 10
const MEDIUM_RANGE_THRESHOLD = 5
/**
* v3-P3-5: 班级对比显著性分析。
* 基于极差和样本量的经验规则判断班级间差异是否具有统计意义。
* - 极差 >= 10 且各班样本量 >= 30显著差异
* - 极差 >= 5可能存在差异含极差大但样本不足的情况
* - 极差 < 5差异不显著
*/
function analyzeSignificance(data: ClassComparisonItem[]): SignificanceResult | null {
if (data.length < 2) return null
let topClass = data[0]
let bottomClass = data[0]
for (const item of data) {
if (item.averageScore > topClass.averageScore) topClass = item
if (item.averageScore < bottomClass.averageScore) bottomClass = item
}
const range = topClass.averageScore - bottomClass.averageScore
const allSufficient = data.every((d) => d.studentCount >= MIN_SAMPLE_SIZE)
let level: SignificanceLevel
if (range >= HIGH_RANGE_THRESHOLD && allSufficient) {
level = "high"
} else if (range >= MEDIUM_RANGE_THRESHOLD) {
level = "medium"
} else {
level = "low"
}
return { range, level, topClass, bottomClass }
}
function formatScore(score: number): string {
return score.toFixed(1)
}
export function ClassComparisonChart({ data }: ClassComparisonChartProps): JSX.Element {
const t = useTranslations("grades")
const [detailsOpen, setDetailsOpen] = useState(false)
const isEmpty = !data || data.length === 0
const chartData = isEmpty
@@ -24,27 +85,47 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
studentCount: d.studentCount,
}))
const significance = isEmpty ? null : analyzeSignificance(data)
const levelColorClass: Record<SignificanceLevel, string> = {
high: "border-destructive/30 bg-destructive/5 text-destructive",
medium: "border-yellow-500/30 bg-yellow-500/5 text-yellow-700 dark:text-yellow-400",
low: "border-emerald-500/30 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400",
}
const levelLabel: Record<SignificanceLevel, string> = {
high: t("classComparison.significanceHigh"),
medium: t("classComparison.significanceMedium"),
low: t("classComparison.significanceLow"),
}
const levelHint: Record<SignificanceLevel, string> = {
high: t("classComparison.significanceHighHint"),
medium: t("classComparison.significanceMediumHint"),
low: t("classComparison.significanceLowHint"),
}
return (
<ChartCardShell
title="Class Comparison"
title={t("analytics.classComparison")}
description={
isEmpty
? "Compare average, pass rate, and excellent rate across classes."
: "Average score, pass rate (≥60%), and excellent rate (≥85%) per class."
? t("classComparison.descriptionEmpty")
: t("classComparison.descriptionNonEmpty")
}
icon={BarChart3}
isEmpty={isEmpty}
emptyTitle="No comparison data"
emptyDescription="Select a grade and subject to compare classes."
emptyTitle={t("classComparison.emptyTitle")}
emptyDescription={t("classComparison.emptyDescription")}
emptyClassName="h-60"
>
<div role="img" aria-label={`班级对比柱状图:${isEmpty ? "暂无数据" : `${data.length} 个班级的均分、及格率与优秀率对比`}`}>
<div role="img" aria-label={isEmpty ? t("classComparison.ariaLabelEmpty") : t("classComparison.ariaLabelNonEmpty", { count: data.length })}>
<SimpleBarChart
data={chartData}
bars={[
{ dataKey: "averageScore", name: "Average (%)", color: "hsl(var(--primary))" },
{ dataKey: "passRate", name: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
{ dataKey: "excellentRate", name: "Excellent (%)", color: "hsl(var(--chart-3))" },
{ dataKey: "averageScore", name: t("chart.averagePercent"), color: "hsl(var(--primary))" },
{ dataKey: "passRate", name: t("chart.passRatePercent"), color: "hsl(var(--chart-2))" },
{ dataKey: "excellentRate", name: t("chart.excellentPercent"), color: "hsl(var(--chart-3))" },
]}
xKey="name"
xTruncateLength={8}
@@ -57,6 +138,57 @@ export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
tooltipClassName="w-[240px]"
/>
</div>
{significance ? (
<div
className="mt-3 space-y-2"
aria-label={t("classComparison.significanceAriaLabel", { level: levelLabel[significance.level] })}
>
<div className={cn("flex items-start gap-2 rounded-md border p-3", levelColorClass[significance.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]}
</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>
</div>
</div>
<Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-center gap-1 rounded-md border border-border/60 bg-muted/30 px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{t("classComparison.significanceDetails")}
<ChevronDown
className={cn("h-3 w-3 transition-transform", detailsOpen && "rotate-180")}
aria-hidden="true"
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<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),
})}
</div>
<div>
{t("classComparison.significanceBottomClass", {
name: significance.bottomClass.className,
score: formatScore(significance.bottomClass.averageScore),
})}
</div>
</div>
</CollapsibleContent>
</Collapsible>
</div>
) : null}
</ChartCardShell>
)
}

View File

@@ -1,3 +1,5 @@
import { getTranslations } from "next-intl/server"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
@@ -19,7 +21,8 @@ interface ClassGradeReportProps {
ranking: ClassRankingItem[]
}
export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
export async function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
const t = await getTranslations("grades")
return (
<div className="space-y-6">
{stats ? (
@@ -28,7 +31,7 @@ export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
<div>
<h3 className="text-lg font-semibold">{stats.className}</h3>
<p className="text-sm text-muted-foreground">
{stats.studentCount} students · {stats.stats.count} grade records
{t("classReport.studentCountInfo", { studentCount: stats.studentCount, recordCount: stats.stats.count })}
</p>
</div>
</div>
@@ -36,8 +39,8 @@ export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
</div>
) : (
<EmptyState
title="No data"
description="No grade records found for this class."
title={t("classReport.noDataTitle")}
description={t("classReport.noDataDescription")}
icon={Trophy}
className="border-none shadow-none"
/>
@@ -46,17 +49,18 @@ export function ClassGradeReport({ stats, ranking }: ClassGradeReportProps) {
{ranking.length > 0 ? (
<Card>
<CardHeader>
<CardTitle>Class Ranking</CardTitle>
<CardTitle>{t("classReport.classRanking")}</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<caption className="sr-only">{t("classReport.caption")}</caption>
<TableHeader>
<TableRow>
<TableHead className="w-16">Rank</TableHead>
<TableHead>Student</TableHead>
<TableHead className="text-right">Average Score</TableHead>
<TableHead className="text-right">Records</TableHead>
<TableHead className="w-16">{t("classReport.rankColumn")}</TableHead>
<TableHead>{t("list.columns.student")}</TableHead>
<TableHead className="text-right">{t("summary.averageScore")}</TableHead>
<TableHead className="text-right">{t("classReport.recordsColumn")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@@ -3,6 +3,7 @@
import { useState } from "react"
import { toast } from "sonner"
import { Download, Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import {
@@ -12,6 +13,7 @@ import {
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import { downloadBase64File } from "@/shared/lib/download"
import { safeActionCall } from "@/shared/lib/action-utils"
import { exportGradesAction } from "../actions"
type ExportButtonProps = {
@@ -29,29 +31,37 @@ export function ExportButton({
examId,
variant = "outline",
size = "default",
label = "导出",
label,
}: ExportButtonProps) {
const t = useTranslations("grades")
const [isExporting, setIsExporting] = useState(false)
const handleExport = async (reportType: "detail" | "class") => {
if (!classId) {
toast.error("请先选择班级")
toast.error(t("export.selectClassFirst"))
return
}
setIsExporting(true)
const result = await exportGradesAction({
classId,
subjectId,
examId,
reportType,
})
setIsExporting(false)
// P3 修复:使用 safeActionCall 包装,确保异常时也能重置 loading 状态
const result = await safeActionCall(
() =>
exportGradesAction({
classId,
subjectId,
examId,
reportType,
}),
{
onError: () => toast.error(t("export.failed")),
onFinally: () => setIsExporting(false),
}
)
if (result.success && result.data) {
if (result?.success && result.data) {
downloadBase64File(result.data.buffer, result.data.filename)
toast.success("导出成功")
} else {
toast.error(result.message ?? "导出失败")
toast.success(t("export.success"))
} else if (result) {
toast.error(result.message ?? t("export.failed"))
}
}
@@ -59,7 +69,7 @@ export function ExportButton({
return (
<Button variant={variant} size={size} disabled>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
{t("export.exporting")}
</Button>
)
}
@@ -69,15 +79,15 @@ export function ExportButton({
<DropdownMenuTrigger asChild>
<Button variant={variant} size={size}>
<Download className="mr-2 h-4 w-4" />
{label}
{label ?? t("export.defaultLabel")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport("detail")}>
{t("export.detailItem")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport("class")}>
{t("export.classReportItem")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -1,19 +1,83 @@
"use client"
import { PieChart as PieChartIcon } from "lucide-react"
import { useTranslations } from "next-intl"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart"
import type { GradeDistributionResult } from "@/modules/grades/types"
const BUCKET_COLORS: Record<string, string> = {
"90-100": "hsl(142, 71%, 45%)",
"80-89": "hsl(217, 91%, 60%)",
"70-79": "hsl(43, 96%, 56%)",
"60-69": "hsl(25, 95%, 53%)",
"<60": "hsl(0, 84%, 60%)",
/**
* v4-P3-4: 色盲友好的双重编码。
* 每个分数段使用不同的 SVG pattern条纹/点状/交叉线等)+ 颜色,
* 确保色觉障碍用户能通过纹理区分各分数段。
*/
const BUCKET_FILLS: Record<string, string> = {
"90-100": "url(#grade-pattern-90-100)",
"80-89": "url(#grade-pattern-80-89)",
"70-79": "url(#grade-pattern-70-79)",
"60-69": "url(#grade-pattern-60-69)",
"<60": "url(#grade-pattern-lt60)",
}
/** v4-P3-4: SVG 图案定义,为每个分数段提供独特的纹理 */
const PATTERN_DEFS = (
<defs>
{/* 90-100: 正向斜条纹 */}
<pattern
id="grade-pattern-90-100"
patternUnits="userSpaceOnUse"
width="8"
height="8"
patternTransform="rotate(45)"
>
<rect width="8" height="8" fill="hsl(142, 71%, 45%)" />
<line x1="0" y1="0" x2="0" y2="8" stroke="hsl(142, 71%, 25%)" strokeWidth="3" />
</pattern>
{/* 80-89: 圆点 */}
<pattern
id="grade-pattern-80-89"
patternUnits="userSpaceOnUse"
width="10"
height="10"
>
<rect width="10" height="10" fill="hsl(217, 91%, 60%)" />
<circle cx="5" cy="5" r="2.5" fill="hsl(217, 91%, 35%)" />
</pattern>
{/* 70-79: 交叉线 */}
<pattern
id="grade-pattern-70-79"
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect width="8" height="8" fill="hsl(43, 96%, 56%)" />
<path d="M0,0 L8,8 M8,0 L0,8" stroke="hsl(43, 96%, 31%)" strokeWidth="1.5" />
</pattern>
{/* 60-69: 反向斜条纹 */}
<pattern
id="grade-pattern-60-69"
patternUnits="userSpaceOnUse"
width="8"
height="8"
patternTransform="rotate(-45)"
>
<rect width="8" height="8" fill="hsl(25, 95%, 53%)" />
<line x1="0" y1="0" x2="0" y2="8" stroke="hsl(25, 95%, 33%)" strokeWidth="3" />
</pattern>
{/* <60: 网格 */}
<pattern
id="grade-pattern-lt60"
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect width="8" height="8" fill="hsl(0, 84%, 60%)" />
<path d="M0,0 L8,0 M0,0 L0,8" stroke="hsl(0, 84%, 40%)" strokeWidth="1.5" />
</pattern>
</defs>
)
interface GradeDistributionChartProps {
data: GradeDistributionResult | null
}
@@ -43,6 +107,7 @@ function isDistributionTooltipPayload(v: unknown): v is DistributionTooltipPaylo
}
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
const t = useTranslations("grades")
const isEmpty = !data || data.totalCount === 0
const chartData = isEmpty
@@ -58,25 +123,25 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
return (
<ChartCardShell
title="Score Distribution"
title={t("distribution.title")}
description={
isEmpty
? "Number of students in each score range (normalized to 0-100)."
: `${data.totalCount} grade record${data.totalCount === 1 ? "" : "s"} across score ranges.`
? t("distribution.descriptionEmpty")
: t("distribution.descriptionNonEmpty", { count: data.totalCount })
}
icon={PieChartIcon}
isEmpty={isEmpty}
emptyTitle="No distribution data"
emptyDescription="Select a class and subject to view score distribution."
emptyTitle={t("distribution.emptyTitle")}
emptyDescription={t("distribution.emptyDescription")}
emptyClassName="h-60"
>
<div role="img" aria-label={`分数分布柱状图:${isEmpty ? "暂无数据" : `${data.totalCount} 条成绩记录分布在 5 个分数区间`}`}>
<div role="img" aria-label={isEmpty ? t("distribution.ariaLabelEmpty") : t("distribution.ariaLabelNonEmpty", { count: data.totalCount })}>
<SimpleBarChart
data={chartData}
bars={[
{
dataKey: "count",
name: "Students",
name: t("chart.students"),
color: "hsl(var(--primary))",
},
]}
@@ -87,7 +152,8 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
heightClassName="h-[280px]"
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
tooltipClassName="w-[200px]"
cellColors={BUCKET_COLORS}
defs={PATTERN_DEFS}
cellColors={BUCKET_FILLS}
tooltipFormatter={(payload: unknown) => {
if (!isDistributionTooltipPayload(payload)) return null
const item = payload.payload
@@ -95,9 +161,9 @@ export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
return (
<div className="flex w-full flex-col gap-0.5">
<span className="text-sm font-medium">
{item.label}: {item.count} student{item.count === 1 ? "" : "s"}
{item.label}: {t("distribution.tooltipStudents", { count: item.count })}
</span>
<span className="text-xs text-muted-foreground">{item.percentage}% of total</span>
<span className="text-xs text-muted-foreground">{t("distribution.tooltipOfTotal", { percentage: item.percentage })}</span>
</div>
)
}}

View File

@@ -0,0 +1,92 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import type { SelectOption } from "../types"
/**
* 学生端成绩过滤器。
*
* v3-P2-1subjects 改为通过 prop 传入,使用 subjectId 作为 value
* 而非硬编码科目名称。若未传入 subjects则不显示科目筛选。
*/
export function GradeFilters({ subjects }: { subjects?: SelectOption[] }) {
const t = useTranslations("grades")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))
const [type, setType] = useQueryState("type", parseAsString.withDefault("all"))
const [semester, setSemester] = useQueryState("semester", parseAsString.withDefault("all"))
const hasFilters = Boolean(search || subject !== "all" || type !== "all" || semester !== "all")
return (
<FilterBar
layout="between"
hasFilters={hasFilters}
onReset={() => {
setSearch(null)
setSubject(null)
setType(null)
setSemester(null)
}}
>
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder={t("filters.searchPlaceholder")}
/>
<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">
<SelectValue placeholder={t("filters.subject")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allSubjects")}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Select value={type} onValueChange={(val) => setType(val === "all" ? null : val)}>
<SelectTrigger className="w-[120px] bg-background border-muted-foreground/20">
<SelectValue placeholder={t("filters.type")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
<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>
<Select value={semester} onValueChange={(val) => setSemester(val === "all" ? null : val)}>
<SelectTrigger className="w-[120px] bg-background border-muted-foreground/20">
<SelectValue placeholder={t("filters.semester")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allSemesters")}</SelectItem>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>
</FilterBar>
)
}

View File

@@ -2,17 +2,19 @@
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { useTranslations } from "next-intl"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
type Option = { id: string; name: string }
import type { SelectOption } from "../types"
interface GradeQueryFiltersProps {
classes: Option[]
subjects: Option[]
classes: SelectOption[]
subjects: SelectOption[]
}
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps) {
const t = useTranslations("grades")
const router = useRouter()
const searchParams = useSearchParams()
@@ -37,13 +39,13 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-4">
<div className="grid gap-2">
<Label className="text-xs">Class</Label>
<Label htmlFor="filter-class" className="text-xs">{t("filters.class")}</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All classes" />
<SelectTrigger id="filter-class" className="h-9">
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All classes</SelectItem>
<SelectItem value="all">{t("filters.allClasses")}</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
@@ -54,13 +56,13 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
</div>
<div className="grid gap-2">
<Label className="text-xs">Subject</Label>
<Label htmlFor="filter-subject" className="text-xs">{t("filters.subject")}</Label>
<Select value={subjectId} onValueChange={(v) => updateParam("subjectId", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All subjects" />
<SelectTrigger id="filter-subject" className="h-9">
<SelectValue placeholder={t("filters.allSubjects")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All subjects</SelectItem>
<SelectItem value="all">{t("filters.allSubjects")}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
@@ -71,31 +73,31 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
</div>
<div className="grid gap-2">
<Label className="text-xs">Type</Label>
<Label htmlFor="filter-type" className="text-xs">{t("filters.type")}</Label>
<Select value={type} onValueChange={(v) => updateParam("type", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All types" />
<SelectTrigger id="filter-type" className="h-9">
<SelectValue placeholder={t("filters.allTypes")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
<SelectItem value="all">{t("filters.allTypes")}</SelectItem>
<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-2">
<Label className="text-xs">Semester</Label>
<Label htmlFor="filter-semester" className="text-xs">{t("filters.semester")}</Label>
<Select value={semester} onValueChange={(v) => updateParam("semester", v)}>
<SelectTrigger className="h-9">
<SelectValue placeholder="All semesters" />
<SelectTrigger id="filter-semester" className="h-9">
<SelectValue placeholder={t("filters.allSemesters")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All semesters</SelectItem>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
<SelectItem value="all">{t("filters.allSemesters")}</SelectItem>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -4,6 +4,7 @@ import { useState } from "react"
import { useFormStatus } from "react-dom"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
@@ -11,26 +12,18 @@ import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Textarea } from "@/shared/components/ui/textarea"
import { safeActionCall } from "@/shared/lib/action-utils"
import { createGradeRecordAction } from "../actions"
type Option = { id: string; name: string }
type GradeType = "exam" | "quiz" | "homework" | "other"
type Semester = "1" | "2"
function isGradeType(v: string): v is GradeType {
return v === "exam" || v === "quiz" || v === "homework" || v === "other"
}
function isSemester(v: string): v is Semester {
return v === "1" || v === "2"
}
import type { SelectOption, GradeRecordType, GradeRecordSemester } from "../types"
import { isGradeType, isSemester } from "../lib/type-guards"
function SubmitButton() {
const t = useTranslations("grades")
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save Record"}
{pending ? t("form.saving") : t("form.save")}
</Button>
)
}
@@ -42,22 +35,23 @@ export function GradeRecordForm({
defaultClassId,
defaultSubjectId,
}: {
classes: Option[]
subjects: Option[]
students: Option[]
classes: SelectOption[]
subjects: SelectOption[]
students: SelectOption[]
defaultClassId?: string
defaultSubjectId?: string
}) {
const t = useTranslations("grades")
const router = useRouter()
const [classId, setClassId] = useState(defaultClassId ?? classes[0]?.id ?? "")
const [subjectId, setSubjectId] = useState(defaultSubjectId ?? subjects[0]?.id ?? "")
const [studentId, setStudentId] = useState(students[0]?.id ?? "")
const [type, setType] = useState<GradeType>("exam")
const [semester, setSemester] = useState<Semester>("1")
const [type, setType] = useState<GradeRecordType>("exam")
const [semester, setSemester] = useState<GradeRecordSemester>("1")
const handleSubmit = async (formData: FormData) => {
if (!classId || !subjectId || !studentId) {
toast.error("Please select class, subject and student")
toast.error(t("form.selectPrompt"))
return
}
formData.set("classId", classId)
@@ -66,29 +60,35 @@ export function GradeRecordForm({
formData.set("type", type)
formData.set("semester", semester)
const result = await createGradeRecordAction(null, formData)
if (result.success) {
// P3 修复:使用 safeActionCall 包装,确保异常时也能正确处理
const result = await safeActionCall(
() => createGradeRecordAction(null, formData),
{
onError: () => toast.error(t("error.failedToCreate")),
}
)
if (result?.success) {
toast.success(result.message)
router.push("/teacher/grades")
router.refresh()
} else {
toast.error(result.message || "Failed to create")
} else if (result) {
toast.error(result.message || t("error.failedToCreate"))
}
}
return (
<Card>
<CardHeader>
<CardTitle>Record Grade</CardTitle>
<CardTitle>{t("form.title")}</CardTitle>
</CardHeader>
<CardContent>
<form action={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Class</Label>
<Label htmlFor="record-class">{t("filters.class")}</Label>
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
<SelectValue placeholder="Select a class" />
<SelectTrigger id="record-class">
<SelectValue placeholder={t("form.selectClass")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
@@ -101,10 +101,10 @@ export function GradeRecordForm({
</div>
<div className="grid gap-2">
<Label>Subject</Label>
<Label htmlFor="record-subject">{t("filters.subject")}</Label>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger>
<SelectValue placeholder="Select a subject" />
<SelectTrigger id="record-subject">
<SelectValue placeholder={t("form.selectSubject")} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
@@ -117,10 +117,10 @@ export function GradeRecordForm({
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Student</Label>
<Label htmlFor="record-student">{t("form.student")}</Label>
<Select value={studentId} onValueChange={setStudentId}>
<SelectTrigger>
<SelectValue placeholder="Select a student" />
<SelectTrigger id="record-student">
<SelectValue placeholder={t("form.selectStudent")} />
</SelectTrigger>
<SelectContent>
{students.map((s) => (
@@ -133,57 +133,57 @@ export function GradeRecordForm({
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="title">Title</Label>
<Input id="title" name="title" placeholder="e.g. Mid-term Exam" required />
<Label htmlFor="title">{t("form.titleLabel")}</Label>
<Input id="title" name="title" placeholder={t("form.titlePlaceholder")} required />
</div>
<div className="grid gap-2">
<Label htmlFor="score">Score</Label>
<Label htmlFor="score">{t("form.score")}</Label>
<Input id="score" name="score" type="number" step="0.01" min="0" required />
</div>
<div className="grid gap-2">
<Label htmlFor="fullScore">Full Score</Label>
<Label htmlFor="fullScore">{t("form.fullScore")}</Label>
<Input id="fullScore" name="fullScore" type="number" step="0.01" min="1" defaultValue="100" />
</div>
<div className="grid gap-2">
<Label>Type</Label>
<Label htmlFor="record-type">{t("filters.type")}</Label>
<Select value={type} onValueChange={(v) => { if (isGradeType(v)) setType(v) }}>
<SelectTrigger>
<SelectTrigger id="record-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="exam">Exam</SelectItem>
<SelectItem value="quiz">Quiz</SelectItem>
<SelectItem value="homework">Homework</SelectItem>
<SelectItem value="other">Other</SelectItem>
<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-2">
<Label>Semester</Label>
<Label htmlFor="record-semester">{t("filters.semester")}</Label>
<Select value={semester} onValueChange={(v) => { if (isSemester(v)) setSemester(v) }}>
<SelectTrigger>
<SelectTrigger id="record-semester">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">Semester 1</SelectItem>
<SelectItem value="2">Semester 2</SelectItem>
<SelectItem value="1">{t("semester.s1")}</SelectItem>
<SelectItem value="2">{t("semester.s2")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="remark">Remark (optional)</Label>
<Textarea id="remark" name="remark" placeholder="Notes about this grade..." />
<Label htmlFor="remark">{t("form.remark")}</Label>
<Textarea id="remark" name="remark" placeholder={t("form.remarkPlaceholder")} />
</div>
</div>
<CardFooter className="justify-end gap-2 px-0">
<Button type="button" variant="outline" onClick={() => router.back()}>
Cancel
{t("form.cancel")}
</Button>
<SubmitButton />
</CardFooter>

View File

@@ -3,7 +3,19 @@
import { useState } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Table,
TableBody,
@@ -22,35 +34,149 @@ import {
} from "@/shared/components/ui/dialog"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { formatDate } from "@/shared/lib/utils"
import { Trash2 } from "lucide-react"
import { safeActionCall } from "@/shared/lib/action-utils"
import { Trash2, Pencil } from "lucide-react"
import { deleteGradeRecordAction } from "../actions"
import type { GradeRecordListItem } from "../types"
import { deleteGradeRecordAction, updateGradeRecordAction, bulkDeleteGradeRecordsAction } from "../actions"
import type { GradeRecordListItem, GradeRecordType, GradeRecordSemester } from "../types"
import { GRADE_TYPE_VARIANT } from "../types"
import { isGradeType, isSemester } from "../lib/type-guards"
import { ScoreCell } from "./score-cell"
type EditableFields = {
id: string
title: string
score: string
fullScore: string
type: GradeRecordType
semester: GradeRecordSemester
remark: string
}
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }) {
const t = useTranslations("grades")
const router = useRouter()
const [deleteId, setDeleteId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [editTarget, setEditTarget] = useState<EditableFields | null>(null)
const [isSaving, setIsSaving] = useState(false)
// v3-P3-2: 批量选择与删除
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false)
const [isBulkDeleting, setIsBulkDeleting] = useState(false)
const handleDelete = async () => {
if (!deleteId) return
setIsDeleting(true)
const result = await deleteGradeRecordAction(deleteId)
setIsDeleting(false)
if (result.success) {
const result = await safeActionCall(
() => deleteGradeRecordAction(deleteId),
{
onError: () => toast.error(t("error.deleteFailed")),
onFinally: () => setIsDeleting(false),
}
)
if (result?.success) {
toast.success(result.message)
setDeleteId(null)
router.refresh()
} else {
toast.error(result.message || "Failed to delete")
} else if (result) {
toast.error(result.message || t("error.deleteFailed"))
}
}
// v3-P3-2: 切换单行选择
const toggleRowSelection = (id: string): void => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
// v3-P3-2: 全选/取消全选
const toggleSelectAll = (): void => {
setSelectedIds((prev) => {
if (prev.size === records.length) {
return new Set()
}
return new Set(records.map((r) => r.id))
})
}
// v3-P3-2: 清空选择
const clearSelection = (): void => {
setSelectedIds(new Set())
}
// v3-P3-2: 批量删除
const handleBulkDelete = async (): Promise<void> => {
if (selectedIds.size === 0) return
setIsBulkDeleting(true)
const ids = Array.from(selectedIds)
const result = await safeActionCall(
() => bulkDeleteGradeRecordsAction(ids),
{
onError: () => toast.error(t("list.bulkDeleteFailed")),
onFinally: () => setIsBulkDeleting(false),
}
)
if (result?.success) {
toast.success(t("list.bulkDeleteSuccess", { count: result.data ?? 0 }))
clearSelection()
setIsBulkDeleteDialogOpen(false)
router.refresh()
} else if (result) {
toast.error(result.message || t("list.bulkDeleteFailed"))
}
}
const startEdit = (r: GradeRecordListItem): void => {
setEditTarget({
id: r.id,
title: r.title,
score: String(r.score),
fullScore: String(r.fullScore),
type: r.type,
semester: r.semester,
remark: r.remark ?? "",
})
}
const handleEditSave = async (): Promise<void> => {
if (!editTarget) return
setIsSaving(true)
const formData = new FormData()
formData.set("title", editTarget.title)
formData.set("score", editTarget.score)
formData.set("fullScore", editTarget.fullScore)
formData.set("type", editTarget.type)
formData.set("semester", editTarget.semester)
formData.set("remark", editTarget.remark)
const result = await safeActionCall(
() => updateGradeRecordAction(editTarget.id, null, formData),
{
onError: () => toast.error(t("edit.failed")),
onFinally: () => setIsSaving(false),
}
)
if (result?.success) {
toast.success(t("edit.success"))
setEditTarget(null)
router.refresh()
} else if (result) {
toast.error(result.message || t("edit.failed"))
}
}
if (records.length === 0) {
return (
<div className="rounded-md border bg-card p-8 text-center text-sm text-muted-foreground">
No grade records found.
{t("list.empty")}
</div>
)
}
@@ -58,69 +184,244 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
return (
<>
<div className="rounded-md border bg-card">
<Table>
<caption className="sr-only"></caption>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Class</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Title</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Type</TableHead>
<TableHead>Semester</TableHead>
<TableHead>Recorded By</TableHead>
<TableHead>Date</TableHead>
<TableHead className="w-12"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.subjectName}</TableCell>
<TableCell>{r.title}</TableCell>
<TableCell className="text-right font-mono">
{r.score} / {r.fullScore}
</TableCell>
<TableCell>
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
</TableCell>
<TableCell>S{r.semester}</TableCell>
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
aria-label={`删除 ${r.studentName}${r.subjectName} 成绩记录`}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
{/* v3-P3-2: 批量操作工具栏 */}
{selectedIds.size > 0 && (
<div className="flex items-center justify-between gap-2 border-b bg-muted/40 px-4 py-2">
<span className="text-sm text-muted-foreground">
{t("list.bulkDeleteSelected", { count: selectedIds.size })}
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
disabled={isBulkDeleting}
>
{t("list.clearSelection")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setIsBulkDeleteDialogOpen(true)}
disabled={isBulkDeleting}
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden="true" />
{t("list.bulkDelete")}
</Button>
</div>
</div>
)}
{/* v4-P1-10: 移动端表格水平滚动 */}
<div className="overflow-x-auto">
<Table>
<caption className="sr-only">{t("list.caption")}</caption>
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={records.length > 0 && selectedIds.size === records.length}
onCheckedChange={toggleSelectAll}
aria-label={t("list.selectAll")}
/>
</TableHead>
<TableHead>{t("list.columns.student")}</TableHead>
<TableHead>{t("list.columns.class")}</TableHead>
<TableHead>{t("list.columns.subject")}</TableHead>
<TableHead>{t("list.columns.title")}</TableHead>
<TableHead className="text-right">{t("list.columns.score")}</TableHead>
<TableHead>{t("list.columns.type")}</TableHead>
<TableHead>{t("list.columns.semester")}</TableHead>
<TableHead>{t("list.columns.recordedBy")}</TableHead>
<TableHead>{t("list.columns.date")}</TableHead>
<TableHead className="w-24">{t("list.columns.actions")}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell>
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => toggleRowSelection(r.id)}
aria-label={t("list.selectRow", { name: r.studentName })}
/>
</TableCell>
<TableCell className="font-medium">{r.studentName}</TableCell>
<TableCell>{r.className}</TableCell>
<TableCell>{r.subjectName}</TableCell>
<TableCell>{r.title}</TableCell>
<TableCell className="text-right">
{/* v4-P1-7: 成绩颜色编码 */}
<ScoreCell score={r.score} fullScore={r.fullScore} />
</TableCell>
<TableCell>
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
</TableCell>
<TableCell>S{r.semester}</TableCell>
<TableCell className="text-muted-foreground">{r.recorderName}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(r)}
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => setDeleteId(r.id)}
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* 编辑对话框 */}
<Dialog open={editTarget !== null} onOpenChange={(open) => !open && setEditTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("edit.title")}</DialogTitle>
<DialogDescription>
{editTarget?.title}
</DialogDescription>
</DialogHeader>
{editTarget && (
<div className="grid grid-cols-1 gap-4 py-2 md:grid-cols-2">
<div className="grid gap-2 md:col-span-2">
<Label htmlFor="edit-title">{t("edit.titleLabel")}</Label>
<Input
id="edit-title"
value={editTarget.title}
onChange={(e) => setEditTarget({ ...editTarget, title: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-score">{t("edit.score")}</Label>
<Input
id="edit-score"
type="number"
step="0.01"
min="0"
value={editTarget.score}
onChange={(e) => setEditTarget({ ...editTarget, score: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-fullScore">{t("edit.fullScore")}</Label>
<Input
id="edit-fullScore"
type="number"
step="0.01"
min="1"
value={editTarget.fullScore}
onChange={(e) => setEditTarget({ ...editTarget, fullScore: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-type">{t("filters.type")}</Label>
<Select
value={editTarget.type}
onValueChange={(v) => { if (isGradeType(v)) setEditTarget({ ...editTarget, type: v }) }}
>
<SelectTrigger id="edit-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-2">
<Label htmlFor="edit-semester">{t("filters.semester")}</Label>
<Select
value={editTarget.semester}
onValueChange={(v) => { if (isSemester(v)) setEditTarget({ ...editTarget, semester: v }) }}
>
<SelectTrigger id="edit-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-2 md:col-span-2">
<Label htmlFor="edit-remark">{t("edit.remark")}</Label>
<Textarea
id="edit-remark"
placeholder={t("edit.remarkPlaceholder")}
value={editTarget.remark}
onChange={(e) => setEditTarget({ ...editTarget, remark: e.target.value })}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditTarget(null)} disabled={isSaving}>
{t("edit.cancel")}
</Button>
<Button onClick={handleEditSave} disabled={isSaving}>
{isSaving ? t("edit.saving") : t("edit.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Grade Record</DialogTitle>
<DialogTitle>{t("delete.title")}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this grade record? This action cannot be undone.
{t("delete.confirmation")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
Cancel
{t("delete.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
{isDeleting ? t("delete.deleting") : t("delete.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* v3-P3-2: 批量删除确认对话框 */}
<Dialog open={isBulkDeleteDialogOpen} onOpenChange={(open) => !open && setIsBulkDeleteDialogOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("list.bulkDelete")}</DialogTitle>
<DialogDescription>
{t("list.bulkDeleteConfirmation", { count: selectedIds.size })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsBulkDeleteDialogOpen(false)}
disabled={isBulkDeleting}
>
{t("delete.cancel")}
</Button>
<Button variant="destructive" onClick={handleBulkDelete} disabled={isBulkDeleting}>
{isBulkDeleting ? t("delete.deleting") : t("delete.confirm")}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,17 +1,21 @@
import { getTranslations } from "next-intl/server"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { StatItem } from "@/shared/components/ui/stat-item"
import { TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
import type { GradeStats } from "../types"
export function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
export async function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
const t = await getTranslations("grades")
if (!stats || stats.count === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
<CardTitle>{t("stats.title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">No data available for statistics.</p>
<p className="text-sm text-muted-foreground">{t("stats.noData")}</p>
</CardContent>
</Card>
)
@@ -20,50 +24,50 @@ export function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
return (
<Card>
<CardHeader>
<CardTitle>Statistics</CardTitle>
<CardTitle>{t("stats.title")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label="Average"
label={t("stats.average")}
value={stats.average.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label="Median"
label={t("stats.median")}
value={stats.median.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label="Max"
label={t("stats.max")}
value={stats.max.toFixed(2)}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatItem
label="Min"
label={t("stats.min")}
value={stats.min.toFixed(2)}
icon={<TrendingDown className="h-4 w-4" />}
/>
<StatItem
label="Std Dev"
label={t("stats.stdDev")}
value={stats.stdDev.toFixed(2)}
icon={<Target className="h-4 w-4" />}
hint="Standard deviation"
hint={t("stats.stdDevHint")}
/>
<StatItem
label="Pass Rate"
label={t("stats.passRate")}
value={`${stats.passRate.toFixed(1)}%`}
icon={<CheckCircle2 className="h-4 w-4" />}
hint="Score >= 60% of full"
hint={t("stats.passRateHint")}
/>
<StatItem
label="Excellent Rate"
label={t("stats.excellentRate")}
value={`${stats.excellentRate.toFixed(1)}%`}
icon={<Award className="h-4 w-4" />}
hint="Score >= 85% of full"
hint={t("stats.excellentRateHint")}
/>
<StatItem
label="Count"
label={t("stats.count")}
value={stats.count}
icon={<BarChart3 className="h-4 w-4" />}
/>

View File

@@ -1,51 +1,169 @@
"use client"
import { useMemo } from "react"
import { useEffect, useMemo, useState } from "react"
import { BarChart3 } from "lucide-react"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { formatDate } from "@/shared/lib/utils"
import type { ClassAverageTrendResult } from "../types"
import type { StudentGradeSummary } from "../types"
export function GradeTrendCard({ summary }: { summary: StudentGradeSummary }) {
const chartData = useMemo(() => {
return [...summary.records]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
interface GradeTrendCardProps {
summary: StudentGradeSummary
/** v3-P2-2: 班级平均趋势数据,传入后会在趋势图中叠加对比线 */
classAverageData?: ClassAverageTrendResult | null
}
export function GradeTrendCard({ summary, classAverageData }: GradeTrendCardProps) {
const t = useTranslations("grades")
// v3-P3-4: 日期范围选择器,通过 URL 参数持久化
const [trendRange, setTrendRange] = useQueryState(
"trendRange",
parseAsString.withDefault("all"),
)
// v3-P3-4: 在 useEffect 中计算日期截止时间,避免在渲染阶段调用 Date.now()
const [cutoff, setCutoff] = useState<number | null>(null)
useEffect(() => {
const days = trendRange === "all" ? null : Number(trendRange)
const next =
days !== null && Number.isFinite(days) && days > 0
? Date.now() - days * 24 * 60 * 60 * 1000
: null
// eslint-disable-next-line react-hooks/set-state-in-effect -- 日期范围变更时需重新计算截止时间戳
setCutoff(next)
}, [trendRange])
const { chartData, hasClassAverage } = useMemo(() => {
// v3-P3-4: 根据日期范围筛选记录
const records =
cutoff !== null
? summary.records.filter((r) => {
const time = new Date(r.createdAt).getTime()
return !Number.isNaN(time) && time >= cutoff
})
: summary.records
// 学生个人趋势:按时间升序
const studentPoints = [...records]
.sort((a, b) => {
const timeA = new Date(a.createdAt).getTime()
const timeB = new Date(b.createdAt).getTime()
if (Number.isNaN(timeA) && Number.isNaN(timeB)) return 0
if (Number.isNaN(timeA)) return 1
if (Number.isNaN(timeB)) return -1
return timeA - timeB
})
.map((r) => ({
title: r.title,
score: Math.round((r.score / r.fullScore) * 100),
// P3 修复:添加 fullScore > 0 校验,避免除零
score: r.fullScore > 0 ? Math.round((r.score / r.fullScore) * 100) : 0,
fullTitle: r.title,
submittedAt: formatDate(r.createdAt),
rawScore: r.score,
maxScore: r.fullScore,
}))
}, [summary.records])
// v3-P2-2: 合并班级平均数据(按 title 对齐)
const classAvgByTitle = new Map<string, number>()
if (classAverageData?.points?.length) {
for (const p of classAverageData.points) {
classAvgByTitle.set(p.title, p.averageScore)
}
}
// 将班级平均分合并到 chartData缺失的 title 不显示对比点)
const merged = studentPoints.map((p) => ({
...p,
classAverage: classAvgByTitle.has(p.title)
? classAvgByTitle.get(p.title)
: undefined,
}))
return {
chartData: merged,
hasClassAverage: classAvgByTitle.size > 0,
}
}, [summary.records, classAverageData, cutoff])
const hasData = chartData.length > 0
// v3-P2-2: 动态构建 series有班级平均时叠加第二条线
const series = hasClassAverage
? [
{
dataKey: "score",
name: t("chart.scorePercent"),
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
{
dataKey: "classAverage",
name: t("chart.classAverage"),
color: "hsl(var(--muted-foreground))",
dotRadius: 3,
activeDotRadius: 5,
},
]
: [
{
dataKey: "score",
name: t("chart.scorePercent"),
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
]
return (
<ChartCardShell
title="Grade Trend"
title={t("analytics.trend")}
icon={BarChart3}
iconClassName="text-muted-foreground"
isEmpty={!hasData}
emptyTitle="No grade records yet"
emptyDescription="Your grade trend will appear here once records are added."
emptyTitle={t("trend.empty")}
emptyDescription={t("trendCard.emptyDescription")}
emptyClassName="h-64"
action={
<Select value={trendRange} onValueChange={setTrendRange}>
<SelectTrigger
className="w-32"
aria-label={t("trendCard.rangeAriaLabel")}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("trendRange.all")}</SelectItem>
<SelectItem value="7">{t("trendRange.days7")}</SelectItem>
<SelectItem value="30">{t("trendRange.days30")}</SelectItem>
<SelectItem value="90">{t("trendRange.days90")}</SelectItem>
</SelectContent>
</Select>
}
>
<div className="rounded-md border bg-card p-4">
<div
className="rounded-md border bg-card p-4"
role="img"
aria-label={
hasData
? t("trendCard.ariaLabelNonEmpty", { count: chartData.length })
: t("trendCard.ariaLabelEmpty")
}
>
<TrendLineChart
data={chartData}
series={[
{
dataKey: "score",
name: "Score (%)",
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
]}
series={series}
heightClassName="h-[240px]"
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
yWidth={30}

View File

@@ -1,6 +1,7 @@
"use client"
import { BarChart3 } 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"
@@ -12,6 +13,7 @@ interface GradeTrendChartProps {
}
export function GradeTrendChart({ data }: GradeTrendChartProps) {
const t = useTranslations("grades")
const isEmpty = !data || data.points.length === 0
const chartData = isEmpty
@@ -28,25 +30,25 @@ export function GradeTrendChart({ data }: GradeTrendChartProps) {
return (
<ChartCardShell
title="Grade Trend"
title={t("analytics.trend")}
description={
isEmpty
? "Score progression over time (normalized to 0-100)."
: `${data.label} · avg ${data.averageScore.toFixed(1)}%`
? t("trendChart.descriptionEmpty")
: t("trendChart.descriptionNonEmpty", { label: data.label, average: data.averageScore.toFixed(1) })
}
icon={BarChart3}
isEmpty={isEmpty}
emptyTitle="No trend data"
emptyDescription="Select a class and subject to view the grade trend."
emptyTitle={t("trendChart.emptyTitle")}
emptyDescription={t("trendChart.emptyDescription")}
emptyClassName="h-60"
>
<div role="img" aria-label={`成绩趋势图:${isEmpty ? "暂无数据" : `${data.label},平均 ${data.averageScore.toFixed(1)}%`}`}>
<div role="img" aria-label={isEmpty ? t("trendChart.ariaLabelEmpty") : t("trendChart.ariaLabelNonEmpty", { label: data.label, average: data.averageScore.toFixed(1) })}>
<TrendLineChart
data={chartData}
series={[
{
dataKey: "normalizedScore",
name: "Score (%)",
name: t("chart.scorePercent"),
color: "hsl(var(--primary))",
dotRadius: 3,
activeDotRadius: 5,

View File

@@ -0,0 +1,78 @@
"use client"
import { useMemo } from "react"
import { BarChart3 } 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 { formatDate } from "@/shared/lib/utils"
import type { RankingTrendResult } from "../types"
/**
* 排名趋势图组件。
*
* v3-P1-3学生页面显示排名趋势Y 轴反转(第 1 名在顶部)。
* 对比同类 K12 系统PowerSchool、Infinite Campus 等)的排名趋势功能。
*/
export function RankingTrendCard({ trend }: { trend: RankingTrendResult | null }) {
const t = useTranslations("grades")
const chartData = useMemo(() => {
if (!trend || trend.points.length === 0) return []
return trend.points.map((p) => ({
title: p.title,
rank: p.rank,
totalStudents: p.totalStudents,
fullTitle: p.title,
submittedAt: formatDate(p.date),
}))
}, [trend])
const hasData = chartData.length > 0
const maxRank = useMemo(() => {
if (chartData.length === 0) return 1
return Math.max(...chartData.map((d) => d.rank), 1)
}, [chartData])
return (
<ChartCardShell
title={t("analytics.rankingTrend")}
icon={BarChart3}
iconClassName="text-muted-foreground"
isEmpty={!hasData}
emptyTitle={t("trend.empty")}
emptyDescription={t("trendCard.emptyDescription")}
emptyClassName="h-64"
>
<div
className="rounded-md border bg-card p-4"
role="img"
aria-label={
hasData
? t("rankingTrend.ariaLabelNonEmpty", { count: chartData.length })
: t("rankingTrend.ariaLabelEmpty")
}
>
<TrendLineChart
data={chartData}
series={[
{
dataKey: "rank",
name: t("analytics.ranking"),
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
]}
yDomain={[Math.max(maxRank, 1), 1]}
yTickFormatter={(value: number) => `#${value}`}
heightClassName="h-[240px]"
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
yWidth={40}
tooltipClassName="w-[200px]"
/>
</div>
</ChartCardShell>
)
}

View File

@@ -0,0 +1,131 @@
import type { JSX } from "react"
import { School, TrendingUp, CheckCircle2, Award } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { Badge } from "@/shared/components/ui/badge"
import { formatNumber } from "@/shared/lib/utils"
import type { SchoolWideGradeSummary } from "../types"
interface SchoolWideSummaryCardProps {
summary: SchoolWideGradeSummary
}
export function SchoolWideSummaryCard({ summary }: SchoolWideSummaryCardProps): JSX.Element {
const { totals, grades } = summary
return (
<div className="space-y-4">
{/* 全校汇总统计卡片 */}
<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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{totals.gradeCount}</div>
<p className="text-xs text-muted-foreground">
{totals.classCount} · {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" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{formatNumber(totals.averageScore)}</div>
<p className="text-xs text-muted-foreground"> {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" />
</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>
</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" />
</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>
</CardContent>
</Card>
</div>
{/* 各年级对比表格 */}
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{grades.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground"></p>
) : (
<div className="rounded-md border">
<Table>
<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>
</TableRow>
</TableHeader>
<TableBody>
{grades.map((g) => (
<TableRow key={g.gradeId}>
<TableCell className="font-medium">
{g.schoolName}
<span className="text-muted-foreground"> / {g.gradeName}</span>
</TableCell>
<TableCell className="text-right tabular-nums">{g.classCount}</TableCell>
<TableCell className="text-right tabular-nums">{g.studentCount}</TableCell>
<TableCell className="text-right tabular-nums">{g.recordCount}</TableCell>
<TableCell className="text-right tabular-nums font-medium">
{formatNumber(g.averageScore)}
</TableCell>
<TableCell className="text-right tabular-nums">
<Badge
variant={g.passRate >= 80 ? "default" : g.passRate >= 60 ? "secondary" : "destructive"}
className="tabular-nums"
>
{formatNumber(g.passRate)}%
</Badge>
</TableCell>
<TableCell className="text-right tabular-nums">
<Badge
variant={g.excellentRate >= 30 ? "default" : "outline"}
className="tabular-nums"
>
{formatNumber(g.excellentRate)}%
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,41 @@
"use client"
import type { JSX } from "react"
import { cn } from "@/shared/lib/utils"
/**
* v4-P1-7: 成绩单元格组件,根据得分率着色。
*
* 着色规则(对标 PowerSchool / Canvas / 超星学习通):
* - 得分率 < 60%:红色(不及格)
* - 得分率 60% ~ 84%:黄色(及格但未达优秀)
* - 得分率 ≥ 85%:绿色(优秀)
* - fullScore <= 0不着色异常数据避免除零
*
* 使用语义化的 Tailwind 类名,避免动态拼接。
*/
export function ScoreCell({
score,
fullScore,
className,
}: {
score: number
fullScore: number
className?: string
}): JSX.Element {
const ratio = fullScore > 0 ? score / fullScore : 1
const isFail = ratio < 0.6
const isExcellent = ratio >= 0.85
const colorClass = isFail
? "text-red-600 font-semibold"
: isExcellent
? "text-green-600 font-semibold"
: "text-yellow-600 font-medium"
return (
<span className={cn("font-mono tabular-nums", colorClass, className)}>
{score} / {fullScore}
</span>
)
}

View File

@@ -1,4 +1,5 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { ChipNav } from "@/shared/components/ui/chip-nav"
@@ -9,12 +10,13 @@ interface StatsClassSelectorProps {
currentSubjectId: string
}
export function StatsClassSelector({
export async function StatsClassSelector({
classes,
subjects,
currentClassId,
currentSubjectId,
}: StatsClassSelectorProps): JSX.Element {
}: StatsClassSelectorProps): Promise<JSX.Element> {
const t = await getTranslations("grades")
return (
<div className="flex flex-wrap gap-2">
<ChipNav
@@ -33,7 +35,7 @@ export function StatsClassSelector({
? `/teacher/grades/stats?classId=${currentClassId}`
: `/teacher/grades/stats?classId=${currentClassId}&subjectId=${id}`
}
allOption={{ id: "all", label: "All Subjects" }}
allOption={{ id: "all", label: t("filters.allSubjects") }}
/>
</div>
</div>

View File

@@ -1,5 +1,6 @@
import { getTranslations } from "next-intl/server"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
@@ -10,23 +11,20 @@ import {
} from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { GraduationCap } from "lucide-react"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { GraduationCap, Trophy } from "lucide-react"
import type { StudentGradeSummary } from "../types"
import { GRADE_TYPE_VARIANT } from "../types"
const typeColors: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
exam: "default",
quiz: "secondary",
homework: "outline",
other: "outline",
}
export async function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }) {
const t = await getTranslations("grades")
export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary | null }) {
if (!summary) {
return (
<EmptyState
title="No data"
description="Student grade summary is not available."
title={t("summary.noDataTitle")}
description={t("summary.noDataDescription")}
icon={GraduationCap}
className="border-none shadow-none"
/>
@@ -35,10 +33,10 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Student</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.student")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentName}</p>
@@ -46,7 +44,7 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Average Score</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.averageScore")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.averageScore.toFixed(2)}</p>
@@ -54,7 +52,24 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Records</CardTitle>
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.classRank")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{summary.rank > 0 ? (
<span className="inline-flex items-center gap-1.5">
<Trophy className="h-5 w-5 text-amber-500" aria-hidden="true" />
{t("summary.rankValue", { rank: summary.rank })}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{t("summary.totalRecords")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.records.length}</p>
@@ -64,28 +79,29 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
{summary.records.length === 0 ? (
<EmptyState
title="No grades yet"
description="There are no grade records for this student."
title={t("summary.noGradesTitle")}
description={t("summary.noGradesDescription")}
icon={GraduationCap}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>Grade History</CardTitle>
<CardTitle>{t("summary.gradeHistory")}</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<caption className="sr-only">{t("summary.caption")}</caption>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Class</TableHead>
<TableHead>Subject</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>Type</TableHead>
<TableHead>Semester</TableHead>
<TableHead>Date</TableHead>
<TableHead>{t("list.columns.title")}</TableHead>
<TableHead>{t("list.columns.class")}</TableHead>
<TableHead>{t("list.columns.subject")}</TableHead>
<TableHead className="text-right">{t("list.columns.score")}</TableHead>
<TableHead>{t("list.columns.type")}</TableHead>
<TableHead>{t("list.columns.semester")}</TableHead>
<TableHead>{t("list.columns.date")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -98,9 +114,7 @@ export function StudentGradeSummary({ summary }: { summary: StudentGradeSummary
{r.score} / {r.fullScore}
</TableCell>
<TableCell>
<Badge variant={typeColors[r.type]} className="capitalize">
{r.type}
</Badge>
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
</TableCell>
<TableCell>S{r.semester}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.createdAt)}</TableCell>

View File

@@ -1,6 +1,7 @@
"use client"
import { Radar } from "lucide-react"
import { useTranslations } from "next-intl"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { ComparisonRadarChart } from "@/shared/components/charts/comparison-radar-chart"
@@ -11,6 +12,7 @@ interface SubjectComparisonChartProps {
}
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
const t = useTranslations("grades")
const isEmpty = !data || data.length === 0
const chartData = isEmpty
@@ -25,19 +27,19 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
return (
<ChartCardShell
title="Subject Comparison"
title={t("analytics.subjectComparison")}
description={
isEmpty
? "Compare performance across subjects for the selected class."
: "Average score and pass rate per subject (normalized to 0-100)."
? t("subjectComparison.descriptionEmpty")
: t("subjectComparison.descriptionNonEmpty")
}
icon={Radar}
isEmpty={isEmpty}
emptyTitle="No comparison data"
emptyDescription="Select a class to compare subject performance."
emptyTitle={t("subjectComparison.emptyTitle")}
emptyDescription={t("subjectComparison.emptyDescription")}
emptyClassName="h-60"
>
<div role="img" aria-label={`科目对比雷达图:${isEmpty ? "暂无数据" : `${data.length} 个科目的均分与及格率对比`}`}>
<div role="img" aria-label={isEmpty ? t("subjectComparison.ariaLabelEmpty") : t("subjectComparison.ariaLabelNonEmpty", { count: data.length })}>
<ComparisonRadarChart
data={chartData}
angleKey="subject"
@@ -48,13 +50,13 @@ export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
series={[
{
dataKey: "averageScore",
name: "Average",
name: t("chart.average"),
color: "hsl(var(--primary))",
fillOpacity: 0.4,
},
{
dataKey: "passRate",
name: "Pass Rate",
name: t("chart.passRate"),
color: "hsl(var(--chart-2))",
fillOpacity: 0.2,
},

View File

@@ -18,6 +18,7 @@
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"
@@ -37,14 +38,20 @@ interface WidgetBoundaryState {
hasError: boolean
}
interface WidgetErrorBoundaryProps {
title: string
fallbackDescription: string
retryLabel: string
loadFailedMessage: string
retryAriaLabel: string
children: ReactNode
}
class WidgetErrorBoundary extends Component<
Pick<
WidgetBoundaryProps,
"title" | "fallbackDescription" | "retryLabel" | "children"
>,
WidgetErrorBoundaryProps,
WidgetBoundaryState
> {
constructor(props: WidgetBoundaryProps) {
constructor(props: WidgetErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
@@ -59,7 +66,6 @@ class WidgetErrorBoundary extends Component<
render(): ReactNode {
if (this.state.hasError) {
const title = this.props.title ?? "区块"
return (
<div
role="alert"
@@ -69,19 +75,19 @@ class WidgetErrorBoundary extends Component<
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">
{title}
{this.props.loadFailedMessage}
</p>
<p className="text-xs text-muted-foreground">
{this.props.fallbackDescription ?? "请重试或刷新页面"}
{this.props.fallbackDescription}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={this.handleReset}
aria-label={`重试加载${title}`}
aria-label={this.props.retryAriaLabel}
>
{this.props.retryLabel ?? "重试"}
{this.props.retryLabel}
</Button>
</div>
)
@@ -93,15 +99,15 @@ class WidgetErrorBoundary extends Component<
function WidgetSkeleton({
height,
title,
loadingAriaLabel,
}: {
height: number
title?: string
loadingAriaLabel: string
}): ReactNode {
return (
<div
role="status"
aria-label={`${title ?? "区块"}加载中`}
aria-label={loadingAriaLabel}
aria-live="polite"
className="space-y-3 p-4"
style={{ minHeight: height }}
@@ -121,15 +127,25 @@ export function WidgetBoundary({
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={title}
fallbackDescription={fallbackDescription}
retryLabel={retryLabel}
title={effectiveTitle}
fallbackDescription={effectiveFallbackDescription}
retryLabel={effectiveRetryLabel}
loadFailedMessage={loadFailedMessage}
retryAriaLabel={retryAriaLabel}
>
<Suspense
fallback={
<WidgetSkeleton height={skeletonHeight} title={title} />
<WidgetSkeleton height={skeletonHeight} loadingAriaLabel={loadingAriaLabel} />
}
>
{children}