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:
165
src/modules/parent/components/child-homework-detail.tsx
Normal file
165
src/modules/parent/components/child-homework-detail.tsx
Normal 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}'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>
|
||||
)
|
||||
}
|
||||
@@ -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}'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>
|
||||
|
||||
Reference in New Issue
Block a user