feat(app): add lesson-plans, practice, and grade dashboard routes
- Add admin/lesson-plans, parent/lesson-plans, student/lesson-plans routes - Add student/practice and teacher/practice routes for adaptive practice - Add management/grade/dashboard and management/grade/practice routes - Add teacher/lesson-plans error and loading boundaries - Update existing admin, parent, student, teacher pages with new features - Update globals.css and proxy middleware
This commit is contained in:
@@ -14,7 +14,7 @@ export default function StudentAttendanceError({ reset }: { error: Error & { dig
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
label: t("actions.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -10,14 +11,15 @@ export default function StudentDiagnosticError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("student")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="学情诊断页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
title={t("error.title")}
|
||||
description={t("error.description")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Stethoscope } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
|
||||
@@ -9,6 +11,7 @@ export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentDiagnosticPage() {
|
||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||
const t = await getTranslations("student")
|
||||
|
||||
const [summary, reportsResult] = await Promise.all([
|
||||
getStudentMasterySummary(ctx.userId),
|
||||
@@ -25,10 +28,10 @@ export default async function StudentDiagnosticPage() {
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Stethoscope className="h-6 w-6" />
|
||||
My Diagnostic
|
||||
{t("diagnostic.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Your knowledge point mastery analysis and diagnostic reports.
|
||||
{t("diagnostic.description")}
|
||||
</p>
|
||||
</div>
|
||||
<StudentDiagnosticView summary={summary} reports={reports} />
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function StudentElectiveError({ reset }: { error: Error & { diges
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
label: t("actions.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { BookX } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentErrorBookError() {
|
||||
const t = useTranslations("student")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookX}
|
||||
title="加载错题本失败"
|
||||
description="发生了一些错误,请刷新页面重试。如果问题持续,请联系管理员。"
|
||||
action={{ label: "刷新页面", onClick: () => window.location.reload() }}
|
||||
title={t("error.title")}
|
||||
description={t("error.description")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -92,6 +93,7 @@ export default async function StudentErrorBookPage({
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
const t = await getTranslations("student")
|
||||
const stats = await getErrorBookStats(ctx.userId)
|
||||
const aiClientService = createAiClientService()
|
||||
|
||||
@@ -100,9 +102,9 @@ export default async function StudentErrorBookPage({
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">错题本</h1>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("errorBook.title")}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
自动收录考试与作业中的错题,科学复习,攻克薄弱点。
|
||||
{t("errorBook.description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentError({
|
||||
error,
|
||||
@@ -10,12 +12,13 @@ export default function StudentError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("student")
|
||||
return (
|
||||
<EmptyState
|
||||
icon={AlertTriangle}
|
||||
title="Something went wrong"
|
||||
description={error.message || "An unexpected error occurred. Please try again."}
|
||||
action={{ label: "Try again", onClick: reset }}
|
||||
title={t("error.title")}
|
||||
description={error.message || t("error.description")}
|
||||
action={{ label: t("error.retry"), onClick: reset }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -10,14 +11,15 @@ export default function StudentGradesError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("student")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="成绩查询页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
title={t("error.title")}
|
||||
description={t("error.description")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -20,6 +20,7 @@ export default async function StudentGradesPage({
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
const [sp, summary, rankingTrend, classAverageTrend, subjectOptions] = await Promise.all([
|
||||
searchParams,
|
||||
getStudentGradeSummary(ctx.userId, ctx.dataScope),
|
||||
@@ -32,7 +33,6 @@ export default async function StudentGradesPage({
|
||||
])
|
||||
|
||||
if (!summary) {
|
||||
const t = await getTranslations("grades")
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
@@ -73,7 +73,7 @@ export default async function StudentGradesPage({
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{summary.studentName}</h2>
|
||||
<p className="text-muted-foreground">{summary.records.length} 条成绩记录</p>
|
||||
<p className="text-muted-foreground">{t("summary.recordCount", { count: summary.records.length })}</p>
|
||||
</div>
|
||||
<GradeFilters subjects={subjectOptions.map((s) => ({ id: s.id, name: s.name }))} />
|
||||
{filteredSummary.records.length > 0 && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -17,6 +18,8 @@ export default async function StudentAssignmentTakePage({
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) return notFound()
|
||||
|
||||
const t = await getTranslations("student")
|
||||
|
||||
const data = await getStudentHomeworkTakeData(assignmentId, student.id)
|
||||
if (!data) return notFound()
|
||||
|
||||
@@ -28,7 +31,7 @@ export default async function StudentAssignmentTakePage({
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
|
||||
<span>{t("assignment.due", { date: data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,9 +45,9 @@ export default async function StudentAssignmentTakePage({
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span>Due: {data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-"}</span>
|
||||
<span>{t("assignment.due", { date: data.assignment.dueAt ? formatDate(data.assignment.dueAt) : "-" })}</span>
|
||||
<span className="mx-2" aria-hidden="true">•</span>
|
||||
<span>Max Attempts: {data.assignment.maxAttempts}</span>
|
||||
<span>{t("assignment.maxAttempts", { count: data.assignment.maxAttempts })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
School,
|
||||
User,
|
||||
} from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentClassById, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -20,14 +21,14 @@ 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",
|
||||
const WEEKDAY_KEYS: Record<number, string> = {
|
||||
1: "mon",
|
||||
2: "tue",
|
||||
3: "wed",
|
||||
4: "thu",
|
||||
5: "fri",
|
||||
6: "sat",
|
||||
7: "sun",
|
||||
}
|
||||
|
||||
export default async function StudentClassDetailPage({
|
||||
@@ -39,6 +40,8 @@ export default async function StudentClassDetailPage({
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) return notFound()
|
||||
|
||||
const t = await getTranslations("student")
|
||||
|
||||
const [classInfo, schedule] = await Promise.all([
|
||||
getStudentClassById(student.id, classId),
|
||||
getStudentSchedule(student.id),
|
||||
@@ -58,14 +61,14 @@ export default async function StudentClassDetailPage({
|
||||
<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
|
||||
{t("classDetail.backToCourses")}
|
||||
</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}
|
||||
{t("classDetail.grade", { grade: classInfo.grade })}
|
||||
</span>
|
||||
{classInfo.homeroom && (
|
||||
<>
|
||||
@@ -78,24 +81,24 @@ export default async function StudentClassDetailPage({
|
||||
<span aria-hidden="true">•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="h-4 w-4" />
|
||||
Room {classInfo.room}
|
||||
{t("classDetail.room", { room: classInfo.room })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<Badge variant="secondary">Active</Badge>
|
||||
<Badge variant="secondary">{t("classDetail.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
|
||||
{t("classDetail.fullSchedule")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm">
|
||||
<Link href="/student/learning/assignments">
|
||||
<PenTool className="mr-2 h-4 w-4" />
|
||||
Assignments
|
||||
{t("classDetail.assignments")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -107,7 +110,7 @@ export default async function StudentClassDetailPage({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<User className="h-4 w-4" />
|
||||
Teacher
|
||||
{t("classDetail.teacher")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
@@ -117,7 +120,7 @@ export default async function StudentClassDetailPage({
|
||||
<span className="font-medium">{classInfo.teacherName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No teacher assigned.</p>
|
||||
<p className="text-muted-foreground">{t("classDetail.noTeacher")}</p>
|
||||
)}
|
||||
{classInfo.teacherEmail && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -138,7 +141,7 @@ export default async function StudentClassDetailPage({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<School className="h-4 w-4" />
|
||||
School
|
||||
{t("classDetail.school")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
@@ -148,12 +151,12 @@ export default async function StudentClassDetailPage({
|
||||
<span className="font-medium">{classInfo.schoolName}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">School info not available.</p>
|
||||
<p className="text-muted-foreground">{t("classDetail.schoolNotAvailable")}</p>
|
||||
)}
|
||||
{classInfo.grade && (
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Grade {classInfo.grade}</span>
|
||||
<span>{t("classDetail.grade", { grade: classInfo.grade })}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -164,22 +167,22 @@ export default async function StudentClassDetailPage({
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-medium">
|
||||
<Building2 className="h-4 w-4" />
|
||||
Classroom
|
||||
{t("classDetail.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>
|
||||
<span className="font-medium">{t("classDetail.room", { room: classInfo.room })}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">Room not assigned.</p>
|
||||
<p className="text-muted-foreground">{t("classDetail.roomNotAssigned")}</p>
|
||||
)}
|
||||
{classInfo.homeroom && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>Homeroom: {classInfo.homeroom}</span>
|
||||
<span>{t("classDetail.homeroom", { homeroom: classInfo.homeroom })}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -191,15 +194,15 @@ export default async function StudentClassDetailPage({
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarDays className="h-5 w-5" />
|
||||
Class Schedule
|
||||
{t("classDetail.classSchedule")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{classSchedule.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={CalendarDays}
|
||||
title="No schedule"
|
||||
description="No timetable entries found for this class."
|
||||
title={t("classDetail.noSchedule")}
|
||||
description={t("classDetail.noScheduleDesc")}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
@@ -211,7 +214,7 @@ export default async function StudentClassDetailPage({
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="w-12 justify-center">
|
||||
{WEEKDAYS[s.weekday]}
|
||||
{t(`weekdays.${WEEKDAY_KEYS[s.weekday]}`)}
|
||||
</Badge>
|
||||
<div>
|
||||
<p className="font-medium">{s.course}</p>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentClasses } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -14,17 +15,18 @@ export default async function StudentCoursesPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const t = await getTranslations("student")
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
|
||||
<p className="text-muted-foreground">Your enrolled classes.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("courses.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("courses.description")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No user found"
|
||||
description="Create a student user to see courses."
|
||||
title={t("courses.noUser")}
|
||||
description={t("courses.noUserDesc")}
|
||||
icon={UserX}
|
||||
/>
|
||||
</div>
|
||||
@@ -51,8 +53,8 @@ export default async function StudentCoursesPage({
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Courses</h2>
|
||||
<p className="text-muted-foreground">Your enrolled classes.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("courses.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("courses.description")}</p>
|
||||
</div>
|
||||
{classes.length > 0 && <CourseFilters />}
|
||||
<StudentCoursesView classes={filteredClasses} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link"
|
||||
import { BookOpen, PenTool, Library, ArrowRight } from "lucide-react"
|
||||
import { BookOpen, PenTool, Library, ArrowRight, UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentClasses } from "@/modules/classes/data-access"
|
||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
@@ -7,16 +8,16 @@ import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { getTextbooks } from "@/modules/textbooks/data-access"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { UserX } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentLearningPage() {
|
||||
const t = await getTranslations("student")
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<EmptyState title="No user found" description="Create a student user to see learning." icon={UserX} />
|
||||
<EmptyState title={t("learning.noUser")} description={t("learning.noUserDesc")} icon={UserX} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,33 +41,33 @@ export default async function StudentLearningPage() {
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Courses",
|
||||
description: "Your enrolled classes.",
|
||||
title: t("learning.courses"),
|
||||
description: t("learning.coursesDesc"),
|
||||
icon: BookOpen,
|
||||
href: "/student/learning/courses",
|
||||
stat: `${classes.length} enrolled`,
|
||||
stat: t("learning.enrolled", { count: classes.length }),
|
||||
},
|
||||
{
|
||||
title: "Assignments",
|
||||
description: "Homework and practice.",
|
||||
title: t("learning.assignments"),
|
||||
description: t("learning.assignmentsDesc"),
|
||||
icon: PenTool,
|
||||
href: "/student/learning/assignments",
|
||||
stat: `${pendingCount} pending${dueSoonCount > 0 ? ` · ${dueSoonCount} due soon` : ""}`,
|
||||
stat: t("learning.pending", { count: pendingCount }) + (dueSoonCount > 0 ? t("learning.dueSoon", { count: dueSoonCount }) : ""),
|
||||
},
|
||||
{
|
||||
title: "Textbooks",
|
||||
description: "Browse course materials.",
|
||||
title: t("learning.textbooks"),
|
||||
description: t("learning.textbooksDesc"),
|
||||
icon: Library,
|
||||
href: "/student/learning/textbooks",
|
||||
stat: `${textbooks.length} available`,
|
||||
stat: t("learning.available", { count: textbooks.length }),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">My Learning</h2>
|
||||
<p className="text-muted-foreground">Your learning hub: courses, assignments, and textbooks.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("learning.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("learning.description")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentLessonPlanViewError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function StudentLessonPlanViewLoading() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentLessonPlanViewPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ planId: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const { planId } = await params
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const plan = await getLessonPlanById(planId, ctx.userId)
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md border border-outline-variant bg-surface-container-low p-4 text-on-surface-variant">
|
||||
{t("readonly.notFound")}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 学生只能查看已发布的课案
|
||||
if (plan.status !== "published") {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-md border border-outline-variant bg-surface-container-low p-4 text-on-surface-variant">
|
||||
{t("readonly.notPublished")}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 拉取教材/章节标题
|
||||
let textbookTitle: string | undefined
|
||||
let chapterTitle: string | undefined
|
||||
if (plan.textbookId) {
|
||||
const textbook = await getTextbookById(plan.textbookId)
|
||||
textbookTitle = textbook?.title
|
||||
if (plan.chapterId) {
|
||||
const chapters = await getChaptersByTextbookId(plan.textbookId)
|
||||
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
|
||||
for (const ch of list) {
|
||||
if (ch.id === plan.chapterId) return ch
|
||||
if (ch.children && ch.children.length > 0) {
|
||||
const found = findChapter(ch.children as typeof chapters)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const chapter = findChapter(chapters)
|
||||
chapterTitle = chapter?.title
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)]">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanReadonlyView
|
||||
doc={plan.content}
|
||||
textbookTitle={textbookTitle}
|
||||
chapterTitle={chapterTitle}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/student/lesson-plans/error.tsx
Normal file
20
src/app/(dashboard)/student/lesson-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentLessonPlansError() {
|
||||
const t = useTranslations("lessonPreparation")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={BookOpen}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{ label: t("error.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
src/app/(dashboard)/student/lesson-plans/loading.tsx
Normal file
17
src/app/(dashboard)/student/lesson-plans/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function StudentLessonPlansLoading() {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
src/app/(dashboard)/student/lesson-plans/page.tsx
Normal file
44
src/app/(dashboard)/student/lesson-plans/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentLessonPlansPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
const [items, subjects] = await Promise.all([
|
||||
getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId),
|
||||
getSubjectOptions(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("student.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("student.description")}</p>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanList
|
||||
initialItems={items}
|
||||
subjects={subjects}
|
||||
viewMode="student"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/app/(dashboard)/student/practice/[sessionId]/error.tsx
Normal file
32
src/app/(dashboard)/student/practice/[sessionId]/error.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export default function PracticeSessionError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}): React.ReactNode {
|
||||
const t = useTranslations("student")
|
||||
useEffect(() => {
|
||||
console.error("Practice session error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<h2 className="text-xl font-semibold">{t("error.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={reset}>{t("error.retry")}</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/student/practice">{t("classDetail.backToCourses")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/app/(dashboard)/student/practice/[sessionId]/loading.tsx
Normal file
14
src/app/(dashboard)/student/practice/[sessionId]/loading.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function PracticeSessionLoading(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<Skeleton className="h-20 w-full rounded-md" />
|
||||
<Skeleton className="h-96 w-full rounded-md" />
|
||||
<div className="flex justify-between">
|
||||
<Skeleton className="h-10 w-24 rounded-md" />
|
||||
<Skeleton className="h-10 w-24 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/app/(dashboard)/student/practice/[sessionId]/page.tsx
Normal file
31
src/app/(dashboard)/student/practice/[sessionId]/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { getPracticeSessionById } from "@/modules/adaptive-practice/data-access"
|
||||
import { PracticeSessionView } from "@/modules/adaptive-practice/components/practice-session-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function PracticeSessionPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ sessionId: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
const { sessionId } = await params
|
||||
|
||||
const session = await getPracticeSessionById(sessionId, ctx.userId)
|
||||
|
||||
if (!session) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<PracticeSessionView session={session} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
src/app/(dashboard)/student/practice/error.tsx
Normal file
26
src/app/(dashboard)/student/practice/error.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
|
||||
export default function PracticeError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}): React.ReactNode {
|
||||
const t = useTranslations("student")
|
||||
useEffect(() => {
|
||||
console.error("Practice page error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<h2 className="text-xl font-semibold">{t("error.title")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
<Button onClick={reset}>{t("error.retry")}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/app/(dashboard)/student/practice/loading.tsx
Normal file
25
src/app/(dashboard)/student/practice/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function PracticeLoading(): React.ReactNode {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Skeleton className="h-96 w-full rounded-md" />
|
||||
<div className="space-y-3 lg:col-span-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/app/(dashboard)/student/practice/page.tsx
Normal file
45
src/app/(dashboard)/student/practice/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { getPracticeSessions, getPracticeStats } from "@/modules/adaptive-practice/data-access"
|
||||
import { PracticeStarter } from "@/modules/adaptive-practice/components/practice-starter"
|
||||
import { PracticeHistory } from "@/modules/adaptive-practice/components/practice-history"
|
||||
import { PracticeStatsCards } from "@/modules/adaptive-practice/components/practice-stats-cards"
|
||||
import { getKnowledgePointOptions } from "@/modules/questions/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentPracticePage(): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
const t = await getTranslations("practice")
|
||||
|
||||
const [stats, sessionsResult, knowledgePoints] = await Promise.all([
|
||||
getPracticeStats(ctx.userId),
|
||||
getPracticeSessions(ctx.userId, { pageSize: 20 }),
|
||||
getKnowledgePointOptions(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("page.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("page.description")}</p>
|
||||
</div>
|
||||
|
||||
<PracticeStatsCards stats={stats} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-1">
|
||||
<PracticeStarter knowledgePoints={knowledgePoints} />
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h2 className="text-lg font-semibold">{t("history.title")}</h2>
|
||||
<PracticeHistory sessions={sessionsResult.data} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
@@ -14,15 +15,20 @@ export default async function StudentSchedulePage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const t = await getTranslations("student")
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
||||
<p className="text-muted-foreground">Your weekly timetable.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("schedule.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("schedule.description")}</p>
|
||||
</div>
|
||||
<EmptyState title="No user found" description="Create a student user to see schedule." icon={UserX} />
|
||||
<EmptyState
|
||||
title={t("schedule.noUser")}
|
||||
description={t("schedule.noUserDesc")}
|
||||
icon={UserX}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,8 +47,8 @@ export default async function StudentSchedulePage({
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
||||
<p className="text-muted-foreground">Your weekly timetable.</p>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("schedule.title")}</h2>
|
||||
<p className="text-muted-foreground">{t("schedule.description")}</p>
|
||||
</div>
|
||||
<StudentScheduleFilters classes={classes} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user