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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 学科错题分布图(仅在"全部学科"视图下显示) */}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
// 多子女:每个子女一张卡片
|
||||
|
||||
@@ -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"
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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-1:subjects 改为通过 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"))
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
{/* 班级错题对比图(仅在"全部班级"视图下显示) */}
|
||||
|
||||
@@ -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>
|
||||
@@ -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" }))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
124
src/modules/grades/components/batch-grade-entry-selectors.tsx
Normal file
124
src/modules/grades/components/batch-grade-entry-selectors.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
src/modules/grades/components/batch-grade-entry-toolbar.tsx
Normal file
56
src/modules/grades/components/batch-grade-entry-toolbar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
238
src/modules/grades/components/grade-record-detail.tsx
Normal file
238
src/modules/grades/components/grade-record-detail.tsx
Normal 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 ?? "",
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
199
src/modules/grades/components/grade-record-row.tsx
Normal file
199
src/modules/grades/components/grade-record-row.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import { cn } from "@/shared/lib/utils"
|
||||
*
|
||||
* 覆盖以下重复模式:
|
||||
* - AttendanceStatsCard 内部 StatItem
|
||||
* - GradeStatsCard 内部 StatItem
|
||||
*
|
||||
* 结构:rounded-lg border bg-card 容器,含 label + icon + value + hint
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
/** 列数(默认 4:mobile=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({
|
||||
|
||||
Reference in New Issue
Block a user