refactor(classes,audit,school,settings,adaptive-practice,announcements,messaging,questions,student): 组件化重构遗漏补全 - 20 个重复组件迁移

补全未被批次覆盖的 9 个模块的重复组件迁移:
- classes: 迁移 students-filters/schedule-filters/class-skeleton/class-error-boundary
- audit: 迁移 3 个 filters + audit-log-table-skeleton + audit-error-boundary
- school: 迁移 grade-insights-filters/school-skeleton/school-error-boundary
- settings: 迁移 settings-section-error-boundary
- adaptive-practice: 迁移 2 个 practice-stats-cards 到 StatsGrid
- announcements: 迁移 announcement-list-skeleton 到 SkeletonCard
- messaging: 迁移 message-list-skeleton 到 SkeletonCard
- questions: 迁移 question-filters 到 app 层
- student: 迁移 course-filters/student-schedule-filters 到 app 层
- 补充 school/settings 模块 i18n error.boundary* 键
- tsc 零错误
This commit is contained in:
SpecialX
2026-07-06 22:22:23 +08:00
parent ee10380462
commit e76c626779
62 changed files with 699 additions and 1247 deletions

View File

@@ -11,7 +11,7 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import { QuestionCascadeFilter } from "./question-cascade-filter"
import { QuestionCascadeFilter } from "@/modules/questions/components/question-cascade-filter"
/**
*

View File

@@ -1,5 +1,5 @@
import { AnnouncementListSkeleton } from "@/modules/announcements/components/announcement-list-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function AdminAnnouncementsLoading() {
return <AnnouncementListSkeleton showCreateButton />
return <SkeletonCard variant="list" />
}

View File

@@ -1,4 +1,4 @@
import { AuditLogTableSkeleton } from "@/modules/audit/components/audit-log-table-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function DataChangeLogsLoading() {
return (
@@ -10,7 +10,7 @@ export default function DataChangeLogsLoading() {
</div>
<div className="h-9 w-32 animate-pulse rounded bg-muted" />
</div>
<AuditLogTableSkeleton />
<SkeletonCard variant="table" />
</div>
)
}

View File

@@ -12,7 +12,7 @@ import {
} from "@/modules/audit/data-access"
import { DataChangeLogView } from "@/modules/audit/components/data-change-log-view"
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
import { AuditErrorBoundary } from "@/modules/audit/components/audit-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import type { DataChangeAction } from "@/modules/audit/types"
export async function generateMetadata(): Promise<Metadata> {
@@ -67,7 +67,7 @@ export default async function DataChangeLogsPage({
</div>
<AuditLogExportButton exportType="dataChange" params={exportParams} />
</div>
<AuditErrorBoundary>
<SectionErrorBoundary namespace="audit">
<DataChangeLogView
items={result.items}
page={result.page}
@@ -77,7 +77,7 @@ export default async function DataChangeLogsPage({
tableOptions={tableOptions}
stats={stats}
/>
</AuditErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { AuditLogTableSkeleton } from "@/modules/audit/components/audit-log-table-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function AuditLogsLoading() {
return (
@@ -10,7 +10,7 @@ export default function AuditLogsLoading() {
</div>
<div className="h-9 w-32 animate-pulse rounded bg-muted" />
</div>
<AuditLogTableSkeleton />
<SkeletonCard variant="table" />
</div>
)
}

View File

@@ -1,4 +1,4 @@
import { AuditLogTableSkeleton } from "@/modules/audit/components/audit-log-table-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function LoginLogsLoading() {
return (
@@ -10,7 +10,7 @@ export default function LoginLogsLoading() {
</div>
<div className="h-9 w-32 animate-pulse rounded bg-muted" />
</div>
<AuditLogTableSkeleton />
<SkeletonCard variant="table" />
</div>
)
}

View File

@@ -8,7 +8,7 @@ import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { getLoginLogs } from "@/modules/audit/data-access"
import { LoginLogView } from "@/modules/audit/components/login-log-view"
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
import { AuditErrorBoundary } from "@/modules/audit/components/audit-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import type { LoginLogAction, LoginLogStatus } from "@/modules/audit/types"
export async function generateMetadata(): Promise<Metadata> {
@@ -65,7 +65,7 @@ export default async function LoginLogsPage({
</div>
<AuditLogExportButton exportType="login" params={exportParams} />
</div>
<AuditErrorBoundary>
<SectionErrorBoundary namespace="audit">
<LoginLogView
items={result.items}
page={result.page}
@@ -73,7 +73,7 @@ export default async function LoginLogsPage({
total={result.total}
totalPages={result.totalPages}
/>
</AuditErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -8,7 +8,7 @@ import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { getAuditLogs, getAuditModuleOptions } from "@/modules/audit/data-access"
import { AuditLogView } from "@/modules/audit/components/audit-log-view"
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
import { AuditErrorBoundary } from "@/modules/audit/components/audit-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import type { AuditLogStatus } from "@/modules/audit/types"
export async function generateMetadata(): Promise<Metadata> {
@@ -64,7 +64,7 @@ export default async function AuditLogsPage({
</div>
<AuditLogExportButton exportType="audit" params={exportParams} />
</div>
<AuditErrorBoundary>
<SectionErrorBoundary namespace="audit">
<AuditLogView
items={result.items}
page={result.page}
@@ -73,7 +73,7 @@ export default async function AuditLogsPage({
totalPages={result.totalPages}
moduleOptions={moduleOptions}
/>
</AuditErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react"
import { ClipboardList } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { QuestionFilters } from "@/modules/questions/components/question-filters"
import { QuestionFilters } from "../../_components/question-filters"
import { CreateQuestionButton } from "@/modules/questions/components/create-question-button"
import { QuestionBankResultsClient } from "@/modules/questions/components/question-bank-results-client"
import { ImportExportButtons } from "@/modules/questions/components/import-export-buttons"

View File

@@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { AcademicYearClient } from "@/modules/school/components/academic-year-view"
import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { getAcademicYears } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
@@ -28,9 +28,9 @@ export default async function AdminAcademicYearPage(): Promise<JSX.Element> {
<h2 className="text-2xl font-bold tracking-tight">{t("academicYear.title")}</h2>
<p className="text-muted-foreground">{t("academicYear.description")}</p>
</div>
<SchoolErrorBoundary>
<SectionErrorBoundary namespace="school">
<AcademicYearClient years={years} />
</SchoolErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access"
import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view"
import { getGrades, getSchools } from "@/modules/school/data-access"
@@ -35,9 +35,9 @@ export default async function AdminSchoolClassesPage(): Promise<JSX.Element> {
<h2 className="text-2xl font-bold tracking-tight">{t("classManagement.title")}</h2>
<p className="text-muted-foreground">{t("classManagement.description")}</p>
</div>
<ClassErrorBoundary>
<SectionErrorBoundary namespace="classes">
<AdminClassesClient classes={classes} teachers={teachers} schools={schools} grades={grades} />
</ClassErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { DepartmentsClient } from "@/modules/school/components/departments-view"
import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { getDepartments } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
@@ -28,9 +28,9 @@ export default async function AdminDepartmentsPage(): Promise<JSX.Element> {
<h2 className="text-2xl font-bold tracking-tight">{t("departments.title")}</h2>
<p className="text-muted-foreground">{t("departments.description")}</p>
</div>
<SchoolErrorBoundary>
<SectionErrorBoundary namespace="school">
<DepartmentsClient departments={departments} />
</SchoolErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -10,7 +10,8 @@ import { getGrades } from "@/modules/school/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getSchoolWideGradeSummary } from "@/modules/grades/data-access-analytics"
import { SchoolWideSummaryCard } from "@/modules/grades/components/school-wide-summary-card"
import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import { ChipNav } from "@/shared/components/ui/chip-nav"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatCard } from "@/shared/components/ui/stat-card"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -74,11 +75,14 @@ export default async function AdminGradeInsightsPage({
)}
{/* 年级筛选ChipNav 即时切换,无整页刷新 */}
<GradeInsightsFilters
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
currentGradeId={selected || "all"}
buildHref={buildHref}
/>
<FilterBar>
<ChipNav
options={grades.map((g) => ({ id: g.id, name: `${g.school.name} / ${g.name}` }))}
currentId={selected || "all"}
buildHref={buildHref}
allOption={{ id: "all", label: t("grades.gradeInsights.selectGrade") }}
/>
</FilterBar>
{!selected ? (
<EmptyState

View File

@@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { GradesClient } from "@/modules/school/components/grades-view"
import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { getGrades, getSchools, getStaffOptions, getGradeOverviewStats } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
@@ -34,9 +34,9 @@ export default async function AdminGradesPage(): Promise<JSX.Element> {
<h2 className="text-2xl font-bold tracking-tight">{t("grades.title")}</h2>
<p className="text-muted-foreground">{t("grades.description")}</p>
</div>
<SchoolErrorBoundary>
<SectionErrorBoundary namespace="school">
<GradesClient grades={grades} schools={schools} staff={staff} gradeStats={gradeStats} />
</SchoolErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { SchoolsClient } from "@/modules/school/components/schools-view"
import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { getSchools } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
@@ -28,9 +28,9 @@ export default async function AdminSchoolsPage(): Promise<JSX.Element> {
<h2 className="text-2xl font-bold tracking-tight">{t("schools.title")}</h2>
<p className="text-muted-foreground">{t("schools.description")}</p>
</div>
<SchoolErrorBoundary>
<SectionErrorBoundary namespace="school">
<SchoolsClient schools={schools} />
</SchoolErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { AnnouncementListSkeleton } from "@/modules/announcements/components/announcement-list-skeleton"
import { SkeletonCard } from "@/shared/components/ui/skeleton"
export default function AnnouncementsLoading() {
return <AnnouncementListSkeleton />
return <SkeletonCard variant="list" />
}

View File

@@ -10,12 +10,12 @@ import { getGradesForStaff } from "@/modules/school/data-access"
import { getGradeDistributionByGradeId } from "@/modules/grades/data-access-analytics"
import { getExamsByGradeId } from "@/modules/exams/data-access"
import { getGradeCoursePlanProgress } from "@/modules/course-plans/data-access"
import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import { GradeDistributionPanel } from "@/modules/school/components/grade-dashboard/grade-distribution-panel"
import { GradeHomeworkPanel } from "@/modules/school/components/grade-dashboard/grade-homework-panel"
import { GradeExamsPanel } from "@/modules/school/components/grade-dashboard/grade-exams-panel"
import { GradeProgressPanel } from "@/modules/school/components/grade-dashboard/grade-progress-panel"
import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ChipNav } from "@/shared/components/ui/chip-nav"
import { BarChart3 } from "lucide-react"
@@ -126,12 +126,15 @@ export default async function GradeDashboardPage({
<p className="text-muted-foreground">{t("grades.gradeDashboard.description")}</p>
</div>
<SchoolErrorBoundary>
<GradeInsightsFilters
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
currentGradeId={selected || "all"}
buildHref={buildHref}
/>
<SectionErrorBoundary namespace="school">
<FilterBar>
<ChipNav
options={grades.map((g) => ({ id: g.id, name: `${g.school.name} / ${g.name}` }))}
currentId={selected || "all"}
buildHref={buildHref}
allOption={{ id: "all", label: t("grades.gradeInsights.selectGrade") }}
/>
</FilterBar>
{!selected ? (
<EmptyState
@@ -175,7 +178,7 @@ export default async function GradeDashboardPage({
)}
</div>
)}
</SchoolErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -7,7 +7,8 @@ import { Permissions } from "@/shared/types/permissions"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getGradesForStaff } from "@/modules/school/data-access"
import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import { ChipNav } from "@/shared/components/ui/chip-nav"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatCard } from "@/shared/components/ui/stat-card"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -72,11 +73,14 @@ export default async function TeacherGradeInsightsPage({ searchParams }: { searc
</div>
{/* 年级筛选ChipNav 即时切换,无整页刷新 */}
<GradeInsightsFilters
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
currentGradeId={selected || "all"}
buildHref={buildHref}
/>
<FilterBar>
<ChipNav
options={grades.map((g) => ({ id: g.id, name: `${g.school.name} / ${g.name}` }))}
currentId={selected || "all"}
buildHref={buildHref}
allOption={{ id: "all", label: t("grades.gradeInsights.selectGrade") }}
/>
</FilterBar>
{!selected ? (
<EmptyState

View File

@@ -1,6 +1,6 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { BarChart3 } from "lucide-react"
import { Activity, BarChart3, BookOpen, CheckCircle2, Target, Users } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
@@ -9,7 +9,8 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getTeacherIdForMutations } from "@/modules/classes/data-access"
import { getGradesForStaff } from "@/modules/school/data-access"
import { GradeInsightsFilters } from "@/modules/school/components/grade-insights-filters"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import { ChipNav } from "@/shared/components/ui/chip-nav"
import {
getGradePracticeOverview,
@@ -17,7 +18,7 @@ import {
getPracticeTypeBreakdown,
} from "@/modules/adaptive-practice/data-access-analytics"
import { getUserIdsByGradeId } from "@/modules/users/data-access"
import { PracticeOverviewStatsCards } from "@/modules/adaptive-practice/components/practice-overview-stats-cards"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { ClassPracticeComparisonTable } from "@/modules/adaptive-practice/components/class-practice-comparison-table"
import { PracticeTypeBreakdownChart } from "@/modules/adaptive-practice/components/practice-type-breakdown-chart"
@@ -38,6 +39,7 @@ export default async function GradePracticePage({
}): Promise<JSX.Element> {
await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
const t = await getTranslations("practice")
const tSchool = await getTranslations("school")
const params = await searchParams
const gradeId = getParam(params, "gradeId")
@@ -94,11 +96,14 @@ export default async function GradePracticePage({
<p className="text-muted-foreground">{t("grade.description")}</p>
</div>
<GradeInsightsFilters
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
currentGradeId={selected || "all"}
buildHref={buildHref}
/>
<FilterBar>
<ChipNav
options={grades.map((g) => ({ id: g.id, name: `${g.school.name} / ${g.name}` }))}
currentId={selected || "all"}
buildHref={buildHref}
allOption={{ id: "all", label: tSchool("grades.gradeInsights.selectGrade") }}
/>
</FilterBar>
{!selected ? (
<EmptyState
@@ -117,12 +122,45 @@ export default async function GradePracticePage({
) : (
<div className="space-y-6">
{/* 年级整体统计卡片 */}
<PracticeOverviewStatsCards
totalClasses={overview.totalClasses}
totalSessions={overview.totalSessions}
totalAnswered={overview.totalQuestionsAnswered}
averageAccuracy={overview.averageAccuracy}
participationRate={overview.participationRate}
<StatsGrid
columns={5}
items={[
{
label: t("teacher.overview.totalClasses"),
value: overview.totalClasses,
icon: BookOpen,
color: "text-blue-600 dark:text-blue-400",
valueClassName: "tabular-nums text-blue-600 dark:text-blue-400",
},
{
label: t("teacher.overview.totalSessions"),
value: overview.totalSessions,
icon: Activity,
color: "text-purple-600 dark:text-purple-400",
valueClassName: "tabular-nums text-purple-600 dark:text-purple-400",
},
{
label: t("teacher.overview.totalAnswered"),
value: overview.totalQuestionsAnswered,
icon: Target,
color: "text-amber-600 dark:text-amber-400",
valueClassName: "tabular-nums text-amber-600 dark:text-amber-400",
},
{
label: t("teacher.overview.averageAccuracy"),
value: `${Math.round(overview.averageAccuracy * 100)}%`,
icon: CheckCircle2,
color: "text-emerald-600 dark:text-emerald-400",
valueClassName: "tabular-nums text-emerald-600 dark:text-emerald-400",
},
{
label: t("teacher.overview.participationRate"),
value: `${Math.round(overview.participationRate * 100)}%`,
icon: Users,
color: "text-rose-600 dark:text-rose-400",
valueClassName: "tabular-nums text-rose-600 dark:text-rose-400",
},
]}
/>
{/* 各班级练习对比表 */}

View File

@@ -8,9 +8,8 @@ import { SectionErrorBoundary } from "@/shared/components/section-error-boundary
import { getNotifications } from "@/modules/notifications/data-access"
import { NotificationList } from "@/modules/notifications/components/notification-list"
import { MessageListSection } from "@/modules/messaging/components/message-list-section"
import { MessageListSkeleton } from "@/modules/messaging/components/message-list-skeleton"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Skeleton, SkeletonCard } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
@@ -63,7 +62,7 @@ export default async function MessagesPage(): Promise<JSX.Element> {
</div>
{/* P2-8 + P2-9: 消息列表区块独立 Suspense + ErrorBoundary局部失败不影响通知区块 */}
<Suspense fallback={<MessageListSkeleton />}>
<Suspense fallback={<SkeletonCard variant="list" />}>
<SectionErrorBoundary namespace="messages">
<MessageListSection userId={ctx.userId} canGroupSend={ctx.dataScope.type === "class_taught"} />
</SectionErrorBoundary>

View File

@@ -1,6 +1,6 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { Target, Users } from "lucide-react"
import { Target, Users, CheckCircle2, TrendingUp, Award } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
@@ -9,7 +9,7 @@ import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getPracticeSessions, getPracticeStats } from "@/modules/adaptive-practice/data-access"
import { PracticeHistory } from "@/modules/adaptive-practice/components/practice-history"
import { PracticeStatsCards } from "@/modules/adaptive-practice/components/practice-stats-cards"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { PracticeServiceProvider } from "@/modules/adaptive-practice/services/practice-service"
import type { PracticeSessionSummary, PracticeStats } from "@/modules/adaptive-practice/types"
import { getUserNamesByIds } from "@/modules/users/data-access"
@@ -86,7 +86,39 @@ export default async function ParentPracticePage(): Promise<JSX.Element> {
{isSingleChild ? (
<>
<WidgetBoundary title={t("parent.stats")} skeletonHeight={160}>
<PracticeStatsCards stats={validItems[0]!.stats} />
<StatsGrid
columns={4}
items={[
{
label: t("stats.totalSessions"),
value: validItems[0]!.stats.totalSessions,
icon: Target,
color: "text-primary",
valueClassName: "tabular-nums",
},
{
label: t("stats.completed"),
value: validItems[0]!.stats.completedSessions,
icon: CheckCircle2,
color: "text-emerald-500",
valueClassName: "tabular-nums",
},
{
label: t("stats.totalAnswered"),
value: validItems[0]!.stats.totalQuestionsAnswered,
icon: Award,
color: "text-amber-500",
valueClassName: "tabular-nums",
},
{
label: t("stats.accuracy"),
value: `${Math.round(validItems[0]!.stats.overallAccuracy * 100)}%`,
icon: TrendingUp,
color: "text-blue-500",
valueClassName: "tabular-nums",
},
]}
/>
</WidgetBoundary>
<div className="space-y-4">
<h2 className="text-lg font-semibold">{t("parent.history")}</h2>
@@ -109,7 +141,39 @@ export default async function ParentPracticePage(): Promise<JSX.Element> {
<Target className="h-5 w-5 text-primary" aria-hidden="true" />
<h3 className="text-lg font-semibold">{item.studentName}</h3>
</div>
<PracticeStatsCards stats={item.stats} />
<StatsGrid
columns={4}
items={[
{
label: t("stats.totalSessions"),
value: item.stats.totalSessions,
icon: Target,
color: "text-primary",
valueClassName: "tabular-nums",
},
{
label: t("stats.completed"),
value: item.stats.completedSessions,
icon: CheckCircle2,
color: "text-emerald-500",
valueClassName: "tabular-nums",
},
{
label: t("stats.totalAnswered"),
value: item.stats.totalQuestionsAnswered,
icon: Award,
color: "text-amber-500",
valueClassName: "tabular-nums",
},
{
label: t("stats.accuracy"),
value: `${Math.round(item.stats.overallAccuracy * 100)}%`,
icon: TrendingUp,
color: "text-blue-500",
valueClassName: "tabular-nums",
},
]}
/>
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">
{t("parent.history")}

View File

@@ -9,7 +9,7 @@ import { getUserProfile } from "@/modules/users/data-access"
import { AvatarUpload } from "@/modules/settings/components/avatar-upload"
import { ProfileStudentOverview, ProfileStudentOverviewSkeleton } from "@/modules/settings/components/profile-student-overview"
import { ProfileTeacherOverview, ProfileTeacherOverviewSkeleton } from "@/modules/settings/components/profile-teacher-overview"
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { resolveProfileOverviewType } from "@/modules/settings/config/profile-overview-config"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -50,13 +50,13 @@ export default async function ProfilePage(): Promise<ReactElement> {
}
/>
<SettingsSectionErrorBoundary>
<SectionErrorBoundary namespace="settings">
<AvatarUpload
currentImage={userProfile.image}
name={userProfile.name}
email={userProfile.email}
/>
</SettingsSectionErrorBoundary>
</SectionErrorBoundary>
<div className="grid gap-6 md:grid-cols-2">
<Card>
@@ -142,19 +142,19 @@ export default async function ProfilePage(): Promise<ReactElement> {
</div>
{overviewType === "student" ? (
<SettingsSectionErrorBoundary>
<SectionErrorBoundary namespace="settings">
<Suspense fallback={<ProfileStudentOverviewSkeleton />}>
<ProfileStudentOverview userId={userId} />
</Suspense>
</SettingsSectionErrorBoundary>
</SectionErrorBoundary>
) : null}
{overviewType === "teacher" ? (
<SettingsSectionErrorBoundary>
<SectionErrorBoundary namespace="settings">
<Suspense fallback={<ProfileTeacherOverviewSkeleton />}>
<ProfileTeacherOverview />
</Suspense>
</SettingsSectionErrorBoundary>
</SectionErrorBoundary>
) : null}
</div>
)

View File

@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function CourseFilters() {
export function CourseFilters(): React.ReactNode {
const t = useTranslations("student")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))

View File

@@ -5,7 +5,7 @@ import { getTranslations } from "next-intl/server"
import { getStudentClasses } from "@/modules/classes/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
import { CourseFilters } from "@/modules/student/components/course-filters"
import { CourseFilters } from "./course-filters"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"

View File

@@ -1,4 +1,5 @@
import type { JSX } from "react"
import { Target, CheckCircle2, TrendingUp, Award } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
@@ -7,7 +8,7 @@ import { Permissions } from "@/shared/types/permissions"
import { getPracticeSessions, getPracticeStats } from "@/modules/adaptive-practice/data-access"
import { PracticeStarterWithNav } from "@/modules/adaptive-practice/components/practice-starter-with-nav"
import { PracticeHistory } from "@/modules/adaptive-practice/components/practice-history"
import { PracticeStatsCards } from "@/modules/adaptive-practice/components/practice-stats-cards"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { PracticeServiceProvider } from "@/modules/adaptive-practice/services/practice-service"
import { getKnowledgePointOptions } from "@/modules/questions/data-access"
@@ -31,7 +32,39 @@ export default async function StudentPracticePage(): Promise<JSX.Element> {
<p className="text-muted-foreground">{t("page.description")}</p>
</div>
<PracticeStatsCards stats={stats} />
<StatsGrid
columns={4}
items={[
{
label: t("stats.totalSessions"),
value: stats.totalSessions,
icon: Target,
color: "text-primary",
valueClassName: "tabular-nums",
},
{
label: t("stats.completed"),
value: stats.completedSessions,
icon: CheckCircle2,
color: "text-emerald-500",
valueClassName: "tabular-nums",
},
{
label: t("stats.totalAnswered"),
value: stats.totalQuestionsAnswered,
icon: Award,
color: "text-amber-500",
valueClassName: "tabular-nums",
},
{
label: t("stats.accuracy"),
value: `${Math.round(stats.overallAccuracy * 100)}%`,
icon: TrendingUp,
color: "text-blue-500",
valueClassName: "tabular-nums",
},
]}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-1">

View File

@@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server"
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
import { StudentScheduleFilters } from "./student-schedule-filters"
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"

View File

@@ -4,17 +4,37 @@ import { useMemo } from "react"
import { useQueryState, parseAsString } from "nuqs"
import { useTranslations } from "next-intl"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import type { StudentEnrolledClass } from "@/modules/classes/types"
export function StudentScheduleFilters({ classes }: { classes: StudentEnrolledClass[] }) {
export function StudentScheduleFilters({
classes,
}: {
classes: StudentEnrolledClass[]
}): React.ReactNode {
const t = useTranslations("student")
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all"))
const options = useMemo(() => [{ id: "all", name: t("scheduleFilters.allClasses") }, ...classes], [classes, t])
const options = useMemo(
() => [{ id: "all", name: t("scheduleFilters.allClasses") }, ...classes],
[classes, t],
)
const hasFilters = classId !== "all"
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<FilterBar
layout="between"
hasFilters={hasFilters}
onReset={() => setClassId(null)}
>
<div className="w-full sm:w-60">
<Select value={classId} onValueChange={setClassId}>
<SelectTrigger>
@@ -29,6 +49,6 @@ export function StudentScheduleFilters({ classes }: { classes: StudentEnrolledCl
</SelectContent>
</Select>
</div>
</div>
</FilterBar>
)
}

View File

@@ -7,7 +7,7 @@ import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getClassHomeworkInsights, getClassSchedule, getClassStudentSubjectScoresV2, getClassStudents } from "@/modules/classes/data-access"
import { ClassAssignmentsWidget } from "@/modules/classes/components/class-detail/class-assignments-widget"
import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { ClassTrendsWidget } from "@/modules/classes/components/class-detail/class-trends-widget"
import { ClassHeader } from "@/modules/classes/components/class-detail/class-header"
import { ClassOverviewStats } from "@/modules/classes/components/class-detail/class-overview-stats"
@@ -77,7 +77,7 @@ export default async function ClassDetailPage({
return (
<div className="flex min-h-screen flex-col bg-muted/10">
<ClassErrorBoundary>
<SectionErrorBoundary namespace="classes">
<ClassHeader
classId={insights.class.id}
name={insights.class.name}
@@ -117,7 +117,7 @@ export default async function ClassDetailPage({
</div>
</div>
</div>
</ClassErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getClassSubjects, getTeacherClasses } from "@/modules/classes/data-access"
import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { MyClassesGrid } from "@/modules/classes/components/my-classes-grid"
export const dynamic = "force-dynamic"
@@ -23,9 +23,9 @@ export default async function MyClassesPage(): Promise<JSX.Element> {
return (
<div className="flex h-full flex-col space-y-4 p-8">
<ClassErrorBoundary>
<SectionErrorBoundary namespace="classes">
<MyClassesGrid classes={classes} subjectOptions={subjectOptions} />
</ClassErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -7,9 +7,9 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getClassSchedule, getTeacherClasses } from "@/modules/classes/data-access"
import { ScheduleFilters } from "@/modules/classes/components/schedule-filters"
import { ScheduleFilters } from "./schedule-filters"
import { ScheduleView } from "@/modules/classes/components/schedule-view"
import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
@@ -79,7 +79,7 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-6">
<ClassErrorBoundary>
<SectionErrorBoundary namespace="classes">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<ScheduleFilters classes={classes} />
</Suspense>
@@ -87,7 +87,7 @@ export default async function SchedulePage({ searchParams }: { searchParams: Pro
<Suspense fallback={<ScheduleResultsFallback />}>
<ScheduleResults searchParams={searchParams} />
</Suspense>
</ClassErrorBoundary>
</SectionErrorBoundary>
</div>
</div>
)

View File

@@ -26,8 +26,9 @@ import {
} from "@/shared/components/ui/dialog"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import type { TeacherClass } from "../types"
import { createClassScheduleItemAction } from "../actions"
import { FilterBar } from "@/shared/components/ui/filter-bar"
import type { TeacherClass } from "@/modules/classes/types"
import { createClassScheduleItemAction } from "@/modules/classes/actions"
export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
const [classId, setClassId] = useQueryState("classId", parseAsString.withDefault("all").withOptions({ shallow: false }))
@@ -73,24 +74,22 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
const title = selectedClass ? selectedClass.name : t("filters.allClasses")
return (
<div className="relative flex items-center justify-between py-2">
<div className="flex items-center gap-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">{t("filters.allClasses")}</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<FilterBar layout="between" gapClassName="gap-2" className="relative py-2">
<Select value={classId} onValueChange={(val) => setClassId(val === "all" ? "all" : val)}>
<SelectTrigger className="h-8 w-[180px] text-xs bg-transparent border-none shadow-none hover:bg-muted/50 focus:ring-0 text-muted-foreground hover:text-foreground">
<SelectValue placeholder={t("filters.allClasses")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs">{t("filters.allClasses")}</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id} className="text-xs">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="absolute left-1/2 -translate-x-1/2 text-sm font-medium text-muted-foreground">
<div className="hidden flex-1 text-center text-sm font-medium text-muted-foreground md:block">
{title}
</div>
@@ -195,6 +194,6 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
</form>
</DialogContent>
</Dialog>
</div>
</FilterBar>
)
}

View File

@@ -7,9 +7,9 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getClassStudents, getTeacherClasses, getStudentsSubjectScores } from "@/modules/classes/data-access"
import { StudentsFilters } from "@/modules/classes/components/students-filters"
import { StudentsFilters } from "./students-filters"
import { StudentsTable } from "@/modules/classes/components/students-table"
import { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
@@ -100,7 +100,7 @@ export default async function StudentsPage({ searchParams }: { searchParams: Pro
return (
<div className="flex h-full flex-col space-y-4 p-8">
<div className="space-y-4">
<ClassErrorBoundary>
<SectionErrorBoundary namespace="classes">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<StudentsFilters classes={classes} defaultClassId={defaultClassId} />
</Suspense>
@@ -108,7 +108,7 @@ export default async function StudentsPage({ searchParams }: { searchParams: Pro
<Suspense fallback={<StudentsResultsFallback />}>
<StudentsResults searchParams={searchParams} defaultClassId={defaultClassId} />
</Suspense>
</ClassErrorBoundary>
</SectionErrorBoundary>
</div>
</div>
)

View File

@@ -4,7 +4,7 @@ import { useState } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
import { Search, UserPlus, ChevronDown, Check } from "lucide-react"
import { UserPlus, ChevronDown, Check } from "lucide-react"
import { toast } from "sonner"
import { Input } from "@/shared/components/ui/input"
@@ -13,9 +13,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu"
import {
Dialog,
@@ -34,8 +34,9 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { Label } from "@/shared/components/ui/label"
import type { TeacherClass } from "../types"
import { enrollStudentByEmailAction } from "../actions"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
import type { TeacherClass } from "@/modules/classes/types"
import { enrollStudentByEmailAction } from "@/modules/classes/actions"
export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherClass[], defaultClassId?: string }) {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("").withOptions({ shallow: false, throttleMs: 500 }))
@@ -83,22 +84,18 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
const statusLabel = status === "all" ? t("filters.allStatuses") : (status === "active" ? t("students.status.active") : t("students.status.inactive"))
return (
<div className="flex items-center justify-between py-2">
<FilterBar layout="between" gapClassName="gap-2" className="py-2">
<div className="flex items-center gap-2">
{/* Search - Minimal */}
<div className="relative group">
<Search className="text-muted-foreground absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 group-hover:text-foreground transition-colors" />
<Input
placeholder={t("students.searchPlaceholder")}
className="pl-8 h-8 w-[180px] text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
value={search}
onChange={(e) => setSearch(e.target.value || null)}
/>
</div>
<FilterSearchInput
value={search}
onChange={(value) => setSearch(value || null)}
placeholder={t("students.searchPlaceholder")}
className="w-auto md:w-[180px]"
inputClassName="h-8 text-xs bg-transparent border-transparent hover:bg-muted/50 focus-visible:bg-background focus-visible:ring-1 focus-visible:ring-ring focus-visible:border-input transition-all"
/>
<div className="h-4 w-[1px] bg-border mx-1" />
<div className="hidden h-4 w-[1px] bg-border mx-1 md:block" />
{/* Class Filter - Compact */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
@@ -129,7 +126,6 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
</DropdownMenuContent>
</DropdownMenu>
{/* Status Filter - Compact */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-1 px-2 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted/50">
@@ -214,7 +210,6 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
</form>
</DialogContent>
</Dialog>
</div>
</FilterBar>
)
}

View File

@@ -1,7 +1,7 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { Suspense } from "react"
import { BarChart3 } from "lucide-react"
import { Activity, BarChart3, BookOpen, CheckCircle2, Target, Users } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
@@ -23,7 +23,7 @@ import {
getStudentsWithoutPractice,
getStudentNameMap,
} from "@/modules/adaptive-practice/data-access-analytics"
import { PracticeOverviewStatsCards } from "@/modules/adaptive-practice/components/practice-overview-stats-cards"
import { StatsGrid } from "@/shared/components/ui/stats-grid"
import { ClassPracticeComparisonTable } from "@/modules/adaptive-practice/components/class-practice-comparison-table"
import { PracticeTypeBreakdownChart } from "@/modules/adaptive-practice/components/practice-type-breakdown-chart"
import { ClassKnowledgePointWeaknessChart } from "@/modules/adaptive-practice/components/class-knowledge-point-weakness-chart"
@@ -183,12 +183,45 @@ async function TeacherPracticeContent({
) : null}
{/* 统计卡片 */}
<PracticeOverviewStatsCards
totalClasses={classOverviews.length}
totalSessions={totalSessions}
totalAnswered={totalAnswered}
averageAccuracy={averageAccuracy}
participationRate={participationRate}
<StatsGrid
columns={5}
items={[
{
label: t("teacher.overview.totalClasses"),
value: classOverviews.length,
icon: BookOpen,
color: "text-blue-600 dark:text-blue-400",
valueClassName: "tabular-nums text-blue-600 dark:text-blue-400",
},
{
label: t("teacher.overview.totalSessions"),
value: totalSessions,
icon: Activity,
color: "text-purple-600 dark:text-purple-400",
valueClassName: "tabular-nums text-purple-600 dark:text-purple-400",
},
{
label: t("teacher.overview.totalAnswered"),
value: totalAnswered,
icon: Target,
color: "text-amber-600 dark:text-amber-400",
valueClassName: "tabular-nums text-amber-600 dark:text-amber-400",
},
{
label: t("teacher.overview.averageAccuracy"),
value: `${Math.round(averageAccuracy * 100)}%`,
icon: CheckCircle2,
color: "text-emerald-600 dark:text-emerald-400",
valueClassName: "tabular-nums text-emerald-600 dark:text-emerald-400",
},
{
label: t("teacher.overview.participationRate"),
value: `${Math.round(participationRate * 100)}%`,
icon: Users,
color: "text-rose-600 dark:text-rose-400",
valueClassName: "tabular-nums text-rose-600 dark:text-rose-400",
},
]}
/>
{/* 班级对比表(仅在"全部班级"视图下显示) */}

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react"
import { ClipboardList } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { QuestionFilters } from "@/modules/questions/components/question-filters"
import { QuestionFilters } from "../../_components/question-filters"
import { CreateQuestionButton } from "@/modules/questions/components/create-question-button"
import { QuestionBankResultsClient } from "@/modules/questions/components/question-bank-results-client"
import { ImportExportButtons } from "@/modules/questions/components/import-export-buttons"

View File

@@ -1,99 +0,0 @@
import { Activity, BookOpen, CheckCircle2, Target, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { Card, CardContent } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
interface PracticeOverviewStatsCardsProps {
totalClasses: number
totalSessions: number
totalAnswered: number
averageAccuracy: number
participationRate: number
className?: string
}
/**
* 专项练习概览统计卡片(教师视图)
*
* 5 个卡片:覆盖班级 / 练习总数 / 答题总数 / 平均正确率 / 参与率
*/
export function PracticeOverviewStatsCards({
totalClasses,
totalSessions,
totalAnswered,
averageAccuracy,
participationRate,
className,
}: PracticeOverviewStatsCardsProps) {
const t = useTranslations("practice")
const cards = [
{
label: t("teacher.overview.totalClasses"),
value: totalClasses,
sub: "",
icon: BookOpen,
color: "text-blue-600 dark:text-blue-400",
bg: "bg-blue-50 dark:bg-blue-950/30",
},
{
label: t("teacher.overview.totalSessions"),
value: totalSessions,
sub: "",
icon: Activity,
color: "text-purple-600 dark:text-purple-400",
bg: "bg-purple-50 dark:bg-purple-950/30",
},
{
label: t("teacher.overview.totalAnswered"),
value: totalAnswered,
sub: "",
icon: Target,
color: "text-amber-600 dark:text-amber-400",
bg: "bg-amber-50 dark:bg-amber-950/30",
},
{
label: t("teacher.overview.averageAccuracy"),
value: `${Math.round(averageAccuracy * 100)}%`,
sub: averageAccuracy >= 0.6 ? "" : "",
icon: CheckCircle2,
color: "text-emerald-600 dark:text-emerald-400",
bg: "bg-emerald-50 dark:bg-emerald-950/30",
},
{
label: t("teacher.overview.participationRate"),
value: `${Math.round(participationRate * 100)}%`,
sub: participationRate >= 0.5 ? "" : "",
icon: Users,
color: "text-rose-600 dark:text-rose-400",
bg: "bg-rose-50 dark:bg-rose-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 tabular-nums", card.color)}>
{card.value}
</div>
{card.sub ? <div className="mt-0.5 text-xs text-muted-foreground">{card.sub}</div> : null}
</div>
<div className={cn("rounded-lg p-2", card.bg)}>
<Icon className={cn("h-4 w-4", card.color)} />
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -1,73 +0,0 @@
"use client"
import { useTranslations } from "next-intl"
import { Target, CheckCircle2, TrendingUp, Award } from "lucide-react"
import { Card, CardContent } from "@/shared/components/ui/card"
import type { PracticeStats } from "../types"
interface PracticeStatsCardsProps {
stats: PracticeStats
}
/**
* 专项练习统计卡片
*/
export function PracticeStatsCards({ stats }: PracticeStatsCardsProps): React.ReactNode {
const t = useTranslations("practice")
const accuracyPercent = Math.round(stats.overallAccuracy * 100)
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{t("stats.totalSessions")}</p>
<p className="text-2xl font-bold">{stats.totalSessions}</p>
</div>
<Target className="h-8 w-8 text-primary opacity-80" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{t("stats.completed")}</p>
<p className="text-2xl font-bold">{stats.completedSessions}</p>
</div>
<CheckCircle2 className="h-8 w-8 text-emerald-500 opacity-80" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{t("stats.totalAnswered")}</p>
<p className="text-2xl font-bold">{stats.totalQuestionsAnswered}</p>
</div>
<Award className="h-8 w-8 text-amber-500 opacity-80" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground">{t("stats.accuracy")}</p>
<p className="text-2xl font-bold">{accuracyPercent}%</p>
</div>
<TrendingUp className="h-8 w-8 text-blue-500 opacity-80" />
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,50 +0,0 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* 公告列表骨架屏P2-5 抽取)。
*
* 此前用户端 `/announcements/loading.tsx` 与管理端 `/admin/announcements/loading.tsx`
* 几乎逐行重复,违反"重复超过 90% 必须抽取共享组件"规则。
*/
export function AnnouncementListSkeleton({
showCreateButton = false,
}: {
showCreateButton?: boolean
}) {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
{showCreateButton ? <Skeleton className="h-9 w-40" /> : null}
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-9 w-[180px]" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-5 w-16" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex items-center gap-2 pt-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-3 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,23 +0,0 @@
"use client"
/**
* 审计模块独立数据区块错误边界。
*
* 薄包装:委托给共享 SectionErrorBoundary使用 common 命名空间。
* 保留同名导出以兼容现有 import。
*/
import type { ReactNode } from "react"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
interface AuditErrorBoundaryProps {
children: ReactNode
}
export function AuditErrorBoundary({ children }: AuditErrorBoundaryProps): ReactNode {
return (
<SectionErrorBoundary namespace="audit">
{children}
</SectionErrorBoundary>
)
}

View File

@@ -1,107 +0,0 @@
"use client"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
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"
interface AuditLogFiltersProps {
moduleOptions: string[]
}
export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
const t = useTranslations("audit")
const [module, setModule] = useQueryState("module", parseAsString.withOptions({ shallow: false }))
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
const [userId, setUserId] = useQueryState("userId", parseAsString.withOptions({ shallow: false }))
const [startDate, setStartDate] = useQueryState(
"startDate",
parseAsString.withOptions({ shallow: false }),
)
const [endDate, setEndDate] = useQueryState(
"endDate",
parseAsString.withOptions({ shallow: false }),
)
const hasFilters = Boolean(module || action || status || userId || startDate || endDate)
return (
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
setModule(null)
setAction(null)
setStatus(null)
setUserId(null)
setStartDate(null)
setEndDate(null)
}}
>
<Select value={module || "all"} onValueChange={(val) => setModule(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background" aria-label={t("filter.modulePlaceholder")}>
<SelectValue placeholder={t("filter.modulePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyModule")}</SelectItem>
{moduleOptions.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder={t("filter.actionPlaceholder")}
className="w-full md:w-[180px] bg-background"
aria-label={t("filter.actionPlaceholder")}
value={action || ""}
onChange={(e) => setAction(e.target.value || null)}
/>
<Input
placeholder={t("filter.userIdPlaceholder")}
className="w-full md:w-[180px] bg-background"
aria-label={t("filter.userIdPlaceholder")}
value={userId || ""}
onChange={(e) => setUserId(e.target.value || null)}
/>
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.statusPlaceholder")}>
<SelectValue placeholder={t("filter.statusPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyStatus")}</SelectItem>
<SelectItem value="success">{t("filter.success")}</SelectItem>
<SelectItem value="failure">{t("filter.failure")}</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.startDate")}
value={startDate || ""}
onChange={(e) => setStartDate(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.endDate")}
value={endDate || ""}
onChange={(e) => setEndDate(e.target.value || null)}
/>
</FilterBar>
)
}

View File

@@ -1,43 +0,0 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* 审计日志表格骨架屏。
* 用于 Suspense fallback 和 loading.tsx。
* aria-hidden 避免屏幕阅读器朗读占位元素。
*/
export function AuditLogTableSkeleton() {
return (
<div className="space-y-4" aria-hidden="true" role="presentation">
<div className="flex flex-wrap gap-3">
<Skeleton className="h-10 w-[160px]" />
<Skeleton className="h-10 w-[180px]" />
<Skeleton className="h-10 w-[140px]" />
<Skeleton className="h-10 w-[160px]" />
<Skeleton className="h-10 w-[160px]" />
</div>
<div className="rounded-md border">
<div className="space-y-0">
<div className="flex border-b bg-muted/40 px-4 py-3">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="mx-2 h-4 flex-1" />
))}
</div>
{Array.from({ length: 8 }).map((_, row) => (
<div key={row} className="flex border-b px-4 py-3">
{Array.from({ length: 7 }).map((_, col) => (
<Skeleton key={col} className="mx-2 h-4 flex-1" />
))}
</div>
))}
</div>
</div>
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,20 @@
"use client"
import { Suspense } from "react"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
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 { SkeletonCard } from "@/shared/components/ui/skeleton"
import { AuditLogTable } from "./audit-log-table"
import { AuditLogFilters } from "./audit-log-filters"
import { AuditLogTableSkeleton } from "./audit-log-table-skeleton"
import { useLogPagination } from "../hooks/use-log-pagination"
import type { AuditLog } from "../types"
@@ -25,11 +35,94 @@ function AuditLogViewInner({
totalPages,
moduleOptions,
}: AuditLogViewProps) {
const t = useTranslations("audit")
const [module, setModule] = useQueryState("module", parseAsString.withOptions({ shallow: false }))
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
const [userId, setUserId] = useQueryState("userId", parseAsString.withOptions({ shallow: false }))
const [startDate, setStartDate] = useQueryState(
"startDate",
parseAsString.withOptions({ shallow: false }),
)
const [endDate, setEndDate] = useQueryState(
"endDate",
parseAsString.withOptions({ shallow: false }),
)
const hasFilters = Boolean(module || action || status || userId || startDate || endDate)
const handlePageChange = useLogPagination()
return (
<div className="space-y-4">
<AuditLogFilters moduleOptions={moduleOptions} />
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
setModule(null)
setAction(null)
setStatus(null)
setUserId(null)
setStartDate(null)
setEndDate(null)
}}
>
<Select value={module || "all"} onValueChange={(val) => setModule(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background" aria-label={t("filter.modulePlaceholder")}>
<SelectValue placeholder={t("filter.modulePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyModule")}</SelectItem>
{moduleOptions.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder={t("filter.actionPlaceholder")}
className="w-full md:w-[180px] bg-background"
aria-label={t("filter.actionPlaceholder")}
value={action || ""}
onChange={(e) => setAction(e.target.value || null)}
/>
<Input
placeholder={t("filter.userIdPlaceholder")}
className="w-full md:w-[180px] bg-background"
aria-label={t("filter.userIdPlaceholder")}
value={userId || ""}
onChange={(e) => setUserId(e.target.value || null)}
/>
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.statusPlaceholder")}>
<SelectValue placeholder={t("filter.statusPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyStatus")}</SelectItem>
<SelectItem value="success">{t("filter.success")}</SelectItem>
<SelectItem value="failure">{t("filter.failure")}</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.startDate")}
value={startDate || ""}
onChange={(e) => setStartDate(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.endDate")}
value={endDate || ""}
onChange={(e) => setEndDate(e.target.value || null)}
/>
</FilterBar>
<AuditLogTable
items={items}
page={page}
@@ -44,7 +137,7 @@ function AuditLogViewInner({
export function AuditLogView(props: AuditLogViewProps) {
return (
<Suspense fallback={<AuditLogTableSkeleton />}>
<Suspense fallback={<SkeletonCard variant="table" />}>
<AuditLogViewInner {...props} />
</Suspense>
)

View File

@@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server"
import { ArrowRight, FileText, LogIn, Database } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { AuditErrorBoundary } from "@/modules/audit/components/audit-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { AuditOverviewStatsBar } from "@/modules/audit/components/audit-overview-stats-bar"
import { AuditActivityTrendChart } from "@/modules/audit/components/audit-activity-trend-chart"
import { DataChangeDistributionChart } from "@/modules/audit/components/data-change-distribution-chart"
@@ -37,22 +37,22 @@ export async function AuditOverviewView({
</div>
{/* 统计卡片栏 */}
<AuditErrorBoundary>
<SectionErrorBoundary namespace="audit">
<AuditOverviewStatsBar stats={stats} />
</AuditErrorBoundary>
</SectionErrorBoundary>
{/* 趋势图 + 分布图 */}
<div className="grid gap-6 lg:grid-cols-2">
<AuditErrorBoundary>
<SectionErrorBoundary namespace="audit">
<AuditActivityTrendChart data={trend} />
</AuditErrorBoundary>
<AuditErrorBoundary>
</SectionErrorBoundary>
<SectionErrorBoundary namespace="audit">
<DataChangeDistributionChart data={distribution} />
</AuditErrorBoundary>
</SectionErrorBoundary>
</div>
{/* 快捷入口 */}
<AuditErrorBoundary>
<SectionErrorBoundary namespace="audit">
<Card>
<CardHeader>
<CardTitle>{t("quickLinks.title")}</CardTitle>
@@ -77,12 +77,12 @@ export async function AuditOverviewView({
</div>
</CardContent>
</Card>
</AuditErrorBoundary>
</SectionErrorBoundary>
{/* 数据保留策略配置 */}
<AuditErrorBoundary>
<SectionErrorBoundary namespace="audit">
<AuditRetentionSettings />
</AuditErrorBoundary>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -1,149 +0,0 @@
"use client"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
import { X } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
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 type { DataChangeStat } from "../types"
interface DataChangeLogFiltersProps {
tableOptions: string[]
stats: DataChangeStat[]
}
export function DataChangeLogFilters({
tableOptions,
stats,
}: DataChangeLogFiltersProps) {
const t = useTranslations("audit")
const [tableName, setTableName] = useQueryState(
"tableName",
parseAsString.withOptions({ shallow: false }),
)
const [action, setAction] = useQueryState(
"action",
parseAsString.withOptions({ shallow: false }),
)
const [userId, setUserId] = useQueryState("userId", parseAsString.withOptions({ shallow: false }))
const [startDate, setStartDate] = useQueryState(
"startDate",
parseAsString.withOptions({ shallow: false }),
)
const [endDate, setEndDate] = useQueryState(
"endDate",
parseAsString.withOptions({ shallow: false }),
)
const hasFilters = Boolean(tableName || action || userId || startDate || endDate)
return (
<div className="space-y-3">
{stats.length > 0 && (
<div className="flex flex-wrap gap-2" role="status" aria-label={t("dataChanges.title")}>
{stats.slice(0, 8).map((s) => (
<Badge key={s.tableName} variant="secondary" className="gap-1">
<span className="font-mono text-xs">{s.tableName}</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs">{s.count}</span>
</Badge>
))}
</div>
)}
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
setTableName(null)
setAction(null)
setUserId(null)
setStartDate(null)
setEndDate(null)
}}
>
<Select
value={tableName || "all"}
onValueChange={(val) => setTableName(val === "all" ? null : val)}
>
<SelectTrigger className="w-[180px] bg-background" aria-label={t("filter.tablePlaceholder")}>
<SelectValue placeholder={t("filter.tablePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyTable")}</SelectItem>
{tableOptions.map((tb) => (
<SelectItem key={tb} value={tb}>
{tb}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={action || "all"}
onValueChange={(val) => setAction(val === "all" ? null : val)}
>
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.actionSelectPlaceholder")}>
<SelectValue placeholder={t("filter.actionSelectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyAction")}</SelectItem>
<SelectItem value="create">{t("filter.create")}</SelectItem>
<SelectItem value="update">{t("filter.update")}</SelectItem>
<SelectItem value="delete">{t("filter.delete")}</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t("filter.userIdPlaceholder")}
className="w-full md:w-[180px] bg-background"
aria-label={t("filter.userIdPlaceholder")}
value={userId || ""}
onChange={(e) => setUserId(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.startDate")}
value={startDate || ""}
onChange={(e) => setStartDate(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.endDate")}
value={endDate || ""}
onChange={(e) => setEndDate(e.target.value || null)}
/>
{hasFilters && (
<Button
variant="ghost"
onClick={() => {
setTableName(null)
setAction(null)
setUserId(null)
setStartDate(null)
setEndDate(null)
}}
className="h-10 px-3"
aria-label={t("filter.reset")}
>
{t("filter.reset")}
<X className="ml-2 h-4 w-4" />
</Button>
)}
</FilterBar>
</div>
)
}

View File

@@ -1,10 +1,21 @@
"use client"
import { Suspense } from "react"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
import { Badge } from "@/shared/components/ui/badge"
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 { SkeletonCard } from "@/shared/components/ui/skeleton"
import { DataChangeLogTable } from "./data-change-log-table"
import { DataChangeLogFilters } from "./data-change-log-filters"
import { AuditLogTableSkeleton } from "./audit-log-table-skeleton"
import { useLogPagination } from "../hooks/use-log-pagination"
import type { DataChangeLog, DataChangeStat } from "../types"
@@ -27,11 +38,109 @@ function DataChangeLogViewInner({
tableOptions,
stats,
}: DataChangeLogViewProps) {
const t = useTranslations("audit")
const [tableName, setTableName] = useQueryState(
"tableName",
parseAsString.withOptions({ shallow: false }),
)
const [action, setAction] = useQueryState(
"action",
parseAsString.withOptions({ shallow: false }),
)
const [userId, setUserId] = useQueryState("userId", parseAsString.withOptions({ shallow: false }))
const [startDate, setStartDate] = useQueryState(
"startDate",
parseAsString.withOptions({ shallow: false }),
)
const [endDate, setEndDate] = useQueryState(
"endDate",
parseAsString.withOptions({ shallow: false }),
)
const hasFilters = Boolean(tableName || action || userId || startDate || endDate)
const handlePageChange = useLogPagination()
return (
<div className="space-y-4">
<DataChangeLogFilters tableOptions={tableOptions} stats={stats} />
{stats.length > 0 && (
<div className="flex flex-wrap gap-2" role="status" aria-label={t("dataChanges.title")}>
{stats.slice(0, 8).map((s) => (
<Badge key={s.tableName} variant="secondary" className="gap-1">
<span className="font-mono text-xs">{s.tableName}</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs">{s.count}</span>
</Badge>
))}
</div>
)}
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
setTableName(null)
setAction(null)
setUserId(null)
setStartDate(null)
setEndDate(null)
}}
>
<Select
value={tableName || "all"}
onValueChange={(val) => setTableName(val === "all" ? null : val)}
>
<SelectTrigger className="w-[180px] bg-background" aria-label={t("filter.tablePlaceholder")}>
<SelectValue placeholder={t("filter.tablePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyTable")}</SelectItem>
{tableOptions.map((tb) => (
<SelectItem key={tb} value={tb}>
{tb}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={action || "all"}
onValueChange={(val) => setAction(val === "all" ? null : val)}
>
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.actionSelectPlaceholder")}>
<SelectValue placeholder={t("filter.actionSelectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyAction")}</SelectItem>
<SelectItem value="create">{t("filter.create")}</SelectItem>
<SelectItem value="update">{t("filter.update")}</SelectItem>
<SelectItem value="delete">{t("filter.delete")}</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t("filter.userIdPlaceholder")}
className="w-full md:w-[180px] bg-background"
aria-label={t("filter.userIdPlaceholder")}
value={userId || ""}
onChange={(e) => setUserId(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.startDate")}
value={startDate || ""}
onChange={(e) => setStartDate(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.endDate")}
value={endDate || ""}
onChange={(e) => setEndDate(e.target.value || null)}
/>
</FilterBar>
<DataChangeLogTable
items={items}
page={page}
@@ -46,7 +155,7 @@ function DataChangeLogViewInner({
export function DataChangeLogView(props: DataChangeLogViewProps) {
return (
<Suspense fallback={<AuditLogTableSkeleton />}>
<Suspense fallback={<SkeletonCard variant="table" />}>
<DataChangeLogViewInner {...props} />
</Suspense>
)

View File

@@ -1,91 +0,0 @@
"use client"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
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"
export function LoginLogFilters() {
const t = useTranslations("audit")
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
const [userId, setUserId] = useQueryState("userId", parseAsString.withOptions({ shallow: false }))
const [startDate, setStartDate] = useQueryState(
"startDate",
parseAsString.withOptions({ shallow: false }),
)
const [endDate, setEndDate] = useQueryState(
"endDate",
parseAsString.withOptions({ shallow: false }),
)
const hasFilters = Boolean(action || status || userId || startDate || endDate)
return (
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
setAction(null)
setStatus(null)
setUserId(null)
setStartDate(null)
setEndDate(null)
}}
>
<Select value={action || "all"} onValueChange={(val) => setAction(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.actionSelectPlaceholder")}>
<SelectValue placeholder={t("filter.actionSelectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyAction")}</SelectItem>
<SelectItem value="signin">{t("filter.signIn")}</SelectItem>
<SelectItem value="signout">{t("filter.signOut")}</SelectItem>
<SelectItem value="signup">{t("filter.signUp")}</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t("filter.userIdPlaceholder")}
className="w-full md:w-[180px] bg-background"
aria-label={t("filter.userIdPlaceholder")}
value={userId || ""}
onChange={(e) => setUserId(e.target.value || null)}
/>
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.statusPlaceholder")}>
<SelectValue placeholder={t("filter.statusPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyStatus")}</SelectItem>
<SelectItem value="success">{t("filter.success")}</SelectItem>
<SelectItem value="failure">{t("filter.failure")}</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.startDate")}
value={startDate || ""}
onChange={(e) => setStartDate(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.endDate")}
value={endDate || ""}
onChange={(e) => setEndDate(e.target.value || null)}
/>
</FilterBar>
)
}

View File

@@ -1,10 +1,20 @@
"use client"
import { Suspense } from "react"
import { useTranslations } from "next-intl"
import { useQueryState, parseAsString } from "nuqs"
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 { SkeletonCard } from "@/shared/components/ui/skeleton"
import { LoginLogTable } from "./login-log-table"
import { LoginLogFilters } from "./login-log-filters"
import { AuditLogTableSkeleton } from "./audit-log-table-skeleton"
import { useLogPagination } from "../hooks/use-log-pagination"
import type { LoginLog } from "../types"
@@ -23,11 +33,82 @@ function LoginLogViewInner({
total,
totalPages,
}: LoginLogViewProps) {
const t = useTranslations("audit")
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
const [userId, setUserId] = useQueryState("userId", parseAsString.withOptions({ shallow: false }))
const [startDate, setStartDate] = useQueryState(
"startDate",
parseAsString.withOptions({ shallow: false }),
)
const [endDate, setEndDate] = useQueryState(
"endDate",
parseAsString.withOptions({ shallow: false }),
)
const hasFilters = Boolean(action || status || userId || startDate || endDate)
const handlePageChange = useLogPagination()
return (
<div className="space-y-4">
<LoginLogFilters />
<FilterBar
layout="wrap"
hasFilters={hasFilters}
onReset={() => {
setAction(null)
setStatus(null)
setUserId(null)
setStartDate(null)
setEndDate(null)
}}
>
<Select value={action || "all"} onValueChange={(val) => setAction(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.actionSelectPlaceholder")}>
<SelectValue placeholder={t("filter.actionSelectPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyAction")}</SelectItem>
<SelectItem value="signin">{t("filter.signIn")}</SelectItem>
<SelectItem value="signout">{t("filter.signOut")}</SelectItem>
<SelectItem value="signup">{t("filter.signUp")}</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t("filter.userIdPlaceholder")}
className="w-full md:w-[180px] bg-background"
aria-label={t("filter.userIdPlaceholder")}
value={userId || ""}
onChange={(e) => setUserId(e.target.value || null)}
/>
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.statusPlaceholder")}>
<SelectValue placeholder={t("filter.statusPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t("filter.anyStatus")}</SelectItem>
<SelectItem value="success">{t("filter.success")}</SelectItem>
<SelectItem value="failure">{t("filter.failure")}</SelectItem>
</SelectContent>
</Select>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.startDate")}
value={startDate || ""}
onChange={(e) => setStartDate(e.target.value || null)}
/>
<Input
type="date"
className="w-full md:w-[160px] bg-background"
aria-label={t("filter.endDate")}
value={endDate || ""}
onChange={(e) => setEndDate(e.target.value || null)}
/>
</FilterBar>
<LoginLogTable
items={items}
page={page}
@@ -42,7 +123,7 @@ function LoginLogViewInner({
export function LoginLogView(props: LoginLogViewProps) {
return (
<Suspense fallback={<AuditLogTableSkeleton />}>
<Suspense fallback={<SkeletonCard variant="table" />}>
<LoginLogViewInner {...props} />
</Suspense>
)

View File

@@ -1,57 +0,0 @@
"use client"
/**
* 班级模块 Error Boundary。
*
* 薄包装:委托给共享 SectionErrorBoundary通过自定义 fallback 实现
* 重试时调用 router.refresh() 刷新服务端数据。
* 保留同名导出以兼容现有 import。
*/
import type { ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
interface ClassErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
}
export function ClassErrorBoundary({
children,
fallback,
}: ClassErrorBoundaryProps): ReactNode {
const t = useTranslations("classes")
const router = useRouter()
const customFallback = (_error: Error, reset: () => void): ReactNode => {
const handleRetry = (): void => {
reset()
router.refresh()
}
return (
<div
role="alert"
className="flex min-h-[400px] flex-col items-center justify-center rounded-md border border-dashed p-8 text-center"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
</div>
<h3 className="mt-4 text-lg font-semibold">{t("errors.boundary.title")}</h3>
<p className="mb-4 mt-2 max-w-md text-sm text-muted-foreground">
{t("errors.boundary.description")}
</p>
<Button onClick={handleRetry}>{t("errors.boundary.retry")}</Button>
</div>
)
}
return (
<SectionErrorBoundary fallback={fallback ? () => fallback : customFallback}>
{children}
</SectionErrorBoundary>
)
}

View File

@@ -1,120 +0,0 @@
import type { JSX } from "react"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
interface ClassListSkeletonProps {
rows?: number
}
export function ClassListSkeleton({ rows = 5 }: ClassListSkeletonProps): JSX.Element {
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-10 rounded-full" />
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Skeleton className="h-4 w-20" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-16" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-24" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-20" />
</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell />
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
export function ClassCardSkeleton(): JSX.Element {
return (
<Card className="shadow-none">
<CardHeader>
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
)
}
export function ClassGridSkeleton({ count = 6 }: { count?: number }): JSX.Element {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: count }).map((_, index) => (
<ClassCardSkeleton key={index} />
))}
</div>
)
}
export function StudentListSkeleton({ rows = 8 }: { rows?: number }): JSX.Element {
return (
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: rows }).map((_, index) => (
<Skeleton key={index} className="h-10 w-full" />
))}
</div>
</div>
)
}
export function ScheduleGridSkeleton({ count = 6 }: { count?: number }): JSX.Element {
return (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: count }).map((_, idx) => (
<div key={idx} className="rounded-lg border bg-card p-6">
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-5 w-20" />
</div>
<div className="mt-6 space-y-3">
<Skeleton className="h-4 w-[70%]" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-4 w-[60%]" />
</div>
</div>
))}
</div>
)
}

View File

@@ -8,7 +8,7 @@ import { MessageList } from "@/modules/messaging/components/message-list"
*
* 独立获取数据,配合 Suspense + ErrorBoundary 实现流式渲染:
* - 页面立即渲染,此区块异步加载
* - 加载中显示 MessageListSkeleton
* - 加载中显示 SkeletonCard
* - 加载失败由 SectionErrorBoundary 捕获,不影响其他区块
*/
export async function MessageListSection({

View File

@@ -1,36 +0,0 @@
import type { JSX } from "react"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* P2-9: 消息列表骨架屏。
*
* 作为 Suspense fallback在 MessageListSection 流式加载期间展示。
*/
export function MessageListSkeleton(): JSX.Element {
return (
<div className="space-y-4" role="status" aria-label="Loading messages">
<div className="flex items-center justify-between">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-10 w-28" />
</div>
<Skeleton className="h-10 w-full" />
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i} aria-hidden="true">
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-3 w-20" />
</CardHeader>
<CardContent className="pt-0">
<Skeleton className="h-4 w-full" />
<Skeleton className="mt-1 h-4 w-3/4" />
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -1,48 +0,0 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { ChipNav } from "@/shared/components/ui/chip-nav"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
interface GradeInsightsFiltersProps {
grades: Array<{ id: string; name: string; schoolName: string }>
currentGradeId: string
buildHref: (gradeId: string) => string
}
/**
* 年级洞察筛选器:使用 ChipNav 替代原生 form get
* 点击 chip 即时通过 URL 参数切换,无整页刷新。
*/
export async function GradeInsightsFilters({
grades,
currentGradeId,
buildHref,
}: GradeInsightsFiltersProps): Promise<JSX.Element> {
const t = await getTranslations("school")
const options = grades.map((g) => ({
id: g.id,
name: `${g.schoolName} / ${g.name}`,
}))
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">{t("grades.gradeInsights.title")}</CardTitle>
<Badge variant="secondary" className="tabular-nums">
{grades.length}
</Badge>
</CardHeader>
<CardContent>
<ChipNav
options={options}
currentId={currentGradeId}
buildHref={buildHref}
allOption={{ id: "all", label: t("grades.gradeInsights.selectGrade") }}
/>
</CardContent>
</Card>
)
}

View File

@@ -1,57 +0,0 @@
"use client"
/**
* 学校模块 Error Boundary。
*
* 薄包装:委托给共享 SectionErrorBoundary通过自定义 fallback 实现
* 重试时调用 router.refresh() 刷新服务端数据。
* 保留同名导出以兼容现有 import。
*/
import type { ReactNode } from "react"
import { AlertCircle } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
interface SchoolErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
}
export function SchoolErrorBoundary({
children,
fallback,
}: SchoolErrorBoundaryProps): ReactNode {
const t = useTranslations("school")
const router = useRouter()
const customFallback = (_error: Error, reset: () => void): ReactNode => {
const handleRetry = (): void => {
reset()
router.refresh()
}
return (
<div
role="alert"
className="flex min-h-[400px] flex-col items-center justify-center rounded-md border border-dashed p-8 text-center"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
</div>
<h3 className="mt-4 text-lg font-semibold">{t("errors.boundary.title")}</h3>
<p className="mb-4 mt-2 max-w-md text-sm text-muted-foreground">
{t("errors.boundary.description")}
</p>
<Button onClick={handleRetry}>{t("errors.boundary.retry")}</Button>
</div>
)
}
return (
<SectionErrorBoundary fallback={fallback ? () => fallback : customFallback}>
{children}
</SectionErrorBoundary>
)
}

View File

@@ -1,69 +0,0 @@
import type { JSX } from "react"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
interface SchoolListSkeletonProps {
rows?: number
}
export function SchoolListSkeleton({ rows = 5 }: SchoolListSkeletonProps): JSX.Element {
return (
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-10 rounded-full" />
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Skeleton className="h-4 w-20" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-16" />
</TableHead>
<TableHead>
<Skeleton className="h-4 w-24" />
</TableHead>
<TableHead className="w-[60px]" />
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: rows }).map((_, index) => (
<TableRow key={index}>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell />
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
export function SchoolCardSkeleton(): JSX.Element {
return (
<Card className="shadow-none">
<CardHeader>
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardContent>
</Card>
)
}

View File

@@ -1,25 +0,0 @@
"use client"
/**
* 设置页分区 Error Boundary
*
* 薄包装:委托给共享 SectionErrorBoundary使用 common 命名空间。
* 保留同名导出以兼容现有 import。
*/
import type { ReactNode } from "react"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
interface SettingsSectionErrorBoundaryProps {
children: ReactNode
}
export function SettingsSectionErrorBoundary({
children,
}: SettingsSectionErrorBoundaryProps): ReactNode {
return (
<SectionErrorBoundary namespace="common">
{children}
</SectionErrorBoundary>
)
}

View File

@@ -12,7 +12,7 @@ import { ProfileSettingsForm } from "@/modules/settings/components/profile-setti
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
import { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
import { SecurityCenterCard } from "@/modules/settings/components/security-center-card"
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
@@ -148,32 +148,32 @@ function SettingsViewInner({
</TabsList>
<TabsContent value="general" className="mt-6 space-y-6">
<SettingsSectionErrorBoundary>
<SectionErrorBoundary namespace="settings">
<Suspense fallback={<SettingsSectionSkeleton />}>
<ProfileSettingsForm user={user} />
{generalExtra}
</Suspense>
</SettingsSectionErrorBoundary>
</SectionErrorBoundary>
</TabsContent>
<TabsContent value="notifications" className="mt-6 space-y-6">
<SettingsSectionErrorBoundary>
<SectionErrorBoundary namespace="settings">
<Suspense fallback={<SettingsSectionSkeleton />}>
<NotificationPreferencesForm preferences={notificationPreferences} />
</Suspense>
</SettingsSectionErrorBoundary>
</SectionErrorBoundary>
</TabsContent>
<TabsContent value="appearance" className="mt-6 space-y-6">
<SettingsSectionErrorBoundary>
<SectionErrorBoundary namespace="settings">
<Suspense fallback={<SettingsSectionSkeleton />}>
<ThemePreferencesCard />
</Suspense>
</SettingsSectionErrorBoundary>
</SectionErrorBoundary>
</TabsContent>
<TabsContent value="security" className="mt-6 space-y-6">
<SettingsSectionErrorBoundary>
<SectionErrorBoundary namespace="settings">
<Suspense fallback={<SettingsSectionSkeleton />}>
<PasswordChangeForm />
<SecurityCenterCard currentDeviceLabel={currentUserAgent} />
@@ -209,7 +209,7 @@ function SettingsViewInner({
</CardContent>
</Card>
</Suspense>
</SettingsSectionErrorBoundary>
</SectionErrorBoundary>
</TabsContent>
</Tabs>
</div>

View File

@@ -405,7 +405,9 @@
"error": {
"title": "Loading failed",
"description": "An error occurred while loading data. Please retry.",
"retry": "Retry"
"retry": "Retry",
"boundaryTitle": "Class data failed to load",
"boundaryDescription": "An error occurred while loading this class data section. Please retry."
},
"actions": {
"classCreated": "Class created successfully",

View File

@@ -403,6 +403,11 @@
"retry": "Retry"
}
},
"error": {
"boundaryTitle": "Loading Failed",
"boundaryDescription": "An error occurred while loading data. Please retry.",
"retry": "Retry"
},
"orgTree": {
"title": "Organization",
"search": "Search school/grade/class...",

View File

@@ -315,6 +315,11 @@
"sectionLoadFailed": "This section failed to load",
"sectionLoadFailedDesc": "Please try again later."
},
"error": {
"boundaryTitle": "Section Error",
"boundaryDescription": "An error occurred while processing the request. Please retry.",
"retry": "Retry"
},
"brand": {
"title": "Brand Configuration",
"description": "School brand information displayed on login/register pages.",

View File

@@ -409,7 +409,9 @@
"error": {
"title": "加载失败",
"description": "数据加载时发生错误,请重试",
"retry": "重试"
"retry": "重试",
"boundaryTitle": "班级数据加载失败",
"boundaryDescription": "班级数据区块加载时发生错误,请重试。"
},
"actions": {
"classCreated": "班级创建成功",

View File

@@ -403,6 +403,11 @@
"retry": "重试"
}
},
"error": {
"boundaryTitle": "加载失败",
"boundaryDescription": "数据加载时发生错误,请重试",
"retry": "重试"
},
"orgTree": {
"title": "组织架构",
"search": "搜索学校/年级/班级...",

View File

@@ -315,6 +315,11 @@
"sectionLoadFailed": "该区块加载失败",
"sectionLoadFailedDesc": "请稍后重试。"
},
"error": {
"boundaryTitle": "区块加载失败",
"boundaryDescription": "处理请求时发生错误,请重试。",
"retry": "重试"
},
"brand": {
"title": "品牌配置",
"description": "学校品牌信息,将显示在登录/注册页面。",