feat(student): 完成 student 模块 v4 剩余修复

- P1-4.2: 新增班级详情页 courses/[classId],展示教师/学校/教室信息与课表

- P2-2.5: 今日课表卡片高亮当前/下一节课(useMemo 实时计算)

- P2-3.9: 作业作答进度网格支持点击跳转题目(scrollIntoView)

- P2-3.10: 作业复习视图显示正确答案(选择/判断/文本题)

- P2-4.4: 课程列表支持按班级名/教师/学校搜索

- P2-5.2: 成绩页新增趋势折线图组件 GradeTrendCard

- P2-9.2/9.3: 诊断报告新增历史记录卡片与弱点练习入口

- P2-10.2: 选课列表支持搜索与选课模式筛选

- P2-11.3: 修复教材阅读页全屏溢出

- P3-1.5: 面包屑保留首个角色段作为根上下文

- P3-7.3: 课表项支持点击跳转至班级详情页(ScheduleList href)
This commit is contained in:
SpecialX
2026-06-22 14:08:34 +08:00
parent c90748124d
commit 30f4983d49
18 changed files with 912 additions and 137 deletions

View File

@@ -0,0 +1,39 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-8">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-80" />
</div>
<div className="grid gap-6 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,234 @@
import Link from "next/link"
import { notFound } from "next/navigation"
import {
BookOpen,
Building2,
CalendarDays,
ChevronLeft,
Mail,
PenTool,
School,
User,
} from "lucide-react"
import { getStudentClassById, getStudentSchedule } from "@/modules/classes/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
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"
export const dynamic = "force-dynamic"
const WEEKDAYS: Record<number, string> = {
1: "Mon",
2: "Tue",
3: "Wed",
4: "Thu",
5: "Fri",
6: "Sat",
7: "Sun",
}
export default async function StudentClassDetailPage({
params,
}: {
params: Promise<{ classId: string }>
}) {
const { classId } = await params
const student = await getCurrentStudentUser()
if (!student) return notFound()
const [classInfo, schedule] = await Promise.all([
getStudentClassById(student.id, classId),
getStudentSchedule(student.id),
])
if (!classInfo) return notFound()
// Filter schedule items for this class
const classSchedule = schedule
.filter((s) => s.classId === classId)
.sort((a, b) => a.weekday - b.weekday || a.startTime.localeCompare(b.startTime))
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<Button asChild variant="ghost" size="sm" className="-ml-2 mb-1">
<Link href="/student/learning/courses">
<ChevronLeft className="mr-1 h-4 w-4" />
Back to Courses
</Link>
</Button>
<h2 className="text-2xl font-bold tracking-tight">{classInfo.name}</h2>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="h-4 w-4" />
Grade {classInfo.grade}
</span>
{classInfo.homeroom && (
<>
<span aria-hidden="true"></span>
<span>{classInfo.homeroom}</span>
</>
)}
{classInfo.room && (
<>
<span aria-hidden="true"></span>
<span className="flex items-center gap-1">
<Building2 className="h-4 w-4" />
Room {classInfo.room}
</span>
</>
)}
<Badge variant="secondary">Active</Badge>
</div>
</div>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/student/schedule?classId=${encodeURIComponent(classInfo.id)}`}>
<CalendarDays className="mr-2 h-4 w-4" />
Full Schedule
</Link>
</Button>
<Button asChild size="sm">
<Link href="/student/learning/assignments">
<PenTool className="mr-2 h-4 w-4" />
Assignments
</Link>
</Button>
</div>
</div>
<div className="grid gap-6 md:grid-cols-3">
{/* Teacher Info */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<User className="h-4 w-4" />
Teacher
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{classInfo.teacherName ? (
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{classInfo.teacherName}</span>
</div>
) : (
<p className="text-muted-foreground">No teacher assigned.</p>
)}
{classInfo.teacherEmail && (
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
<a
href={`mailto:${classInfo.teacherEmail}`}
className="text-primary hover:underline"
>
{classInfo.teacherEmail}
</a>
</div>
)}
</CardContent>
</Card>
{/* School Info */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<School className="h-4 w-4" />
School
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{classInfo.schoolName ? (
<div className="flex items-center gap-2">
<School className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{classInfo.schoolName}</span>
</div>
) : (
<p className="text-muted-foreground">School info not available.</p>
)}
{classInfo.grade && (
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span>Grade {classInfo.grade}</span>
</div>
)}
</CardContent>
</Card>
{/* Class Info */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-sm font-medium">
<Building2 className="h-4 w-4" />
Classroom
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
{classInfo.room ? (
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Room {classInfo.room}</span>
</div>
) : (
<p className="text-muted-foreground">Room not assigned.</p>
)}
{classInfo.homeroom && (
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
<span>Homeroom: {classInfo.homeroom}</span>
</div>
)}
</CardContent>
</Card>
</div>
{/* Schedule */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarDays className="h-5 w-5" />
Class Schedule
</CardTitle>
</CardHeader>
<CardContent>
{classSchedule.length === 0 ? (
<EmptyState
icon={CalendarDays}
title="No schedule"
description="No timetable entries found for this class."
className="border-none shadow-none"
/>
) : (
<div className="space-y-3">
{classSchedule.map((s) => (
<div
key={s.id}
className="flex items-center justify-between rounded-md border p-3"
>
<div className="flex items-center gap-3">
<Badge variant="outline" className="w-12 justify-center">
{WEEKDAYS[s.weekday]}
</Badge>
<div>
<p className="font-medium">{s.course}</p>
{s.location && (
<p className="text-xs text-muted-foreground">{s.location}</p>
)}
</div>
</div>
<div className="text-sm text-muted-foreground tabular-nums">
{s.startTime} - {s.endTime}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -3,15 +3,27 @@ import { UserX } from "lucide-react"
import { getStudentClasses } from "@/modules/classes/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
import { CourseFilters } from "@/modules/student/components/course-filters"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
export default async function StudentCoursesPage() {
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function StudentCoursesPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const student = await getCurrentStudentUser()
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
@@ -25,16 +37,31 @@ export default async function StudentCoursesPage() {
)
}
const classes = await getStudentClasses(student.id)
const [sp, classes] = await Promise.all([
searchParams,
getStudentClasses(student.id),
])
const q = (getParam(sp, "q") || "").toLowerCase().trim()
const filteredClasses = q
? classes.filter((c) => {
return (
c.name.toLowerCase().includes(q) ||
(c.teacherName?.toLowerCase().includes(q) ?? false) ||
(c.schoolName?.toLowerCase().includes(q) ?? false) ||
(c.homeroom?.toLowerCase().includes(q) ?? false)
)
})
: classes
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p>
</div>
<StudentCoursesView classes={classes} />
{classes.length > 0 && <CourseFilters />}
<StudentCoursesView classes={filteredClasses} />
</div>
)
}