完整性更新

现在已经实现了大部分基础功能
This commit is contained in:
SpecialX
2026-01-08 11:14:03 +08:00
parent 0da2eac0b4
commit 57807def37
155 changed files with 26421 additions and 1036 deletions

View File

@@ -0,0 +1,17 @@
import Link from "next/link"
import { Button } from "@/shared/components/ui/button"
export function StudentDashboardHeader({ studentName }: { studentName: string }) {
return (
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<div className="text-sm text-muted-foreground">Welcome back, {studentName}.</div>
</div>
<Button asChild variant="outline">
<Link href="/student/learning/assignments">View assignments</Link>
</Button>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import type { StudentDashboardProps } from "@/modules/dashboard/types"
import { StudentDashboardHeader } from "./student-dashboard-header"
import { StudentGradesCard } from "./student-grades-card"
import { StudentRankingCard } from "./student-ranking-card"
import { StudentStatsGrid } from "./student-stats-grid"
import { StudentTodayScheduleCard } from "./student-today-schedule-card"
import { StudentUpcomingAssignmentsCard } from "./student-upcoming-assignments-card"
export function StudentDashboard({
studentName,
enrolledClassCount,
dueSoonCount,
overdueCount,
gradedCount,
todayScheduleItems,
upcomingAssignments,
grades,
}: StudentDashboardProps) {
return (
<div className="space-y-6">
<StudentDashboardHeader studentName={studentName} />
<StudentStatsGrid
enrolledClassCount={enrolledClassCount}
dueSoonCount={dueSoonCount}
overdueCount={overdueCount}
gradedCount={gradedCount}
/>
<div className="grid gap-4 md:grid-cols-2">
<StudentGradesCard grades={grades} />
<StudentRankingCard ranking={grades.ranking} />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<StudentTodayScheduleCard items={todayScheduleItems} />
<StudentUpcomingAssignmentsCard upcomingAssignments={upcomingAssignments} />
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
import Link from "next/link"
import { BarChart3 } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
const hasGradeTrend = grades.trend.length > 0
const hasRecentGrades = grades.recent.length > 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
Recent Grades
</CardTitle>
</CardHeader>
<CardContent>
{!hasGradeTrend ? (
<EmptyState
icon={BarChart3}
title="No graded work yet"
description="Finish and submit assignments to see your score trend."
className="border-none h-72"
/>
) : (
<div className="space-y-4">
<div className="rounded-md border bg-card p-4">
<svg viewBox="0 0 100 40" className="h-24 w-full">
<polyline
fill="none"
stroke="currentColor"
strokeWidth="2"
points={grades.trend
.map((p, i) => {
const t = grades.trend.length > 1 ? i / (grades.trend.length - 1) : 0
const x = t * 100
const v = Number.isFinite(p.percentage) ? Math.max(0, Math.min(100, p.percentage)) : 0
const y = 40 - (v / 100) * 40
return `${x},${y}`
})
.join(" ")}
className="text-primary"
/>
</svg>
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
<div>
Latest:{" "}
<span className="font-medium text-foreground tabular-nums">
{Math.round(grades.trend[grades.trend.length - 1]?.percentage ?? 0)}%
</span>
</div>
<div>
Points:{" "}
<span className="font-medium text-foreground tabular-nums">
{grades.trend[grades.trend.length - 1]?.score ?? 0}/{grades.trend[grades.trend.length - 1]?.maxScore ?? 0}
</span>
</div>
</div>
</div>
{!hasRecentGrades ? null : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Assignment</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">When</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{grades.recent.map((r) => (
<TableRow key={r.assignmentId} className="h-12">
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${r.assignmentId}`} className="hover:underline">
{r.assignmentTitle}
</Link>
</TableCell>
<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>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,47 @@
import { Trophy } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { StudentRanking } from "@/modules/homework/types"
export function StudentRankingCard({ ranking }: { ranking: StudentRanking | null }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-4 w-4 text-muted-foreground" />
Ranking
</CardTitle>
</CardHeader>
<CardContent>
{!ranking ? (
<EmptyState
icon={Trophy}
title="No ranking available"
description="Join a class and complete graded work to see your rank."
className="border-none h-72"
/>
) : (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="rounded-md border bg-card p-4">
<div className="text-sm text-muted-foreground">Class Rank</div>
<div className="mt-1 text-3xl font-bold tabular-nums">
{ranking.rank}/{ranking.classSize}
</div>
</div>
<div className="rounded-md border bg-card p-4">
<div className="text-sm text-muted-foreground">Overall</div>
<div className="mt-1 text-3xl font-bold tabular-nums">{Math.round(ranking.percentage)}%</div>
<div className="text-xs text-muted-foreground tabular-nums">
{ranking.totalScore}/{ranking.totalMaxScore} pts
</div>
</div>
</div>
<div className="text-sm text-muted-foreground">Based on latest graded submissions per assignment for your class.</div>
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,66 @@
import { BookOpen, CheckCircle2, PenTool, TriangleAlert } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
type Stat = {
title: string
value: string
description: string
icon: typeof BookOpen
}
export function StudentStatsGrid({
enrolledClassCount,
dueSoonCount,
overdueCount,
gradedCount,
}: {
enrolledClassCount: number
dueSoonCount: number
overdueCount: number
gradedCount: number
}) {
const stats: readonly Stat[] = [
{
title: "My Classes",
value: String(enrolledClassCount),
description: "Enrolled classes",
icon: BookOpen,
},
{
title: "Due Soon",
value: String(dueSoonCount),
description: "Next 7 days",
icon: PenTool,
},
{
title: "Overdue",
value: String(overdueCount),
description: "Needs attention",
icon: TriangleAlert,
},
{
title: "Graded",
value: String(gradedCount),
description: "With score",
icon: CheckCircle2,
},
]
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold tabular-nums">{stat.value}</div>
<div className="text-xs text-muted-foreground">{stat.description}</div>
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { CalendarDays, CalendarX, Clock, MapPin } 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 type { StudentTodayScheduleItem } from "@/modules/dashboard/types"
export function StudentTodayScheduleCard({ items }: { items: StudentTodayScheduleItem[] }) {
const hasSchedule = items.length > 0
return (
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-muted-foreground" />
Today&apos;s Schedule
</CardTitle>
</CardHeader>
<CardContent>
{!hasSchedule ? (
<EmptyState
icon={CalendarX}
title="No classes today"
description="Your timetable is clear for today."
className="border-none h-72"
/>
) : (
<div className="space-y-4">
{items.map((item) => (
<div key={item.id} className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0">
<div className="space-y-1 min-w-0">
<div className="font-medium leading-none truncate">{item.course}</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
<div className="flex items-center">
<Clock className="mr-1 h-3 w-3" />
<span>
{item.startTime}{item.endTime}
</span>
</div>
{item.location ? (
<div className="flex items-center">
<MapPin className="mr-1 h-3 w-3" />
<span className="truncate">{item.location}</span>
</div>
) : null}
</div>
</div>
<Badge variant="secondary" className="shrink-0">
{item.className}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,83 @@
import Link from "next/link"
import { PenTool } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate } from "@/shared/lib/utils"
import type { StudentHomeworkAssignmentListItem } from "@/modules/homework/types"
const getStatusVariant = (status: string): "default" | "secondary" | "outline" => {
if (status === "graded") return "default"
if (status === "submitted") return "secondary"
if (status === "in_progress") return "secondary"
return "outline"
}
const getStatusLabel = (status: string) => {
if (status === "graded") return "Graded"
if (status === "submitted") return "Submitted"
if (status === "in_progress") return "In progress"
return "Not started"
}
export function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
const hasAssignments = upcomingAssignments.length > 0
return (
<Card className="lg:col-span-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<PenTool className="h-4 w-4 text-muted-foreground" />
Upcoming Assignments
</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href="/student/learning/assignments">View all</Link>
</Button>
</CardHeader>
<CardContent>
{!hasAssignments ? (
<EmptyState
icon={PenTool}
title="No assignments"
description="You have no assigned homework right now."
className="border-none h-72"
/>
) : (
<div className="rounded-md border bg-card">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Title</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Status</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Due</TableHead>
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{upcomingAssignments.map((a) => (
<TableRow key={a.id} className="h-12">
<TableCell className="font-medium">
<Link href={`/student/learning/assignments/${a.id}`} className="truncate hover:underline">
{a.title}
</Link>
</TableCell>
<TableCell>
<Badge variant={getStatusVariant(a.progressStatus)} className="capitalize">
{getStatusLabel(a.progressStatus)}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{a.dueAt ? formatDate(a.dueAt) : "-"}</TableCell>
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
)
}