-
-
+
+
-
+
-
+
{t("analytics.classComparisonLabel")}
@@ -115,9 +124,9 @@ export async function AnalyticsFilters({
className="gap-1.5"
/>
-
+
- {/* v3-P2-7: 学期和考试筛选 */}
+ {/* 学期和考试筛选 */}
{t("analytics.semester")}
diff --git a/src/app/(dashboard)/teacher/grades/analytics/page.tsx b/src/app/(dashboard)/teacher/grades/analytics/page.tsx
index 6e070a9..0c8daef 100644
--- a/src/app/(dashboard)/teacher/grades/analytics/page.tsx
+++ b/src/app/(dashboard)/teacher/grades/analytics/page.tsx
@@ -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({
-
({ 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" }))}
diff --git a/src/app/(dashboard)/teacher/grades/page.tsx b/src/app/(dashboard)/teacher/grades/page.tsx
index 5de3ba3..a2dd013 100644
--- a/src/app/(dashboard)/teacher/grades/page.tsx
+++ b/src/app/(dashboard)/teacher/grades/page.tsx
@@ -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({
-
+
{total === 0 && !hasFilters ? (
+ 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 (
-
+
{
+ router.push("/teacher/grades")
+ }}
+ >
-
+
)
}
diff --git a/src/modules/attendance/components/attendance-stats-card.tsx b/src/modules/attendance/components/attendance-stats-card.tsx
deleted file mode 100644
index 245cbb7..0000000
--- a/src/modules/attendance/components/attendance-stats-card.tsx
+++ /dev/null
@@ -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 (
-
- )
- }
-
- return (
-
-
- {t("title.teacherStats")}
-
-
-
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
- }
- />
-
-
-
- )
-}
diff --git a/src/modules/attendance/components/attendance-stats-cards.tsx b/src/modules/attendance/components/attendance-stats-cards.tsx
deleted file mode 100644
index 8634858..0000000
--- a/src/modules/attendance/components/attendance-stats-cards.tsx
+++ /dev/null
@@ -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 (
-
- {cards.map((card) => (
-
-
- {card.title}
-
-
-
-
-
- {card.value}
-
-
- ))}
-
- )
-}
diff --git a/src/modules/attendance/components/student-attendance-view.tsx b/src/modules/attendance/components/student-attendance-view.tsx
index d8c7ae9..d806faf 100644
--- a/src/modules/attendance/components/student-attendance-view.tsx
+++ b/src/modules/attendance/components/student-attendance-view.tsx
@@ -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({
-
+ {summary.stats.total === 0 ? (
+
+ ) : (
+
+ )}
{summary.recentRecords.length === 0 ? (
- {cards.map((card) => (
-
-
- {card.title}
-
-
-
-
-
- {card.value}
-
-
- ))}
-
- )
-}
diff --git a/src/modules/error-book/components/analytics-stats-cards.tsx b/src/modules/error-book/components/analytics-stats-cards.tsx
deleted file mode 100644
index 9ad2a71..0000000
--- a/src/modules/error-book/components/analytics-stats-cards.tsx
+++ /dev/null
@@ -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 (
-
- {cards.map((card) => {
- const Icon = card.icon
- return (
-
-
-
-
-
{card.label}
-
- {card.value}
-
-
{card.sub}
-
-
-
-
-
-
-
- )
- })}
-
- )
-}
diff --git a/src/modules/error-book/components/error-book-stats-cards.tsx b/src/modules/error-book/components/error-book-stats-cards.tsx
deleted file mode 100644
index 85507c2..0000000
--- a/src/modules/error-book/components/error-book-stats-cards.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
-
- 0}
- isLoading={isLoading}
- />
-
- )
-}
diff --git a/src/modules/grades/components/batch-grade-entry-selectors.tsx b/src/modules/grades/components/batch-grade-entry-selectors.tsx
new file mode 100644
index 0000000..3f36123
--- /dev/null
+++ b/src/modules/grades/components/batch-grade-entry-selectors.tsx
@@ -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 (
+
+
+ {t("batchByExam.selectExam")}
+
+
+
+
+
+
+
+
+ {t("batchByExam.guideSelectExam")}
+
+
+
+ )
+ }
+
+ return (
+
+
+ {t("batchByExam.title")}
+
+
+
+
+
+
+
+
+
+
+ {/* 试卷信息 */}
+
+
+ {t("batchByExam.questionCount", { count: exam.questions.length })}
+
+
+ {t("batchByExam.fullScore", { score: exam.totalScore })}
+
+
+
+
+ )
+}
diff --git a/src/modules/grades/components/batch-grade-entry-toolbar.tsx b/src/modules/grades/components/batch-grade-entry-toolbar.tsx
new file mode 100644
index 0000000..fb52e70
--- /dev/null
+++ b/src/modules/grades/components/batch-grade-entry-toolbar.tsx
@@ -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 (
+ <>
+
+
+
+
+
+ {/* 提示 */}
+
+
+ {t("batchByExam.pasteHint")}
+
+ >
+ )
+}
diff --git a/src/modules/grades/components/batch-grade-entry.tsx b/src/modules/grades/components/batch-grade-entry.tsx
index e8bd06f..d6a1626 100644
--- a/src/modules/grades/components/batch-grade-entry.tsx
+++ b/src/modules/grades/components/batch-grade-entry.tsx
@@ -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 (
-
-
- {t("batchByExam.selectExam")}
-
-
-
-
-
-
-
-
- {t("batchByExam.guideSelectExam")}
-
-
-
- )
- }
-
return (
- {/* 试卷 + 班级选择器 */}
-
-
- {t("batchByExam.title")}
-
-
-
-
-
-
-
-
-
-
- {/* 试卷信息 */}
-
-
- {t("batchByExam.questionCount", { count: exam.questions.length })}
-
-
- {t("batchByExam.fullScore", { score: exam.totalScore })}
-
-
-
-
+
{/* 未选班级 */}
{!defaultClassId ? (
@@ -390,6 +319,12 @@ export function BatchGradeEntryByExam({
{t("batchByExam.selectClassFirst")}
+ ) : !exam ? (
+
+
+ {t("batchByExam.selectExam")}
+
+
) : students.length === 0 ? (
@@ -419,34 +354,17 @@ export function BatchGradeEntryByExam({
isScoreInvalid={isScoreInvalid}
/>
- {/* 提交按钮 */}
-
-
-
-
-
- {/* 提示 */}
-
-
- {t("batchByExam.pasteHint")}
-
+ {/* 底部工具栏:保存/清除按钮 + 粘贴提示 */}
+ setScores({})}
+ onSubmit={handleSubmit}
+ />
>
)}
- {/* 确认对话框 */}
+ {/* 切换试卷/班级确认对话框 */}
-