fix(dashboard): v3 审计修复 — 数据完整性、i18n、类型安全、死代码清理

P0 修复(严重):
- admin ContentRow 标签与值错配(stats.users→textbooks 等 6 处)
- admin/error.tsx 硬编码中文替换为 useTranslations
- UserGrowthChart 空数据时渲染 EmptyState(userGrowth/homeworkTrend 永远为空数组)

P1 修复(高):
- 新增 admin/dashboard 和 student/dashboard 的 loading.tsx + error.tsx
- 抽取 DashboardLoadingSkeleton 和 DashboardErrorFallback 共享组件,消除 5 套重复文件
- formatDate/formatLongDate 传入用户 locale(admin/teacher/student 共 6 个组件)
- 移除死代码:getCachedAdminDashboard、AvatarImage src={undefined}、TeacherStats isLoading prop
- filterTodaySchedule 改为泛型函数,消除 as 类型断言
- 辅助函数 getStatus/getDueUrgency 新增显式返回类型
- UserGrowthChart 新增 labelKey prop 区分用户增长/作业提交趋势标签

P2 修复(中):
- 4 个组件从客户端转为服务端组件(DashboardGreetingHeader、TeacherQuickActions、TeacherDashboardHeader、StudentDashboardHeader)
- Student dashboard 空状态新增 CTA(viewSchedule、viewAll)
- TeacherHomeworkCard 图标按钮新增 aria-label
- TeacherTodoCard 排序逻辑重写为可读的 if/return 模式

同步更新:
- docs/architecture/005_architecture_data.json 新增 DashboardLoadingSkeleton、DashboardErrorFallback 条目
- 新增 docs/architecture/audit/dashboard-audit-report-v3.md 审计报告
- dashboard.json 新增 6 个 i18n 键(textbooks/chapters/questions/exams/totalAssignments/totalSubmissions)
This commit is contained in:
SpecialX
2026-06-22 18:36:46 +08:00
parent f62b8c0f86
commit 682d385ee2
41 changed files with 4387 additions and 1979 deletions

View File

@@ -0,0 +1,165 @@
import Link from "next/link"
import { PenTool, TriangleAlert } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { cn, formatDate } from "@/shared/lib/utils"
import {
STUDENT_HOMEWORK_PROGRESS_VARIANT,
STUDENT_HOMEWORK_PROGRESS_LABEL,
} from "@/modules/homework/types"
import type { ChildHomeworkSummaryData } from "@/modules/parent/types"
type DueUrgency = "overdue" | "urgent" | "normal"
const getDueUrgency = (dueAt: string | null, now: Date): DueUrgency | null => {
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"
return "normal"
}
const PROGRESS_LABEL: Record<string, string> = {
not_started: "Not started",
in_progress: "In progress",
submitted: "Submitted",
graded: "Graded",
}
/**
* 作业详情视图:展示所有作业的完整信息(状态、截止时间、提交时间、分数、尝试次数)。
* 用于详情页 homework tab让家长查看子女作业全貌。
*/
export function ChildHomeworkDetail({
summary,
childId,
childName,
}: {
summary: ChildHomeworkSummaryData
childId: string
childName: string
}) {
const hasAssignments = summary.recentAssignments.length > 0
const now = new Date()
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<PenTool className="h-4 w-4 text-muted-foreground" aria-hidden />
{childName}&apos;s Homework
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-center">
<div className="rounded-md bg-muted/50 p-2">
<div className="text-xs text-muted-foreground">Pending</div>
<div className="text-lg font-semibold tabular-nums">{summary.pendingCount}</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="text-xs text-muted-foreground">Submitted</div>
<div className="text-lg font-semibold tabular-nums">{summary.submittedCount}</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="text-xs text-muted-foreground">Graded</div>
<div className="text-lg font-semibold tabular-nums">{summary.gradedCount}</div>
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<TriangleAlert className="h-3 w-3" aria-hidden />
Overdue
</div>
<div
className={cn(
"text-lg font-semibold tabular-nums",
summary.overdueCount > 0 && "text-destructive",
)}
>
{summary.overdueCount}
</div>
</div>
</div>
{!hasAssignments ? (
<EmptyState
icon={PenTool}
title="No assignments"
description="No homework assigned right now."
className="border-none h-48"
/>
) : (
<div className="space-y-2">
<div className="text-xs font-medium uppercase text-muted-foreground">
All Assignments
</div>
{summary.recentAssignments.map((a) => {
const urgency = getDueUrgency(a.dueAt, now)
const isGraded = a.progressStatus === "graded"
const isSubmitted = a.progressStatus === "submitted"
const scoreText =
a.latestScore !== null ? `${a.latestScore} pts` : isGraded ? "Graded" : "-"
return (
<div
key={a.id}
className="rounded-md border bg-card p-3 space-y-2 min-h-[88px]"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
{a.subjectName ? (
<Badge variant="outline" className="text-[10px] shrink-0">
{a.subjectName}
</Badge>
) : null}
<div className="font-medium text-sm truncate">{a.title}</div>
</div>
</div>
<div className="text-sm font-medium tabular-nums shrink-0">
{scoreText}
</div>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<StatusBadge
status={a.progressStatus}
variantMap={STUDENT_HOMEWORK_PROGRESS_VARIANT}
labelMap={STUDENT_HOMEWORK_PROGRESS_LABEL}
className="text-[10px]"
/>
<span aria-label="Progress status">
{PROGRESS_LABEL[a.progressStatus] ?? a.progressStatus}
</span>
{a.dueAt ? (
<span
className={cn(
!isSubmitted && !isGraded && urgency === "overdue" && "text-destructive font-medium",
)}
>
Due {formatDate(a.dueAt)}
</span>
) : null}
{a.latestSubmittedAt ? (
<span>Submitted {formatDate(a.latestSubmittedAt)}</span>
) : null}
<span className="tabular-nums">
Attempts {a.attemptsUsed}/{a.maxAttempts}
</span>
</div>
<Link
href={`/parent/children/${childId}?tab=homework`}
className="inline-flex min-h-[36px] items-center text-xs text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded"
>
View details
</Link>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -4,37 +4,14 @@ import { PenTool, TriangleAlert } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatusBadge } from "@/shared/components/ui/status-badge"
import { cn, formatDate } from "@/shared/lib/utils"
import type { StudentHomeworkProgressStatus } from "@/modules/homework/types"
import {
STUDENT_HOMEWORK_PROGRESS_VARIANT,
STUDENT_HOMEWORK_PROGRESS_LABEL,
} from "@/modules/homework/types"
import type { ChildHomeworkSummaryData } from "@/modules/parent/types"
const getStatusVariant = (
status: StudentHomeworkProgressStatus,
): "default" | "secondary" | "outline" => {
switch (status) {
case "graded":
return "default"
case "submitted":
case "in_progress":
return "secondary"
case "not_started":
return "outline"
}
}
const getStatusLabel = (status: StudentHomeworkProgressStatus): string => {
switch (status) {
case "graded":
return "Graded"
case "submitted":
return "Submitted"
case "in_progress":
return "In progress"
case "not_started":
return "Not started"
}
}
type DueUrgency = "overdue" | "urgent" | "normal"
const getDueUrgency = (dueAt: string | null, now: Date): DueUrgency | null => {
@@ -63,7 +40,7 @@ export function ChildHomeworkSummary({
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<PenTool className="h-4 w-4 text-muted-foreground" />
<PenTool className="h-4 w-4 text-muted-foreground" aria-hidden />
{childName}&apos;s Homework
</CardTitle>
</CardHeader>
@@ -83,7 +60,7 @@ export function ChildHomeworkSummary({
</div>
<div className="rounded-md bg-muted/50 p-2">
<div className="text-xs text-muted-foreground flex items-center justify-center gap-1">
<TriangleAlert className="h-3 w-3" />
<TriangleAlert className="h-3 w-3" aria-hidden />
Overdue
</div>
<div
@@ -112,18 +89,29 @@ export function ChildHomeworkSummary({
{summary.recentAssignments.map((a) => {
const urgency = getDueUrgency(a.dueAt, now)
const isGraded = a.progressStatus === "graded"
const scoreText = a.latestScore !== null ? `${a.latestScore} pts` : isGraded ? "Graded" : "-"
return (
<Link
key={a.id}
href={`/parent/children/${childId}?tab=homework`}
className="flex items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors"
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="font-medium text-sm truncate">{a.title}</div>
<div className="flex items-center gap-2">
{a.subjectName ? (
<Badge variant="outline" className="text-[10px] shrink-0">
{a.subjectName}
</Badge>
) : null}
<div className="font-medium text-sm truncate">{a.title}</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant={getStatusVariant(a.progressStatus)} className="text-[10px]">
{getStatusLabel(a.progressStatus)}
</Badge>
<StatusBadge
status={a.progressStatus}
variantMap={STUDENT_HOMEWORK_PROGRESS_VARIANT}
labelMap={STUDENT_HOMEWORK_PROGRESS_LABEL}
className="text-[10px]"
/>
{a.dueAt ? (
<span
className={cn(
@@ -136,14 +124,14 @@ export function ChildHomeworkSummary({
</div>
</div>
<div className="text-sm font-medium tabular-nums shrink-0 ml-2">
{a.latestScore ?? "-"}
{scoreText}
</div>
</Link>
)
})}
<Link
href={`/parent/children/${childId}?tab=homework`}
className="block text-center text-xs text-muted-foreground hover:text-foreground transition-colors pt-1"
className="block text-center text-xs text-muted-foreground hover:text-foreground transition-colors pt-1 min-h-[36px] flex items-center justify-center"
>
View all
</Link>