refactor(dashboard): V2 审计重构 — i18n 补齐 + 共享抽象 + 单测 + a11y

V2 审计报告(docs/architecture/audit/dashboard-audit-report-v2.md)发现并修复:

- P0 i18n:10 个子组件硬编码字符串全部接入 next-intl(teacher-quick-actions /
  teacher-classes-card / teacher-homework-card / teacher-schedule /
  recent-submissions / teacher-grade-trends / student-grades-card /
  student-today-schedule-card / student-upcoming-assignments-card /
  admin-dashboard),新增 ~50 个翻译键
- P1 共享抽象:新增 DashboardGreetingHeader 组件,消除 teacher/student
  头部 90% 重复代码,两个 Header 改为薄包装
- P2 单测:为 6 个纯函数添加 31 个单元测试
  (tests/integration/dashboard/dashboard-utils.test.ts)
- P2 a11y:admin 表格 caption、teacher/student 视图语义化标签
  (header / section aria-label / aside aria-label)
- 同步架构图 004/005
This commit is contained in:
SpecialX
2026-06-22 17:01:00 +08:00
parent 10c668f36a
commit e997abaf5e
41 changed files with 1811 additions and 516 deletions

View File

@@ -1,150 +1,147 @@
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
import { Badge } from "@/shared/components/ui/badge";
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";
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
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;
};
id: string
classId: string
className: string
course: string
startTime: string
endTime: string
location: string | null
}
export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
const t = await getTranslations("dashboard")
const hasSchedule = items.length > 0
export function TeacherSchedule({ items }: { items: TeacherTodayScheduleItem[] }) {
const hasSchedule = items.length > 0;
const getStatus = (start: string, end: string) => {
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 = startH * 60 + startM;
const endTime = endH * 60 + endM;
const now = new Date()
const currentTime = now.getHours() * 60 + now.getMinutes()
if (currentTime >= startTime && currentTime <= endTime) return "live";
if (currentTime < startTime) return "upcoming";
return "past";
};
const [startH, startM] = start.split(":").map(Number)
const [endH, endM] = end.split(":").map(Number)
const startTime = (startH ?? 0) * 60 + (startM ?? 0)
const endTime = (endH ?? 0) * 60 + (endM ?? 0)
if (currentTime >= startTime && currentTime <= endTime) return "live"
if (currentTime < startTime) return "upcoming"
return "past"
}
return (
<Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
Today&apos;s Schedule
{t("sections.todaySchedule")}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{!hasSchedule ? (
<EmptyState
icon={CalendarX}
title="No Classes Today"
description="No timetable entries."
action={{ label: "View schedule", href: "/teacher/classes/schedule" }}
className="border-none h-[200px]"
/>
<EmptyState
icon={CalendarX}
title={t("empty.noClassesToday")}
description={t("empty.noClassesTodayDesc")}
action={{ label: t("quickActions.viewSchedule"), href: "/teacher/classes/schedule" }}
className="border-none h-[200px]"
/>
) : (
<ScrollArea className="h-[240px] px-6 py-2">
<div className="relative space-y-0 ml-1">
{/* Vertical Timeline Line */}
<div className="absolute left-[11px] -top-2 -bottom-2 w-px bg-border/50" />
{/* Top Fade Hint */}
<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 isLive = status === "live";
const isPast = status === "past";
const isLast = index === items.length - 1;
const status = getStatus(item.startTime, item.endTime)
const isLive = status === "live"
const isPast = status === "past"
const isLast = index === items.length - 1
return (
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
{/* Timeline Dot */}
<div className={cn(
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
isLive ? "bg-primary border-primary" :
isPast ? "bg-muted border-muted-foreground/30" :
"bg-background border-primary"
)} />
return (
<div key={item.id} className="relative pl-8 py-2 first:pt-0 last:pb-0 group">
<div className={cn(
"absolute left-[7px] top-[14px] h-2.5 w-2.5 rounded-full border-2 ring-4 ring-background transition-colors z-10",
isLive ? "bg-primary border-primary" :
isPast ? "bg-muted border-muted-foreground/30" :
"bg-background border-primary"
)} />
<Link
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
className={cn(
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
"bg-card"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"font-medium text-sm truncate",
isLive ? "text-primary" : "text-foreground"
)}>
{item.course}
</span>
{isLive && (
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
LIVE
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
<span>{item.className}</span>
{item.location && (
<>
<span>·</span>
<span className="flex items-center">
<MapPin className="mr-0.5 h-2.5 w-2.5" />
{item.location}
</span>
</>
)}
</div>
<Link
href={`/teacher/classes/my/${encodeURIComponent(item.classId)}`}
className={cn(
"block rounded-md border p-2.5 transition-all hover:bg-muted/50",
isLive ? "bg-primary/5 border-primary/50 shadow-sm" :
isPast ? "opacity-60 grayscale bg-muted/20 border-transparent" :
"bg-card"
)}
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className={cn(
"font-medium text-sm truncate",
isLive ? "text-primary" : "text-foreground"
)}>
{item.course}
</span>
{isLive && (
<Badge variant="default" className="h-4 px-1 text-[9px] animate-pulse">
{t("badge.live")}
</Badge>
)}
</div>
<div className={cn(
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
isLive ? "text-primary" : "text-muted-foreground"
)}>
{item.startTime}
<span className="text-[10px] opacity-70 ml-0.5"> {item.endTime}</span>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
<span>{item.className}</span>
{item.location && (
<>
<span>·</span>
<span className="flex items-center">
<MapPin className="mr-0.5 h-2.5 w-2.5" />
{item.location}
</span>
</>
)}
</div>
</div>
</Link>
{/* Connection Line to Next (if not last) */}
{!isLast && (
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
)}
</div>
);
<div className={cn(
"text-right text-xs font-medium tabular-nums whitespace-nowrap",
isLive ? "text-primary" : "text-muted-foreground"
)}>
{item.startTime}
<span className="text-[10px] opacity-70 ml-0.5"> {item.endTime}</span>
</div>
</div>
</Link>
{!isLast && (
<div className="absolute left-[11px] top-[24px] bottom-[-8px] w-px bg-border" />
)}
</div>
)
})}
{/* Bottom Hint */}
{items.length > 3 ? (
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
Scroll for more
</div>
<div className="text-[10px] text-center text-muted-foreground pt-2 pb-1 opacity-50">
{t("schedule.scrollForMore")}
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
No more classes today
</div>
<div className="text-[10px] text-center text-muted-foreground pt-4 pb-1 opacity-50 italic">
{t("schedule.noMoreClasses")}
</div>
)}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
);
)
}