feat(app): add error/loading boundaries and update dashboard routes

- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes

- Add dashboard-error-fallback and dashboard-loading-skeleton components

- Add student/learning page, parent/leave routes, teacher textbook components

- Update existing app routes across auth, dashboard, and API endpoints

- Update proxy middleware and next-auth type declarations
This commit is contained in:
SpecialX
2026-06-23 17:38:28 +08:00
parent c4d3433cc9
commit 1a9377222c
90 changed files with 1690 additions and 741 deletions

View File

@@ -1,6 +1,7 @@
"use client"
import { useTranslations } from "next-intl"
import { BarChart3 } from "lucide-react"
import {
LineChart,
Line,
@@ -11,13 +12,28 @@ import {
ResponsiveContainer,
} from "recharts"
import { EmptyState } from "@/shared/components/ui/empty-state"
interface UserGrowthChartProps {
data: Array<{ date: string; count: number }>
/** Translation key for the line/tooltip label (e.g. "chart.newUsers" or "chart.newSubmissions") */
labelKey?: "chart.newUsers" | "chart.newSubmissions"
}
export function UserGrowthChart({ data }: UserGrowthChartProps) {
export function UserGrowthChart({ data, labelKey = "chart.newUsers" }: UserGrowthChartProps) {
const t = useTranslations("dashboard")
if (data.length === 0) {
return (
<EmptyState
icon={BarChart3}
title={t("empty.noData")}
description={t("empty.noDataDesc")}
className="border-none h-[240px]"
/>
)
}
return (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
@@ -41,7 +57,7 @@ export function UserGrowthChart({ data }: UserGrowthChartProps) {
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={{ fill: "hsl(var(--primary))", r: 3 }}
name={t("chart.newUsers")}
name={t(labelKey)}
/>
</LineChart>
</ResponsiveContainer>

View File

@@ -0,0 +1,30 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
/**
* 仪表盘通用错误边界回退 UI。
*
* 用于各角色 dashboard 路由的 `error.tsx`,消除重复代码。
* 接收 Next.js 注入的 `reset` 函数用于重试。
*/
export function DashboardErrorFallback({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("dashboard")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("error.loadFailed")}
description={t("error.loadFailedDesc")}
action={{
label: t("error.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -1,7 +1,5 @@
"use client"
import type { ReactNode } from "react"
import { useTranslations } from "next-intl"
import { getLocale, getTranslations } from "next-intl/server"
import { formatLongDate } from "@/shared/lib/utils"
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
@@ -11,15 +9,16 @@ import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
* 教师与学生仪表盘头部 90% 重复,统一抽象为此组件。
* 通过 `actions` slot 注入角色专属快捷操作。
*/
export function DashboardGreetingHeader({
export async function DashboardGreetingHeader({
userName,
actions,
}: {
userName: string
actions?: ReactNode
}) {
const t = useTranslations("dashboard")
const today = formatLongDate(new Date())
const t = await getTranslations("dashboard")
const locale = await getLocale()
const today = formatLongDate(new Date(), locale)
const greetingKey = getGreetingKey(new Date())
return (

View File

@@ -0,0 +1,44 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* 仪表盘通用加载骨架屏。
*
* 用于各角色 dashboard 路由的 `loading.tsx`,消除重复代码。
* 布局:页头 + 4 列统计卡片 + 列表骨架。
*/
export function DashboardLoadingSkeleton() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,5 +1,3 @@
"use client"
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
interface StudentDashboardHeaderProps {

View File

@@ -2,7 +2,7 @@
import Link from "next/link"
import { BarChart3 } from "lucide-react"
import { useTranslations } from "next-intl"
import { useTranslations, useLocale } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
@@ -13,6 +13,7 @@ import type { StudentDashboardGradeProps } from "@/modules/homework/types"
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
const t = useTranslations("dashboard")
const locale = useLocale()
const hasGradeTrend = grades.trend.length > 0
const hasRecentGrades = grades.recent.length > 0
@@ -20,7 +21,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
title: item.assignmentTitle,
score: Math.round(item.percentage),
fullTitle: item.assignmentTitle,
submittedAt: formatDate(item.submittedAt),
submittedAt: formatDate(item.submittedAt, locale),
rawScore: item.score,
maxScore: item.maxScore,
}))
@@ -102,7 +103,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
<TableCell className="tabular-nums">
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt)}</TableCell>
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt, locale)}</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -1,6 +1,6 @@
import Link from "next/link"
import { PenTool } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { getLocale, getTranslations } from "next-intl/server"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -27,7 +27,7 @@ const getActionVariant = (status: string): "default" | "secondary" | "outline" =
return "default"
}
const getDueUrgency = (dueAt: string | null) => {
const getDueUrgency = (dueAt: string | null): "overdue" | "urgent" | "warning" | "normal" | null => {
if (!dueAt) return null
const now = new Date()
const due = new Date(dueAt)
@@ -41,6 +41,7 @@ const getDueUrgency = (dueAt: string | null) => {
export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
const t = await getTranslations("dashboard")
const locale = await getLocale()
const hasAssignments = upcomingAssignments.length > 0
return (
@@ -60,6 +61,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: {
icon={PenTool}
title={t("empty.noAssignmentsStudent")}
description={t("empty.noAssignmentsStudentDesc")}
action={{ label: t("quickActions.viewAll"), href: "/student/learning/assignments" }}
className="border-none h-72"
/>
) : (
@@ -103,7 +105,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: {
!isGraded && urgency === "overdue" && "text-destructive font-medium",
!isGraded && urgency === "urgent" && "text-orange-500 font-medium"
)}>
{a.dueAt ? formatDate(a.dueAt) : "-"}
{a.dueAt ? formatDate(a.dueAt, locale) : "-"}
</TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
<TableCell className="text-right">

View File

@@ -1,9 +1,9 @@
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import { getTranslations, getLocale } from "next-intl/server"
import { Inbox, ArrowRight } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -32,6 +32,7 @@ export async function RecentSubmissions({
emptyDescription,
}: RecentSubmissionsProps) {
const t = await getTranslations("dashboard")
const locale = await getLocale()
const hasSubmissions = submissions.length > 0
return (
@@ -73,7 +74,6 @@ export async function RecentSubmissions({
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8 border">
<AvatarImage src={undefined} alt={item.studentName} />
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{item.studentName.charAt(0)}
</AvatarFallback>
@@ -93,7 +93,7 @@ export async function RecentSubmissions({
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">
{item.submittedAt ? formatDate(item.submittedAt) : "-"}
{item.submittedAt ? formatDate(item.submittedAt, locale) : "-"}
</span>
{item.isLate && (
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">

View File

@@ -1,5 +1,3 @@
"use client"
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
import { TeacherQuickActions } from "./teacher-quick-actions"

View File

@@ -59,7 +59,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
.reverse()
.slice(0, 3)
.map((item, i) => (
<div key={i} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
<div key={item.fullTitle || `item-${i}`} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
<div className="text-xs text-muted-foreground truncate" title={item.fullTitle}>
{item.fullTitle}
</div>

View File

@@ -1,13 +1,11 @@
"use client"
import Link from "next/link"
import { PlusCircle, CheckSquare, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { getTranslations } from "next-intl/server"
import { Button } from "@/shared/components/ui/button"
export function TeacherQuickActions() {
const t = useTranslations("dashboard")
export async function TeacherQuickActions() {
const t = await getTranslations("dashboard")
return (
<div className="flex items-center space-x-2">

View File

@@ -21,14 +21,14 @@ export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleIt
const t = await getTranslations("dashboard")
const hasSchedule = items.length > 0
const getStatus = (start: string, end: string) => {
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 = (startH ?? 0) * 60 + (startM ?? 0)
const endTime = (endH ?? 0) * 60 + (endM ?? 0)
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"

View File

@@ -7,7 +7,6 @@ interface TeacherStatsProps {
activeAssignmentsCount: number
averageScore: number
submissionRate: number
isLoading?: boolean
}
export async function TeacherStats({
@@ -15,7 +14,6 @@ export async function TeacherStats({
activeAssignmentsCount,
averageScore,
submissionRate,
isLoading = false,
}: TeacherStatsProps) {
const t = await getTranslations("dashboard")
@@ -29,7 +27,6 @@ export async function TeacherStats({
href="/teacher/homework/submissions?status=submitted"
highlight={toGradeCount > 0}
color="text-amber-500"
isLoading={isLoading}
/>
<StatCard
title={t("stats.activeAssignments")}
@@ -38,7 +35,6 @@ export async function TeacherStats({
icon={PenTool}
href="/teacher/homework/assignments?status=published"
color="text-blue-500"
isLoading={isLoading}
/>
<StatCard
title={t("stats.averageScore")}
@@ -47,7 +43,6 @@ export async function TeacherStats({
icon={TrendingUp}
href="#grade-trends"
color="text-emerald-500"
isLoading={isLoading}
/>
<StatCard
title={t("stats.submissionRate")}
@@ -56,7 +51,6 @@ export async function TeacherStats({
icon={BarChart}
href="#grade-trends"
color="text-purple-500"
isLoading={isLoading}
/>
</div>
)

View File

@@ -49,13 +49,17 @@ export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
<div className="space-y-1">
{items
.filter((item) => item.count > 0)
.sort((a, b) => (a.variant === "urgent" ? -1 : 1) - (b.variant === "urgent" ? -1 : 1))
.sort((a, b) => {
if (a.variant === "urgent" && b.variant !== "urgent") return -1
if (a.variant !== "urgent" && b.variant === "urgent") return 1
return 0
})
.map((item, idx) => {
const style = VARIANT_STYLES[item.variant]
const Icon = style.icon
return (
<Link
key={idx}
key={item.href || item.label || `item-${idx}`}
href={item.href}
className="group flex items-center justify-between rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 hover:border-border transition-colors"
>