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:
@@ -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"
|
||||
|
||||
/**
|
||||
* 题库筛选栏。
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 各班级练习对比表 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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(""))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -102,8 +101,8 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="h-8 gap-1.5 text-xs px-3 shadow-none border-transparent bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
<Button
|
||||
className="h-8 gap-1.5 text-xs px-3 shadow-none border-transparent bg-transparent text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
disabled={classes.length === 0}
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -195,6 +194,6 @@ export function ScheduleFilters({ classes }: { classes: TeacherClass[] }) {
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
@@ -108,8 +105,8 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[200px]">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal">Filter by Class</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setClassId("all")}
|
||||
<DropdownMenuItem
|
||||
onClick={() => setClassId("all")}
|
||||
className="text-xs flex items-center justify-between"
|
||||
>
|
||||
{t("filters.allClasses")}
|
||||
@@ -117,9 +114,9 @@ export function StudentsFilters({ classes, defaultClassId }: { classes: TeacherC
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{classes.map((c) => (
|
||||
<DropdownMenuItem
|
||||
key={c.id}
|
||||
onClick={() => setClassId(c.id)}
|
||||
<DropdownMenuItem
|
||||
key={c.id}
|
||||
onClick={() => setClassId(c.id)}
|
||||
className="text-xs flex items-center justify-between"
|
||||
>
|
||||
<span className="truncate">{c.name}</span>
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 班级对比表(仅在"全部班级"视图下显示) */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { MessageList } from "@/modules/messaging/components/message-list"
|
||||
*
|
||||
* 独立获取数据,配合 Suspense + ErrorBoundary 实现流式渲染:
|
||||
* - 页面立即渲染,此区块异步加载
|
||||
* - 加载中显示 MessageListSkeleton
|
||||
* - 加载中显示 SkeletonCard
|
||||
* - 加载失败由 SectionErrorBoundary 捕获,不影响其他区块
|
||||
*/
|
||||
export async function MessageListSection({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -409,7 +409,9 @@
|
||||
"error": {
|
||||
"title": "加载失败",
|
||||
"description": "数据加载时发生错误,请重试",
|
||||
"retry": "重试"
|
||||
"retry": "重试",
|
||||
"boundaryTitle": "班级数据加载失败",
|
||||
"boundaryDescription": "班级数据区块加载时发生错误,请重试。"
|
||||
},
|
||||
"actions": {
|
||||
"classCreated": "班级创建成功",
|
||||
|
||||
@@ -403,6 +403,11 @@
|
||||
"retry": "重试"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"boundaryTitle": "加载失败",
|
||||
"boundaryDescription": "数据加载时发生错误,请重试",
|
||||
"retry": "重试"
|
||||
},
|
||||
"orgTree": {
|
||||
"title": "组织架构",
|
||||
"search": "搜索学校/年级/班级...",
|
||||
|
||||
@@ -315,6 +315,11 @@
|
||||
"sectionLoadFailed": "该区块加载失败",
|
||||
"sectionLoadFailedDesc": "请稍后重试。"
|
||||
},
|
||||
"error": {
|
||||
"boundaryTitle": "区块加载失败",
|
||||
"boundaryDescription": "处理请求时发生错误,请重试。",
|
||||
"retry": "重试"
|
||||
},
|
||||
"brand": {
|
||||
"title": "品牌配置",
|
||||
"description": "学校品牌信息,将显示在登录/注册页面。",
|
||||
|
||||
Reference in New Issue
Block a user