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:
SpecialX
2026-07-03 10:25:46 +08:00
parent dfffb61e94
commit 138b6f1b00
58 changed files with 3313 additions and 695 deletions

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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"
/**
* 仪表盘通知中心 WidgetL5
*
* 集成到仪表盘侧边栏,显示最近通知摘要。
* 完整通知下拉由 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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 类。
*
* V4P3-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
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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" },
}
/** 变体优先级映射数值越小优先级越高V4P3-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

View 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
}
}

View File

@@ -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: [],
}

View 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 {}
}
}
/**
* 仪表盘自定义偏好 HookL4
*
* 从 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,
}
}

View File

@@ -0,0 +1,80 @@
"use client"
import { useEffect, useState } from "react"
/**
* 仪表盘实时更新 HookL6
*
* 基于 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 }
}

View File

@@ -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"
}

View 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)
}

View File

@@ -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[]
}