diff --git a/docs/architecture/005_architecture_data.json b/docs/architecture/005_architecture_data.json index 8fd8f8e..1d105fb 100644 --- a/docs/architecture/005_architecture_data.json +++ b/docs/architecture/005_architecture_data.json @@ -1096,6 +1096,12 @@ "signature": "useLocalStorage(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void]", "purpose": "localStorage持久化Hook" }, + { + "name": "useCurrentTime", + "file": "hooks/use-current-time.ts", + "signature": "useCurrentTime(intervalMs?: number): Date", + "purpose": "V4(P2-5)新增:返回当前时间并按指定间隔自动更新的 Hook,用于需要随时间刷新的 UI(如课表'进行中'徽章),避免 useMemo 依赖 [items] 导致过时" + }, { "name": "usePermission", "file": "hooks/use-permission.ts", @@ -6650,6 +6656,23 @@ "usedBy": [ "dashboard/actions.getAdminDashboardAction" ] + }, + { + "name": "getAdminDashboardStreams", + "signature": "() => Promise", + "deps": [ + "auth-guard.requirePermission", + "users/data-access.getUsersDashboardStats", + "classes/data-access.getClassesDashboardStats", + "textbooks/data-access.getTextbooksDashboardStats", + "questions/data-access.getQuestionsDashboardStats", + "exams/data-access.getExamsDashboardStats", + "homework/stats-service.getHomeworkDashboardStats" + ], + "purpose": "V4(P2-1)新增:管理员仪表盘流式数据源,返回各独立数据源的未解析 Promise,供各分区组件用 React use() 独立消费实现流式渲染", + "usedBy": [ + "app/(dashboard)/admin/dashboard/page.tsx" + ] } ], "types": [ @@ -6814,6 +6837,41 @@ "name": "DashboardErrorFallback", "file": "dashboard-error-fallback", "purpose": "V3 新增:仪表盘共享错误边界组件,含 i18n + reset() 重试,消除 5 个 error.tsx 路由文件的重复代码" + }, + { + "name": "AdminStatsBar", + "file": "admin-dashboard/admin-sections", + "purpose": "V4(P2-1)新增:管理员顶部统计栏流式组件,用 React use() 独立消费 usersStats/classesStats/homeworkStats Promise" + }, + { + "name": "AdminContentCard", + "file": "admin-dashboard/admin-sections", + "purpose": "V4(P2-1)新增:管理员内容统计卡片流式组件,用 React use() 消费 textbooksStats/questionsStats/examsStats Promise" + }, + { + "name": "AdminHomeworkActivityCard", + "file": "admin-dashboard/admin-sections", + "purpose": "V4(P2-1)新增:管理员作业活跃度卡片流式组件,用 React use() 消费 homeworkStats Promise" + }, + { + "name": "AdminUserRolesCard", + "file": "admin-dashboard/admin-sections", + "purpose": "V4(P2-1)新增:管理员用户角色分布卡片流式组件,用 React use() 消费 usersStats Promise" + }, + { + "name": "AdminRecentUsersTable", + "file": "admin-dashboard/admin-sections", + "purpose": "V4(P2-1)新增:管理员最近注册用户表流式组件,用 React use() 消费 usersStats Promise + getLocale" + }, + { + "name": "AdminTrendCharts", + "file": "admin-dashboard/admin-sections", + "purpose": "V4(P2-1)新增:管理员趋势图表组件(静态,趋势数据待接入)" + }, + { + "name": "AdminHeaderBadges", + "file": "admin-dashboard/admin-sections", + "purpose": "V4(P2-1)新增:管理员页头徽章流式组件,用 React use() 消费 usersStats Promise" } ] } @@ -7425,10 +7483,13 @@ { "name": "SecurityCenterCard", "file": "components/security-center-card.tsx", - "purpose": "安全中心卡片(P2-9 新增;v2 已增强:2FA 开关改为禁用状态显示'即将推出'、新增'登出所有其他会话'按钮、通过 currentDeviceLabel 标记当前会话、纯函数抽取到 lib/security-utils.ts;2FA 状态存储在 system_settings 表;登录历史来自 login_logs 表;i18n:settings.security.center)", + "purpose": "安全中心卡片(P2-9 新增;v2 增强:会话远程登出、currentDeviceLabel、纯函数抽取;v3 增强:完整 TOTP 2FA 流程 — 启用(QR码+验证码+备份码)/关闭/重新生成备份码,三个 Dialog;2FA 状态存储在 system_settings 表;登录历史来自 login_logs 表;i18n:settings.security.center)", "deps": [ "getSecurityCenterAction", - "toggleTwoFactorAction", + "setupTwoFactorAction", + "verifyTwoFactorAction", + "disableTwoFactorAction", + "regenerateBackupCodesAction", "revokeAllOtherSessionsAction", "lib/security-utils.parseUserAgent", "lib/security-utils.formatRelativeTime" diff --git a/src/app/(dashboard)/admin/dashboard/page.tsx b/src/app/(dashboard)/admin/dashboard/page.tsx index b1969cb..3a65812 100644 --- a/src/app/(dashboard)/admin/dashboard/page.tsx +++ b/src/app/(dashboard)/admin/dashboard/page.tsx @@ -3,7 +3,7 @@ import type { JSX } from "react" import { getTranslations } from "next-intl/server" import { AdminDashboardView } from "@/modules/dashboard/components/admin-dashboard/admin-dashboard" -import { getAdminDashboardAction } from "@/modules/dashboard/actions" +import { getAdminDashboardStreams } from "@/modules/dashboard/streams" export const dynamic = "force-dynamic" @@ -16,6 +16,7 @@ export async function generateMetadata(): Promise { } export default async function AdminDashboardPage(): Promise { - const data = await getAdminDashboardAction() - return + // 权限校验在此完成(阻塞),返回后各分区 Promise 并行执行、独立流式渲染 + const streams = await getAdminDashboardStreams() + return } diff --git a/src/app/(dashboard)/parent/dashboard/page.tsx b/src/app/(dashboard)/parent/dashboard/page.tsx index 734b87c..fac447f 100644 --- a/src/app/(dashboard)/parent/dashboard/page.tsx +++ b/src/app/(dashboard)/parent/dashboard/page.tsx @@ -1,11 +1,19 @@ import type { Metadata } from "next" import type { JSX } from "react" import { getTranslations } from "next-intl/server" +import { use } from "react" +import { Users } from "lucide-react" import { getParentDashboardAction } from "@/modules/dashboard/actions" import { ParentDashboard } from "@/modules/parent/components/parent-dashboard" import { ParentNoChildrenPage } from "@/modules/parent/components/parent-children-data-page" -import { Users } from "lucide-react" +import type { ActionState } from "@/shared/types/action-state" +import type { ParentDashboardData } from "@/modules/parent/types" + +type ParentDashboardResult = ActionState<{ + data: ParentDashboardData | null + hasChildren: boolean +}> export const dynamic = "force-dynamic" @@ -18,8 +26,27 @@ export async function generateMetadata(): Promise { } export default async function ParentDashboardPage(): Promise { + // 传入未解析的 Promise,视图内用 React `use()` 消费,启用 Suspense 流式渲染 + const dataPromise = getParentDashboardAction() + return +} + +function ParentDashboardResolver({ dataPromise }: { dataPromise: Promise }) { + const result = use(dataPromise) + if (!result.success || !result.data) { + throw new Error(result.message ?? "Failed to load parent dashboard") + } + return +} + +async function ParentDashboardBody({ + data, + hasChildren, +}: { + data: ParentDashboardData | null + hasChildren: boolean +}) { const t = await getTranslations("dashboard") - const { data, hasChildren } = await getParentDashboardAction() if (!data || !hasChildren) { return ( diff --git a/src/app/(dashboard)/student/dashboard/page.tsx b/src/app/(dashboard)/student/dashboard/page.tsx index 8a73774..0a2afec 100644 --- a/src/app/(dashboard)/student/dashboard/page.tsx +++ b/src/app/(dashboard)/student/dashboard/page.tsx @@ -4,8 +4,6 @@ import { getTranslations } from "next-intl/server" import { StudentDashboard } from "@/modules/dashboard/components/student-dashboard/student-dashboard-view" import { getStudentDashboardAction } from "@/modules/dashboard/actions" -import { EmptyState } from "@/shared/components/ui/empty-state" -import { UserX } from "lucide-react" export const dynamic = "force-dynamic" @@ -18,26 +16,7 @@ export async function generateMetadata(): Promise { } export default async function StudentDashboardPage(): Promise { - const t = await getTranslations("dashboard") - const { student, dashboardProps } = await getStudentDashboardAction() - - if (!student || !dashboardProps) { - return ( - - ) - } - - return ( -
- -
- ) + // 传入未解析的 Promise,视图内用 React `use()` 消费,启用 Suspense 流式渲染 + const dataPromise = getStudentDashboardAction() + return } diff --git a/src/app/(dashboard)/teacher/dashboard/page.tsx b/src/app/(dashboard)/teacher/dashboard/page.tsx index 56ca475..466ffbd 100644 --- a/src/app/(dashboard)/teacher/dashboard/page.tsx +++ b/src/app/(dashboard)/teacher/dashboard/page.tsx @@ -16,6 +16,7 @@ export async function generateMetadata(): Promise { } export default async function TeacherDashboardPage(): Promise { - const data = await getTeacherDashboardAction() - return + // 传入未解析的 Promise,视图内用 React `use()` 消费,启用 Suspense 流式渲染 + const dataPromise = getTeacherDashboardAction() + return } diff --git a/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx b/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx index 88f0101..c5b301f 100644 --- a/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx +++ b/src/modules/dashboard/components/admin-dashboard/admin-dashboard.tsx @@ -1,35 +1,39 @@ import type { ReactNode } from "react" +import { Suspense } from "react" import Link from "next/link" import { getTranslations } from "next-intl/server" import { - Activity, - BookOpen, CalendarCheck, CalendarClock, - ClipboardList, - FileText, FolderOpen, - LayoutDashboard, - Library, Megaphone, Upload, - Users, - ChevronRight, } from "lucide-react" -import type { AdminDashboardData } from "@/modules/dashboard/types" -import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" -import { StatCard } from "@/shared/components/ui/stat-card" +import { Card, CardContent } from "@/shared/components/ui/card" import { PageHeader } from "@/shared/components/ui/page-header" -import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" -import { EmptyState } from "@/shared/components/ui/empty-state" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" -import { formatDate } from "@/shared/lib/utils" +import { Badge } from "@/shared/components/ui/badge" +import { Skeleton } from "@/shared/components/ui/skeleton" import { DashboardSection } from "../dashboard-section" -import { UserGrowthChart } from "./user-growth-chart" +import type { AdminDashboardStreams } from "../../streams" +import { + AdminContentCard, + AdminHeaderBadges, + AdminHomeworkActivityCard, + AdminRecentUsersTable, + AdminStatsBar, + AdminTrendCharts, + AdminUserRolesCard, +} from "./admin-sections" -export async function AdminDashboardView({ data }: { data: AdminDashboardData }) { +/** + * 管理员仪表盘视图(P2-1 流式架构) + * + * 页面外壳(标题 + 快捷操作)立即渲染;各数据分区用 React `use()` 独立消费 streams 中的 Promise, + * 在各自的 `` Suspense 边界内流式填充,互不阻塞。 + */ +export async function AdminDashboardView({ streams }: { streams: AdminDashboardStreams }) { const t = await getTranslations("dashboard") return ( @@ -51,28 +55,18 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData }) {t("quickActions.newAnnouncement")} - - - {t("badge.activeSessions", { count: data.activeSessionsCount })} - - - - {t("badge.users", { count: data.userCount })} - + }> + + } /> -
- - - - -
+
- {/* 快捷操作 */} + {/* 快捷操作 — 纯静态,无需数据获取 */}
- {/* 趋势图表 */} -
- - - - {t("sections.userGrowthTrend")} - - - - - - - - - - {t("sections.homeworkSubmissionTrend")} - - - - - - -
+ + +
- - - {t("sections.userRoles")} - - - {data.userRoleCounts.length === 0 ? ( - - ) : ( - data.userRoleCounts.map((r) => ( -
- {r.role} -
{r.count}
-
- )) - )} -
-
+
- - - - {t("sections.content")} - - - } /> - } /> - } /> - } /> - - + - - - - {t("sections.homeworkActivity")} - - - } /> - } /> - } /> - - +
- - - {t("sections.recentUsers")} - - - {data.recentUsers.length === 0 ? ( - - ) : ( - - - - - {t("table.name")} - {t("table.email")} - {t("table.role")} - {t("table.created")} - - - - {data.recentUsers.map((u) => ( - - {u.name || "-"} - {u.email} - - {u.role ?? t("badge.unknown")} - - {formatDate(u.createdAt)} - - ))} - -
{t("sections.recentUsers")}
- )} -
- -
-
-
+
) } -function ContentRow({ - label, - value, - icon, -}: { - label: string - value: number - icon: ReactNode -}) { +function HeaderBadgeSkeleton(): ReactNode { return ( -
-
- {icon} -
{label}
-
-
{value}
-
+ <> + + + + + + + + + ) } diff --git a/src/modules/dashboard/components/admin-dashboard/admin-sections.tsx b/src/modules/dashboard/components/admin-dashboard/admin-sections.tsx new file mode 100644 index 0000000..c5c10d9 --- /dev/null +++ b/src/modules/dashboard/components/admin-dashboard/admin-sections.tsx @@ -0,0 +1,231 @@ +import { use } from "react" +import Link from "next/link" +import { getLocale } from "next-intl/server" +import { + Activity, + BookOpen, + ClipboardList, + FileText, + LayoutDashboard, + Library, + Users, + ChevronRight, +} from "lucide-react" + +import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" +import { StatCard } from "@/shared/components/ui/stat-card" +import { Badge } from "@/shared/components/ui/badge" +import { Button } from "@/shared/components/ui/button" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" +import { formatDate } from "@/shared/lib/utils" +import type { AdminDashboardStreams } from "../../streams" +import { UserGrowthChart } from "./user-growth-chart" + +type TranslationFunction = Awaited> + +// ─── 顶部统计栏 ──────────────────────────────────────────── + +export function AdminStatsBar({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) { + const [usersStats, classesStats, homeworkStats] = use(Promise.all([ + streams.usersStats, + streams.classesStats, + streams.homeworkStats, + ])) + + return ( +
+ + + + +
+ ) +} + +// ─── 页头徽章(活跃会话 + 用户数) ───────────────────────── + +export function AdminHeaderBadges({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) { + const usersStats = use(streams.usersStats) + + return ( + <> + + + {t("badge.activeSessions", { count: usersStats.activeSessionsCount })} + + + + {t("badge.users", { count: usersStats.userCount })} + + + ) +} + +// ─── 内容统计卡片 ────────────────────────────────────────── + +export function AdminContentCard({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) { + const [textbooksStats, questionsStats, examsStats] = use(Promise.all([ + streams.textbooksStats, + streams.questionsStats, + streams.examsStats, + ])) + + return ( + + + {t("sections.content")} + + + } /> + } /> + } /> + } /> + + + ) +} + +// ─── 作业活跃度卡片 ──────────────────────────────────────── + +export function AdminHomeworkActivityCard({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) { + const homeworkStats = use(streams.homeworkStats) + + return ( + + + {t("sections.homeworkActivity")} + + + } /> + } /> + } /> + + + ) +} + +// ─── 用户角色分布卡片 ────────────────────────────────────── + +export function AdminUserRolesCard({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) { + const usersStats = use(streams.usersStats) + + return ( + + + {t("sections.userRoles")} + + + {usersStats.userRoleCounts.length === 0 ? ( + + ) : ( + usersStats.userRoleCounts.map((r) => ( +
+ {r.role} +
{r.count}
+
+ )) + )} +
+
+ ) +} + +// ─── 趋势图表 ────────────────────────────────────────────── + +export function AdminTrendCharts({ t }: { t: TranslationFunction }) { + return ( +
+ + + {t("sections.userGrowthTrend")} + + + + + + + + {t("sections.homeworkSubmissionTrend")} + + + + + +
+ ) +} + +// ─── 最近注册用户表 ──────────────────────────────────────── + +export function AdminRecentUsersTable({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) { + const locale = use(getLocale()) + const usersStats = use(streams.usersStats) + + return ( + + + {t("sections.recentUsers")} + + + {usersStats.recentUsers.length === 0 ? ( + + ) : ( + + + + + {t("table.name")} + {t("table.email")} + {t("table.role")} + {t("table.created")} + + + + {usersStats.recentUsers.map((u) => ( + + {u.name || "-"} + {u.email} + + {u.role ?? t("badge.unknown")} + + {formatDate(u.createdAt, locale)} + + ))} + +
{t("sections.recentUsers")}
+ )} +
+ +
+
+
+ ) +} + +// ─── 辅助组件 ────────────────────────────────────────────── + +function ContentRow({ + label, + value, + icon, +}: { + label: string + value: number + icon: React.ReactNode +}) { + return ( +
+
+ {icon} +
{label}
+
+
{value}
+
+ ) +} diff --git a/src/modules/dashboard/components/dashboard-section.test.tsx b/src/modules/dashboard/components/dashboard-section.test.tsx new file mode 100644 index 0000000..6d95f62 --- /dev/null +++ b/src/modules/dashboard/components/dashboard-section.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen } from "@testing-library/react" +import { DashboardSection, DashboardSectionSkeleton } from "./dashboard-section" + +// Mock next-intl to avoid provider setup +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => { + const messages: Record = { + "sectionLoadFailed": "区块加载失败", + "sectionLoadFailedDesc": "请重试", + "retry": "重试", + } + return messages[key] ?? key + }, +})) + +describe("DashboardSectionSkeleton", () => { + it("renders stats variant with 4 skeleton cards", () => { + const { container } = render() + const cards = container.querySelectorAll('[class*="card"]') + expect(cards.length).toBeGreaterThanOrEqual(4) + }) + + it("renders chart variant with skeleton", () => { + const { container } = render() + expect(container.querySelector('[class*="h-[200px]"]')).toBeTruthy() + }) + + it("renders table variant with 5 skeleton rows", () => { + const { container } = render() + const skeletons = container.querySelectorAll('[class*="h-10"]') + expect(skeletons.length).toBe(5) + }) + + it("renders card variant by default", () => { + const { container } = render() + expect(container.querySelector('[class*="h-4"]')).toBeTruthy() + }) +}) + +describe("DashboardSection", () => { + it("renders children when no error", () => { + render( + +
Content
+
+ ) + expect(screen.getByTestId("child")).toBeTruthy() + expect(screen.getByText("Content")).toBeTruthy() + }) + + it("renders error fallback when child throws", () => { + const ThrowingChild = (): never => { + throw new Error("test error") + } + + // Suppress console.error for this test + const spy = vi.spyOn(console, "error").mockImplementation(() => {}) + + render( + + + + ) + + expect(screen.getByText("区块加载失败")).toBeTruthy() + expect(screen.getByText("重试")).toBeTruthy() + spy.mockRestore() + }) +}) diff --git a/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx b/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx index 915c7c0..1eff743 100644 --- a/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-dashboard-view.tsx @@ -1,6 +1,10 @@ +import { use } from "react" import { getTranslations } from "next-intl/server" +import { UserX } from "lucide-react" +import type { ActionState } from "@/shared/types/action-state" import type { StudentDashboardProps } from "@/modules/dashboard/types" +import { EmptyState } from "@/shared/components/ui/empty-state" import { DashboardSection } from "../dashboard-section" import { StudentDashboardHeader } from "./student-dashboard-header" @@ -9,30 +13,59 @@ import { StudentStatsGrid } from "./student-stats-grid" import { StudentTodayScheduleCard } from "./student-today-schedule-card" import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card" -export async function StudentDashboard({ - studentName, - enrolledClassCount, - dueSoonCount, - overdueCount, - gradedCount, - todayScheduleItems, - upcomingAssignments, - grades, -}: StudentDashboardProps) { +type StudentDashboardResult = ActionState<{ + student: { id: string; name: string } | null + dashboardProps: Omit | null +}> + +/** + * 学生仪表盘视图(P2-1 流式架构) + * + * 接收未解析的 Promise,用 React `use()` 消费。 + * 页面外壳立即渲染,数据到达后在 DashboardSection 的 Suspense 边界内填充。 + */ +export function StudentDashboard({ dataPromise }: { dataPromise: Promise }) { + const result = use(dataPromise) + if (!result.success || !result.data) { + throw new Error(result.message ?? "Failed to load student dashboard") + } + + return +} + +async function StudentDashboardBody({ + result, +}: { + result: NonNullable +}) { const t = await getTranslations("dashboard") + + if (!result.student || !result.dashboardProps) { + return ( + + ) + } + + const { student, dashboardProps } = result + return (
- +
@@ -42,15 +75,15 @@ export async function StudentDashboard({ className="lg:col-span-2 space-y-6" > - + - +
diff --git a/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx b/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx index c25f337..d0dc521 100644 --- a/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-today-schedule-card.tsx @@ -10,6 +10,7 @@ import { Badge } from "@/shared/components/ui/badge" import { ScheduleList } from "@/shared/components/schedule/schedule-list" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { EmptyState } from "@/shared/components/ui/empty-state" +import { useCurrentTime } from "@/shared/hooks" import { cn } from "@/shared/lib/utils" import type { StudentTodayScheduleItem } from "@/modules/dashboard/types" @@ -21,9 +22,9 @@ const timeToMinutes = (t: string): number => { export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) { const t = useTranslations("dashboard") const hasSchedule = items.length > 0 + const now = useCurrentTime() const { currentId, nextId } = useMemo(() => { - const now = new Date() const nowMin = now.getHours() * 60 + now.getMinutes() let currentId: string | null = null let nextId: string | null = null @@ -40,7 +41,7 @@ export function StudentTodayScheduleCard({ items }: { items: StudentTodaySchedul } } return { currentId, nextId } - }, [items]) + }, [items, now]) return ( @@ -59,6 +60,7 @@ export function StudentTodayScheduleCard({ items }: { items: StudentTodaySchedul icon={CalendarX} title={t("empty.noClassesToday")} description={t("empty.noClassesTodayDesc")} + action={{ label: t("quickActions.viewSchedule"), href: "/student/schedule" }} className="border-none h-72" /> ) : ( diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx index b33f324..127a005 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-view.tsx @@ -1,5 +1,7 @@ +import { use } from "react" import type { TeacherDashboardData } from "@/modules/dashboard/types" import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-utils" +import type { ActionState } from "@/shared/types/action-state" import { getTranslations } from "next-intl/server" import type { TeacherTodoItem } from "./teacher-todo-card" @@ -13,11 +15,24 @@ import { TeacherStats } from "./teacher-stats" import { TeacherGradeTrends } from "./teacher-grade-trends" import { TeacherTodoCard } from "./teacher-todo-card" -interface TeacherDashboardViewProps { - data: TeacherDashboardData & { metrics: TeacherDashboardMetrics } +type TeacherDashboardResult = ActionState + +/** + * 教师仪表盘视图(P2-1 流式架构) + * + * 接收未解析的 Promise,用 React `use()` 消费。 + * 页面外壳立即渲染,数据到达后在 DashboardSection 的 Suspense 边界内填充。 + */ +export function TeacherDashboardView({ dataPromise }: { dataPromise: Promise }) { + const result = use(dataPromise) + if (!result.success || !result.data) { + throw new Error(result.message ?? "Failed to load teacher dashboard") + } + + return } -export async function TeacherDashboardView({ data }: TeacherDashboardViewProps) { +async function TeacherDashboardContent({ data }: { data: TeacherDashboardData & { metrics: TeacherDashboardMetrics } }) { const t = await getTranslations("dashboard") const { metrics } = data @@ -58,13 +73,15 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps) />
-
-
-
- - - -
+
+ {/* 课表:移动端首位,桌面端右上 — 仅渲染一次(P2-9 修复,原为双实例) */} +
+ + + +
+ +
@@ -81,12 +98,7 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
-