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

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