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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||
|
||||
interface StudentDashboardHeaderProps {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user