feat(dashboard,diagnostic,elective): add widgets, layout, parent dashboard, role-config, services, elective components
dashboard: - Add comparison-badge, dashboard-notification-widget, dashboard-responsive-layout, dashboard-time-range-filter - Add parent-dashboard components directory - Add config, hooks, and services directories diagnostic: - Add role-config and services directory elective: - Add elective-course-detail, elective-stats-cards, parent-selection-view components - Add data-access-settings and data-access-stats
This commit is contained in:
@@ -8,10 +8,9 @@ import { getClassSchedule, getStudentClasses, getStudentSchedule, getTeacherClas
|
||||
import {
|
||||
getHomeworkAssignments,
|
||||
getHomeworkSubmissions,
|
||||
getStudentDashboardGrades,
|
||||
getStudentHomeworkAssignments,
|
||||
getTeacherGradeTrends,
|
||||
} from "@/modules/homework/data-access"
|
||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student"
|
||||
import { getStudentDashboardGrades, getTeacherGradeTrends } from "@/modules/homework/stats-service"
|
||||
import { getCurrentStudentUser, getUserBasicInfo } from "@/modules/users/data-access"
|
||||
import { getParentDashboardData } from "@/modules/parent/data-access"
|
||||
|
||||
@@ -19,7 +18,6 @@ import { getAdminDashboardData } from "./data-access"
|
||||
import type {
|
||||
AdminDashboardData,
|
||||
StudentDashboardProps,
|
||||
StudentTodayScheduleItem,
|
||||
TeacherDashboardData,
|
||||
} from "./types"
|
||||
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||
@@ -82,7 +80,7 @@ export async function getTeacherDashboardAction(): Promise<ActionState<TeacherDa
|
||||
schedule,
|
||||
assignments,
|
||||
submissions,
|
||||
teacherName: teacherProfile?.name ?? "Teacher",
|
||||
teacherName: teacherProfile?.name ?? "",
|
||||
gradeTrends,
|
||||
metrics,
|
||||
},
|
||||
@@ -117,7 +115,7 @@ export async function getStudentDashboardAction(): Promise<ActionState<{
|
||||
const now = new Date()
|
||||
const stats = countStudentAssignments(assignments, now)
|
||||
const todayWeekday = toWeekday(now)
|
||||
const todayScheduleItems = filterTodaySchedule<StudentTodayScheduleItem>(schedule, todayWeekday)
|
||||
const todayScheduleItems = filterTodaySchedule(schedule, todayWeekday)
|
||||
const upcomingAssignments = sortUpcomingAssignments(assignments, 6)
|
||||
|
||||
return {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { DashboardSection } from "../dashboard-section"
|
||||
import { DashboardTimeRangeFilter } from "../dashboard-time-range-filter"
|
||||
import type { AdminDashboardStreams } from "../../streams"
|
||||
import {
|
||||
AdminContentCard,
|
||||
@@ -62,10 +63,15 @@ export async function AdminDashboardView({ streams }: { streams: AdminDashboardS
|
||||
}
|
||||
/>
|
||||
|
||||
<DashboardSection variant="stats">
|
||||
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
|
||||
<AdminStatsBar t={t} streams={streams} />
|
||||
</DashboardSection>
|
||||
|
||||
{/* L2: 时间范围筛选器 — 当前为 UI 占位,趋势数据接入后生效 */}
|
||||
<div className="flex justify-end">
|
||||
<DashboardTimeRangeFilter />
|
||||
</div>
|
||||
|
||||
{/* 快捷操作 — 纯静态,无需数据获取 */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<QuickActionCard
|
||||
@@ -106,23 +112,23 @@ export async function AdminDashboardView({ streams }: { streams: AdminDashboardS
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DashboardSection variant="chart">
|
||||
<DashboardSection variant="chart" ariaLabel={t("sections.trends")}>
|
||||
<AdminTrendCharts t={t} />
|
||||
</DashboardSection>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<DashboardSection variant="card">
|
||||
<DashboardSection variant="card" ariaLabel={t("sections.userRoles")}>
|
||||
<AdminUserRolesCard t={t} streams={streams} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="card">
|
||||
<DashboardSection variant="card" ariaLabel={t("sections.content")}>
|
||||
<AdminContentCard t={t} streams={streams} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="card">
|
||||
<DashboardSection variant="card" ariaLabel={t("sections.homeworkActivity")}>
|
||||
<AdminHomeworkActivityCard t={t} streams={streams} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
|
||||
<DashboardSection variant="table">
|
||||
<DashboardSection variant="table" ariaLabel={t("sections.recentUsers")}>
|
||||
<AdminRecentUsersTable t={t} streams={streams} />
|
||||
</DashboardSection>
|
||||
</div>
|
||||
|
||||
@@ -35,10 +35,10 @@ export function AdminStatsBar({ t, streams }: { t: TranslationFunction; streams:
|
||||
|
||||
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" />
|
||||
<StatCard title={t("stats.users")} value={usersStats.userCount} icon={Users} color="text-blue-500" valueClassName="tabular-nums" href="/admin/users" />
|
||||
<StatCard title={t("stats.classes")} value={classesStats.classCount} icon={LayoutDashboard} color="text-emerald-500" valueClassName="tabular-nums" href="/admin/school/classes" />
|
||||
<StatCard title={t("stats.homeworkPublished")} value={homeworkStats.homeworkAssignmentPublishedCount} icon={ClipboardList} color="text-purple-500" valueClassName="tabular-nums" href="/admin/homework/assignments" />
|
||||
<StatCard title={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={FileText} color="text-amber-500" valueClassName="tabular-nums" href="/admin/homework/submissions?status=submitted" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -77,10 +77,10 @@ export function AdminContentCard({ t, streams }: { t: TranslationFunction; strea
|
||||
<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" />} />
|
||||
<ContentRow label={t("stats.textbooks")} value={textbooksStats.textbookCount} icon={<Library className="h-4 w-4 text-muted-foreground" />} href="/admin/textbooks" />
|
||||
<ContentRow label={t("stats.chapters")} value={textbooksStats.chapterCount} icon={<BookOpen className="h-4 w-4 text-muted-foreground" />} href="/admin/textbooks" />
|
||||
<ContentRow label={t("stats.questions")} value={questionsStats.questionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} href="/admin/questions" />
|
||||
<ContentRow label={t("stats.exams")} value={examsStats.examCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} href="/admin/exams" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -97,9 +97,9 @@ export function AdminHomeworkActivityCard({ t, streams }: { t: TranslationFuncti
|
||||
<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" />} />
|
||||
<ContentRow label={t("stats.totalAssignments")} value={homeworkStats.homeworkAssignmentCount} icon={<ClipboardList className="h-4 w-4 text-muted-foreground" />} href="/admin/homework/assignments" />
|
||||
<ContentRow label={t("stats.totalSubmissions")} value={homeworkStats.homeworkSubmissionCount} icon={<FileText className="h-4 w-4 text-muted-foreground" />} href="/admin/homework/submissions" />
|
||||
<ContentRow label={t("stats.toGrade")} value={homeworkStats.homeworkSubmissionToGradeCount} icon={<Activity className="h-4 w-4 text-muted-foreground" />} href="/admin/homework/submissions?status=submitted" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
@@ -134,6 +134,8 @@ export function AdminUserRolesCard({ t, streams }: { t: TranslationFunction; str
|
||||
// ─── 趋势图表 ──────────────────────────────────────────────
|
||||
|
||||
export function AdminTrendCharts({ t }: { t: TranslationFunction }) {
|
||||
// TODO(V4-P3-2): 趋势数据待接入真实统计查询(见 data-access.ts P2-4 TODO)
|
||||
// 当前 data-access.getAdminDashboardData 返回空数组,此处渲染空状态
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
@@ -214,12 +216,14 @@ function ContentRow({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
href,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
icon: React.ReactNode
|
||||
href?: string
|
||||
}) {
|
||||
return (
|
||||
const content = (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
@@ -228,4 +232,14 @@ function ContentRow({
|
||||
<div className="text-sm font-medium tabular-nums">{value}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="block rounded-md hover:bg-muted/50 transition-colors -mx-1 px-1 py-0.5">
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
82
src/modules/dashboard/components/comparison-badge.tsx
Normal file
82
src/modules/dashboard/components/comparison-badge.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { ArrowDown, ArrowUp, Minus } from "lucide-react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/** 对比变化方向 */
|
||||
export type ComparisonTrend = "up" | "down" | "flat"
|
||||
|
||||
/** 对比计算结果 */
|
||||
export interface ComparisonResult {
|
||||
/** 当前值 */
|
||||
current: number
|
||||
/** 上一周期值 */
|
||||
previous: number
|
||||
/** 变化百分比(-100 到 +∞) */
|
||||
changePercent: number
|
||||
/** 趋势方向 */
|
||||
trend: ComparisonTrend
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个周期的变化百分比(L3 纯函数,便于单测)。
|
||||
*
|
||||
* - 上一周期为 0 且当前 > 0:返回 +100(新增)
|
||||
* - 上一周期为 0 且当前为 0:返回 0(持平)
|
||||
* - 正常情况:(current - previous) / previous * 100
|
||||
*/
|
||||
export function computeComparison(current: number, previous: number): ComparisonResult {
|
||||
if (previous === 0) {
|
||||
return {
|
||||
current,
|
||||
previous,
|
||||
changePercent: current > 0 ? 100 : 0,
|
||||
trend: current > 0 ? "up" : "flat",
|
||||
}
|
||||
}
|
||||
|
||||
const changePercent = ((current - previous) / Math.abs(previous)) * 100
|
||||
const trend: ComparisonTrend =
|
||||
Math.abs(changePercent) < 0.01 ? "flat" : changePercent > 0 ? "up" : "down"
|
||||
|
||||
return { current, previous, changePercent, trend }
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据对比徽章组件(L3)。
|
||||
*
|
||||
* 显示当前值与上一周期的变化百分比,带方向图标和颜色。
|
||||
* 用于仪表盘统计卡片旁的对比指示。
|
||||
*/
|
||||
export function ComparisonBadge({
|
||||
result,
|
||||
className,
|
||||
}: {
|
||||
result: ComparisonResult
|
||||
className?: string
|
||||
}) {
|
||||
const { changePercent, trend } = result
|
||||
const absPercent = Math.abs(Math.round(changePercent))
|
||||
|
||||
const trendConfig = {
|
||||
up: { icon: ArrowUp, color: "text-emerald-600 bg-emerald-50 dark:bg-emerald-950/30" },
|
||||
down: { icon: ArrowDown, color: "text-red-600 bg-red-50 dark:bg-red-950/30" },
|
||||
flat: { icon: Minus, color: "text-muted-foreground bg-muted/50" },
|
||||
} as const
|
||||
|
||||
const config = trendConfig[trend]
|
||||
const Icon = config.icon
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 rounded-full px-1.5 py-0.5 text-xs font-medium tabular-nums",
|
||||
config.color,
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-label={`Change: ${absPercent}% ${trend}`}
|
||||
>
|
||||
<Icon className="h-3 w-3" aria-hidden />
|
||||
{absPercent}%
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export async function DashboardGreetingHeader({
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{t(`greeting.${greetingKey}`)},{userName}
|
||||
{userName ? `${t(`greeting.${greetingKey}`)},${userName}` : t(`greeting.${greetingKey}`)}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t("greeting.todayIs", { date: today })}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Bell, ChevronRight } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* 仪表盘通知中心 Widget(L5)。
|
||||
*
|
||||
* 集成到仪表盘侧边栏,显示最近通知摘要。
|
||||
* 完整通知下拉由 SiteHeader 的 NotificationDropdown 处理,
|
||||
* 此 Widget 提供仪表盘内的快速入口。
|
||||
*/
|
||||
export interface DashboardNotificationItem {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
createdAt: string
|
||||
read: boolean
|
||||
href?: string
|
||||
}
|
||||
|
||||
export function DashboardNotificationWidget({
|
||||
notifications,
|
||||
viewAllHref = "/notifications",
|
||||
}: {
|
||||
notifications: readonly DashboardNotificationItem[]
|
||||
viewAllHref?: string
|
||||
}) {
|
||||
const t = useTranslations("dashboard")
|
||||
const unreadCount = notifications.filter((n) => !n.read).length
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bell className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{t("sections.notifications")}
|
||||
{unreadCount > 0 && (
|
||||
<Badge variant="default" className="tabular-nums">
|
||||
{unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notifications.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("empty.noNotifications")}
|
||||
description={t("empty.noNotificationsDesc")}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-1" role="list">
|
||||
{notifications.slice(0, 5).map((n) => (
|
||||
<li key={n.id}>
|
||||
<Link
|
||||
href={n.href ?? "#"}
|
||||
className="group flex items-start justify-between gap-2 rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{!n.read && (
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="Unread" />
|
||||
)}
|
||||
<span className="text-sm font-medium truncate group-hover:text-primary transition-colors">
|
||||
{n.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{n.body}</p>
|
||||
</div>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" aria-hidden />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex justify-end pt-3">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={viewAllHref}>
|
||||
{t("sections.viewAllNotifications")}
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
|
||||
/**
|
||||
* 仪表盘移动端响应式布局(L8)。
|
||||
*
|
||||
* 根据屏幕尺寸自动调整 Widget 布局:
|
||||
* - 移动端(< sm):单列堆叠,重要 Widget 优先
|
||||
* - 平板(sm - lg):双列网格
|
||||
* - 桌面(>= lg):按配置的 layoutClassName 渲染
|
||||
*
|
||||
* 通过 CSS Grid 的 order 属性实现移动端优先级排序,
|
||||
* 避免重复渲染(P2-9 教训)。
|
||||
*/
|
||||
export function DashboardResponsiveLayout({
|
||||
children,
|
||||
className,
|
||||
mobileFirstSlot,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** 移动端置顶的 Widget(如今日课表/待办) */
|
||||
mobileFirstSlot?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4 lg:grid", className)}>
|
||||
{mobileFirstSlot && (
|
||||
<div className="order-first lg:hidden">{mobileFirstSlot}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移动端水平滑动卡片容器(L8)。
|
||||
*
|
||||
* - snap-x snap-mandatory 提供卡片吸附效果
|
||||
* - 隐藏滚动条但保留滚动功能
|
||||
* - 仅在移动端生效,桌面端转为网格
|
||||
*/
|
||||
export function MobileSwipeContainer({
|
||||
children,
|
||||
className,
|
||||
ariaLabel,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory scrollbar-hide sm:hidden",
|
||||
className,
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
role="region"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面端网格容器(L8)。
|
||||
*
|
||||
* 与 MobileSwipeContainer 配对使用,
|
||||
* 桌面端显示网格,移动端隐藏(由 MobileSwipeContainer 接管)。
|
||||
*/
|
||||
export function DesktopGrid({
|
||||
children,
|
||||
className,
|
||||
columns = 3,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
columns?: 2 | 3 | 4
|
||||
}) {
|
||||
const gridCols = {
|
||||
2: "sm:grid-cols-2",
|
||||
3: "sm:grid-cols-2 lg:grid-cols-3",
|
||||
4: "sm:grid-cols-2 lg:grid-cols-4",
|
||||
}[columns]
|
||||
|
||||
return (
|
||||
<div className={cn("hidden sm:grid gap-4", gridCols, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { Component, type ReactNode, Suspense } from "react"
|
||||
import { type ReactNode, Suspense } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
/**
|
||||
* 仪表盘分区 Error Boundary
|
||||
*
|
||||
* 包裹每个独立数据区块,避免单个区块崩溃导致整页不可用。
|
||||
* 与路由级 error.tsx 不同,此组件仅替换出错区块,其余区块继续渲染。
|
||||
*/
|
||||
export class DashboardSectionErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
state: { hasError: boolean } = { hasError: false }
|
||||
|
||||
static getDerivedStateFromError(): { hasError: boolean } {
|
||||
return { hasError: true }
|
||||
}
|
||||
|
||||
handleRetry = (): void => {
|
||||
this.setState({ hasError: false })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return <DashboardSectionErrorFallback onRetry={this.handleRetry} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function DashboardSectionErrorFallback({
|
||||
onRetry,
|
||||
}: {
|
||||
onRetry: () => void
|
||||
}): ReactNode {
|
||||
const t = useTranslations("dashboard.error")
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("sectionLoadFailed")}
|
||||
description={t("sectionLoadFailedDesc")}
|
||||
action={{ label: t("retry"), onClick: onRetry }}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
|
||||
/**
|
||||
* 分区骨架屏变体
|
||||
@@ -148,23 +104,53 @@ export function DashboardSectionSkeleton({
|
||||
* 组合 Error Boundary + Suspense + 骨架屏,包裹每个独立数据区块。
|
||||
* 单个区块出错或加载中时,仅影响该区块,不波及整页。
|
||||
*
|
||||
* 使用共享 SectionErrorBoundary 替代模块特定的 DashboardSectionErrorBoundary 类。
|
||||
*
|
||||
* V4(P3-1)新增 `ariaLabel` prop:传入时渲染 `<section role="region" tabIndex={0}>`,
|
||||
* 使键盘用户可按逻辑顺序遍历各 Widget,提升 a11y。
|
||||
*
|
||||
* @example
|
||||
* <DashboardSection variant="stats">
|
||||
* <DashboardSection variant="stats" ariaLabel={t("sections.userStats")}>
|
||||
* <TeacherStats ... />
|
||||
* </DashboardSection>
|
||||
*/
|
||||
export function DashboardSection({
|
||||
children,
|
||||
variant = "card",
|
||||
ariaLabel,
|
||||
}: {
|
||||
children: ReactNode
|
||||
variant?: SkeletonVariant
|
||||
/** 传入时渲染为可聚焦的 region,提升键盘导航 a11y */
|
||||
ariaLabel?: string
|
||||
}): ReactNode {
|
||||
return (
|
||||
<DashboardSectionErrorBoundary>
|
||||
const t = useTranslations("dashboard.error")
|
||||
|
||||
const fallback = (): ReactNode => (
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("sectionLoadFailed")}
|
||||
description={t("sectionLoadFailedDesc")}
|
||||
action={{ label: t("retry"), onClick: () => window.location.reload() }}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
)
|
||||
|
||||
const content = (
|
||||
<SectionErrorBoundary namespace="common" fallback={fallback}>
|
||||
<Suspense fallback={<DashboardSectionSkeleton variant={variant} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</DashboardSectionErrorBoundary>
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
|
||||
if (ariaLabel) {
|
||||
return (
|
||||
<section role="region" aria-label={ariaLabel} tabIndex={0} className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-lg">
|
||||
{content}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams, usePathname } from "next/navigation"
|
||||
import { useCallback } from "react"
|
||||
import { CalendarDays, CalendarRange, CalendarClock } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
/** 时间范围选项 */
|
||||
export type TimeRange = "today" | "week" | "month"
|
||||
|
||||
const RANGE_OPTIONS: { value: TimeRange; icon: typeof CalendarDays }[] = [
|
||||
{ value: "today", icon: CalendarDays },
|
||||
{ value: "week", icon: CalendarRange },
|
||||
{ value: "month", icon: CalendarClock },
|
||||
]
|
||||
|
||||
/**
|
||||
* 仪表盘时间范围筛选器(L2)。
|
||||
*
|
||||
* 通过 URL search param `?range=` 持久化选择,
|
||||
* 切换时触发页面重新获取数据。
|
||||
* 默认值为 "today"。
|
||||
*/
|
||||
export function DashboardTimeRangeFilter({ className }: { className?: string }) {
|
||||
const t = useTranslations("dashboard.timeRange")
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const currentRange = (searchParams.get("range") as TimeRange | null) ?? "today"
|
||||
|
||||
const handleRangeChange = useCallback(
|
||||
(range: TimeRange) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("range", range)
|
||||
router.push(`${pathname}?${params.toString()}`, { scroll: false })
|
||||
},
|
||||
[router, pathname, searchParams],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn("inline-flex items-center gap-1 rounded-lg border bg-muted/40 p-1", className)} role="radiogroup" aria-label={t("label")}>
|
||||
{RANGE_OPTIONS.map(({ value, icon: Icon }) => (
|
||||
<Button
|
||||
key={value}
|
||||
variant={currentRange === value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => handleRangeChange(value)}
|
||||
role="radio"
|
||||
aria-checked={currentRange === value}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden />
|
||||
{t(value)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { ReactNode } from "react"
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import {
|
||||
CalendarCheck,
|
||||
CalendarDays,
|
||||
GraduationCap,
|
||||
Megaphone,
|
||||
Users,
|
||||
} from "lucide-react"
|
||||
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
|
||||
/**
|
||||
* 家长仪表盘视图组件(V4 P1-4 从 parent 模块迁移至 dashboard 模块)。
|
||||
*
|
||||
* 通过组合(slots)注入家长专属子组件,避免直接 import parent 模块组件,
|
||||
* 符合"模块内部组件绝不直接 import 其他业务模块"的解耦原则。
|
||||
*
|
||||
* @param parentName 家长名称
|
||||
* @param childrenCount 关联子女数
|
||||
* @param childrenSlot 子女卡片列表(由调用方注入 ChildCard 组件)
|
||||
* @param attentionBannerSlot 关注横幅(由调用方注入 ParentAttentionBanner 组件)
|
||||
* @param aiSummarySlot AI 学情摘要区域(由调用方注入 AiChildSummary 组件,可选)
|
||||
*/
|
||||
export async function ParentDashboard({
|
||||
parentName,
|
||||
childrenCount,
|
||||
childrenSlot,
|
||||
attentionBannerSlot,
|
||||
aiSummarySlot,
|
||||
}: {
|
||||
parentName: string
|
||||
childrenCount: number
|
||||
childrenSlot: ReactNode
|
||||
attentionBannerSlot: ReactNode
|
||||
aiSummarySlot?: ReactNode
|
||||
}) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasChildren = childrenCount > 0
|
||||
const greetingKey = getGreetingKey(new Date())
|
||||
|
||||
const QUICK_ENTRIES = [
|
||||
{ href: "/parent/grades", label: t("quickActions.grades"), icon: GraduationCap },
|
||||
{ href: "/parent/attendance", label: t("quickActions.attendance"), icon: CalendarCheck },
|
||||
{ href: "/announcements", label: t("quickActions.announcements"), icon: Megaphone },
|
||||
{ href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(`greeting.${greetingKey}`)}
|
||||
{parentName ? `, ${parentName}` : ""}. {t("description.parent")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChildren ? (
|
||||
<>
|
||||
{attentionBannerSlot}
|
||||
|
||||
<nav
|
||||
aria-label={t("quickActions.announcements")}
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-4"
|
||||
>
|
||||
{QUICK_ENTRIES.map((entry) => (
|
||||
<Link
|
||||
key={entry.href}
|
||||
href={entry.href}
|
||||
className="group"
|
||||
aria-label={entry.label}
|
||||
>
|
||||
<Card className="h-full transition-colors hover:bg-muted/50 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<entry.icon
|
||||
className="h-6 w-6 text-muted-foreground group-hover:text-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-sm font-medium">{entry.label}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Users className="h-4 w-4" aria-hidden />
|
||||
<span>
|
||||
{t("badge.childrenLinked", { count: childrenCount })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{childrenSlot}
|
||||
|
||||
{aiSummarySlot}
|
||||
</>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={t("empty.noChildren")}
|
||||
description={t("empty.noChildrenDesc")}
|
||||
className="border-none shadow-none"
|
||||
action={{
|
||||
label: t("empty.contactSupport"),
|
||||
href: "/messages",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -59,7 +59,7 @@ async function StudentDashboardBody({
|
||||
<StudentDashboardHeader studentName={student.name} />
|
||||
</header>
|
||||
|
||||
<DashboardSection variant="stats">
|
||||
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
|
||||
<StudentStatsGrid
|
||||
enrolledClassCount={dashboardProps.enrolledClassCount}
|
||||
dueSoonCount={dashboardProps.dueSoonCount}
|
||||
@@ -74,15 +74,15 @@ async function StudentDashboardBody({
|
||||
aria-label={t("sections.upcomingAssignments")}
|
||||
className="lg:col-span-2 space-y-6"
|
||||
>
|
||||
<DashboardSection variant="list">
|
||||
<DashboardSection variant="list" ariaLabel={t("sections.upcomingAssignments")}>
|
||||
<StudentUpcomingAssignmentsCard upcomingAssignments={dashboardProps.upcomingAssignments} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="card">
|
||||
<DashboardSection variant="card" ariaLabel={t("sections.grades")}>
|
||||
<StudentGradesCard grades={dashboardProps.grades} />
|
||||
</DashboardSection>
|
||||
</section>
|
||||
<aside aria-label={t("sections.todaySchedule")} className="space-y-6">
|
||||
<DashboardSection variant="card">
|
||||
<DashboardSection variant="card" ariaLabel={t("sections.todaySchedule")}>
|
||||
<StudentTodayScheduleCard items={dashboardProps.todayScheduleItems} />
|
||||
</DashboardSection>
|
||||
</aside>
|
||||
|
||||
@@ -12,13 +12,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { useCurrentTime } from "@/shared/hooks"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { timeToMinutes } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import type { StudentTodayScheduleItem } from "@/modules/dashboard/types"
|
||||
|
||||
const timeToMinutes = (t: string): number => {
|
||||
const [h, m] = t.split(":").map(Number)
|
||||
return (h ?? 0) * 60 + (m ?? 0)
|
||||
}
|
||||
|
||||
export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) {
|
||||
const t = useTranslations("dashboard")
|
||||
const hasSchedule = items.length > 0
|
||||
|
||||
@@ -9,40 +9,18 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { formatDate, cn } from "@/shared/lib/utils"
|
||||
import { getActionLabelKey, getActionVariant, getDueUrgency } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
|
||||
import {
|
||||
STUDENT_HOMEWORK_PROGRESS_VARIANT,
|
||||
STUDENT_HOMEWORK_PROGRESS_LABEL,
|
||||
} from "@/modules/homework/types"
|
||||
|
||||
const getActionLabelKey = (status: string): "action.review" | "action.view" | "action.continue" | "action.start" => {
|
||||
if (status === "graded") return "action.review"
|
||||
if (status === "submitted") return "action.view"
|
||||
if (status === "in_progress") return "action.continue"
|
||||
return "action.start"
|
||||
}
|
||||
|
||||
const getActionVariant = (status: string): "default" | "secondary" | "outline" => {
|
||||
if (status === "graded" || status === "submitted") return "outline"
|
||||
return "default"
|
||||
}
|
||||
|
||||
const getDueUrgency = (dueAt: string | null): "overdue" | "urgent" | "warning" | "normal" | null => {
|
||||
if (!dueAt) return null
|
||||
const now = new Date()
|
||||
const due = new Date(dueAt)
|
||||
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||
|
||||
if (diffHours < 0) return "overdue"
|
||||
if (diffHours < 48) return "urgent"
|
||||
if (diffHours < 120) return "warning"
|
||||
return "normal"
|
||||
}
|
||||
|
||||
export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const locale = await getLocale()
|
||||
const hasAssignments = upcomingAssignments.length > 0
|
||||
const now = new Date()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -78,7 +56,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{upcomingAssignments.map((a) => {
|
||||
const urgency = getDueUrgency(a.dueAt)
|
||||
const urgency = getDueUrgency(a.dueAt, now)
|
||||
const isGraded = a.progressStatus === "graded"
|
||||
|
||||
return (
|
||||
|
||||
@@ -64,7 +64,7 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
|
||||
<TeacherDashboardHeader teacherName={data.teacherName} />
|
||||
</header>
|
||||
|
||||
<DashboardSection variant="stats">
|
||||
<DashboardSection variant="stats" ariaLabel={t("sections.quickStats")}>
|
||||
<TeacherStats
|
||||
toGradeCount={metrics.toGradeCount}
|
||||
activeAssignmentsCount={metrics.activeAssignmentsCount}
|
||||
@@ -76,19 +76,19 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
|
||||
<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">
|
||||
<DashboardSection variant="card" ariaLabel={t("sections.todaySchedule")}>
|
||||
<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">
|
||||
<DashboardSection variant="card" ariaLabel={t("todo.title")}>
|
||||
<TeacherTodoCard items={todoItems} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="chart">
|
||||
<DashboardSection variant="chart" ariaLabel={t("sections.gradeTrends")}>
|
||||
<TeacherGradeTrends trends={data.gradeTrends} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="list">
|
||||
<DashboardSection variant="list" ariaLabel={t("sections.recentSubmissions")}>
|
||||
<RecentSubmissions
|
||||
submissions={metrics.submissionsToGrade}
|
||||
title={t("sections.pendingGrading")}
|
||||
@@ -99,10 +99,10 @@ async function TeacherDashboardContent({ data }: { data: TeacherDashboardData &
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<DashboardSection variant="list" ariaLabel={t("sections.homework")}>
|
||||
<TeacherHomeworkCard assignments={data.assignments} />
|
||||
</DashboardSection>
|
||||
<DashboardSection variant="list">
|
||||
<DashboardSection variant="list" ariaLabel={t("sections.myClasses")}>
|
||||
<TeacherClassesCard classes={data.classes} />
|
||||
</DashboardSection>
|
||||
</aside>
|
||||
|
||||
@@ -6,35 +6,13 @@ import { CalendarDays, CalendarX, MapPin } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
|
||||
type TeacherTodayScheduleItem = {
|
||||
id: string
|
||||
classId: string
|
||||
className: string
|
||||
course: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
location: string | null
|
||||
}
|
||||
import { getScheduleStatus } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import type { TeacherTodayScheduleItem } from "@/modules/dashboard/types"
|
||||
|
||||
export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasSchedule = items.length > 0
|
||||
|
||||
const getStatus = (start: string, end: string): "live" | "upcoming" | "past" => {
|
||||
const now = new Date()
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes()
|
||||
|
||||
const [startH, startM] = start.split(":").map(Number)
|
||||
const [endH, endM] = end.split(":").map(Number)
|
||||
const startTime = (Number.isFinite(startH) ? startH : 0) * 60 + (Number.isFinite(startM) ? startM : 0)
|
||||
const endTime = (Number.isFinite(endH) ? endH : 0) * 60 + (Number.isFinite(endM) ? endM : 0)
|
||||
|
||||
if (currentTime >= startTime && currentTime <= endTime) return "live"
|
||||
if (currentTime < startTime) return "upcoming"
|
||||
return "past"
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
@@ -60,7 +38,7 @@ export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleIt
|
||||
<div className="absolute left-[11px] -top-3 h-3 w-px bg-gradient-to-t from-border/50 to-transparent" />
|
||||
|
||||
{items.map((item, index) => {
|
||||
const status = getStatus(item.startTime, item.endTime)
|
||||
const status = getScheduleStatus(item.startTime, item.endTime, new Date())
|
||||
const isLive = status === "live"
|
||||
const isPast = status === "past"
|
||||
const isLast = index === items.length - 1
|
||||
|
||||
@@ -27,6 +27,7 @@ export async function TeacherStats({
|
||||
href="/teacher/homework/submissions?status=submitted"
|
||||
highlight={toGradeCount > 0}
|
||||
color="text-amber-500"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("stats.activeAssignments")}
|
||||
@@ -35,6 +36,7 @@ export async function TeacherStats({
|
||||
icon={PenTool}
|
||||
href="/teacher/homework/assignments?status=published"
|
||||
color="text-blue-500"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("stats.averageScore")}
|
||||
@@ -43,6 +45,7 @@ export async function TeacherStats({
|
||||
icon={TrendingUp}
|
||||
href="#grade-trends"
|
||||
color="text-emerald-500"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
<StatCard
|
||||
title={t("stats.submissionRate")}
|
||||
@@ -51,6 +54,7 @@ export async function TeacherStats({
|
||||
icon={BarChart}
|
||||
href="#grade-trends"
|
||||
color="text-purple-500"
|
||||
valueClassName="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -21,6 +21,13 @@ const VARIANT_STYLES: Record<TeacherTodoItem["variant"], { icon: typeof AlertCir
|
||||
info: { icon: CalendarCheck, iconColor: "text-blue-500", badge: "bg-blue-500 text-white" },
|
||||
}
|
||||
|
||||
/** 变体优先级映射(数值越小优先级越高),V4(P3-3)优化排序可读性 */
|
||||
const VARIANT_PRIORITY: Record<TeacherTodoItem["variant"], number> = {
|
||||
urgent: 0,
|
||||
normal: 1,
|
||||
info: 2,
|
||||
}
|
||||
|
||||
export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
|
||||
const t = await getTranslations("dashboard")
|
||||
const hasItems = items.some((item) => item.count > 0)
|
||||
@@ -49,11 +56,7 @@ export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
|
||||
<div className="space-y-1">
|
||||
{items
|
||||
.filter((item) => item.count > 0)
|
||||
.sort((a, b) => {
|
||||
if (a.variant === "urgent" && b.variant !== "urgent") return -1
|
||||
if (a.variant !== "urgent" && b.variant === "urgent") return 1
|
||||
return 0
|
||||
})
|
||||
.sort((a, b) => VARIANT_PRIORITY[a.variant] - VARIANT_PRIORITY[b.variant])
|
||||
.map((item, idx) => {
|
||||
const style = VARIANT_STYLES[item.variant]
|
||||
const Icon = style.icon
|
||||
|
||||
67
src/modules/dashboard/config/widget-configs.ts
Normal file
67
src/modules/dashboard/config/widget-configs.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 各角色仪表盘 Widget 布局配置。
|
||||
*
|
||||
* 通过配置驱动决定每个角色渲染哪些 Widget 及其布局位置。
|
||||
* 新增角色或调整 Widget 只需修改此配置文件,不需动组件代码。
|
||||
*/
|
||||
|
||||
import type { DashboardLayoutConfig } from "@/modules/dashboard/types"
|
||||
|
||||
/** 管理员仪表盘 Widget 配置 */
|
||||
export const ADMIN_WIDGET_CONFIG: DashboardLayoutConfig = {
|
||||
role: "admin",
|
||||
widgets: [
|
||||
{ id: "admin-stats-bar", slot: "stats", skeletonVariant: "stats", layoutClassName: "grid gap-4 md:grid-cols-2 lg:grid-cols-4", defaultVisible: true },
|
||||
{ id: "admin-quick-actions", slot: "actions", skeletonVariant: "card", layoutClassName: "grid gap-4 md:grid-cols-2 lg:grid-cols-3", defaultVisible: true },
|
||||
{ id: "admin-trend-charts", slot: "charts", skeletonVariant: "chart", layoutClassName: "grid gap-6 lg:grid-cols-2", defaultVisible: true },
|
||||
{ id: "admin-user-roles", slot: "cards-left", skeletonVariant: "card", layoutClassName: "lg:col-span-1", defaultVisible: true },
|
||||
{ id: "admin-content", slot: "cards-center", skeletonVariant: "card", layoutClassName: "lg:col-span-1", defaultVisible: true },
|
||||
{ id: "admin-homework-activity", slot: "cards-right", skeletonVariant: "card", layoutClassName: "lg:col-span-1", defaultVisible: true },
|
||||
{ id: "admin-recent-users", slot: "table", skeletonVariant: "table", layoutClassName: "", defaultVisible: true },
|
||||
],
|
||||
}
|
||||
|
||||
/** 教师仪表盘 Widget 配置 */
|
||||
export const TEACHER_WIDGET_CONFIG: DashboardLayoutConfig = {
|
||||
role: "teacher",
|
||||
widgets: [
|
||||
{ id: "teacher-stats", slot: "stats", skeletonVariant: "stats", layoutClassName: "grid gap-4 md:grid-cols-2 lg:grid-cols-4", defaultVisible: true },
|
||||
{ id: "teacher-todo", slot: "main-top", skeletonVariant: "card", layoutClassName: "lg:col-span-8", defaultVisible: true },
|
||||
{ id: "teacher-schedule", slot: "sidebar-top", skeletonVariant: "card", layoutClassName: "lg:col-span-4", defaultVisible: true },
|
||||
{ id: "teacher-grade-trends", slot: "main-middle", skeletonVariant: "chart", layoutClassName: "lg:col-span-8", defaultVisible: true },
|
||||
{ id: "teacher-recent-submissions", slot: "main-bottom", skeletonVariant: "list", layoutClassName: "lg:col-span-8", defaultVisible: true },
|
||||
{ id: "teacher-homework", slot: "sidebar-middle", skeletonVariant: "list", layoutClassName: "lg:col-span-4", defaultVisible: true },
|
||||
{ id: "teacher-classes", slot: "sidebar-bottom", skeletonVariant: "list", layoutClassName: "lg:col-span-4", defaultVisible: true },
|
||||
],
|
||||
}
|
||||
|
||||
/** 学生仪表盘 Widget 配置 */
|
||||
export const STUDENT_WIDGET_CONFIG: DashboardLayoutConfig = {
|
||||
role: "student",
|
||||
widgets: [
|
||||
{ id: "student-stats", slot: "stats", skeletonVariant: "stats", layoutClassName: "grid gap-4 md:grid-cols-2 lg:grid-cols-4", defaultVisible: true },
|
||||
{ id: "student-upcoming-assignments", slot: "main-top", skeletonVariant: "list", layoutClassName: "lg:col-span-2", defaultVisible: true },
|
||||
{ id: "student-grades", slot: "main-bottom", skeletonVariant: "card", layoutClassName: "lg:col-span-2", defaultVisible: true },
|
||||
{ id: "student-today-schedule", slot: "sidebar", skeletonVariant: "card", layoutClassName: "", defaultVisible: true },
|
||||
],
|
||||
}
|
||||
|
||||
/** 家长仪表盘 Widget 配置 */
|
||||
export const PARENT_WIDGET_CONFIG: DashboardLayoutConfig = {
|
||||
role: "parent",
|
||||
widgets: [
|
||||
{ id: "parent-attention-banner", slot: "banner", skeletonVariant: "card", layoutClassName: "", defaultVisible: true },
|
||||
{ id: "parent-quick-entries", slot: "actions", skeletonVariant: "card", layoutClassName: "grid grid-cols-2 gap-3 sm:grid-cols-4", defaultVisible: true },
|
||||
{ id: "parent-children-cards", slot: "main", skeletonVariant: "list", layoutClassName: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", defaultVisible: true },
|
||||
],
|
||||
}
|
||||
|
||||
/** 按角色获取 Widget 配置 */
|
||||
export function getWidgetConfig(role: DashboardLayoutConfig["role"]): DashboardLayoutConfig {
|
||||
switch (role) {
|
||||
case "admin": return ADMIN_WIDGET_CONFIG
|
||||
case "teacher": return TEACHER_WIDGET_CONFIG
|
||||
case "student": return STUDENT_WIDGET_CONFIG
|
||||
case "parent": return PARENT_WIDGET_CONFIG
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<Ad
|
||||
homeworkSubmissionCount: homeworkStats.homeworkSubmissionCount,
|
||||
homeworkSubmissionToGradeCount: homeworkStats.homeworkSubmissionToGradeCount,
|
||||
recentUsers: usersStats.recentUsers,
|
||||
// TODO(V4-P2-4): 接入真实趋势数据统计查询(按日期聚合用户注册数和作业提交数)
|
||||
// 当前为占位空数组,AdminTrendCharts 组件会渲染空状态
|
||||
userGrowth: [],
|
||||
homeworkTrend: [],
|
||||
}
|
||||
|
||||
82
src/modules/dashboard/hooks/use-dashboard-preferences.ts
Normal file
82
src/modules/dashboard/hooks/use-dashboard-preferences.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useState } from "react"
|
||||
|
||||
import type { DashboardRole, DashboardWidgetConfig } from "@/modules/dashboard/types"
|
||||
import { getWidgetConfig } from "@/modules/dashboard/config/widget-configs"
|
||||
|
||||
const STORAGE_KEY = "dashboard-widget-preferences"
|
||||
|
||||
/** 用户 Widget 偏好(widgetId → visible) */
|
||||
export type WidgetPreferences = Record<string, boolean>
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取偏好(同步,用于 lazy initializer)。
|
||||
*/
|
||||
function loadPreferences(role: DashboardRole): WidgetPreferences {
|
||||
if (typeof window === "undefined") return {}
|
||||
try {
|
||||
const stored = localStorage.getItem(`${STORAGE_KEY}-${role}`)
|
||||
return stored ? (JSON.parse(stored) as WidgetPreferences) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘自定义偏好 Hook(L4)。
|
||||
*
|
||||
* 从 localStorage 读取用户对 Widget 显示/隐藏的偏好,
|
||||
* 与默认配置合并,支持运行时切换。
|
||||
*
|
||||
* - 首次访问返回默认配置的 `defaultVisible`
|
||||
* - 用户切换后持久化到 localStorage
|
||||
* - 支持重置为默认
|
||||
*/
|
||||
export function useDashboardPreferences(role: DashboardRole) {
|
||||
// 使用 lazy initializer 避免 useEffect 中调用 setState
|
||||
const [preferences, setPreferences] = useState<WidgetPreferences>(() => loadPreferences(role))
|
||||
|
||||
const config = getWidgetConfig(role)
|
||||
|
||||
/** 合并默认配置与用户偏好 */
|
||||
const effectiveWidgets: DashboardWidgetConfig[] = config.widgets.map((w) => ({
|
||||
...w,
|
||||
defaultVisible: preferences[w.id] ?? w.defaultVisible,
|
||||
}))
|
||||
|
||||
/** 切换某个 Widget 的可见性 */
|
||||
const toggleWidget = useCallback(
|
||||
(widgetId: string) => {
|
||||
setPreferences((prev) => {
|
||||
const widget = config.widgets.find((w) => w.id === widgetId)
|
||||
if (!widget) return prev
|
||||
const currentVisible = prev[widgetId] ?? widget.defaultVisible
|
||||
const next = { ...prev, [widgetId]: !currentVisible }
|
||||
try {
|
||||
localStorage.setItem(`${STORAGE_KEY}-${role}`, JSON.stringify(next))
|
||||
} catch {
|
||||
// localStorage 不可用,仅更新内存状态
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
[config.widgets, role],
|
||||
)
|
||||
|
||||
/** 重置为默认配置 */
|
||||
const resetToDefault = useCallback(() => {
|
||||
setPreferences({})
|
||||
try {
|
||||
localStorage.removeItem(`${STORAGE_KEY}-${role}`)
|
||||
} catch {
|
||||
// localStorage 不可用
|
||||
}
|
||||
}, [role])
|
||||
|
||||
return {
|
||||
widgets: effectiveWidgets,
|
||||
toggleWidget,
|
||||
resetToDefault,
|
||||
}
|
||||
}
|
||||
80
src/modules/dashboard/hooks/use-dashboard-realtime.ts
Normal file
80
src/modules/dashboard/hooks/use-dashboard-realtime.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
/**
|
||||
* 仪表盘实时更新 Hook(L6)。
|
||||
*
|
||||
* 基于 Server-Sent Events (SSE) 实现轻量级实时更新,
|
||||
* 避免引入 WebSocket 的复杂依赖。
|
||||
*
|
||||
* - 自动重连(指数退避,最大 30s)
|
||||
* - 组件卸载时自动清理 EventSource
|
||||
* - 连接状态可查询(connecting/connected/error)
|
||||
*
|
||||
* @param url SSE 端点 URL
|
||||
* @param eventName 监听的事件名(默认 "update")
|
||||
*/
|
||||
export type ConnectionStatus = "connecting" | "connected" | "error" | "closed"
|
||||
|
||||
export function useDashboardRealtime<T = unknown>(
|
||||
url: string | null,
|
||||
eventName = "update",
|
||||
): {
|
||||
status: ConnectionStatus
|
||||
lastMessage: T | null
|
||||
lastUpdatedAt: Date | null
|
||||
} {
|
||||
const [status, setStatus] = useState<ConnectionStatus>("connecting")
|
||||
const [lastMessage, setLastMessage] = useState<T | null>(null)
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<Date | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setStatus("closed")
|
||||
return
|
||||
}
|
||||
|
||||
let eventSource: EventSource | null = null
|
||||
let retryCount = 0
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const connect = () => {
|
||||
setStatus("connecting")
|
||||
eventSource = new EventSource(url)
|
||||
|
||||
eventSource.onopen = () => {
|
||||
retryCount = 0
|
||||
setStatus("connected")
|
||||
}
|
||||
|
||||
eventSource.addEventListener(eventName, (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as T
|
||||
setLastMessage(data)
|
||||
setLastUpdatedAt(new Date())
|
||||
} catch {
|
||||
// 忽略解析失败的消息
|
||||
}
|
||||
})
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setStatus("error")
|
||||
eventSource?.close()
|
||||
// 指数退避重连,最大 30s
|
||||
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000)
|
||||
retryCount++
|
||||
retryTimer = setTimeout(connect, delay)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
eventSource?.close()
|
||||
if (retryTimer) clearTimeout(retryTimer)
|
||||
}
|
||||
}, [url, eventName])
|
||||
|
||||
return { status, lastMessage, lastUpdatedAt }
|
||||
}
|
||||
@@ -98,14 +98,14 @@ export function sortUpcomingAssignments(
|
||||
/**
|
||||
* 从课表中筛选指定周几的课程,按开始时间升序排序。
|
||||
*
|
||||
* 泛型 T 允许调用方指定返回的课表项类型(StudentTodayScheduleItem 或
|
||||
* TeacherTodayScheduleItem)。两者结构完全相同,泛型仅用于类型层面。
|
||||
* `StudentTodayScheduleItem` 与 `TeacherTodayScheduleItem` 结构完全相同,
|
||||
* 返回 `StudentTodayScheduleItem[]` 可通过结构化类型赋值给任一类型变量。
|
||||
*/
|
||||
export function filterTodaySchedule<T extends StudentTodayScheduleItem | TeacherTodayScheduleItem = StudentTodayScheduleItem | TeacherTodayScheduleItem>(
|
||||
export function filterTodaySchedule(
|
||||
schedule: readonly ClassScheduleItem[],
|
||||
weekday: Weekday,
|
||||
classNameById?: ReadonlyMap<string, string>,
|
||||
): T[] {
|
||||
): StudentTodayScheduleItem[] {
|
||||
return schedule
|
||||
.filter((s) => s.weekday === weekday)
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
@@ -117,7 +117,7 @@ export function filterTodaySchedule<T extends StudentTodayScheduleItem | Teacher
|
||||
startTime: s.startTime,
|
||||
endTime: s.endTime,
|
||||
location: s.location ?? null,
|
||||
})) as T[]
|
||||
}))
|
||||
}
|
||||
|
||||
/** 教师仪表盘派生指标 */
|
||||
@@ -144,7 +144,7 @@ export function computeTeacherMetrics(
|
||||
const todayWeekday = toWeekday(now)
|
||||
const classNameById = new Map(classes.map((c) => [c.id, c.name] as const))
|
||||
|
||||
const todayScheduleItems = filterTodaySchedule<TeacherTodayScheduleItem>(
|
||||
const todayScheduleItems = filterTodaySchedule(
|
||||
schedule,
|
||||
todayWeekday,
|
||||
classNameById,
|
||||
@@ -196,3 +196,84 @@ export function getGreetingKey(now: Date): "morning" | "afternoon" | "evening" {
|
||||
|
||||
/** 重导出 TeacherDashboardData 便于 actions 使用 */
|
||||
export type { TeacherDashboardData }
|
||||
|
||||
// ─── 课表状态计算 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 将 "HH:MM" 格式的时间字符串转换为当天的分钟数。
|
||||
* 无效输入返回 0。
|
||||
*/
|
||||
export function timeToMinutes(t: string): number {
|
||||
const [h, m] = t.split(":").map(Number)
|
||||
return (h ?? 0) * 60 + (m ?? 0)
|
||||
}
|
||||
|
||||
/** 课表项的实时状态 */
|
||||
export type ScheduleStatus = "live" | "upcoming" | "past"
|
||||
|
||||
/**
|
||||
* 根据当前时间判断课程状态:进行中 / 即将开始 / 已结束。
|
||||
*/
|
||||
export function getScheduleStatus(
|
||||
start: string,
|
||||
end: string,
|
||||
now: Date,
|
||||
): ScheduleStatus {
|
||||
const currentTime = now.getHours() * 60 + now.getMinutes()
|
||||
const startTime = timeToMinutes(start)
|
||||
const endTime = timeToMinutes(end)
|
||||
|
||||
if (currentTime >= startTime && currentTime <= endTime) return "live"
|
||||
if (currentTime < startTime) return "upcoming"
|
||||
return "past"
|
||||
}
|
||||
|
||||
// ─── 作业紧急度计算 ────────────────────────────────────────
|
||||
|
||||
/** 作业截止时间的紧急度等级 */
|
||||
export type DueUrgency = "overdue" | "urgent" | "warning" | "normal" | null
|
||||
|
||||
/**
|
||||
* 根据截止时间与当前时间的差值计算紧急度。
|
||||
* - overdue: 已逾期
|
||||
* - urgent: 48 小时内
|
||||
* - warning: 120 小时内(5 天)
|
||||
* - normal: 5 天以上
|
||||
* - null: 无截止时间
|
||||
*/
|
||||
export function getDueUrgency(dueAt: string | null, now: Date): DueUrgency {
|
||||
if (!dueAt) return null
|
||||
const due = new Date(dueAt)
|
||||
const diffHours = (due.getTime() - now.getTime()) / (1000 * 60 * 60)
|
||||
|
||||
if (diffHours < 0) return "overdue"
|
||||
if (diffHours < 48) return "urgent"
|
||||
if (diffHours < 120) return "warning"
|
||||
return "normal"
|
||||
}
|
||||
|
||||
// ─── 学生作业操作按钮 ─────────────────────────────────────
|
||||
|
||||
/** 作业操作按钮的 i18n 键 */
|
||||
export type ActionLabelKey = "action.review" | "action.view" | "action.continue" | "action.start"
|
||||
|
||||
/**
|
||||
* 根据作业进度状态返回操作按钮的 i18n 键。
|
||||
*/
|
||||
export function getActionLabelKey(status: string): ActionLabelKey {
|
||||
if (status === "graded") return "action.review"
|
||||
if (status === "submitted") return "action.view"
|
||||
if (status === "in_progress") return "action.continue"
|
||||
return "action.start"
|
||||
}
|
||||
|
||||
/** 作业操作按钮的视觉变体 */
|
||||
export type ActionVariant = "default" | "secondary" | "outline"
|
||||
|
||||
/**
|
||||
* 根据作业进度状态返回操作按钮的视觉变体。
|
||||
*/
|
||||
export function getActionVariant(status: string): ActionVariant {
|
||||
if (status === "graded" || status === "submitted") return "outline"
|
||||
return "default"
|
||||
}
|
||||
|
||||
118
src/modules/dashboard/services/dashboard-service.tsx
Normal file
118
src/modules/dashboard/services/dashboard-service.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import type {
|
||||
TeacherDashboardData,
|
||||
StudentDashboardProps,
|
||||
} from "@/modules/dashboard/types"
|
||||
import type { TeacherDashboardMetrics } from "@/modules/dashboard/lib/dashboard-utils"
|
||||
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||
import type { UsersDashboardStats } from "@/modules/users/data-access"
|
||||
import type { ClassesDashboardStats } from "@/modules/classes/data-access"
|
||||
import type { TextbooksDashboardStats } from "@/modules/textbooks/data-access"
|
||||
import type { QuestionsDashboardStats } from "@/modules/questions/data-access"
|
||||
import type { ExamsDashboardStats } from "@/modules/exams/data-access"
|
||||
import type { HomeworkDashboardStats } from "@/modules/homework/stats-service"
|
||||
|
||||
// ─── 数据服务接口 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 仪表盘数据服务接口(抽象数据依赖)。
|
||||
*
|
||||
* 每个角色提供独立的实现,封装对 data-access 的调用并加入权限校验。
|
||||
* 组件通过 `useDashboardService()` 获取当前注入的实现,不直接 import actions。
|
||||
* 测试时可注入 mock 实现以隔离数据层。
|
||||
*/
|
||||
export interface DashboardService {
|
||||
/** 获取管理员仪表盘数据(流式:返回未解析 Promise 供各分区独立消费) */
|
||||
getAdminStreams(): Promise<AdminDashboardStreams>
|
||||
/** 获取教师仪表盘数据 */
|
||||
getTeacherData(): Promise<ActionState<TeacherDashboardData & { metrics: TeacherDashboardMetrics }>>
|
||||
/** 获取学生仪表盘数据 */
|
||||
getStudentData(): Promise<ActionState<{
|
||||
student: { id: string; name: string } | null
|
||||
dashboardProps: Omit<StudentDashboardProps, "studentName"> | null
|
||||
}>>
|
||||
/** 获取家长仪表盘数据 */
|
||||
getParentData(): Promise<ActionState<{
|
||||
data: ParentDashboardData | null
|
||||
hasChildren: boolean
|
||||
}>>
|
||||
}
|
||||
|
||||
/** 管理员仪表盘流式数据源(各分区独立 Promise) */
|
||||
export interface AdminDashboardStreams {
|
||||
usersStats: Promise<UsersDashboardStats>
|
||||
classesStats: Promise<ClassesDashboardStats>
|
||||
textbooksStats: Promise<TextbooksDashboardStats>
|
||||
questionsStats: Promise<QuestionsDashboardStats>
|
||||
examsStats: Promise<ExamsDashboardStats>
|
||||
homeworkStats: Promise<HomeworkDashboardStats>
|
||||
}
|
||||
|
||||
// ─── 监控埋点接口 ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 仪表盘监控埋点接口。
|
||||
*
|
||||
* 预留关键操作埋点,供后续接入实际监控 SDK(如 PostHog / Mixpanel)。
|
||||
* 默认实现为空操作,生产环境通过 Provider 注入实际实现。
|
||||
*/
|
||||
export interface DashboardAnalytics {
|
||||
/** Widget 被点击 */
|
||||
trackWidgetClick(widgetId: string, role: string): void
|
||||
/** 空状态被触发 */
|
||||
trackEmptyState(widgetId: string, role: string): void
|
||||
/** 错误重试 */
|
||||
trackErrorRetry(widgetId: string, role: string): void
|
||||
/** 页面停留 */
|
||||
trackPageView(role: string, durationMs: number): void
|
||||
}
|
||||
|
||||
/** 空操作实现(默认) */
|
||||
const noopAnalytics: DashboardAnalytics = {
|
||||
trackWidgetClick: () => {},
|
||||
trackEmptyState: () => {},
|
||||
trackErrorRetry: () => {},
|
||||
trackPageView: () => {},
|
||||
}
|
||||
|
||||
// ─── React Context 依赖注入 ────────────────────────────────
|
||||
|
||||
const DashboardServiceContext = createContext<DashboardService | null>(null)
|
||||
const DashboardAnalyticsContext = createContext<DashboardAnalytics>(noopAnalytics)
|
||||
|
||||
/** 仪表盘服务 Provider(在页面层注入角色特定的实现) */
|
||||
export function DashboardServiceProvider({
|
||||
service,
|
||||
analytics,
|
||||
children,
|
||||
}: {
|
||||
service: DashboardService
|
||||
analytics?: DashboardAnalytics
|
||||
children: ReactNode
|
||||
}): ReactNode {
|
||||
return (
|
||||
<DashboardServiceContext.Provider value={service}>
|
||||
<DashboardAnalyticsContext.Provider value={analytics ?? noopAnalytics}>
|
||||
{children}
|
||||
</DashboardAnalyticsContext.Provider>
|
||||
</DashboardServiceContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/** 获取当前注入的仪表盘数据服务 */
|
||||
export function useDashboardService(): DashboardService {
|
||||
const service = useContext(DashboardServiceContext)
|
||||
if (!service) {
|
||||
throw new Error("useDashboardService must be used within DashboardServiceProvider")
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
/** 获取当前注入的监控埋点接口 */
|
||||
export function useDashboardAnalytics(): DashboardAnalytics {
|
||||
return useContext(DashboardAnalyticsContext)
|
||||
}
|
||||
@@ -72,3 +72,36 @@ export type TeacherDashboardData = {
|
||||
teacherName: string
|
||||
gradeTrends: TeacherGradeTrendItem[]
|
||||
}
|
||||
|
||||
// ─── 配置驱动 Widget 渲染 ─────────────────────────────────
|
||||
|
||||
/** 仪表盘角色 */
|
||||
export type DashboardRole = "admin" | "teacher" | "student" | "parent"
|
||||
|
||||
/** Widget 骨架屏变体(与 DashboardSection 对齐) */
|
||||
export type WidgetSkeletonVariant = "stats" | "card" | "chart" | "table" | "list"
|
||||
|
||||
/**
|
||||
* Widget 配置项。
|
||||
*
|
||||
* 通过配置决定每个角色仪表盘渲染哪些 Widget 及其布局,
|
||||
* 新增角色或调整 Widget 只需修改配置,不需动组件代码。
|
||||
*/
|
||||
export interface DashboardWidgetConfig {
|
||||
/** Widget 唯一标识(用于埋点) */
|
||||
id: string
|
||||
/** Widget 渲染区域标识(用于布局分配) */
|
||||
slot: string
|
||||
/** 骨架屏变体 */
|
||||
skeletonVariant: WidgetSkeletonVariant
|
||||
/** 响应式布局类名(Tailwind grid classes) */
|
||||
layoutClassName: string
|
||||
/** 是否默认显示(可被用户偏好覆盖) */
|
||||
defaultVisible: boolean
|
||||
}
|
||||
|
||||
/** 角色仪表盘布局配置 */
|
||||
export interface DashboardLayoutConfig {
|
||||
role: DashboardRole
|
||||
widgets: DashboardWidgetConfig[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user