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:
@@ -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>
|
||||
)
|
||||
}
|
||||
234
src/app/(dashboard)/student/learning/courses/[classId]/page.tsx
Normal file
234
src/app/(dashboard)/student/learning/courses/[classId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user