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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/modules/elective/components/elective-filters.tsx
Normal file
49
src/modules/elective/components/elective-filters.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
src/modules/grades/components/grade-trend-card.tsx
Normal file
57
src/modules/grades/components/grade-trend-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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">
|
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||||
Auto-saving enabled
|
{isBusy ? "Submitting..." : "Submit Assignment"}
|
||||||
</span>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={isBusy} size="sm">
|
|
||||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
|
||||||
{isBusy ? "Submitting..." : "Submit Assignment"}
|
|
||||||
</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 "Start Assignment" button above to begin. The timer will start once you confirm.
|
Click the "Start Assignment" button above to begin. Your answers will be saved when you click "Save Answer".
|
||||||
</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">
|
||||||
@@ -369,6 +416,40 @@ export function HomeworkTakeView({ assignmentId, initialData }: HomeworkTakeView
|
|||||||
</Badge>
|
</Badge>
|
||||||
</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>
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) : []
|
||||||
<RadioGroupItem value={o.id} id={`${q.questionId}-${o.id}`} />
|
const isCorrectOption = isGraded && correctIds.includes(o.id)
|
||||||
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
|
return (
|
||||||
{o.text}
|
<div
|
||||||
</Label>
|
key={o.id}
|
||||||
</div>
|
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}`} />
|
||||||
|
<Label htmlFor={`${q.questionId}-${o.id}`} className="flex-1 font-normal">
|
||||||
|
{o.text}
|
||||||
|
{isCorrectOption && (
|
||||||
|
<span className="ml-2 text-xs font-medium text-emerald-700">✓ Correct</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
25
src/modules/student/components/course-filters.tsx
Normal file
25
src/modules/student/components/course-filters.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,21 +142,24 @@ 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="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
<div className={classes.length === 0 ? "rounded-lg border bg-card p-6 shadow-sm" : "mb-6 flex items-center gap-3"}>
|
||||||
<PlusCircle className="h-5 w-5 text-primary" />
|
<div className={classes.length === 0 ? "mb-6 flex items-center gap-3" : ""}>
|
||||||
</div>
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
<div>
|
<PlusCircle className="h-5 w-5 text-primary" />
|
||||||
<h3 className="text-lg font-semibold">Join a Class</h3>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div>
|
||||||
Enter the invitation code provided by your teacher to enroll.
|
<h3 className="text-lg font-semibold">Join a Class</h3>
|
||||||
</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Enter the invitation code provided by your teacher to enroll.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user