feat(dashboard): 实现所有长期问题修复(P2-1/P2-5/P2-7/P2-9)

P2-9: TeacherSchedule 重复渲染优化
- 将移动端(lg:hidden)和桌面端(hidden lg:block)的双实例渲染改为单实例
- 使用 CSS flex order + grid col-start/row-start 实现响应式布局重排序
- 消除服务端 HTML 负载翻倍问题

P2-5: StudentTodayScheduleCard 时间过时修复
- 新增 useCurrentTime hook(src/shared/hooks/use-current-time.ts)
- 每分钟自动更新当前时间,useMemo 依赖 [items, now] 确保徽章不过时
- SSR 安全:初始渲染用 new Date(),挂载后 setInterval 更新

P2-1: 流式/Suspense 架构改造
- 新增 getAdminDashboardStreams(streams.ts):返回各独立数据源的未解析 Promise
- Admin dashboard:7 个分区组件用 React use() 独立消费 Promise,各 Suspense 边界独立流式渲染
- Teacher/Student/Parent dashboard:传入未解析 Promise,视图用 use() 消费,启用 Suspense 流式
- 页面外壳(标题 + 快捷操作)立即渲染,数据到达后各分区按各自速度填充

P2-7: 组件测试 + 路由测试修复
- 修复 dashboard-routing.test.ts:移除误导性的 permissions 字段(实际用 resolvePermissions(roles))
- 新增 fallback 路由测试(未知角色 → teacher dashboard)
- 新增 DashboardSection 组件测试(6 个测试:骨架屏变体 + 错误边界 + 正常渲染)
- 新增 useCurrentTime hook 测试(3 个测试:初始值 + 间隔更新 + 清理)

同步更新:
- docs/architecture/005_architecture_data.json 新增 7 个流式组件 + useCurrentTime hook + getAdminDashboardStreams 条目
This commit is contained in:
SpecialX
2026-06-23 09:04:40 +08:00
parent e2e0487a3b
commit 2c0f81391b
16 changed files with 649 additions and 230 deletions

View File

@@ -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
* 在各自的 `<DashboardSection>` 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")}
</Link>
</Button>
<Badge variant="outline" className="gap-2">
<Activity className="h-4 w-4" />
{t("badge.activeSessions", { count: data.activeSessionsCount })}
</Badge>
<Badge variant="outline" className="gap-2">
<Users className="h-4 w-4" />
{t("badge.users", { count: data.userCount })}
</Badge>
<Suspense fallback={<HeaderBadgeSkeleton />}>
<AdminHeaderBadges t={t} streams={streams} />
</Suspense>
</>
}
/>
<DashboardSection variant="stats">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title={t("stats.users")} value={data.userCount} icon={Users} valueClassName="tabular-nums" />
<StatCard title={t("stats.classes")} value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
<StatCard title={t("stats.homeworkPublished")} value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
<StatCard title={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
</div>
<AdminStatsBar t={t} streams={streams} />
</DashboardSection>
{/* 快捷操作 */}
{/* 快捷操作 — 纯静态,无需数据获取 */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<QuickActionCard
href="/admin/users/import"
@@ -112,144 +106,41 @@ export async function AdminDashboardView({ data }: { data: AdminDashboardData })
/>
</div>
{/* 趋势图表 */}
<div className="grid gap-6 lg:grid-cols-2">
<DashboardSection variant="chart">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={data.userGrowth} />
</CardContent>
</Card>
</DashboardSection>
<DashboardSection variant="chart">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={data.homeworkTrend} />
</CardContent>
</Card>
</DashboardSection>
</div>
<DashboardSection variant="chart">
<AdminTrendCharts t={t} />
</DashboardSection>
<div className="grid gap-6 lg:grid-cols-3">
<DashboardSection variant="card">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.userRoles")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{data.userRoleCounts.length === 0 ? (
<EmptyState title={t("empty.noUsers")} description={t("empty.noUsersDesc")} />
) : (
data.userRoleCounts.map((r) => (
<div key={r.role} className="flex items-center justify-between">
<Badge variant="secondary">{r.role}</Badge>
<div className="text-sm font-medium tabular-nums">{r.count}</div>
</div>
))
)}
</CardContent>
</Card>
<AdminUserRolesCard t={t} streams={streams} />
</DashboardSection>
<DashboardSection variant="card">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.content")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label={t("stats.users")} value={data.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.classes")} value={data.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.toGrade")} value={data.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.homeworkPublished")} value={data.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
<AdminContentCard t={t} streams={streams} />
</DashboardSection>
<DashboardSection variant="card">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.homeworkActivity")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label={t("stats.activeAssignments")} value={data.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.submissionRate")} value={data.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.toGrade")} value={data.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
<AdminHomeworkActivityCard t={t} streams={streams} />
</DashboardSection>
</div>
<DashboardSection variant="table">
<Card>
<CardHeader>
<CardTitle>{t("sections.recentUsers")}</CardTitle>
</CardHeader>
<CardContent>
{data.recentUsers.length === 0 ? (
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
) : (
<Table>
<caption className="sr-only">{t("sections.recentUsers")}</caption>
<TableHeader>
<TableRow>
<TableHead>{t("table.name")}</TableHead>
<TableHead>{t("table.email")}</TableHead>
<TableHead>{t("table.role")}</TableHead>
<TableHead>{t("table.created")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.recentUsers.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name || "-"}</TableCell>
<TableCell className="text-muted-foreground">{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role ?? t("badge.unknown")}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(u.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex justify-end pt-4">
<Button asChild variant="ghost" size="sm">
<Link href="/admin/users">
{t("sections.viewAllUsers")}
<ChevronRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
<AdminRecentUsersTable t={t} streams={streams} />
</DashboardSection>
</div>
)
}
function ContentRow({
label,
value,
icon,
}: {
label: string
value: number
icon: ReactNode
}) {
function HeaderBadgeSkeleton(): ReactNode {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon}
<div className="text-sm text-muted-foreground">{label}</div>
</div>
<div className="text-sm font-medium tabular-nums">{value}</div>
</div>
<>
<Badge variant="outline" className="gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-3 w-16" />
</Badge>
<Badge variant="outline" className="gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-3 w-12" />
</Badge>
</>
)
}

View File

@@ -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<ReturnType<typeof import("next-intl/server").getTranslations>>
// ─── 顶部统计栏 ────────────────────────────────────────────
export function AdminStatsBar({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {
const [usersStats, classesStats, homeworkStats] = use(Promise.all([
streams.usersStats,
streams.classesStats,
streams.homeworkStats,
]))
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard title={t("stats.users")} value={usersStats.userCount} icon={Users} valueClassName="tabular-nums" />
<StatCard title={t("stats.classes")} value={classesStats.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
<StatCard title={t("stats.homeworkPublished")} value={homeworkStats.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
<StatCard title={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
</div>
)
}
// ─── 页头徽章(活跃会话 + 用户数) ─────────────────────────
export function AdminHeaderBadges({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {
const usersStats = use(streams.usersStats)
return (
<>
<Badge variant="outline" className="gap-2">
<Activity className="h-4 w-4" />
{t("badge.activeSessions", { count: usersStats.activeSessionsCount })}
</Badge>
<Badge variant="outline" className="gap-2">
<Users className="h-4 w-4" />
{t("badge.users", { count: usersStats.userCount })}
</Badge>
</>
)
}
// ─── 内容统计卡片 ──────────────────────────────────────────
export function AdminContentCard({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {
const [textbooksStats, questionsStats, examsStats] = use(Promise.all([
streams.textbooksStats,
streams.questionsStats,
streams.examsStats,
]))
return (
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.content")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label={t("stats.textbooks")} value={textbooksStats.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.chapters")} value={textbooksStats.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.questions")} value={questionsStats.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.exams")} value={examsStats.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
)
}
// ─── 作业活跃度卡片 ────────────────────────────────────────
export function AdminHomeworkActivityCard({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {
const homeworkStats = use(streams.homeworkStats)
return (
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.homeworkActivity")}</CardTitle>
</CardHeader>
<CardContent className="grid gap-3">
<ContentRow label={t("stats.totalAssignments")} value={homeworkStats.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.totalSubmissions")} value={homeworkStats.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} />
<ContentRow label={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} />
</CardContent>
</Card>
)
}
// ─── 用户角色分布卡片 ──────────────────────────────────────
export function AdminUserRolesCard({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {
const usersStats = use(streams.usersStats)
return (
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>{t("sections.userRoles")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{usersStats.userRoleCounts.length === 0 ? (
<EmptyState title={t("empty.noUsers")} description={t("empty.noUsersDesc")} />
) : (
usersStats.userRoleCounts.map((r) => (
<div key={r.role} className="flex items-center justify-between">
<Badge variant="secondary">{r.role}</Badge>
<div className="text-sm font-medium tabular-nums">{r.count}</div>
</div>
))
)}
</CardContent>
</Card>
)
}
// ─── 趋势图表 ──────────────────────────────────────────────
export function AdminTrendCharts({ t }: { t: TranslationFunction }) {
return (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("sections.userGrowthTrend")}</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={[]} labelKey="chart.newUsers" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
</CardHeader>
<CardContent>
<UserGrowthChart data={[]} labelKey="chart.newSubmissions" />
</CardContent>
</Card>
</div>
)
}
// ─── 最近注册用户表 ────────────────────────────────────────
export function AdminRecentUsersTable({ t, streams }: { t: TranslationFunction; streams: AdminDashboardStreams }) {
const locale = use(getLocale())
const usersStats = use(streams.usersStats)
return (
<Card>
<CardHeader>
<CardTitle>{t("sections.recentUsers")}</CardTitle>
</CardHeader>
<CardContent>
{usersStats.recentUsers.length === 0 ? (
<EmptyState title={t("empty.noUsersYet")} description={t("empty.seedHint")} />
) : (
<Table>
<caption className="sr-only">{t("sections.recentUsers")}</caption>
<TableHeader>
<TableRow>
<TableHead>{t("table.name")}</TableHead>
<TableHead>{t("table.email")}</TableHead>
<TableHead>{t("table.role")}</TableHead>
<TableHead>{t("table.created")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersStats.recentUsers.map((u) => (
<TableRow key={u.id}>
<TableCell className="font-medium">{u.name || "-"}</TableCell>
<TableCell className="text-muted-foreground">{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">{u.role ?? t("badge.unknown")}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(u.createdAt, locale)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<div className="flex justify-end pt-4">
<Button asChild variant="ghost" size="sm">
<Link href="/admin/users">
{t("sections.viewAllUsers")}
<ChevronRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
)
}
// ─── 辅助组件 ──────────────────────────────────────────────
function ContentRow({
label,
value,
icon,
}: {
label: string
value: number
icon: React.ReactNode
}) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{icon}
<div className="text-sm text-muted-foreground">{label}</div>
</div>
<div className="text-sm font-medium tabular-nums">{value}</div>
</div>
)
}

View File

@@ -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<string, string> = {
"sectionLoadFailed": "区块加载失败",
"sectionLoadFailedDesc": "请重试",
"retry": "重试",
}
return messages[key] ?? key
},
}))
describe("DashboardSectionSkeleton", () => {
it("renders stats variant with 4 skeleton cards", () => {
const { container } = render(<DashboardSectionSkeleton variant="stats" />)
const cards = container.querySelectorAll('[class*="card"]')
expect(cards.length).toBeGreaterThanOrEqual(4)
})
it("renders chart variant with skeleton", () => {
const { container } = render(<DashboardSectionSkeleton variant="chart" />)
expect(container.querySelector('[class*="h-[200px]"]')).toBeTruthy()
})
it("renders table variant with 5 skeleton rows", () => {
const { container } = render(<DashboardSectionSkeleton variant="table" />)
const skeletons = container.querySelectorAll('[class*="h-10"]')
expect(skeletons.length).toBe(5)
})
it("renders card variant by default", () => {
const { container } = render(<DashboardSectionSkeleton />)
expect(container.querySelector('[class*="h-4"]')).toBeTruthy()
})
})
describe("DashboardSection", () => {
it("renders children when no error", () => {
render(
<DashboardSection variant="card">
<div data-testid="child">Content</div>
</DashboardSection>
)
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(
<DashboardSection variant="card">
<ThrowingChild />
</DashboardSection>
)
expect(screen.getByText("区块加载失败")).toBeTruthy()
expect(screen.getByText("重试")).toBeTruthy()
spy.mockRestore()
})
})

View File

@@ -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<StudentDashboardProps, "studentName"> | null
}>
/**
* 学生仪表盘视图P2-1 流式架构)
*
* 接收未解析的 Promise用 React `use()` 消费。
* 页面外壳立即渲染,数据到达后在 DashboardSection 的 Suspense 边界内填充。
*/
export function StudentDashboard({ dataPromise }: { dataPromise: Promise<StudentDashboardResult> }) {
const result = use(dataPromise)
if (!result.success || !result.data) {
throw new Error(result.message ?? "Failed to load student dashboard")
}
return <StudentDashboardBody result={result.data} />
}
async function StudentDashboardBody({
result,
}: {
result: NonNullable<StudentDashboardResult["data"]>
}) {
const t = await getTranslations("dashboard")
if (!result.student || !result.dashboardProps) {
return (
<EmptyState
title={t("empty.noStudent")}
description={t("empty.noStudentDesc")}
icon={UserX}
className="border-none shadow-none h-auto"
/>
)
}
const { student, dashboardProps } = result
return (
<div className="space-y-6">
<header>
<StudentDashboardHeader studentName={studentName} />
<StudentDashboardHeader studentName={student.name} />
</header>
<DashboardSection variant="stats">
<StudentStatsGrid
enrolledClassCount={enrolledClassCount}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
ranking={grades.ranking}
enrolledClassCount={dashboardProps.enrolledClassCount}
dueSoonCount={dashboardProps.dueSoonCount}
overdueCount={dashboardProps.overdueCount}
gradedCount={dashboardProps.gradedCount}
ranking={dashboardProps.grades.ranking}
/>
</DashboardSection>
@@ -42,15 +75,15 @@ export async function StudentDashboard({
className="lg:col-span-2 space-y-6"
>
<DashboardSection variant="list">
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
<StudentUpcomingAssignmentsCard upcomingAssignments={dashboardProps.upcomingAssignments} />
</DashboardSection>
<DashboardSection variant="card">
<StudentGradesCard grades={grades} />
<StudentGradesCard grades={dashboardProps.grades} />
</DashboardSection>
</section>
<aside aria-label={t("sections.todaySchedule")} className="space-y-6">
<DashboardSection variant="card">
<StudentTodayScheduleCard items={todayScheduleItems} />
<StudentTodayScheduleCard items={dashboardProps.todayScheduleItems} />
</DashboardSection>
</aside>
</div>

View File

@@ -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 (
<Card>
@@ -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"
/>
) : (

View File

@@ -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<TeacherDashboardData & { metrics: TeacherDashboardMetrics }>
/**
* 教师仪表盘视图P2-1 流式架构)
*
* 接收未解析的 Promise用 React `use()` 消费。
* 页面外壳立即渲染,数据到达后在 DashboardSection 的 Suspense 边界内填充。
*/
export function TeacherDashboardView({ dataPromise }: { dataPromise: Promise<TeacherDashboardResult> }) {
const result = use(dataPromise)
if (!result.success || !result.data) {
throw new Error(result.message ?? "Failed to load teacher dashboard")
}
return <TeacherDashboardContent data={result.data} />
}
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)
/>
</DashboardSection>
<div className="grid gap-6 lg:grid-cols-12">
<section aria-label={t("sections.pendingGrading")} className="flex flex-col gap-6 lg:col-span-8">
<div className="lg:hidden">
<DashboardSection variant="card">
<TeacherSchedule items={metrics.todayScheduleItems} />
</DashboardSection>
</div>
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-12">
{/* 课表:移动端首位,桌面端右上 — 仅渲染一次P2-9 修复,原为双实例) */}
<div className="order-1 lg:col-start-9 lg:col-span-4 lg:row-start-1">
<DashboardSection variant="card">
<TeacherSchedule items={metrics.todayScheduleItems} />
</DashboardSection>
</div>
<section aria-label={t("sections.pendingGrading")} className="flex flex-col gap-6 order-2 lg:col-start-1 lg:col-span-8 lg:row-start-1 lg:row-span-2">
<DashboardSection variant="card">
<TeacherTodoCard items={todoItems} />
</DashboardSection>
@@ -81,12 +98,7 @@ export async function TeacherDashboardView({ data }: TeacherDashboardViewProps)
</DashboardSection>
</section>
<aside aria-label={t("sections.myClasses")} className="flex flex-col gap-6 lg:col-span-4">
<div className="hidden lg:block">
<DashboardSection variant="card">
<TeacherSchedule items={metrics.todayScheduleItems} />
</DashboardSection>
</div>
<aside aria-label={t("sections.myClasses")} className="flex flex-col gap-6 order-3 lg:col-start-9 lg:col-span-4 lg:row-start-2">
<DashboardSection variant="list">
<TeacherHomeworkCard assignments={data.assignments} />
</DashboardSection>