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