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

@@ -2,28 +2,51 @@ import { getAuthContext } from "@/shared/lib/auth-guard"
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections" import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
import { StudentSelectionView } from "@/modules/elective/components/student-selection-view" import { StudentSelectionView } from "@/modules/elective/components/student-selection-view"
import { ElectiveFilters } from "@/modules/elective/components/elective-filters"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function StudentElectivePage() { 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 StudentElectivePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const ctx = await getAuthContext() const ctx = await getAuthContext()
const studentId = ctx.userId const studentId = ctx.userId
const [availableCourses, mySelections] = await Promise.all([ const [sp, availableCourses, mySelections] = await Promise.all([
searchParams,
getAvailableCoursesForStudent(studentId), getAvailableCoursesForStudent(studentId),
getStudentSelections(studentId), getStudentSelections(studentId),
]) ])
const q = (getParam(sp, "q") || "").toLowerCase().trim()
const modeFilter = getParam(sp, "mode") || "all"
const filteredCourses = availableCourses.filter((c) => {
if (q && !c.name.toLowerCase().includes(q) && !(c.teacherName?.toLowerCase().includes(q) ?? false)) return false
if (modeFilter !== "all" && c.selectionMode !== modeFilter) return false
return true
})
return ( return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2> <h2 className="text-2xl font-bold tracking-tight">Elective Courses</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Browse available electives and manage your selections. Browse available electives and manage your selections.
</p> </p>
</div> </div>
{availableCourses.length > 0 && <ElectiveFilters />}
<StudentSelectionView <StudentSelectionView
availableCourses={availableCourses} availableCourses={filteredCourses}
mySelections={mySelections} mySelections={mySelections}
/> />
</div> </div>

View File

@@ -1,19 +1,34 @@
import { getAuthContext } from "@/shared/lib/auth-guard" import { getAuthContext } from "@/shared/lib/auth-guard"
import { getStudentGradeSummary } from "@/modules/grades/data-access" import { getStudentGradeSummary } from "@/modules/grades/data-access"
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary" import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
import { GradeFilters } from "@/modules/grades/components/grade-filters"
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { UserX } from "lucide-react" import { UserX } from "lucide-react"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function StudentGradesPage() { type SearchParams = { [key: string]: string | string[] | undefined }
const ctx = await getAuthContext()
const summary = await getStudentGradeSummary(ctx.userId) const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function StudentGradesPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const ctx = await getAuthContext()
const [sp, summary] = await Promise.all([
searchParams,
getStudentGradeSummary(ctx.userId),
])
if (!summary) { if (!summary) {
return ( return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2> <h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
<p className="text-muted-foreground">View your grade records.</p> <p className="text-muted-foreground">View your grade records.</p>
@@ -28,13 +43,34 @@ export default async function StudentGradesPage() {
) )
} }
// 应用筛选
const q = (getParam(sp, "q") || "").toLowerCase().trim()
const subjectFilter = getParam(sp, "subject") || "all"
const typeFilter = getParam(sp, "type") || "all"
const semesterFilter = getParam(sp, "semester") || "all"
const filteredRecords = summary.records.filter((r) => {
if (q && !r.title.toLowerCase().includes(q)) return false
if (subjectFilter !== "all" && r.subjectName !== subjectFilter) return false
if (typeFilter !== "all" && r.type !== typeFilter) return false
if (semesterFilter !== "all" && r.semester !== semesterFilter) return false
return true
})
const filteredSummary = {
...summary,
records: filteredRecords,
}
return ( return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2> <h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
<p className="text-muted-foreground">View your grade records.</p> <p className="text-muted-foreground">View your grade records.</p>
</div> </div>
<StudentGradeSummary summary={summary} /> <GradeFilters />
{filteredSummary.records.length > 0 && <GradeTrendCard summary={filteredSummary} />}
<StudentGradeSummary summary={filteredSummary} />
</div> </div>
) )
} }

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 { getStudentClasses } from "@/modules/classes/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access"
import { StudentCoursesView } from "@/modules/student/components/student-courses-view" 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" import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic" 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() const student = await getCurrentStudentUser()
if (!student) { if (!student) {
return ( return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2> <h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p> <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 ( return (
<div className="flex h-full flex-col space-y-8 p-8"> <div className="space-y-8">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">Courses</h2> <h2 className="text-2xl font-bold tracking-tight">Courses</h2>
<p className="text-muted-foreground">Your enrolled classes.</p> <p className="text-muted-foreground">Your enrolled classes.</p>
</div> </div>
<StudentCoursesView classes={classes} /> {classes.length > 0 && <CourseFilters />}
<StudentCoursesView classes={filteredClasses} />
</div> </div>
) )
} }

View File

@@ -2,14 +2,14 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() { export default function Loading() {
return ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden"> <div className="flex h-[calc(100vh-4rem-3rem)] flex-col overflow-hidden">
<div className="flex items-center justify-between border-b py-3 px-6 shrink-0"> <div className="flex items-center justify-between border-b py-3 px-6 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-6 w-48" /> <Skeleton className="h-6 w-48" />
<Skeleton className="h-5 w-16 rounded-full" /> <Skeleton className="h-5 w-16 rounded-full" />
</div> </div>
</div> </div>
<div className="flex-1 p-6"> <div className="flex-1">
<Skeleton className="h-full w-full" /> <Skeleton className="h-full w-full" />
</div> </div>
</div> </div>

View File

@@ -29,7 +29,7 @@ export default async function StudentTextbookDetailPage({
if (!textbook) notFound() if (!textbook) notFound()
return ( return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-muted/5"> <div className="flex h-[calc(100vh-4rem-3rem)] flex-col overflow-hidden bg-muted/5">
<div className="flex items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10"> <div className="flex items-center justify-between border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 py-3 px-6 shrink-0 z-10">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1> <h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
@@ -43,7 +43,7 @@ export default async function StudentTextbookDetailPage({
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden p-6"> <div className="flex-1 overflow-hidden">
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card"> <div className="h-full flex items-center justify-center rounded-lg border border-dashed bg-card">
<EmptyState <EmptyState

View File

@@ -192,6 +192,48 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
return list return list
}) })
/**
* Get a single class details for a student (verifies enrollment).
* Returns null if the student is not enrolled in the class.
*/
export const getStudentClassById = cache(
async (studentId: string, classId: string): Promise<StudentEnrolledClass | null> => {
const sid = studentId.trim()
const cid = classId.trim()
if (!sid || !cid) return null
const rows = await db
.select({
id: classes.id,
schoolName: classes.schoolName,
name: classes.name,
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, sid), eq(classEnrollments.classId, cid), eq(classEnrollments.status, "active")))
.limit(1)
if (rows.length === 0) return null
const r = rows[0]
return {
id: r.id,
schoolName: r.schoolName,
name: r.name,
grade: r.grade,
homeroom: r.homeroom,
room: r.room,
teacherName: r.teacherName,
teacherEmail: r.teacherEmail,
}
}
)
export const getClassStudents = cache( export const getClassStudents = cache(
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => { async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
const teacherId = params?.teacherId ?? (await getSessionTeacherId()) const teacherId = params?.teacherId ?? (await getSessionTeacherId())

View File

@@ -1,20 +1,14 @@
"use client" "use client"
import { useState } from "react" import Link from "next/link"
import { useRouter } from "next/navigation" import { Award, AlertTriangle, Lightbulb, FileText, History, ArrowRight } from "lucide-react"
import { toast } from "sonner"
import { Award, AlertTriangle, Lightbulb, FileText, TrendingUp } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { usePermission } from "@/shared/hooks" import { formatDate } from "@/shared/lib/utils"
import { Permissions } from "@/shared/types/permissions"
import { MasteryRadarChart } from "./mastery-radar-chart" import { MasteryRadarChart } from "./mastery-radar-chart"
import { generateStudentReportAction } from "../actions"
import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types" import type { DiagnosticReportWithDetails, MasteryRadarPoint, StudentMasterySummary } from "../types"
interface StudentDiagnosticViewProps { interface StudentDiagnosticViewProps {
@@ -24,28 +18,6 @@ interface StudentDiagnosticViewProps {
} }
export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) { export function StudentDiagnosticView({ summary, reports, classAverageMastery }: StudentDiagnosticViewProps) {
const router = useRouter()
const { hasPermission } = usePermission()
const canManage = hasPermission(Permissions.DIAGNOSTIC_MANAGE)
const [period, setPeriod] = useState(new Date().toISOString().slice(0, 7))
const [isGenerating, setIsGenerating] = useState(false)
const handleGenerate = async () => {
if (!summary) return
setIsGenerating(true)
const formData = new FormData()
formData.set("studentId", summary.studentId)
formData.set("period", period)
const result = await generateStudentReportAction(null, formData)
setIsGenerating(false)
if (result.success) {
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || "Failed to generate report")
}
}
if (!summary) { if (!summary) {
return ( return (
<EmptyState <EmptyState
@@ -149,9 +121,17 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{summary.weaknesses.map((m) => ( {summary.weaknesses.map((m) => (
<li key={m.knowledgePointId} className="flex items-center justify-between"> <li key={m.knowledgePointId} className="flex items-center justify-between gap-2">
<span className="text-sm">{m.knowledgePointName}</span> <div className="flex items-center gap-2 min-w-0">
<Badge variant="destructive">{m.masteryLevel.toFixed(1)}%</Badge> <span className="text-sm truncate">{m.knowledgePointName}</span>
<Badge variant="destructive" className="shrink-0">{m.masteryLevel.toFixed(1)}%</Badge>
</div>
<Button asChild variant="ghost" size="sm" className="h-7 shrink-0 text-xs">
<Link href="/student/learning/assignments">
Practice
<ArrowRight className="ml-1 h-3 w-3" />
</Link>
</Button>
</li> </li>
))} ))}
</ul> </ul>
@@ -160,38 +140,6 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
</Card> </Card>
</div> </div>
{/* 生成报告 */}
{canManage ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Generate Diagnostic Report
</CardTitle>
<CardDescription>
Generate an AI-analyzed diagnostic report for this student.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-3">
<div className="grid gap-2">
<Label htmlFor="period" className="text-xs">Period (YYYY-MM)</Label>
<Input
id="period"
type="month"
value={period}
onChange={(e) => setPeriod(e.target.value)}
className="w-[180px]"
/>
</div>
<Button onClick={handleGenerate} disabled={isGenerating}>
{isGenerating ? "Generating..." : "Generate Report"}
</Button>
</div>
</CardContent>
</Card>
) : null}
{/* 最新报告 / 建议 */} {/* 最新报告 / 建议 */}
{latestReport ? ( {latestReport ? (
<Card> <Card>
@@ -224,6 +172,44 @@ export function StudentDiagnosticView({ summary, reports, classAverageMastery }:
</CardContent> </CardContent>
</Card> </Card>
) : null} ) : null}
{/* 历史报告列表 */}
{publishedReports.length > 1 ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-4 w-4" />
Report History
</CardTitle>
<CardDescription>Past diagnostic reports (newest first).</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{publishedReports.map((r) => (
<div
key={r.id}
className="flex items-center justify-between gap-3 rounded-md border p-3"
>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{r.period ?? "Untitled period"}
</span>
<Badge variant="outline" className="text-[10px]">
{r.reportType}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{formatDate(r.createdAt)}
{r.overallScore !== null ? ` · Overall: ${r.overallScore.toFixed(1)}%` : ""}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
) : null}
</div> </div>
) )
} }

View File

@@ -0,0 +1,49 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function ElectiveFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [mode, setMode] = useQueryState("mode", parseAsString.withDefault("all"))
const hasFilters = Boolean(search || mode !== "all")
return (
<FilterBar
layout="between"
hasFilters={hasFilters}
onReset={() => {
setSearch(null)
setMode(null)
}}
>
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="Search by course name, teacher..."
/>
<div className="flex flex-wrap gap-2 w-full md:w-auto">
<Select value={mode} onValueChange={(val) => setMode(val === "all" ? null : val)}>
<SelectTrigger className="w-[160px] bg-background border-muted-foreground/20">
<SelectValue placeholder="Selection Mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Modes</SelectItem>
<SelectItem value="fcfs">First Come First Served</SelectItem>
<SelectItem value="lottery">Lottery</SelectItem>
</SelectContent>
</Select>
</div>
</FilterBar>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
import { useMemo } from "react"
import { BarChart3 } from "lucide-react"
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
import { formatDate } from "@/shared/lib/utils"
import type { StudentGradeSummary } from "../types"
export function GradeTrendCard({ summary }: { summary: StudentGradeSummary }) {
const chartData = useMemo(() => {
return [...summary.records]
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.map((r) => ({
title: r.title,
score: Math.round((r.score / r.fullScore) * 100),
fullTitle: r.title,
submittedAt: formatDate(r.createdAt),
rawScore: r.score,
maxScore: r.fullScore,
}))
}, [summary.records])
const hasData = chartData.length > 0
return (
<ChartCardShell
title="Grade Trend"
icon={BarChart3}
iconClassName="text-muted-foreground"
isEmpty={!hasData}
emptyTitle="No grade records yet"
emptyDescription="Your grade trend will appear here once records are added."
emptyClassName="h-64"
>
<div className="rounded-md border bg-card p-4">
<TrendLineChart
data={chartData}
series={[
{
dataKey: "score",
name: "Score (%)",
color: "hsl(var(--primary))",
dotRadius: 4,
activeDotRadius: 6,
},
]}
heightClassName="h-[240px]"
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
yWidth={30}
tooltipClassName="w-[200px]"
/>
</div>
</ChartCardShell>
)
}

View File

@@ -1,7 +1,8 @@
"use client" "use client"
import { useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import Link from "next/link"
import { toast } from "sonner" import { toast } from "sonner"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group" import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
@@ -12,7 +13,18 @@ import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea" import { Textarea } from "@/shared/components/ui/textarea"
import { ScrollArea } from "@/shared/components/ui/scroll-area" import { ScrollArea } from "@/shared/components/ui/scroll-area"
import { Clock, CheckCircle2, Save, FileText } from "lucide-react" import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/components/ui/alert-dialog"
import { Clock, CheckCircle2, Save, FileText, ChevronLeft, TriangleAlert } from "lucide-react"
import { formatDate, cn } from "@/shared/lib/utils"
import type { StudentHomeworkTakeData } from "../types" import type { StudentHomeworkTakeData } from "../types"
import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions" import { saveHomeworkAnswerAction, startHomeworkSubmissionAction, submitHomeworkAction } from "../actions"
@@ -64,6 +76,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null) const [submissionId, setSubmissionId] = useState<string | null>(initialData.submission?.id ?? null)
const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started") const [submissionStatus, setSubmissionStatus] = useState<string>(initialData.submission?.status ?? "not_started")
const [isBusy, setIsBusy] = useState(false) const [isBusy, setIsBusy] = useState(false)
const [showSubmitConfirm, setShowSubmitConfirm] = useState(false)
const initialAnswersByQuestionId = useMemo(() => { const initialAnswersByQuestionId = useMemo(() => {
const map = new Map<string, { answer: unknown }>() const map = new Map<string, { answer: unknown }>()
@@ -83,6 +96,30 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const canEdit = isStarted && Boolean(submissionId) const canEdit = isStarted && Boolean(submissionId)
const showQuestions = submissionStatus !== "not_started" const showQuestions = submissionStatus !== "not_started"
// 离开警告:作答中未提交时关闭/刷新页面会丢失答案
useEffect(() => {
if (!canEdit) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
e.returnValue = ""
}
window.addEventListener("beforeunload", handler)
return () => window.removeEventListener("beforeunload", handler)
}, [canEdit])
// 截止时间与紧急度
const dueAt = initialData.assignment.dueAt
const now = new Date()
const dueDate = dueAt ? new Date(dueAt) : null
const isOverdue = dueDate ? dueDate < now : false
const hoursUntilDue = dueDate ? Math.floor((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60)) : null
const isUrgent = hoursUntilDue !== null && hoursUntilDue >= 0 && hoursUntilDue < 24
// 尝试次数
const maxAttempts = initialData.assignment.maxAttempts
const attemptsUsed = initialData.submission?.attemptNo ?? 0
const attemptsRemaining = Math.max(0, maxAttempts - attemptsUsed)
const handleStart = async () => { const handleStart = async () => {
setIsBusy(true) setIsBusy(true)
const fd = new FormData() const fd = new FormData()
@@ -144,11 +181,26 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
setIsBusy(false) setIsBusy(false)
} }
// 统计未作答题目数
const unansweredCount = initialData.questions.filter((q) => {
const v = answersByQuestionId[q.questionId]?.answer
if (v === undefined || v === null) return true
if (typeof v === "string" && v.trim() === "") return true
if (Array.isArray(v) && v.length === 0) return true
return false
}).length
return ( return (
<div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12"> <div className="grid h-[calc(100vh-10rem)] grid-cols-1 gap-6 lg:grid-cols-12">
<div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card"> <div className="lg:col-span-9 flex flex-col h-full overflow-hidden rounded-md border bg-card">
<div className="border-b p-4 flex items-center justify-between bg-muted/30"> <div className="border-b p-4 flex items-center justify-between bg-muted/30">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button asChild variant="ghost" size="sm" className="mr-1">
<Link href="/student/learning/assignments">
<ChevronLeft className="mr-1 h-4 w-4" />
Back
</Link>
</Button>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<FileText className="h-4 w-4 text-primary" /> <FileText className="h-4 w-4 text-primary" />
</div> </div>
@@ -169,15 +221,10 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
{isBusy ? "Starting..." : "Start Assignment"} {isBusy ? "Starting..." : "Start Assignment"}
</Button> </Button>
) : ( ) : (
<div className="flex items-center gap-2"> <Button onClick={() => setShowSubmitConfirm(true)} disabled={isBusy} size="sm">
<span className="text-xs text-muted-foreground hidden sm:inline-block">
Auto-saving enabled
</span>
<Button onClick={handleSubmit} disabled={isBusy} size="sm">
<CheckCircle2 className="mr-2 h-4 w-4" /> <CheckCircle2 className="mr-2 h-4 w-4" />
{isBusy ? "Submitting..." : "Submit Assignment"} {isBusy ? "Submitting..." : "Submit Assignment"}
</Button> </Button>
</div>
)} )}
</div> </div>
@@ -190,7 +237,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</div> </div>
<h3 className="text-lg font-medium">Ready to start?</h3> <h3 className="text-lg font-medium">Ready to start?</h3>
<p className="text-muted-foreground max-w-sm mt-2 mb-6"> <p className="text-muted-foreground max-w-sm mt-2 mb-6">
Click the &quot;Start Assignment&quot; button above to begin. The timer will start once you confirm. Click the &quot;Start Assignment&quot; button above to begin. Your answers will be saved when you click &quot;Save Answer&quot;.
</p> </p>
<Button onClick={handleStart} disabled={isBusy}> <Button onClick={handleStart} disabled={isBusy}>
Start Now Start Now
@@ -204,7 +251,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
const value = answersByQuestionId[q.questionId]?.answer const value = answersByQuestionId[q.questionId]?.answer
return ( return (
<Card key={q.questionId} className="border-l-4 border-l-primary shadow-sm"> <Card key={q.questionId} id={`question-${q.questionId}`} className="border-l-4 border-l-primary shadow-sm scroll-mt-4">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -370,6 +417,40 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</div> </div>
</div> </div>
{dueAt && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Due Date</Label>
<div className={cn(
"mt-1 flex items-center gap-2 text-sm font-medium",
isOverdue ? "text-destructive" : isUrgent ? "text-orange-500" : "text-foreground"
)}>
{(isOverdue || isUrgent) && <TriangleAlert className="h-4 w-4" />}
<span>{formatDate(dueAt)}</span>
</div>
{isOverdue && (
<p className="mt-1 text-xs text-destructive">Overdue</p>
)}
{isUrgent && !isOverdue && hoursUntilDue !== null && (
<p className="mt-1 text-xs text-orange-500">
{hoursUntilDue === 0 ? "Less than 1 hour left" : `${hoursUntilDue} hour${hoursUntilDue === 1 ? "" : "s"} left`}
</p>
)}
</div>
)}
{maxAttempts > 0 && (
<div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Attempts</Label>
<div className="mt-1 text-sm">
<span className="font-medium">{attemptsUsed}</span>
<span className="text-muted-foreground"> / {maxAttempts} used</span>
{attemptsRemaining > 0 && (
<span className="text-muted-foreground"> · {attemptsRemaining} remaining</span>
)}
</div>
</div>
)}
<div> <div>
<Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label> <Label className="text-xs text-muted-foreground uppercase tracking-wider">Description</Label>
<p className="mt-1 text-sm text-muted-foreground leading-relaxed"> <p className="mt-1 text-sm text-muted-foreground leading-relaxed">
@@ -387,15 +468,21 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
(Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true) (Array.isArray(answersByQuestionId[q.questionId]?.answer) ? (answersByQuestionId[q.questionId]?.answer as unknown[]).length > 0 : true)
return ( return (
<div <button
key={q.questionId} key={q.questionId}
className={` type="button"
h-8 w-8 rounded flex items-center justify-center text-xs font-medium border onClick={() => {
${hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"} const el = document.getElementById(`question-${q.questionId}`)
`} if (el) el.scrollIntoView({ behavior: "smooth", block: "start" })
}}
className={cn(
"h-8 w-8 rounded flex items-center justify-center text-xs font-medium border transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
hasAnswer ? "bg-primary text-primary-foreground border-primary" : "bg-background text-muted-foreground border-input"
)}
aria-label={`Jump to question ${i + 1}`}
> >
{i + 1} {i + 1}
</div> </button>
) )
})} })}
</div> </div>
@@ -406,7 +493,7 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
{canEdit && ( {canEdit && (
<div className="border-t p-4 bg-muted/20"> <div className="border-t p-4 bg-muted/20">
<Button className="w-full" onClick={handleSubmit} disabled={isBusy}> <Button className="w-full" onClick={() => setShowSubmitConfirm(true)} disabled={isBusy}>
{isBusy ? "Submitting..." : "Submit All"} {isBusy ? "Submitting..." : "Submit All"}
</Button> </Button>
<p className="mt-2 text-xs text-center text-muted-foreground"> <p className="mt-2 text-xs text-center text-muted-foreground">
@@ -415,6 +502,33 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
</div> </div>
)} )}
</div> </div>
{/* 提交二次确认对话框 */}
<AlertDialog open={showSubmitConfirm} onOpenChange={setShowSubmitConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Submission</AlertDialogTitle>
<AlertDialogDescription>
{unansweredCount > 0
? `You have ${unansweredCount} unanswered question${unansweredCount === 1 ? "" : "s"}. Submitted answers cannot be changed. Are you sure you want to submit?`
: "All questions have been answered. Submitted answers cannot be changed. Are you sure you want to submit?"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isBusy}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isBusy}
onClick={(e) => {
e.preventDefault()
setShowSubmitConfirm(false)
void handleSubmit()
}}
>
{isBusy ? "Submitting..." : "Confirm Submit"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }

View File

@@ -15,7 +15,7 @@ import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
type Option = { id: string; text: string } type Option = { id: string; text: string; isCorrect?: boolean }
const getQuestionText = (content: unknown): string => { const getQuestionText = (content: unknown): string => {
if (!isRecord(content)) return "" if (!isRecord(content)) return ""
@@ -32,11 +32,29 @@ const getOptions = (content: unknown): Option[] => {
const id = typeof item.id === "string" ? item.id : "" const id = typeof item.id === "string" ? item.id : ""
const text = typeof item.text === "string" ? item.text : "" const text = typeof item.text === "string" ? item.text : ""
if (!id || !text) continue if (!id || !text) continue
out.push({ id, text }) const isCorrect = item.isCorrect === true
out.push({ id, text, isCorrect })
} }
return out return out
} }
const getChoiceCorrectIds = (content: unknown): string[] => {
return getOptions(content).filter((o) => o.isCorrect).map((o) => o.id)
}
const getJudgmentCorrectAnswer = (content: unknown): boolean | null => {
if (!isRecord(content)) return null
return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null
}
const getTextCorrectAnswers = (content: unknown): string[] => {
if (!isRecord(content)) return []
const raw = content.correctAnswer
if (typeof raw === "string") return [raw]
if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string")
return []
}
const toAnswerShape = (questionType: string, v: unknown) => { const toAnswerShape = (questionType: string, v: unknown) => {
if (questionType === "text") return { answer: typeof v === "string" ? v : "" } if (questionType === "text") return { answer: typeof v === "string" ? v : "" }
if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false } if (questionType === "judgment") return { answer: typeof v === "boolean" ? v : false }
@@ -144,6 +162,16 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
<div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]"> <div className="rounded-md border p-3 bg-muted/20 text-sm min-h-[60px]">
{typeof value === "string" ? value : <span className="text-muted-foreground italic">No answer provided</span>} {typeof value === "string" ? value : <span className="text-muted-foreground italic">No answer provided</span>}
</div> </div>
{isGraded && (() => {
const correctTexts = getTextCorrectAnswers(q.questionContent)
if (correctTexts.length === 0) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<div className="font-medium text-emerald-700 mb-1">Correct Answer</div>
<div className="text-emerald-900">{correctTexts.join(" / ")}</div>
</div>
)
})()}
</div> </div>
) : q.questionType === "judgment" ? ( ) : q.questionType === "judgment" ? (
<div className="grid gap-2"> <div className="grid gap-2">
@@ -161,6 +189,16 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
<Label htmlFor={`${q.questionId}-false`} className="flex-1 font-normal">False</Label> <Label htmlFor={`${q.questionId}-false`} className="flex-1 font-normal">False</Label>
</div> </div>
</RadioGroup> </RadioGroup>
{isGraded && (() => {
const correct = getJudgmentCorrectAnswer(q.questionContent)
if (correct === null) return null
return (
<div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm">
<span className="font-medium text-emerald-700">Correct Answer: </span>
<span className="text-emerald-900">{correct ? "True" : "False"}</span>
</div>
)
})()}
</div> </div>
) : q.questionType === "single_choice" ? ( ) : q.questionType === "single_choice" ? (
<div className="grid gap-2"> <div className="grid gap-2">
@@ -169,14 +207,26 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
disabled disabled
className="flex flex-col gap-2" className="flex flex-col gap-2"
> >
{options.map((o) => ( {options.map((o) => {
<div key={o.id} className="flex items-center space-x-2 rounded-md border p-3 bg-muted/20"> const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : []
const isCorrectOption = isGraded && correctIds.includes(o.id)
return (
<div
key={o.id}
className={`flex items-center space-x-2 rounded-md border p-3 bg-muted/20 ${
isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""
}`}
>
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} /> <RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal"> <Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
{o.text} {o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700"> Correct</span>
)}
</Label> </Label>
</div> </div>
))} )
})}
</RadioGroup> </RadioGroup>
</div> </div>
) : q.questionType === "multiple_choice" ? ( ) : q.questionType === "multiple_choice" ? (
@@ -184,8 +234,15 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{options.map((o) => { {options.map((o) => {
const selected = Array.isArray(value) ? value.includes(o.id) : false const selected = Array.isArray(value) ? value.includes(o.id) : false
const correctIds = isGraded ? getChoiceCorrectIds(q.questionContent) : []
const isCorrectOption = isGraded && correctIds.includes(o.id)
return ( return (
<div key={o.id} className="flex items-start space-x-2 rounded-md border p-3 bg-muted/20"> <div
key={o.id}
className={`flex items-start space-x-2 rounded-md border p-3 bg-muted/20 ${
isCorrectOption ? "border-emerald-300 bg-emerald-50" : ""
}`}
>
<Checkbox <Checkbox
id={`${q.questionId}-${o.id}`} id={`${q.questionId}-${o.id}`}
checked={selected} checked={selected}
@@ -193,6 +250,9 @@ export function HomeworkReviewView({ initialData }: HomeworkReviewViewProps) {
/> />
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal leading-normal"> <Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal leading-normal">
{o.text} {o.text}
{isCorrectOption && (
<span className="ml-2 text-xs font-medium text-emerald-700"> Correct</span>
)}
</Label> </Label>
</div> </div>
) )

View File

@@ -61,13 +61,20 @@ export function SiteHeader() {
// Generate breadcrumbs // Generate breadcrumbs
const segments = pathname.split("/").filter(Boolean) const segments = pathname.split("/").filter(Boolean)
const roleSegments = new Set(["admin", "teacher", "student", "parent"])
const breadcrumbs = segments const breadcrumbs = segments
.map((segment, index) => { .map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join("/")}` const href = `/${segments.slice(0, index + 1).join("/")}`
const title = BREADCRUMB_MAP.get(href) || segment.charAt(0).toUpperCase() + segment.slice(1) const title = BREADCRUMB_MAP.get(href) || segment.charAt(0).toUpperCase() + segment.slice(1)
return { href, title, isLast: index === segments.length - 1 } return { href, title, isLast: index === segments.length - 1, isRole: roleSegments.has(segment.toLowerCase()) }
})
.filter((b, idx) => {
// Keep the first role segment (root context), filter out subsequent role segments
if (b.isRole) {
return idx === 0
}
return true
}) })
.filter((b) => !["admin", "teacher", "student", "parent"].includes(b.title.toLowerCase()))
return ( return (
<header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm"> <header className="bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 flex h-16 items-center border-b px-4 backdrop-blur-sm">

View File

@@ -0,0 +1,25 @@
"use client"
import { useQueryState, parseAsString } from "nuqs"
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function CourseFilters() {
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const hasFilters = Boolean(search)
return (
<FilterBar
layout="between"
hasFilters={hasFilters}
onReset={() => setSearch(null)}
>
<FilterSearchInput
value={search}
onChange={(v) => setSearch(v || null)}
placeholder="Search by class name, teacher, school..."
/>
</FilterBar>
)
}

View File

@@ -11,7 +11,7 @@ import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label" import { Label } from "@/shared/components/ui/label"
import { BookOpen, Building2, Inbox, CalendarDays, User, PlusCircle, PenTool } from "lucide-react" import { BookOpen, Building2, Inbox, CalendarDays, User, PlusCircle, PenTool, Mail, School } from "lucide-react"
import type { StudentEnrolledClass } from "@/modules/classes/types" import type { StudentEnrolledClass } from "@/modules/classes/types"
import { joinClassByInvitationCodeAction } from "@/modules/classes/actions" import { joinClassByInvitationCodeAction } from "@/modules/classes/actions"
@@ -22,7 +22,11 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) {
<CardHeader className="bg-muted/30 pb-4"> <CardHeader className="bg-muted/30 pb-4">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="line-clamp-1 text-lg">{c.name}</CardTitle> <CardTitle className="line-clamp-1 text-lg">
<Link href={`/student/learning/courses/${encodeURIComponent(c.id)}`} className="hover:underline">
{c.name}
</Link>
</CardTitle>
<CardDescription className="flex items-center gap-2 text-xs"> <CardDescription className="flex items-center gap-2 text-xs">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<BookOpen className="h-3 w-3" /> <BookOpen className="h-3 w-3" />
@@ -44,12 +48,26 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) {
<CardContent className="flex-1 space-y-4 py-4"> <CardContent className="flex-1 space-y-4 py-4">
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
{c.schoolName && (
<div className="flex items-center gap-2 text-muted-foreground">
<School className="h-4 w-4" />
<span>{c.schoolName}</span>
</div>
)}
{c.teacherName && ( {c.teacherName && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<User className="h-4 w-4" /> <User className="h-4 w-4" />
<span>{c.teacherName}</span> <span>{c.teacherName}</span>
</div> </div>
)} )}
{c.teacherEmail && (
<div className="flex items-center gap-2 text-muted-foreground">
<Mail className="h-4 w-4" />
<a href={`mailto:${c.teacherEmail}`} className="hover:underline hover:text-foreground">
{c.teacherEmail}
</a>
</div>
)}
{c.room && ( {c.room && (
<div className="flex items-center gap-2 text-muted-foreground"> <div className="flex items-center gap-2 text-muted-foreground">
<Building2 className="h-4 w-4" /> <Building2 className="h-4 w-4" />
@@ -60,6 +78,12 @@ const ClassCard = memo(function ClassCard({ c }: { c: StudentEnrolledClass }) {
</CardContent> </CardContent>
<CardFooter className="flex gap-2 border-t bg-muted/10 p-4"> <CardFooter className="flex gap-2 border-t bg-muted/10 p-4">
<Button asChild variant="outline" size="sm" className="flex-1">
<Link href={`/student/learning/courses/${encodeURIComponent(c.id)}`}>
<BookOpen className="mr-2 h-4 w-4" />
Details
</Link>
</Button>
<Button asChild variant="outline" size="sm" className="flex-1"> <Button asChild variant="outline" size="sm" className="flex-1">
<Link href={`/student/schedule?classId=${encodeURIComponent(c.id)}`}> <Link href={`/student/schedule?classId=${encodeURIComponent(c.id)}`}>
<CalendarDays className="mr-2 h-4 w-4" /> <CalendarDays className="mr-2 h-4 w-4" />
@@ -118,13 +142,15 @@ export function StudentCoursesView({
<EmptyState <EmptyState
icon={Inbox} icon={Inbox}
title="No courses yet" title="No courses yet"
description="You are not enrolled in any classes. Join a class to get started." description="You are not enrolled in any classes. Join a class below to get started."
className="py-12" className="py-12"
/> />
)} )}
<div className="rounded-lg border bg-card p-6 shadow-sm"> {/* 加入班级表单:无课程时置顶,有课程时置底 */}
<div className="mb-6 flex items-center gap-3"> <div className={classes.length === 0 ? "" : "rounded-lg border bg-card p-6 shadow-sm"}>
<div className={classes.length === 0 ? "rounded-lg border bg-card p-6 shadow-sm" : "mb-6 flex items-center gap-3"}>
<div className={classes.length === 0 ? "mb-6 flex items-center gap-3" : ""}>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<PlusCircle className="h-5 w-5 text-primary" /> <PlusCircle className="h-5 w-5 text-primary" />
</div> </div>
@@ -135,6 +161,7 @@ export function StudentCoursesView({
</p> </p>
</div> </div>
</div> </div>
</div>
<form action={handleJoin} className="flex flex-col gap-4 sm:flex-row sm:items-end"> <form action={handleJoin} className="flex flex-col gap-4 sm:flex-row sm:items-end">
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">

View File

@@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { CalendarX } from "lucide-react" import { CalendarX } from "lucide-react"
import { ScheduleList } from "@/shared/components/schedule/schedule-list" import { ScheduleList } from "@/shared/components/schedule/schedule-list"
import { cn } from "@/shared/lib/utils"
import type { StudentScheduleItem } from "@/modules/classes/types" import type { StudentScheduleItem } from "@/modules/classes/types"
@@ -15,6 +16,16 @@ const WEEKDAYS: Array<{ key: 1 | 2 | 3 | 4 | 5 | 6 | 7; label: string }> = [
{ key: 7, label: "Sun" }, { key: 7, label: "Sun" },
] ]
const getTodayWeekday = (): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
// getDay() returns 0 (Sun) - 6 (Sat); convert to 1 (Mon) - 7 (Sun)
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
const day = new Date().getDay()
if (day < 0 || day > 6) {
throw new Error(`Invalid day from getDay(): ${day}`)
}
return WEEKDAY_MAP[day]
}
export function StudentScheduleView({ items }: { items: StudentScheduleItem[] }) { export function StudentScheduleView({ items }: { items: StudentScheduleItem[] }) {
if (items.length === 0) { if (items.length === 0) {
return ( return (
@@ -27,6 +38,8 @@ export function StudentScheduleView({ items }: { items: StudentScheduleItem[] })
) )
} }
const todayKey = getTodayWeekday()
const itemsByDay = new Map<number, StudentScheduleItem[]>() const itemsByDay = new Map<number, StudentScheduleItem[]>()
for (const item of items) { for (const item of items) {
const list = itemsByDay.get(item.weekday) ?? [] const list = itemsByDay.get(item.weekday) ?? []
@@ -41,17 +54,33 @@ export function StudentScheduleView({ items }: { items: StudentScheduleItem[] })
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
{WEEKDAYS.map((d) => { {WEEKDAYS.map((d) => {
const dayItems = itemsByDay.get(d.key) ?? [] const dayItems = itemsByDay.get(d.key) ?? []
const isToday = d.key === todayKey
return ( return (
<Card key={d.key}> <Card
key={d.key}
className={cn(
isToday && "border-primary ring-1 ring-primary/30 shadow-sm"
)}
>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">{d.label}</CardTitle> <CardTitle className="flex items-center gap-2 text-sm font-medium">
<span>{d.label}</span>
{isToday && (
<span className="rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary-foreground">
Today
</span>
)}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{dayItems.length === 0 ? ( {dayItems.length === 0 ? (
<div className="text-sm text-muted-foreground">No classes.</div> <div className="text-sm text-muted-foreground">No classes.</div>
) : ( ) : (
<ScheduleList <ScheduleList
items={dayItems} items={dayItems.map((item) => ({
...item,
href: `/student/learning/courses/${encodeURIComponent(item.classId)}`,
}))}
variant="card" variant="card"
spacingClassName="space-y-3" spacingClassName="space-y-3"
/> />

View File

@@ -1,6 +1,7 @@
"use client" "use client"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import Link from "next/link"
import { Clock, MapPin } from "lucide-react" import { Clock, MapPin } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
@@ -23,6 +24,8 @@ export interface ScheduleListItemData {
endTime: string endTime: string
location?: string | null location?: string | null
className?: string className?: string
/** Optional href to navigate to when the item is clicked */
href?: string
} }
interface ScheduleListItemProps { interface ScheduleListItemProps {
@@ -46,8 +49,8 @@ export function ScheduleListItem({
? "flex items-center justify-between border-b pb-4 last:border-0 last:pb-0" ? "flex items-center justify-between border-b pb-4 last:border-0 last:pb-0"
: "flex items-start justify-between gap-3 rounded-md border bg-card p-3" : "flex items-start justify-between gap-3 rounded-md border bg-card p-3"
return ( const content = (
<div key={item.id} className={cn(wrapperClass, className)}> <>
<div className="space-y-1 min-w-0"> <div className="space-y-1 min-w-0">
<div className="font-medium leading-none truncate">{item.course}</div> <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 flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
@@ -72,6 +75,23 @@ export function ScheduleListItem({
{item.className} {item.className}
</Badge> </Badge>
) : null} ) : null}
</>
)
if (item.href) {
return (
<Link
href={item.href}
className={cn(wrapperClass, className, "transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring")}
>
{content}
</Link>
)
}
return (
<div className={cn(wrapperClass, className)}>
{content}
</div> </div>
) )
} }