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:
@@ -1096,6 +1096,12 @@
|
||||
"signature": "useLocalStorage<T>(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<AdminDashboardStreams>",
|
||||
"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"
|
||||
|
||||
@@ -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<Metadata> {
|
||||
}
|
||||
|
||||
export default async function AdminDashboardPage(): Promise<JSX.Element> {
|
||||
const data = await getAdminDashboardAction()
|
||||
return <AdminDashboardView data={data} />
|
||||
// 权限校验在此完成(阻塞),返回后各分区 Promise 并行执行、独立流式渲染
|
||||
const streams = await getAdminDashboardStreams()
|
||||
return <AdminDashboardView streams={streams} />
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
}
|
||||
|
||||
export default async function ParentDashboardPage(): Promise<JSX.Element> {
|
||||
// 传入未解析的 Promise,视图内用 React `use()` 消费,启用 Suspense 流式渲染
|
||||
const dataPromise = getParentDashboardAction()
|
||||
return <ParentDashboardResolver dataPromise={dataPromise} />
|
||||
}
|
||||
|
||||
function ParentDashboardResolver({ dataPromise }: { dataPromise: Promise<ParentDashboardResult> }) {
|
||||
const result = use(dataPromise)
|
||||
if (!result.success || !result.data) {
|
||||
throw new Error(result.message ?? "Failed to load parent dashboard")
|
||||
}
|
||||
return <ParentDashboardBody data={result.data.data} hasChildren={result.data.hasChildren} />
|
||||
}
|
||||
|
||||
async function ParentDashboardBody({
|
||||
data,
|
||||
hasChildren,
|
||||
}: {
|
||||
data: ParentDashboardData | null
|
||||
hasChildren: boolean
|
||||
}) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const { data, hasChildren } = await getParentDashboardAction()
|
||||
|
||||
if (!data || !hasChildren) {
|
||||
return (
|
||||
|
||||
@@ -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<Metadata> {
|
||||
}
|
||||
|
||||
export default async function StudentDashboardPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("dashboard")
|
||||
const { student, dashboardProps } = await getStudentDashboardAction()
|
||||
|
||||
if (!student || !dashboardProps) {
|
||||
return (
|
||||
<EmptyState
|
||||
title={t("empty.noStudent")}
|
||||
description={t("empty.noStudentDesc")}
|
||||
icon={UserX}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<StudentDashboard
|
||||
studentName={student.name}
|
||||
{...dashboardProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
// 传入未解析的 Promise,视图内用 React `use()` 消费,启用 Suspense 流式渲染
|
||||
const dataPromise = getStudentDashboardAction()
|
||||
return <StudentDashboard dataPromise={dataPromise} />
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
}
|
||||
|
||||
export default async function TeacherDashboardPage(): Promise<JSX.Element> {
|
||||
const data = await getTeacherDashboardAction()
|
||||
return <TeacherDashboardView data={data} />
|
||||
// 传入未解析的 Promise,视图内用 React `use()` 消费,启用 Suspense 流式渲染
|
||||
const dataPromise = getTeacherDashboardAction()
|
||||
return <TeacherDashboardView dataPromise={dataPromise} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<AdminTrendCharts t={t} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="chart">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("sections.homeworkSubmissionTrend")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserGrowthChart data={data.homeworkTrend} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</DashboardSection>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
70
src/modules/dashboard/components/dashboard-section.test.tsx
Normal file
70
src/modules/dashboard/components/dashboard-section.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
34
src/modules/dashboard/streams.ts
Normal file
34
src/modules/dashboard/streams.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import "server-only"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getClassesDashboardStats } from "@/modules/classes/data-access"
|
||||
import { getExamsDashboardStats } from "@/modules/exams/data-access"
|
||||
import { getHomeworkDashboardStats } from "@/modules/homework/stats-service"
|
||||
import { getQuestionsDashboardStats } from "@/modules/questions/data-access"
|
||||
import { getTextbooksDashboardStats } from "@/modules/textbooks/data-access"
|
||||
import { getUsersDashboardStats } from "@/modules/users/data-access"
|
||||
|
||||
/**
|
||||
* 管理员仪表盘流式数据源(P2-1 修复)
|
||||
*
|
||||
* 返回各独立数据源的 Promise(未解析),供各分区组件用 React `use()` 独立消费。
|
||||
* 页面外壳可立即渲染,各分区按各自数据到达顺序流式填充,互不阻塞。
|
||||
*
|
||||
* 权限校验在此函数内完成(同步阻塞),返回后各 Promise 并行执行。
|
||||
*/
|
||||
export async function getAdminDashboardStreams() {
|
||||
const ctx = await requirePermission(Permissions.DASHBOARD_ADMIN_READ)
|
||||
const scope = ctx.dataScope
|
||||
|
||||
return {
|
||||
usersStats: getUsersDashboardStats(),
|
||||
classesStats: getClassesDashboardStats(),
|
||||
textbooksStats: getTextbooksDashboardStats(),
|
||||
questionsStats: getQuestionsDashboardStats(),
|
||||
examsStats: getExamsDashboardStats(scope),
|
||||
homeworkStats: getHomeworkDashboardStats(scope),
|
||||
}
|
||||
}
|
||||
|
||||
export type AdminDashboardStreams = Awaited<ReturnType<typeof getAdminDashboardStreams>>
|
||||
@@ -1,5 +1,6 @@
|
||||
export { useActionWithToast } from "./use-action-with-toast"
|
||||
export { useAriaLive } from "./use-aria-live"
|
||||
export { useCurrentTime } from "./use-current-time"
|
||||
export { useDebounce } from "./use-debounce"
|
||||
export { useMediaQuery } from "./use-media-query"
|
||||
export { useLocalStorage } from "./use-local-storage"
|
||||
|
||||
40
src/shared/hooks/use-current-time.test.ts
Normal file
40
src/shared/hooks/use-current-time.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { act, renderHook } from "@testing-library/react"
|
||||
import { useCurrentTime } from "./use-current-time"
|
||||
|
||||
describe("useCurrentTime", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it("returns a Date on initial render", () => {
|
||||
const { result } = renderHook(() => useCurrentTime(60000))
|
||||
expect(result.current).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it("updates after the specified interval", () => {
|
||||
const initialTime = new Date(2026, 0, 1, 10, 0, 0)
|
||||
vi.setSystemTime(initialTime)
|
||||
|
||||
const { result } = renderHook(() => useCurrentTime(60000))
|
||||
expect(result.current.getTime()).toBe(initialTime.getTime())
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000)
|
||||
})
|
||||
|
||||
const expectedTime = new Date(2026, 0, 1, 10, 1, 0)
|
||||
expect(result.current.getTime()).toBe(expectedTime.getTime())
|
||||
})
|
||||
|
||||
it("clears interval on unmount", () => {
|
||||
const { unmount } = renderHook(() => useCurrentTime(60000))
|
||||
const clearIntervalSpy = vi.spyOn(global, "clearInterval")
|
||||
unmount()
|
||||
expect(clearIntervalSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
22
src/shared/hooks/use-current-time.ts
Normal file
22
src/shared/hooks/use-current-time.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
/**
|
||||
* 返回当前时间,按指定间隔自动更新。
|
||||
*
|
||||
* SSR 安全:首次渲染返回 `initial`(默认 `new Date()`),挂载后开始定时更新。
|
||||
* 用于需要随时间刷新的 UI(如"进行中"徽章),避免 useMemo 依赖 `[items]` 导致过时。
|
||||
*
|
||||
* @param intervalMs 更新间隔(毫秒),默认 60000(1 分钟)
|
||||
*/
|
||||
export function useCurrentTime(intervalMs = 60000): Date {
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), intervalMs)
|
||||
return () => clearInterval(id)
|
||||
}, [intervalMs])
|
||||
|
||||
return now
|
||||
}
|
||||
@@ -17,6 +17,13 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
import DashboardPage from "@/app/(dashboard)/dashboard/page"
|
||||
|
||||
/**
|
||||
* 仪表盘路由分发测试
|
||||
*
|
||||
* 注意:实际代码用 `resolvePermissions(roles)` 从角色推导权限,
|
||||
* 而非读取 user.permissions 字段。因此 mock 中只需设置 roles,
|
||||
* 无需(也不应)设置 permissions 字段,以免误导。
|
||||
*/
|
||||
describe("dashboard route dispatcher", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
@@ -35,30 +42,37 @@ describe("dashboard route dispatcher", () => {
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/login")
|
||||
})
|
||||
|
||||
it("redirects admin (school:manage) to admin dashboard", async () => {
|
||||
it("redirects admin to admin dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({
|
||||
user: { id: "u_admin", roles: ["admin"], permissions: ["school:manage"] },
|
||||
user: { id: "u_admin", roles: ["admin"] },
|
||||
})
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/admin/dashboard")
|
||||
})
|
||||
|
||||
it("redirects student (homework:submit without exam:create) to student dashboard", async () => {
|
||||
it("redirects student to student dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({
|
||||
user: { id: "u_student", roles: ["student"], permissions: ["homework:submit"] },
|
||||
user: { id: "u_student", roles: ["student"] },
|
||||
})
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/student/dashboard")
|
||||
})
|
||||
|
||||
it("redirects parent to parent dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({
|
||||
user: { id: "u_parent", roles: ["parent"], permissions: ["exam:read"] },
|
||||
user: { id: "u_parent", roles: ["parent"] },
|
||||
})
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/parent/dashboard")
|
||||
})
|
||||
|
||||
it("redirects teacher (with exam:create) to teacher dashboard", async () => {
|
||||
it("redirects teacher to teacher dashboard", async () => {
|
||||
mocks.authMock.mockResolvedValue({
|
||||
user: { id: "u_teacher", roles: ["teacher"], permissions: ["exam:create", "exam:read"] },
|
||||
user: { id: "u_teacher", roles: ["teacher"] },
|
||||
})
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/teacher/dashboard")
|
||||
})
|
||||
|
||||
it("redirects unknown role to teacher dashboard (fallback)", async () => {
|
||||
mocks.authMock.mockResolvedValue({
|
||||
user: { id: "u_unknown", roles: [] },
|
||||
})
|
||||
await expect(DashboardPage()).rejects.toThrow("REDIRECT:/teacher/dashboard")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user