refactor(grades,attendance,error-book,elective): 组件化重构第 1 批 - 低风险模块

- grades: 拆分 batch-grade-entry (457→374) + grade-record-list (523→231),新增 4 个子组件
- attendance: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- error-book: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- elective: 迁移 stats-cards/filters 到 StatsGrid/FilterBar 底座
- 删除 12 个重复组件,新增 app 层 filter 组件就近放置
- tsc 零错误,lint 无新增错误
This commit is contained in:
SpecialX
2026-07-06 19:01:57 +08:00
parent 11ddc8ccbe
commit 19a05091d3
96 changed files with 8641 additions and 1088 deletions

View File

@@ -1,5 +1,6 @@
"use client"
import type { JSX } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useCallback } from "react"
import { useTranslations } from "next-intl"
@@ -12,19 +13,26 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Input } from "@/shared/components/ui/input"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import {
ATTENDANCE_STATUS_OPTIONS,
ATTENDANCE_STATUS_LABEL_KEYS,
} from "@/shared/constants/attendance-status"
import { ATTENDANCE_STATUS_OPTIONS, ATTENDANCE_STATUS_LABEL_KEYS } from "../constants"
type Option = { id: string; name: string }
interface AttendanceFiltersProps {
classes: Option[]
interface AdminAttendanceFiltersProps {
classes: Array<{ id: string; name: string }>
}
export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
/**
* FilterBar + Select
*
* modules/attendance/components/attendance-filters.tsx
* shared wrapper
*/
export function AdminAttendanceFilters({ classes }: AdminAttendanceFiltersProps): JSX.Element {
const t = useTranslations("attendance")
const router = useRouter()
const searchParams = useSearchParams()
const t = useTranslations("attendance")
const updateParam = useCallback(
(key: string, value: string) => {
@@ -42,13 +50,22 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
const classId = searchParams.get("classId") ?? "all"
const status = searchParams.get("status") ?? "all"
const date = searchParams.get("date") ?? ""
const hasFilters = classId !== "all" || status !== "all" || date !== ""
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-3">
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
router.push("/admin/attendance")
}}
>
<div className="grid gap-2">
<Label className="text-xs">{t("filters.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" aria-label={t("filters.class")}>
<SelectTrigger id="filter-class" className="h-9" aria-label={t("filters.class")}>
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
@@ -63,9 +80,11 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
</div>
<div className="grid gap-2">
<Label className="text-xs">{t("filters.status")}</Label>
<Label htmlFor="filter-status" className="text-xs">
{t("filters.status")}
</Label>
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger className="h-9" aria-label={t("filters.status")}>
<SelectTrigger id="filter-status" className="h-9" aria-label={t("filters.status")}>
<SelectValue placeholder={t("filters.allStatuses")} />
</SelectTrigger>
<SelectContent>
@@ -80,8 +99,11 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
</div>
<div className="grid gap-2">
<Label className="text-xs">{t("filters.date")}</Label>
<Label htmlFor="filter-date" className="text-xs">
{t("filters.date")}
</Label>
<Input
id="filter-date"
type="date"
value={date}
onChange={(e) => updateParam("date", e.target.value)}
@@ -89,6 +111,6 @@ export function AttendanceFilters({ classes }: AttendanceFiltersProps) {
aria-label={t("filters.date")}
/>
</div>
</div>
</FilterBar>
)
}

View File

@@ -1,10 +1,11 @@
import Link from "next/link"
import type { JSX } from "react"
import { BarChart3, ClipboardList } from "lucide-react"
import { BarChart3, CheckCircle2, ClipboardList, Clock, FileText, LogOut, School, Users, XCircle } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
@@ -13,8 +14,7 @@ import { getGrades } from "@/modules/school/data-access"
import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access"
import { getClassComparison } from "@/modules/attendance/data-access-stats"
import { getAttendanceGradeCorrelation } from "@/modules/attendance/data-access-correlation"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { AttendanceStatsCards } from "@/modules/attendance/components/attendance-stats-cards"
import { AdminAttendanceFilters } from "./admin-attendance-filters"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout"
import { ClassComparisonCard } from "@/modules/attendance/components/class-comparison-card"
@@ -82,6 +82,21 @@ export default async function AdminAttendancePage({
const gradeOptions = grades.map((g) => ({ id: g.id, name: g.name }))
const statsItems = [
{ label: t("stats.totalRecords"), value: stats.total, icon: FileText, color: "text-blue-500" },
{ label: t("stats.present"), value: stats.present, icon: CheckCircle2, color: "text-green-500" },
{ label: t("stats.absent"), value: stats.absent, icon: XCircle, color: "text-red-500" },
{ label: t("stats.late"), value: stats.late, icon: Clock, color: "text-yellow-500" },
{ label: t("stats.earlyLeave"), value: stats.earlyLeave, icon: LogOut, color: "text-orange-500" },
{ label: t("stats.schoolActivity"), value: stats.schoolActivity, icon: School, color: "text-cyan-500" },
{
label: t("stats.attendanceRate"),
value: `${stats.presentRate.toFixed(1)}%`,
icon: Users,
color: "text-primary",
},
]
const header = (
<div className="flex items-center justify-between space-y-2">
<div>
@@ -100,8 +115,8 @@ export default async function AdminAttendancePage({
return (
<AttendancePageLayout
header={header}
stats={<AttendanceStatsCards stats={stats} />}
filters={<AttendanceFilters classes={classOptions} />}
stats={<StatsGrid items={statsItems} columns={4} />}
filters={<AdminAttendanceFilters classes={classOptions} />}
>
<div className="space-y-6">
{result.items.length === 0 && !classId && !status && !date ? (

View File

@@ -1,12 +1,46 @@
import type { JSX } from "react"
import { BookOpen, Users, Gauge, Shuffle } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { getElectiveOverviewStats } from "@/modules/elective/data-access-stats"
import { ElectiveStatsCards } from "@/modules/elective/components/elective-stats-cards"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
/**
* 管理员概览统计卡片 - 异步 RSC loader
* 独立 Suspense 边界,可流式渲染
*/
export async function StatsCardsLoader(): Promise<JSX.Element> {
const stats = await getElectiveOverviewStats()
return <ElectiveStatsCards stats={stats} />
const [stats, t] = await Promise.all([
getElectiveOverviewStats(),
getTranslations("elective"),
])
const items = [
{
label: t("stats.totalCourses"),
value: stats.totalCourses,
icon: BookOpen,
color: "text-blue-500",
},
{
label: t("stats.totalEnrolled"),
value: stats.totalEnrolled,
icon: Users,
color: "text-green-500",
},
{
label: t("stats.avgUtilization"),
value: t("stats.utilizationRate", { rate: stats.avgUtilization }),
icon: Gauge,
color: "text-purple-500",
},
{
label: t("stats.pendingLottery"),
value: stats.pendingLottery,
icon: Shuffle,
color: "text-orange-500",
},
]
return <StatsGrid items={items} />
}

View File

@@ -1,13 +1,14 @@
import type { JSX } from "react"
import type { Metadata } from "next"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { BarChart3, BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
@@ -23,7 +24,6 @@ import {
} from "@/modules/error-book/data-access-analytics"
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
import { SubjectTabs } from "@/modules/error-book/components/subject-tabs"
import { AnalyticsStatsCards } from "@/modules/error-book/components/analytics-stats-cards"
import { SubjectDistributionChart } from "@/modules/error-book/components/subject-distribution-chart"
import { KnowledgePointWeaknessChart } from "@/modules/error-book/components/knowledge-point-weakness-chart"
import { ChapterWeaknessChart } from "@/modules/error-book/components/chapter-weakness-chart"
@@ -120,6 +120,15 @@ async function AdminErrorBookContent({
.sort((a, b) => b.totalCount - a.totalCount)
.slice(0, 50)
const analyticsAvg = (limitedStudentIds.length > 0 ? totalErrorItems / limitedStudentIds.length : 0).toFixed(1)
const analyticsItems = [
{ label: t("analyticsStats.coverage"), value: studentsWithErrorBook.length, icon: BookOpen, color: "text-blue-600 dark:text-blue-400", valueClassName: "text-blue-600 dark:text-blue-400", description: t("analyticsStats.coverageSub", { total: limitedStudentIds.length }) },
{ label: t("analyticsStats.totalErrors"), value: totalErrorItems, icon: TrendingUp, color: "text-rose-600 dark:text-rose-400", valueClassName: "text-rose-600 dark:text-rose-400", description: t("analyticsStats.totalErrorsSub", { avg: analyticsAvg }) },
{ label: t("analyticsStats.avgMastery"), value: `${Math.round(averageMasteryRate * 100)}%`, icon: CheckCircle2, color: "text-emerald-600 dark:text-emerald-400", valueClassName: "text-emerald-600 dark:text-emerald-400", description: averageMasteryRate >= 0.6 ? t("analyticsStats.avgMasteryGood") : t("analyticsStats.avgMasteryNeedImprove") },
{ label: t("analyticsStats.dueReview"), value: totalDueReview, icon: Clock, color: "text-amber-600 dark:text-amber-400", valueClassName: "text-amber-600 dark:text-amber-400", description: totalDueReview > 0 ? t("analyticsStats.dueReviewNeedAttention") : t("analyticsStats.dueReviewNone") },
{ label: t("analyticsStats.knowledgePoints"), value: knowledgePointCount, icon: Brain, color: "text-purple-600 dark:text-purple-400", valueClassName: "text-purple-600 dark:text-purple-400", description: knowledgePointCount > 5 ? t("analyticsStats.knowledgePointsWide") : t("analyticsStats.knowledgePointsFocused") },
]
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div>
@@ -139,14 +148,7 @@ async function AdminErrorBookContent({
{/* 统计卡片 */}
<WidgetBoundary title={t("admin.title")}>
<AnalyticsStatsCards
totalStudents={limitedStudentIds.length}
studentsWithErrorBook={studentsWithErrorBook.length}
totalErrorItems={totalErrorItems}
averageMasteryRate={averageMasteryRate}
dueReviewCount={totalDueReview}
knowledgePointCount={knowledgePointCount}
/>
<StatsGrid items={analyticsItems} columns={5} className="gap-3" />
</WidgetBoundary>
{/* 学科错题分布图(仅在"全部学科"视图下显示) */}

View File

@@ -1,5 +1,5 @@
import type { JSX } from "react"
import { Users } from "lucide-react"
import { BookX, Clock, GraduationCap, Repeat, Sparkles, Users } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
@@ -7,6 +7,7 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { formatNumber } from "@/shared/lib/utils"
@@ -16,7 +17,6 @@ import {
getTopWrongQuestionsByStudentIds,
getKnowledgePointWeakness,
} from "@/modules/error-book/data-access-analytics"
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
import { getTranslations } from "next-intl/server"
@@ -56,6 +56,11 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
getKnowledgePointWeakness(childrenIds, 5),
])
const singleStats = childStatsList[0]
const singleMasteredPercent = singleStats.totalCount > 0
? Math.round(singleStats.masteredRate * 100)
: 0
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
@@ -66,7 +71,16 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
{childrenIds.length === 1 ? (
// 单子女:直接展示统计卡片
<WidgetBoundary title={t("parent.title")} skeletonHeight={300}>
<ErrorBookStatsCards stats={childStatsList[0]} />
<StatsGrid
columns={5}
items={[
{ label: t("stats.total"), value: singleStats.totalCount, icon: BookX, description: t("stats.totalDesc") },
{ label: t("stats.new"), value: singleStats.newCount, icon: Sparkles, color: "text-blue-500", description: t("stats.newDesc") },
{ label: t("stats.learning"), value: singleStats.learningCount, icon: Repeat, color: "text-amber-500", description: t("stats.learningDesc") },
{ label: t("stats.mastered"), value: singleStats.masteredCount, icon: GraduationCap, color: "text-emerald-500", description: t("stats.masteredDesc", { rate: singleMasteredPercent }) },
{ label: t("stats.dueReview"), value: singleStats.dueReviewCount, icon: Clock, color: "text-rose-500", description: t("stats.dueReviewDesc"), highlight: singleStats.dueReviewCount > 0 },
]}
/>
</WidgetBoundary>
) : (
// 多子女:每个子女一张卡片

View File

@@ -1,7 +1,7 @@
import type { JSX } from "react"
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
import { StudentAvailableCoursesSection } from "@/modules/elective/components/student-selection-view"
import { ElectiveFilters } from "@/modules/elective/components/elective-filters"
import { ElectiveFilters } from "./elective-filters"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
/**

View File

@@ -36,7 +36,7 @@ export function ElectiveFilters() {
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={mode} onValueChange={(val) => setMode(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20" aria-label={t("fields.selectionMode")}>
<SelectTrigger className="w-40 bg-background border-muted-foreground/20" aria-label={t("fields.selectionMode")}>
<SelectValue placeholder={t("fields.selectionMode")} />
</SelectTrigger>
<SelectContent>

View File

@@ -1,16 +1,16 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getErrorBookItems, getErrorBookStats } from "@/modules/error-book/data-access"
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
import { ErrorBookFilters } from "@/modules/error-book/components/error-book-filters"
import type { ErrorBookStatusValue, ErrorBookSourceTypeValue } from "@/modules/error-book/types"
import {
AiClientProvider,
@@ -18,6 +18,7 @@ import {
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
import { StudentErrorBookListClient } from "./student-error-book-list-client"
import { AddErrorBookDialogWithQuestions } from "./add-error-book-dialog-with-questions"
import { ErrorBookFilters } from "./error-book-filters"
export const dynamic = "force-dynamic"
@@ -81,6 +82,10 @@ export default async function StudentErrorBookPage({
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
const t = await getTranslations("student")
const stats = await getErrorBookStats(ctx.userId)
const tStats = await getTranslations("errorBook")
const masteredPercent = stats.totalCount > 0
? Math.round(stats.masteredRate * 100)
: 0
const aiClientService = createCoreAiClientService()
return (
@@ -99,7 +104,16 @@ export default async function StudentErrorBookPage({
</div>
<WidgetBoundary title={t("errorBook.title")} skeletonHeight={120}>
<ErrorBookStatsCards stats={stats} />
<StatsGrid
columns={5}
items={[
{ label: tStats("stats.total"), value: stats.totalCount, icon: BookX, description: tStats("stats.totalDesc") },
{ label: tStats("stats.new"), value: stats.newCount, icon: Sparkles, color: "text-blue-500", description: tStats("stats.newDesc") },
{ label: tStats("stats.learning"), value: stats.learningCount, icon: Repeat, color: "text-amber-500", description: tStats("stats.learningDesc") },
{ label: tStats("stats.mastered"), value: stats.masteredCount, icon: GraduationCap, color: "text-emerald-500", description: tStats("stats.masteredDesc", { rate: masteredPercent }) },
{ label: tStats("stats.dueReview"), value: stats.dueReviewCount, icon: Clock, color: "text-rose-500", description: tStats("stats.dueReviewDesc"), highlight: stats.dueReviewCount > 0 },
]}
/>
</WidgetBoundary>
<div className="space-y-4">

View File

@@ -6,7 +6,7 @@ import { getRankingTrend, getClassAverageTrend } from "@/modules/grades/data-acc
import { getStudentPositionInClassDistribution, getStudentGrowthArchive } from "@/modules/grades/data-access-analytics"
import { getSubjectOptions } from "@/modules/school/data-access"
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
import { GradeFilters } from "@/modules/grades/components/grade-filters"
import { StudentGradeFilters } from "./student-grade-filters"
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
import { RankingTrendCard } from "@/modules/grades/components/ranking-trend-card"
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
@@ -82,7 +82,7 @@ export default async function StudentGradesPage({
<h2 className="text-2xl font-bold tracking-tight">{summary.studentName}</h2>
<p className="text-muted-foreground">{t("summary.recordCount", { count: summary.records.length })}</p>
</div>
<GradeFilters subjects={subjectOptions.map((s) => ({ id: s.id, name: s.name }))} />
<StudentGradeFilters subjects={subjectOptions.map((s) => ({ id: s.id, name: s.name }))} />
{filteredSummary.records.length > 0 && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<GradeTrendCard summary={filteredSummary} classAverageData={classAverageTrend} />

View File

@@ -13,15 +13,17 @@ import {
} from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import type { SelectOption } from "../types"
interface StudentGradeFiltersProps {
subjects?: Array<{ id: string; name: string }>
}
/**
*
* FilterBar / FilterSearchInput / Select
*
* v3-P2-1subjects prop 使 subjectId value
* subjects
* Phase 4.6 `modules/grades/components/grade-filters.tsx`
* wrapper shared
*/
export function GradeFilters({ subjects }: { subjects?: SelectOption[] }): JSX.Element {
export function StudentGradeFilters({ subjects }: StudentGradeFiltersProps): JSX.Element {
const t = useTranslations("grades")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [subject, setSubject] = useQueryState("subject", parseAsString.withDefault("all"))

View File

@@ -10,7 +10,7 @@ import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getAttendanceRecords } from "@/modules/attendance/data-access"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { TeacherAttendanceFilters } from "./teacher-attendance-filters"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout"
import type { AttendanceStatus } from "@/modules/attendance/types"
@@ -90,7 +90,7 @@ export default async function TeacherAttendancePage({
return (
<AttendancePageLayout
header={header}
filters={<AttendanceFilters classes={classOptions} />}
filters={<TeacherAttendanceFilters classes={classOptions} />}
>
{result.items.length === 0 && !hasFilters ? (
<EmptyState

View File

@@ -5,7 +5,7 @@ import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getClassAttendanceStats, getClassAttendanceWarnings, getAttendanceTrend } from "@/modules/attendance/data-access-stats"
import { AttendanceStatsCard } from "@/modules/attendance/components/attendance-stats-card"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { AttendanceStatsClassSelector } from "@/modules/attendance/components/attendance-stats-class-selector"
import { AttendanceWarningsCard } from "@/modules/attendance/components/attendance-warnings-card"
@@ -14,7 +14,17 @@ import type { TrendGranularity } from "@/modules/attendance/trend-compute"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { BarChart3, FileText } from "lucide-react"
import {
BarChart3,
CheckCircle2,
Clock,
FileText,
LogOut,
School,
TrendingUp,
Users,
XCircle,
} from "lucide-react"
export const dynamic = "force-dynamic"
@@ -91,7 +101,37 @@ export default async function AttendanceStatsPage({
{summary ? (
<>
<AttendanceStatsCard stats={summary.stats} />
{summary.stats.total === 0 ? (
<EmptyState
title={t("stats.noData")}
description={t("stats.noDataDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
) : (
<StatsGrid
items={[
{ label: t("stats.totalRecords"), value: summary.stats.total, icon: Users },
{ label: t("stats.present"), value: summary.stats.present, icon: CheckCircle2 },
{ label: t("stats.absent"), value: summary.stats.absent, icon: XCircle },
{ label: t("stats.late"), value: summary.stats.late, icon: Clock },
{ label: t("stats.earlyLeave"), value: summary.stats.earlyLeave, icon: LogOut },
{ label: t("stats.excused"), value: summary.stats.excused, icon: FileText },
{ label: t("stats.schoolActivity"), value: summary.stats.schoolActivity, icon: School },
{
label: t("stats.attendanceRate"),
value: `${summary.stats.presentRate.toFixed(1)}%`,
icon: TrendingUp,
},
{
label: t("stats.lateRate"),
value: `${summary.stats.lateRate.toFixed(1)}%`,
icon: Clock,
},
]}
columns={4}
/>
)}
<AttendanceWarningsCard summary={warnings} />
<AttendanceTrendChart summary={trend} granularity={granularity} />
<div>

View File

@@ -0,0 +1,116 @@
"use client"
import type { JSX } from "react"
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"
import { Input } from "@/shared/components/ui/input"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import {
ATTENDANCE_STATUS_OPTIONS,
ATTENDANCE_STATUS_LABEL_KEYS,
} from "@/shared/constants/attendance-status"
interface TeacherAttendanceFiltersProps {
classes: Array<{ id: string; name: string }>
}
/**
* 教师端考勤筛选器(基于共享底座 FilterBar + Select
*
* 组件化重构:从 modules/attendance/components/attendance-filters.tsx 内联至调用方目录,
* 直接复用 shared 底座组件,消除模块级 wrapper。
*/
export function TeacherAttendanceFilters({ classes }: TeacherAttendanceFiltersProps): JSX.Element {
const t = useTranslations("attendance")
const router = useRouter()
const searchParams = useSearchParams()
const updateParam = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value && value !== "all") {
params.set(key, value)
} else {
params.delete(key)
}
router.push(`?${params.toString()}`)
},
[router, searchParams]
)
const classId = searchParams.get("classId") ?? "all"
const status = searchParams.get("status") ?? "all"
const date = searchParams.get("date") ?? ""
const hasFilters = classId !== "all" || status !== "all" || date !== ""
return (
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
router.push("/teacher/attendance")
}}
>
<div className="grid gap-2">
<Label htmlFor="filter-class" className="text-xs">
{t("filters.class")}
</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
<SelectTrigger id="filter-class" className="h-9" aria-label={t("filters.class")}>
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allClasses")}</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="filter-status" className="text-xs">
{t("filters.status")}
</Label>
<Select value={status} onValueChange={(v) => updateParam("status", v)}>
<SelectTrigger id="filter-status" className="h-9" aria-label={t("filters.status")}>
<SelectValue placeholder={t("filters.allStatuses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filters.allStatuses")}</SelectItem>
{ATTENDANCE_STATUS_OPTIONS.map((s) => (
<SelectItem key={s} value={s}>
{t(ATTENDANCE_STATUS_LABEL_KEYS[s])}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="filter-date" className="text-xs">
{t("filters.date")}
</Label>
<Input
id="filter-date"
type="date"
value={date}
onChange={(e) => updateParam("date", e.target.value)}
className="h-9"
aria-label={t("filters.date")}
/>
</div>
</FilterBar>
)
}

View File

@@ -1,12 +1,13 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { BarChart3, BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getStudentIdsByClassIds, getClassIdsByGradeIds } from "@/modules/classes/data-access"
@@ -23,7 +24,6 @@ import {
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
import { SubjectTabs } from "@/modules/error-book/components/subject-tabs"
import { ClassFilter } from "@/modules/error-book/components/class-filter"
import { AnalyticsStatsCards } from "@/modules/error-book/components/analytics-stats-cards"
import { ClassErrorBarChart } from "@/modules/error-book/components/class-error-bar-chart"
import { KnowledgePointWeaknessChart } from "@/modules/error-book/components/knowledge-point-weakness-chart"
import { ChapterWeaknessChart } from "@/modules/error-book/components/chapter-weakness-chart"
@@ -128,6 +128,15 @@ async function TeacherErrorBookContent({
// 按错题数降序排列
const sortedSummaries = [...summaries].sort((a, b) => b.totalCount - a.totalCount)
const analyticsAvg = (queryStudentIds.length > 0 ? totalErrorItems / queryStudentIds.length : 0).toFixed(1)
const analyticsItems = [
{ label: t("analyticsStats.coverage"), value: studentsWithErrorBook.length, icon: BookOpen, color: "text-blue-600 dark:text-blue-400", valueClassName: "text-blue-600 dark:text-blue-400", description: t("analyticsStats.coverageSub", { total: queryStudentIds.length }) },
{ label: t("analyticsStats.totalErrors"), value: totalErrorItems, icon: TrendingUp, color: "text-rose-600 dark:text-rose-400", valueClassName: "text-rose-600 dark:text-rose-400", description: t("analyticsStats.totalErrorsSub", { avg: analyticsAvg }) },
{ label: t("analyticsStats.avgMastery"), value: `${Math.round(averageMasteryRate * 100)}%`, icon: CheckCircle2, color: "text-emerald-600 dark:text-emerald-400", valueClassName: "text-emerald-600 dark:text-emerald-400", description: averageMasteryRate >= 0.6 ? t("analyticsStats.avgMasteryGood") : t("analyticsStats.avgMasteryNeedImprove") },
{ label: t("analyticsStats.dueReview"), value: totalDueReview, icon: Clock, color: "text-amber-600 dark:text-amber-400", valueClassName: "text-amber-600 dark:text-amber-400", description: totalDueReview > 0 ? t("analyticsStats.dueReviewNeedAttention") : t("analyticsStats.dueReviewNone") },
{ label: t("analyticsStats.knowledgePoints"), value: knowledgePointCount, icon: Brain, color: "text-purple-600 dark:text-purple-400", valueClassName: "text-purple-600 dark:text-purple-400", description: knowledgePointCount > 5 ? t("analyticsStats.knowledgePointsWide") : t("analyticsStats.knowledgePointsFocused") },
]
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div>
@@ -159,14 +168,7 @@ async function TeacherErrorBookContent({
{/* 统计卡片 */}
<WidgetBoundary title={t("teacher.title")} skeletonHeight={120}>
<AnalyticsStatsCards
totalStudents={queryStudentIds.length}
studentsWithErrorBook={studentsWithErrorBook.length}
totalErrorItems={totalErrorItems}
averageMasteryRate={averageMasteryRate}
dueReviewCount={totalDueReview}
knowledgePointCount={knowledgePointCount}
/>
<StatsGrid items={analyticsItems} columns={5} className="gap-3" />
</WidgetBoundary>
{/* 班级错题对比图(仅在"全部班级"视图下显示) */}

View File

@@ -2,8 +2,9 @@ import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { ChipNav } from "@/shared/components/ui/chip-nav"
import { FilterBar } from "@/shared/components/ui/filter-bar"
interface AnalyticsFiltersProps {
interface AnalyticsGradeFiltersProps {
classes: Array<{ id: string; name: string }>
grades: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
@@ -15,7 +16,17 @@ interface AnalyticsFiltersProps {
currentSemester: string
}
export async function AnalyticsFilters({
/**
* FilterBar + ChipNav
*
* Phase 4.6 `modules/grades/components/analytics-filters.tsx`
* wrapper shared
*
* chip ////
* ChipNav buildHref + Link URL client state
* 使 ChipNav Select FilterBar
*/
export async function AnalyticsGradeFilters({
classes,
grades,
subjects,
@@ -25,7 +36,7 @@ export async function AnalyticsFilters({
currentGradeId,
currentExamId,
currentSemester,
}: AnalyticsFiltersProps): Promise<JSX.Element> {
}: AnalyticsGradeFiltersProps): Promise<JSX.Element> {
const t = await getTranslations("grades")
const buildHref = (overrides: {
classId?: string
@@ -53,12 +64,10 @@ export async 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)
@@ -79,8 +88,8 @@ export async function AnalyticsFilters({
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">
<FilterBar layout="wrap" gapClassName="gap-4">
<div className="space-y-2 md:flex-1">
<div className="text-xs font-medium text-muted-foreground">{t("analytics.class")}</div>
<ChipNav
options={classes}
@@ -91,7 +100,7 @@ export async function AnalyticsFilters({
/>
</div>
<div className="space-y-2">
<div className="space-y-2 md:flex-1">
<div className="text-xs font-medium text-muted-foreground">{t("analytics.subject")}</div>
<ChipNav
options={subjects}
@@ -103,7 +112,7 @@ export async function AnalyticsFilters({
/>
</div>
<div className="space-y-2">
<div className="space-y-2 md:flex-1">
<div className="text-xs font-medium text-muted-foreground">
{t("analytics.classComparisonLabel")}
</div>
@@ -115,9 +124,9 @@ export async function AnalyticsFilters({
className="gap-1.5"
/>
</div>
</div>
</FilterBar>
{/* 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>

View File

@@ -23,7 +23,7 @@ import { GradeTrendChart } from "@/modules/grades/components/grade-trend-chart"
import { ClassComparisonChart } from "@/modules/grades/components/class-comparison-chart"
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
import { AnalyticsFilters } from "@/modules/grades/components/analytics-filters"
import { AnalyticsGradeFilters } from "./analytics-grade-filters"
import { KnowledgePointMasteryChart } from "@/modules/grades/components/knowledge-point-mastery-chart"
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
@@ -142,7 +142,7 @@ export default async function GradeAnalyticsPage({
</Button>
</div>
<AnalyticsFilters
<AnalyticsGradeFilters
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}

View File

@@ -12,11 +12,11 @@ import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherClasses } from "@/modules/classes/data-access"
import { getGradeRecords } from "@/modules/grades/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { GradeQueryFilters } from "@/modules/grades/components/grade-query-filters"
import { GradeRecordList } from "@/modules/grades/components/grade-record-list"
import { ExportButton } from "@/modules/grades/components/export-button"
import { ExcelImportDialog } from "@/modules/grades/components/excel-import-dialog"
import { isGradeType, isSemester } from "@/modules/grades/lib/type-guards"
import { TeacherGradeQueryFilters } from "./teacher-grade-query-filters"
export const dynamic = "force-dynamic"
@@ -104,7 +104,7 @@ export default async function TeacherGradesPage({
</div>
</div>
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
<TeacherGradeQueryFilters classes={classOptions} subjects={subjectOptions} />
{total === 0 && !hasFilters ? (
<EmptyState

View File

@@ -6,15 +6,20 @@ 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"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import type { SelectOption } from "../types"
interface GradeQueryFiltersProps {
classes: SelectOption[]
subjects: SelectOption[]
interface TeacherGradeQueryFiltersProps {
classes: Array<{ id: string; name: string }>
subjects: Array<{ id: string; name: string }>
}
export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps): JSX.Element {
/**
* FilterBar + Select
*
* Phase 4.6 `modules/grades/components/grade-query-filters.tsx`
* wrapper shared
*/
export function TeacherGradeQueryFilters({ classes, subjects }: TeacherGradeQueryFiltersProps): JSX.Element {
const t = useTranslations("grades")
const router = useRouter()
const searchParams = useSearchParams()
@@ -37,8 +42,18 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
const type = searchParams.get("type") ?? "all"
const semester = searchParams.get("semester") ?? "all"
const hasFilters = Boolean(
(classId !== "all") || (subjectId !== "all") || (type !== "all") || (semester !== "all")
)
return (
<div className="grid grid-cols-1 gap-4 rounded-lg border bg-card p-4 md:grid-cols-4">
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
router.push("/teacher/grades")
}}
>
<div className="grid gap-2">
<Label htmlFor="filter-class" className="text-xs">{t("filters.class")}</Label>
<Select value={classId} onValueChange={(v) => updateParam("classId", v)}>
@@ -102,6 +117,6 @@ export function GradeQueryFilters({ classes, subjects }: GradeQueryFiltersProps)
</SelectContent>
</Select>
</div>
</div>
</FilterBar>
)
}

View File

@@ -1,88 +0,0 @@
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatItem } from "@/shared/components/ui/stat-item"
import {
Users,
CheckCircle2,
XCircle,
Clock,
LogOut,
FileText,
School,
TrendingUp,
BarChart3,
} from "lucide-react"
import type { AttendanceStats } from "../types"
export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) {
const t = useTranslations("attendance")
if (!stats || stats.total === 0) {
return (
<EmptyState
title={t("stats.noData")}
description={t("stats.noDataDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
)
}
return (
<Card>
<CardHeader>
<CardTitle>{t("title.teacherStats")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label={t("stats.totalRecords")}
value={stats.total}
icon={<Users className="h-4 w-4" />}
/>
<StatItem
label={t("stats.present")}
value={stats.present}
icon={<CheckCircle2 className="h-4 w-4" />}
/>
<StatItem
label={t("stats.absent")}
value={stats.absent}
icon={<XCircle className="h-4 w-4" />}
/>
<StatItem
label={t("stats.late")}
value={stats.late}
icon={<Clock className="h-4 w-4" />}
/>
<StatItem
label={t("stats.earlyLeave")}
value={stats.earlyLeave}
icon={<LogOut className="h-4 w-4" />}
/>
<StatItem
label={t("stats.excused")}
value={stats.excused}
icon={<FileText className="h-4 w-4" />}
/>
<StatItem
label={t("stats.schoolActivity")}
value={stats.schoolActivity}
icon={<School className="h-4 w-4" />}
/>
<StatItem
label={t("stats.attendanceRate")}
value={`${stats.presentRate.toFixed(1)}%`}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatItem
label={t("stats.lateRate")}
value={`${stats.lateRate.toFixed(1)}%`}
icon={<Clock className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,83 +0,0 @@
import { Users, CheckCircle2, XCircle, Clock, LogOut, FileText, School } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import type { AttendanceStats } from "../types"
interface AttendanceStatsCardsProps {
stats: AttendanceStats
}
export function AttendanceStatsCards({ stats }: AttendanceStatsCardsProps) {
const t = useTranslations("attendance")
const cards = [
{
title: t("stats.totalRecords"),
value: stats.total,
icon: FileText,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
title: t("stats.present"),
value: stats.present,
icon: CheckCircle2,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
title: t("stats.absent"),
value: stats.absent,
icon: XCircle,
color: "text-red-500",
bgColor: "bg-red-500/10",
},
{
title: t("stats.late"),
value: stats.late,
icon: Clock,
color: "text-yellow-500",
bgColor: "bg-yellow-500/10",
},
{
title: t("stats.earlyLeave"),
value: stats.earlyLeave,
icon: LogOut,
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
{
title: t("stats.schoolActivity"),
value: stats.schoolActivity,
icon: School,
color: "text-cyan-500",
bgColor: "bg-cyan-500/10",
},
{
title: t("stats.attendanceRate"),
value: `${stats.presentRate.toFixed(1)}%`,
icon: Users,
color: "text-primary",
bgColor: "bg-primary/10",
},
]
return (
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
{cards.map((card) => (
<Card key={card.title} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md", card.bgColor)}>
<card.icon className={cn("h-4 w-4", card.color)} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{card.value}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -10,9 +10,20 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { CalendarCheck } from "lucide-react"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import {
BarChart3,
CalendarCheck,
CheckCircle2,
Clock,
FileText,
LogOut,
School,
TrendingUp,
Users,
XCircle,
} from "lucide-react"
import { AttendanceStatsCard } from "./attendance-stats-card"
import {
ATTENDANCE_STATUS_BADGE_VARIANTS,
ATTENDANCE_STATUS_LABEL_KEYS,
@@ -62,7 +73,37 @@ export function StudentAttendanceView({
</Card>
</div>
<AttendanceStatsCard stats={summary.stats} />
{summary.stats.total === 0 ? (
<EmptyState
title={t("stats.noData")}
description={t("stats.noDataDescription")}
icon={BarChart3}
className="border-none shadow-none"
/>
) : (
<StatsGrid
items={[
{ label: t("stats.totalRecords"), value: summary.stats.total, icon: Users },
{ label: t("stats.present"), value: summary.stats.present, icon: CheckCircle2 },
{ label: t("stats.absent"), value: summary.stats.absent, icon: XCircle },
{ label: t("stats.late"), value: summary.stats.late, icon: Clock },
{ label: t("stats.earlyLeave"), value: summary.stats.earlyLeave, icon: LogOut },
{ label: t("stats.excused"), value: summary.stats.excused, icon: FileText },
{ label: t("stats.schoolActivity"), value: summary.stats.schoolActivity, icon: School },
{
label: t("stats.attendanceRate"),
value: `${summary.stats.presentRate.toFixed(1)}%`,
icon: TrendingUp,
},
{
label: t("stats.lateRate"),
value: `${summary.stats.lateRate.toFixed(1)}%`,
icon: Clock,
},
]}
columns={4}
/>
)}
{summary.recentRecords.length === 0 ? (
<EmptyState

View File

@@ -1,71 +0,0 @@
import { BookOpen, Users, Gauge, Shuffle } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import type { ElectiveOverviewStats } from "../data-access-stats"
/**
* 选课模块管理员概览统计卡片网格。
*
* 复用模式:与 attendance-stats-cards 一致的 4 卡片网格。
* 设计原则:
* - 通过 props 注入数据,不直接调用 data-access便于测试与复用
* - 使用 i18n key 解析标题value 由父组件传入已格式化的数据
*/
export function ElectiveStatsCards({ stats }: { stats: ElectiveOverviewStats }) {
const t = useTranslations("elective")
const cards = [
{
title: t("stats.totalCourses"),
value: stats.totalCourses,
icon: BookOpen,
color: "text-blue-500",
bgColor: "bg-blue-500/10",
},
{
title: t("stats.totalEnrolled"),
value: stats.totalEnrolled,
icon: Users,
color: "text-green-500",
bgColor: "bg-green-500/10",
},
{
title: t("stats.avgUtilization"),
value: t("stats.utilizationRate", { rate: stats.avgUtilization }),
icon: Gauge,
color: "text-purple-500",
bgColor: "bg-purple-500/10",
},
{
title: t("stats.pendingLottery"),
value: stats.pendingLottery,
icon: Shuffle,
color: "text-orange-500",
bgColor: "bg-orange-500/10",
},
]
return (
<div
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
role="region"
aria-label={t("stats.totalCourses")}
>
{cards.map((card) => (
<Card key={card.title} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={cn("flex h-8 w-8 items-center justify-center rounded-md", card.bgColor)}>
<card.icon className={cn("h-4 w-4", card.color)} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{card.value}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -1,109 +0,0 @@
"use client"
import { BookOpen, Brain, CheckCircle2, Clock, TrendingUp } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
interface AnalyticsStatsCardsProps {
totalStudents: number
studentsWithErrorBook: number
totalErrorItems: number
averageMasteryRate: number
dueReviewCount: number
/** 涉及的知识点数 */
knowledgePointCount?: number
className?: string
}
/**
* 错题分析统计卡片(教师/管理员视图)
* 5 个卡片:覆盖学生/错题总数/平均掌握率/待复习/知识点数
*/
export function AnalyticsStatsCards({
totalStudents,
studentsWithErrorBook,
totalErrorItems,
averageMasteryRate,
dueReviewCount,
knowledgePointCount,
className,
}: AnalyticsStatsCardsProps) {
const t = useTranslations("errorBook")
const avg = (totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)
const cards = [
{
label: t("analyticsStats.coverage"),
value: studentsWithErrorBook,
sub: t("analyticsStats.coverageSub", { total: totalStudents }),
icon: BookOpen,
color: "text-blue-600 dark:text-blue-400",
bg: "bg-blue-50 dark:bg-blue-950/30",
},
{
label: t("analyticsStats.totalErrors"),
value: totalErrorItems,
sub: t("analyticsStats.totalErrorsSub", { avg }),
icon: TrendingUp,
color: "text-rose-600 dark:text-rose-400",
bg: "bg-rose-50 dark:bg-rose-950/30",
},
{
label: t("analyticsStats.avgMastery"),
value: `${Math.round(averageMasteryRate * 100)}%`,
sub: averageMasteryRate >= 0.6
? t("analyticsStats.avgMasteryGood")
: t("analyticsStats.avgMasteryNeedImprove"),
icon: CheckCircle2,
color: "text-emerald-600 dark:text-emerald-400",
bg: "bg-emerald-50 dark:bg-emerald-950/30",
},
{
label: t("analyticsStats.dueReview"),
value: dueReviewCount,
sub: dueReviewCount > 0
? t("analyticsStats.dueReviewNeedAttention")
: t("analyticsStats.dueReviewNone"),
icon: Clock,
color: "text-amber-600 dark:text-amber-400",
bg: "bg-amber-50 dark:bg-amber-950/30",
},
{
label: t("analyticsStats.knowledgePoints"),
value: knowledgePointCount ?? 0,
sub: knowledgePointCount && knowledgePointCount > 5
? t("analyticsStats.knowledgePointsWide")
: t("analyticsStats.knowledgePointsFocused"),
icon: Brain,
color: "text-purple-600 dark:text-purple-400",
bg: "bg-purple-50 dark:bg-purple-950/30",
},
]
return (
<div className={cn("grid gap-3 sm:grid-cols-2 lg:grid-cols-5", className)}>
{cards.map((card) => {
const Icon = card.icon
return (
<Card key={card.label} className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="text-xs text-muted-foreground">{card.label}</div>
<div className={cn("mt-1 text-2xl font-bold", card.color)}>
{card.value}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">{card.sub}</div>
</div>
<div className={cn("rounded-lg p-2", card.bg)}>
<Icon className={cn("h-4 w-4", card.color)} />
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -1,64 +0,0 @@
"use client"
import { BookX, Clock, GraduationCap, Repeat, Sparkles } from "lucide-react"
import { useTranslations } from "next-intl"
import { StatCard } from "@/shared/components/ui/stat-card"
import type { ErrorBookStats } from "../types"
interface ErrorBookStatsCardsProps {
stats: ErrorBookStats
isLoading?: boolean
}
export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
const t = useTranslations("errorBook")
const masteredPercent = stats.totalCount > 0
? Math.round(stats.masteredRate * 100)
: 0
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<StatCard
title={t("stats.total")}
value={stats.totalCount}
icon={BookX}
description={t("stats.totalDesc")}
isLoading={isLoading}
/>
<StatCard
title={t("stats.new")}
value={stats.newCount}
icon={Sparkles}
color="text-blue-500"
description={t("stats.newDesc")}
isLoading={isLoading}
/>
<StatCard
title={t("stats.learning")}
value={stats.learningCount}
icon={Repeat}
color="text-amber-500"
description={t("stats.learningDesc")}
isLoading={isLoading}
/>
<StatCard
title={t("stats.mastered")}
value={stats.masteredCount}
icon={GraduationCap}
color="text-emerald-500"
description={t("stats.masteredDesc", { rate: masteredPercent })}
isLoading={isLoading}
/>
<StatCard
title={t("stats.dueReview")}
value={stats.dueReviewCount}
icon={Clock}
color="text-rose-500"
description={t("stats.dueReviewDesc")}
highlight={stats.dueReviewCount > 0}
isLoading={isLoading}
/>
</div>
)
}

View File

@@ -0,0 +1,124 @@
"use client"
import type { JSX } from "react"
import { Info } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import type { ExamOptionForEntry, ExamForGradeEntry } from "@/modules/exams/types"
type ClassOption = { id: string; name: string }
interface Props {
exams: ExamOptionForEntry[]
classes: ClassOption[]
exam: ExamForGradeEntry | null
defaultExamId?: string
defaultClassId?: string
onExamChange: (examId: string) => void
onClassChange: (classId: string) => void
}
/**
* 按试卷批量录入成绩的试卷/班级选择器(含未选试卷态)。
*
* Phase 4.6 重构:从 batch-grade-entry.tsx 拆分。
* - 未选试卷时显示单个试卷选择器 + 引导提示
* - 已选试卷时显示试卷/班级双列选择器 + 试卷信息徽章
*/
export function BatchGradeEntrySelectors({
exams,
classes,
exam,
defaultExamId,
defaultClassId,
onExamChange,
onClassChange,
}: Props): JSX.Element {
const t = useTranslations("grades")
// 未选试卷
if (!exam) {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.selectExam")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select onValueChange={onExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount} · {e.totalScore}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.guideSelectExam")}</span>
</div>
</CardContent>
</Card>
)
}
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.title")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select value={defaultExamId} onValueChange={onExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("batchByExam.selectClass")}</Label>
<Select value={defaultClassId} onValueChange={onClassChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectClassPlaceholder")} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 试卷信息 */}
<div className="md:col-span-2 flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">
{t("batchByExam.questionCount", { count: exam.questions.length })}
</Badge>
<Badge variant="secondary">
{t("batchByExam.fullScore", { score: exam.totalScore })}
</Badge>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,56 @@
"use client"
import type { JSX } from "react"
import { Info } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
interface Props {
isSubmitting: boolean
hasInvalidScores: boolean
onClear: () => void
onSubmit: () => void
}
/**
* 按试卷批量录入成绩的底部工具栏(清除/保存按钮 + 粘贴提示)。
*
* Phase 4.6 重构:从 batch-grade-entry.tsx 拆分。
*/
export function BatchGradeEntryToolbar({
isSubmitting,
hasInvalidScores,
onClear,
onSubmit,
}: Props): JSX.Element {
const t = useTranslations("grades")
return (
<>
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={onClear}
disabled={isSubmitting}
>
{t("batchByExam.clear")}
</Button>
<Button
type="button"
onClick={onSubmit}
disabled={isSubmitting || hasInvalidScores}
>
{isSubmitting ? t("batchByExam.saving") : t("batchByExam.saveAll")}
</Button>
</div>
{/* 提示 */}
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.pasteHint")}</span>
</div>
</>
)
}

View File

@@ -4,13 +4,8 @@ import { useState, useRef, useMemo, type JSX, type KeyboardEvent, type Clipboard
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Info } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Button } from "@/shared/components/ui/button"
import { Label } from "@/shared/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent } from "@/shared/components/ui/card"
import { safeActionCall } from "@/shared/lib/action-utils"
import { batchCreateGradeRecordsByExamAction } from "../actions"
@@ -18,6 +13,8 @@ import { useBatchGradeEntryUndo } from "../hooks/use-batch-grade-entry-undo"
import { BatchGradeEntryStats } from "./batch-grade-entry-stats"
import { BatchGradeEntryDialog, type PendingSwitch } from "./batch-grade-entry-dialog"
import { BatchGradeEntryTable } from "./batch-grade-entry-table"
import { BatchGradeEntrySelectors } from "./batch-grade-entry-selectors"
import { BatchGradeEntryToolbar } from "./batch-grade-entry-toolbar"
import type { ExamOptionForEntry, ExamForGradeEntry } from "@/modules/exams/types"
type Student = { id: string; name: string; email: string }
@@ -34,11 +31,13 @@ interface Props {
}
/**
* 按试卷批量录入成绩主组件。
* 按试卷批量录入成绩主组件(容器)
*
* P1-6 重构:将表格、统计栏、确认对话框拆分为独立子组件。
* Phase 4.6 重构:进一步将试卷/班级选择器(含未选态)与底部工具栏拆出,
* 容器仅负责状态编排与 Server Action 调用。
* - useBatchGradeEntryUndo Hook 封装撤销逻辑 + 类型守卫P1-7 修复 `as` 断言)
* - BatchGradeEntryTable / BatchGradeEntryStats / BatchGradeEntryDialog 为组合子组件
* - BatchGradeEntrySelectors / BatchGradeEntryTable / BatchGradeEntryStats / BatchGradeEntryToolbar / BatchGradeEntryDialog 为组合子组件
*/
export function BatchGradeEntryByExam({
exams,
@@ -301,87 +300,17 @@ export function BatchGradeEntryByExam({
}
}
// 未选试卷
if (!exam) {
return (
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.selectExam")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select onValueChange={handleExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount} · {e.totalScore}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.guideSelectExam")}</span>
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* 试卷 + 班级选择器 */}
<Card className="shadow-none">
<CardHeader>
<CardTitle className="text-base">{t("batchByExam.title")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>{t("batchByExam.selectExam")}</Label>
<Select value={defaultExamId} onValueChange={handleExamChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectExamPlaceholder")} />
</SelectTrigger>
<SelectContent>
{exams.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.title}{e.subjectName} · {e.questionCount}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t("batchByExam.selectClass")}</Label>
<Select value={defaultClassId} onValueChange={handleClassChange}>
<SelectTrigger>
<SelectValue placeholder={t("batchByExam.selectClassPlaceholder")} />
</SelectTrigger>
<SelectContent>
{filteredClasses.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 试卷信息 */}
<div className="md:col-span-2 flex flex-wrap gap-2 text-sm">
<Badge variant="secondary">
{t("batchByExam.questionCount", { count: exam.questions.length })}
</Badge>
<Badge variant="secondary">
{t("batchByExam.fullScore", { score: exam.totalScore })}
</Badge>
</div>
</CardContent>
</Card>
<BatchGradeEntrySelectors
exams={exams}
classes={filteredClasses}
exam={exam}
defaultExamId={defaultExamId}
defaultClassId={defaultClassId}
onExamChange={handleExamChange}
onClassChange={handleClassChange}
/>
{/* 未选班级 */}
{!defaultClassId ? (
@@ -390,6 +319,12 @@ export function BatchGradeEntryByExam({
{t("batchByExam.selectClassFirst")}
</CardContent>
</Card>
) : !exam ? (
<Card className="shadow-none">
<CardContent className="py-8 text-center text-muted-foreground">
{t("batchByExam.selectExam")}
</CardContent>
</Card>
) : students.length === 0 ? (
<Card className="shadow-none">
<CardContent className="py-8 text-center text-muted-foreground">
@@ -419,34 +354,17 @@ export function BatchGradeEntryByExam({
isScoreInvalid={isScoreInvalid}
/>
{/* 提交按钮 */}
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="outline"
onClick={() => setScores({})}
disabled={isSubmitting}
>
{t("batchByExam.clear")}
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={isSubmitting || hasInvalidScores}
>
{isSubmitting ? t("batchByExam.saving") : t("batchByExam.saveAll")}
</Button>
</div>
{/* 提示 */}
<div className="flex items-start gap-2 rounded-md bg-muted/50 p-3 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<span>{t("batchByExam.pasteHint")}</span>
</div>
{/* 底部工具栏:保存/清除按钮 + 粘贴提示 */}
<BatchGradeEntryToolbar
isSubmitting={isSubmitting}
hasInvalidScores={hasInvalidScores}
onClear={() => setScores({})}
onSubmit={handleSubmit}
/>
</>
)}
{/* 确认对话框 */}
{/* 切换试卷/班级确认对话框 */}
<BatchGradeEntryDialog
pendingSwitch={pendingSwitch}
onConfirm={confirmPendingSwitch}

View File

@@ -12,9 +12,9 @@ import {
TableRow,
} from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Trophy } from "lucide-react"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { Trophy, TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
import { GradeStatsCard } from "./grade-stats-card"
import type { ClassGradeStats, ClassRankingItem } from "../types"
interface ClassGradeReportProps {
@@ -36,7 +36,45 @@ export async function ClassGradeReport({ stats, ranking }: ClassGradeReportProps
</p>
</div>
</div>
<GradeStatsCard stats={stats.stats} />
{stats.stats.count === 0 ? (
<Card>
<CardHeader>
<CardTitle>{t("stats.title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t("stats.noData")}</p>
</CardContent>
</Card>
) : (
<StatsGrid
columns={4}
items={[
{ label: t("stats.average"), value: stats.stats.average.toFixed(2), icon: BarChart3 },
{ label: t("stats.median"), value: stats.stats.median.toFixed(2), icon: BarChart3 },
{ label: t("stats.max"), value: stats.stats.max.toFixed(2), icon: TrendingUp },
{ label: t("stats.min"), value: stats.stats.min.toFixed(2), icon: TrendingDown },
{
label: t("stats.stdDev"),
value: stats.stats.stdDev.toFixed(2),
icon: Target,
description: t("stats.stdDevHint"),
},
{
label: t("stats.passRate"),
value: `${stats.stats.passRate.toFixed(1)}%`,
icon: CheckCircle2,
description: t("stats.passRateHint"),
},
{
label: t("stats.excellentRate"),
value: `${stats.stats.excellentRate.toFixed(1)}%`,
icon: Award,
description: t("stats.excellentRateHint"),
},
{ label: t("stats.count"), value: stats.stats.count, icon: BarChart3 },
]}
/>
)}
</div>
) : (
<EmptyState

View File

@@ -0,0 +1,238 @@
"use client"
import type { JSX } from "react"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import type { GradeRecordListItem, GradeRecordType, GradeRecordSemester } from "../types"
import { isGradeType, isSemester } from "../lib/type-guards"
export type EditableFields = {
id: string
title: string
score: string
fullScore: string
type: GradeRecordType
semester: GradeRecordSemester
remark: string
}
interface DetailDialogsProps {
editTarget: EditableFields | null
deleteId: string | null
isBulkDeleteDialogOpen: boolean
isSaving: boolean
isDeleting: boolean
isBulkDeleting: boolean
bulkDeleteCount: number
onEditTargetChange: (target: EditableFields) => void
onCancelEdit: () => void
onSaveEdit: () => void
onCancelDelete: () => void
onConfirmDelete: () => void
onCloseBulkDelete: () => void
onConfirmBulkDelete: () => void
}
/**
* 成绩记录详情/编辑/删除对话框集合。
*
* Phase 4.6 重构:从 grade-record-list.tsx 拆分。
* 包含三个对话框:
* - 编辑对话框(标题/分数/满分/类型/学期/备注)
* - 单条删除确认对话框
* - 批量删除确认对话框
*/
export function GradeRecordDetail({
editTarget,
deleteId,
isBulkDeleteDialogOpen,
isSaving,
isDeleting,
isBulkDeleting,
bulkDeleteCount,
onEditTargetChange,
onCancelEdit,
onSaveEdit,
onCancelDelete,
onConfirmDelete,
onCloseBulkDelete,
onConfirmBulkDelete,
}: DetailDialogsProps): JSX.Element {
const t = useTranslations("grades")
return (
<>
{/* 编辑对话框 */}
<Dialog open={editTarget !== null} onOpenChange={(open) => !open && onCancelEdit()}>
<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) => onEditTargetChange({ ...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) => onEditTargetChange({ ...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) => onEditTargetChange({ ...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)) onEditTargetChange({ ...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)) onEditTargetChange({ ...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) => onEditTargetChange({ ...editTarget, remark: e.target.value })}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onCancelEdit} disabled={isSaving}>
{t("edit.cancel")}
</Button>
<Button onClick={onSaveEdit} disabled={isSaving}>
{isSaving ? t("edit.saving") : t("edit.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 单条删除确认对话框 */}
<Dialog open={deleteId !== null} onOpenChange={(open) => !open && onCancelDelete()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("delete.title")}</DialogTitle>
<DialogDescription>
{t("delete.confirmation")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancelDelete} disabled={isDeleting}>
{t("delete.cancel")}
</Button>
<Button variant="destructive" onClick={onConfirmDelete} disabled={isDeleting}>
{isDeleting ? t("delete.deleting") : t("delete.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* v3-P3-2: 批量删除确认对话框 */}
<Dialog open={isBulkDeleteDialogOpen} onOpenChange={(open) => !open && onCloseBulkDelete()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("list.bulkDelete")}</DialogTitle>
<DialogDescription>
{t("list.bulkDeleteConfirmation", { count: bulkDeleteCount })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={onCloseBulkDelete}
disabled={isBulkDeleting}
>
{t("delete.cancel")}
</Button>
<Button variant="destructive" onClick={onConfirmBulkDelete} disabled={isBulkDeleting}>
{isBulkDeleting ? t("delete.deleting") : t("delete.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
/** 把 GradeRecordListItem 转换为 EditableFields用于 startEdit */
export function toEditableFields(r: GradeRecordListItem): EditableFields {
return {
id: r.id,
title: r.title,
score: String(r.score),
fullScore: String(r.fullScore),
type: r.type,
semester: r.semester,
remark: r.remark ?? "",
}
}

View File

@@ -5,62 +5,34 @@ import { useState, useOptimistic, useTransition } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Trash2 } from "lucide-react"
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,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { formatDate } from "@/shared/lib/utils"
import { safeActionCall } from "@/shared/lib/action-utils"
import { Trash2, Pencil } from "lucide-react"
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
}
import type { GradeRecordListItem } from "../types"
import { GradeRecordRow } from "./grade-record-row"
import { GradeRecordDetail, toEditableFields, type EditableFields } from "./grade-record-detail"
/**
* 成绩记录列表容器。
*
* Phase 4.6 重构:
* - 行渲染(桌面表格 + 移动端卡片)拆分至 GradeRecordRow
* - 编辑/单条删除/批量删除对话框拆分至 GradeRecordDetail
* 容器只负责状态编排、Server Action 调用与组合。
*
* Phase 4.5:编辑保存改用 useOptimistic + useTransition在列表中即时显示乐观记录。
* v3-P3-2批量选择与删除。
*/
export function GradeRecordList({ records }: { records: GradeRecordListItem[] }): JSX.Element {
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)
// Phase 4.5: 编辑保存改用 useOptimistic + useTransition,在列表中即时显示乐观记录
// Phase 4.5: 编辑保存改用 useOptimistic + useTransition在列表中即时显示乐观记录
const [optimisticRecords, addOptimisticRecord] = useOptimistic<
GradeRecordListItem[],
GradeRecordListItem
@@ -74,7 +46,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false)
const [isBulkDeleting, setIsBulkDeleting] = useState(false)
const handleDelete = async () => {
const handleDelete = async (): Promise<void> => {
if (!deleteId) return
setIsDeleting(true)
const result = await safeActionCall(
@@ -144,22 +116,14 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
}
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 ?? "",
})
setEditTarget(toEditableFields(r))
}
const handleEditSave = (): void => {
if (!editTarget) return
const existing = records.find((r) => r.id === editTarget.id)
if (!existing) return
// Phase 4.5: 构造乐观记录,在 transition 内 addOptimistic + await action + refresh
// Phase 4.5: 构造乐观记录在 transition 内 addOptimistic + await action + refresh
const optimisticRecord: GradeRecordListItem = {
...existing,
title: editTarget.title,
@@ -189,7 +153,7 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
if (result?.success) {
toast.success(t("edit.success"))
setEditTarget(null)
// 同步数据源,让 useOptimistic 回滚到最新服务端值
// 同步数据源让 useOptimistic 回滚到最新服务端值
await router.refresh()
} else if (result) {
toast.error(result.message || t("edit.failed"))
@@ -235,289 +199,33 @@ export function GradeRecordList({ records }: { records: GradeRecordListItem[] })
</div>
</div>
)}
{/* v4-P1-10: 桌面端表格md 及以上显示) */}
<div className="hidden overflow-x-auto md:block">
<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>
</TableHeader>
<TableBody>
{optimisticRecords.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>{t(`semester.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>
{/* P3-8: 移动端卡片堆叠视图md 以下显示) */}
<div
className="block space-y-3 p-4 md:hidden"
role="list"
aria-label={t("list.caption")}
>
{optimisticRecords.map((r) => (
<div
key={r.id}
className="rounded-md border border-border/60 bg-background p-3 shadow-sm"
role="listitem"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => toggleRowSelection(r.id)}
aria-label={t("list.selectRow", { name: r.studentName })}
/>
<div>
<div className="font-medium">{r.studentName}</div>
<div className="text-xs text-muted-foreground">
{r.className} · {r.subjectName}
</div>
</div>
</div>
<div className="text-right">
<ScoreCell score={r.score} fullScore={r.fullScore} />
<div className="mt-1 flex items-center justify-end gap-1.5">
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
<span className="text-xs text-muted-foreground">
{t(`semester.s${r.semester}`)}
</span>
</div>
</div>
</div>
<div className="mt-2 border-t border-border/40 pt-2">
<div className="text-sm">{r.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{r.recorderName} · {formatDate(r.createdAt)}
</div>
</div>
<div className="mt-2 flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => startEdit(r)}
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Pencil className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("edit.title")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() => setDeleteId(r.id)}
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("delete.title")}
</Button>
</div>
</div>
))}
</div>
<GradeRecordRow
records={optimisticRecords}
selectedIds={selectedIds}
onToggleRow={toggleRowSelection}
onToggleSelectAll={toggleSelectAll}
onEdit={startEdit}
onDelete={setDeleteId}
/>
</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>{t("delete.title")}</DialogTitle>
<DialogDescription>
{t("delete.confirmation")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
{t("delete.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={isDeleting}>
{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>
</Dialog>
<GradeRecordDetail
editTarget={editTarget}
deleteId={deleteId}
isBulkDeleteDialogOpen={isBulkDeleteDialogOpen}
isSaving={isSaving}
isDeleting={isDeleting}
isBulkDeleting={isBulkDeleting}
bulkDeleteCount={selectedIds.size}
onEditTargetChange={setEditTarget}
onCancelEdit={() => setEditTarget(null)}
onSaveEdit={handleEditSave}
onCancelDelete={() => setDeleteId(null)}
onConfirmDelete={handleDelete}
onCloseBulkDelete={() => setIsBulkDeleteDialogOpen(false)}
onConfirmBulkDelete={handleBulkDelete}
/>
</>
)
}

View File

@@ -0,0 +1,199 @@
"use client"
import type { JSX } from "react"
import { useTranslations } from "next-intl"
import { Pencil, Trash2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { formatDate } from "@/shared/lib/utils"
import type { GradeRecordListItem } from "../types"
import { GRADE_TYPE_VARIANT } from "../types"
import { ScoreCell } from "./score-cell"
interface Props {
records: GradeRecordListItem[]
selectedIds: Set<string>
onToggleRow: (id: string) => void
onToggleSelectAll: () => void
onEdit: (record: GradeRecordListItem) => void
onDelete: (id: string) => void
}
/**
* 成绩记录行渲染(桌面表格 + 移动端卡片)。
*
* Phase 4.6 重构:从 grade-record-list.tsx 拆分。
* - 桌面端md 及以上的 Table 行
* - 移动端md 以下的卡片堆叠视图role=list/listitem 保证 a11y
* 包含 v3-P3-2 多选复选框 + 全选 + v4-P1-7 ScoreCell 颜色编码。
*/
export function GradeRecordRow({
records,
selectedIds,
onToggleRow,
onToggleSelectAll,
onEdit,
onDelete,
}: Props): JSX.Element {
const t = useTranslations("grades")
return (
<>
{/* v4-P1-10: 桌面端表格md 及以上显示) */}
<div className="hidden overflow-x-auto md:block">
<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={onToggleSelectAll}
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>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell>
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => onToggleRow(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>{t(`semester.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={() => onEdit(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={() => onDelete(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>
{/* P3-8: 移动端卡片堆叠视图md 以下显示) */}
<div
className="block space-y-3 p-4 md:hidden"
role="list"
aria-label={t("list.caption")}
>
{records.map((r) => (
<div
key={r.id}
className="rounded-md border border-border/60 bg-background p-3 shadow-sm"
role="listitem"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.has(r.id)}
onCheckedChange={() => onToggleRow(r.id)}
aria-label={t("list.selectRow", { name: r.studentName })}
/>
<div>
<div className="font-medium">{r.studentName}</div>
<div className="text-xs text-muted-foreground">
{r.className} · {r.subjectName}
</div>
</div>
</div>
<div className="text-right">
<ScoreCell score={r.score} fullScore={r.fullScore} />
<div className="mt-1 flex items-center justify-end gap-1.5">
<StatusBadge status={r.type} variantMap={GRADE_TYPE_VARIANT} />
<span className="text-xs text-muted-foreground">
{t(`semester.s${r.semester}`)}
</span>
</div>
</div>
</div>
<div className="mt-2 border-t border-border/40 pt-2">
<div className="text-sm">{r.title}</div>
<div className="mt-1 text-xs text-muted-foreground">
{r.recorderName} · {formatDate(r.createdAt)}
</div>
</div>
<div className="mt-2 flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => onEdit(r)}
aria-label={t("list.editAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Pencil className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("edit.title")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-destructive"
onClick={() => onDelete(r.id)}
aria-label={t("list.deleteAriaLabel", { studentName: r.studentName, subjectName: r.subjectName })}
>
<Trash2 className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
{t("delete.title")}
</Button>
</div>
</div>
))}
</div>
</>
)
}

View File

@@ -1,79 +0,0 @@
import type { JSX } from "react"
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 async function GradeStatsCard({ stats }: { stats: GradeStats | null }): Promise<JSX.Element> {
const t = await getTranslations("grades")
if (!stats || stats.count === 0) {
return (
<Card>
<CardHeader>
<CardTitle>{t("stats.title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{t("stats.noData")}</p>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle>{t("stats.title")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label={t("stats.average")}
value={stats.average.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label={t("stats.median")}
value={stats.median.toFixed(2)}
icon={<BarChart3 className="h-4 w-4" />}
/>
<StatItem
label={t("stats.max")}
value={stats.max.toFixed(2)}
icon={<TrendingUp className="h-4 w-4" />}
/>
<StatItem
label={t("stats.min")}
value={stats.min.toFixed(2)}
icon={<TrendingDown className="h-4 w-4" />}
/>
<StatItem
label={t("stats.stdDev")}
value={stats.stdDev.toFixed(2)}
icon={<Target className="h-4 w-4" />}
hint={t("stats.stdDevHint")}
/>
<StatItem
label={t("stats.passRate")}
value={`${stats.passRate.toFixed(1)}%`}
icon={<CheckCircle2 className="h-4 w-4" />}
hint={t("stats.passRateHint")}
/>
<StatItem
label={t("stats.excellentRate")}
value={`${stats.excellentRate.toFixed(1)}%`}
icon={<Award className="h-4 w-4" />}
hint={t("stats.excellentRateHint")}
/>
<StatItem
label={t("stats.count")}
value={stats.count}
icon={<BarChart3 className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
)
}

View File

@@ -7,7 +7,6 @@ import { cn } from "@/shared/lib/utils"
*
* 覆盖以下重复模式:
* - AttendanceStatsCard 内部 StatItem
* - GradeStatsCard 内部 StatItem
*
* 结构rounded-lg border bg-card 容器,含 label + icon + value + hint
*/

View File

@@ -9,7 +9,7 @@ import { StatCard } from "@/shared/components/ui/stat-card"
* 覆盖以下重复模式:
* - attendance-stats-cards / error-book-stats-cards / elective-stats-cards
* - practice-stats-cards / practice-overview-stats-cards
* - grade-stats-card / analytics-stats-cards
* - analytics-stats-cards
*/
interface StatItem {
/** 卡片标题 */
@@ -33,18 +33,19 @@ interface StatItem {
interface StatsGridProps {
/** 统计项列表 */
items: StatItem[]
/** 列数(默认响应式mobile=1, md=2, lg=4 */
columns?: 2 | 3 | 4
/** 列数(默认 4mobile=1, md=2, lg=N */
columns?: 2 | 3 | 4 | 5
/** 加载态 */
isLoading?: boolean
/** 容器额外类名 */
className?: string
}
const columnsClass: Record<2 | 3 | 4, string> = {
const columnsClass: Record<2 | 3 | 4 | 5, string> = {
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
5: "grid-cols-1 md:grid-cols-2 lg:grid-cols-5",
}
export function StatsGrid({