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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user