feat(app): add error/loading boundaries across all dashboard routes and new routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add admin announcements edit, audit-logs overview, curriculum-map, invitation-codes, permissions, questions, roles routes - Add admin elective detail and components, files, course-plans, users, scheduling boundaries - Add messages group-compose route - Add parent course-plans, elective, grades report-card, practice routes - Add student course-plans, elective detail, error-book dialogs, grades report-card, learning study-path, leave, schedule boundaries - Add teacher attendance report, classes boundaries, course-plans boundaries, elective, exams analytics/edit-rich/all/create/new, grades report-card, homework boundaries, leave, lesson-plans calendar - Add auth loading, onboarding loading, api cron
This commit is contained in:
@@ -1,24 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
|
||||
export default function StudentAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return <RouteErrorBoundary reset={reset} namespace="attendance" />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
@@ -8,6 +9,7 @@ import { UserX } from "lucide-react"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentAttendancePage() {
|
||||
await requirePermission(Permissions.ATTENDANCE_READ)
|
||||
const ctx = await getAuthContext()
|
||||
const t = await getTranslations("attendance")
|
||||
|
||||
@@ -21,8 +23,8 @@ export default async function StudentAttendancePage() {
|
||||
<p className="text-muted-foreground">{t("description.student")}</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={t("errors.notFound")}
|
||||
description={t("errors.unexpected")}
|
||||
title={t("list.empty")}
|
||||
description={t("list.emptyDescription")}
|
||||
icon={UserX}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
|
||||
20
src/app/(dashboard)/student/course-plans/[id]/error.tsx
Normal file
20
src/app/(dashboard)/student/course-plans/[id]/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentCoursePlanDetailError() {
|
||||
const t = useTranslations("coursePlans")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={t("errors.loadFailed")}
|
||||
description={t("errors.loadFailedDesc")}
|
||||
action={{ label: t("errors.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/app/(dashboard)/student/course-plans/[id]/loading.tsx
Normal file
23
src/app/(dashboard)/student/course-plans/[id]/loading.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function StudentCoursePlanDetailLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-[240px]" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
src/app/(dashboard)/student/course-plans/[id]/page.tsx
Normal file
42
src/app/(dashboard)/student/course-plans/[id]/page.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentCoursePlanDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const { id } = await params
|
||||
|
||||
// 学生视角:通过 class_members scope 获取 classIds
|
||||
let classIds: string[] = []
|
||||
if (ctx.dataScope.type === "class_members") {
|
||||
classIds = ctx.dataScope.classIds
|
||||
}
|
||||
|
||||
const plan =
|
||||
classIds.length > 0
|
||||
? await getCoursePlanById(id, { userId: ctx.userId, isAdmin: false, classIds })
|
||||
: null
|
||||
|
||||
if (!plan) notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<CoursePlanDetail
|
||||
plan={plan}
|
||||
backHref="/student/course-plans"
|
||||
successHref="/student/course-plans"
|
||||
textbooksHref="/student/learning/textbooks"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/student/course-plans/error.tsx
Normal file
20
src/app/(dashboard)/student/course-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function StudentCoursePlansError() {
|
||||
const t = useTranslations("coursePlans")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={t("errors.loadFailed")}
|
||||
description={t("errors.loadFailedDesc")}
|
||||
action={{ label: t("errors.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
src/app/(dashboard)/student/course-plans/loading.tsx
Normal file
17
src/app/(dashboard)/student/course-plans/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function StudentCoursePlansLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[160px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
41
src/app/(dashboard)/student/course-plans/page.tsx
Normal file
41
src/app/(dashboard)/student/course-plans/page.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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 { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentCoursePlansPage(): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const t = await getTranslations("coursePlans")
|
||||
|
||||
// 学生视角:通过 class_members scope 获取 classIds
|
||||
let classIds: string[] = []
|
||||
if (ctx.dataScope.type === "class_members") {
|
||||
classIds = ctx.dataScope.classIds
|
||||
}
|
||||
|
||||
const plans =
|
||||
classIds.length > 0
|
||||
? await getCoursePlans(
|
||||
{ status: "active" },
|
||||
{ userId: ctx.userId, isAdmin: false, classIds }
|
||||
)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("student.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("student.description")}</p>
|
||||
</div>
|
||||
<CoursePlanList
|
||||
plans={plans}
|
||||
detailBaseHref="/student/course-plans"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default async function StudentDiagnosticPage() {
|
||||
{t("diagnostic.description")}
|
||||
</p>
|
||||
</div>
|
||||
<StudentDiagnosticView summary={summary} reports={reports} />
|
||||
<StudentDiagnosticView summary={summary} reports={reports} role="student" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
28
src/app/(dashboard)/student/elective/[id]/loading.tsx
Normal file
28
src/app/(dashboard)/student/elective/[id]/loading.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
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-64" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
src/app/(dashboard)/student/elective/[id]/page.tsx
Normal file
47
src/app/(dashboard)/student/elective/[id]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { notFound } from "next/navigation"
|
||||
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 { getElectiveCourseById } from "@/modules/elective/data-access"
|
||||
import { ElectiveCourseDetail } from "@/modules/elective/components/elective-course-detail"
|
||||
import { ElectivePageLayout } from "@/modules/elective/components/elective-page-layout"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function StudentElectiveDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("elective")
|
||||
await requirePermission(Permissions.ELECTIVE_READ)
|
||||
const { id } = await params
|
||||
|
||||
const course = await getElectiveCourseById(id)
|
||||
|
||||
if (!course) notFound()
|
||||
|
||||
const header = (
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("title.detail")}</h2>
|
||||
<p className="text-muted-foreground">{t("description.detail")}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<ElectivePageLayout header={header}>
|
||||
{/*
|
||||
学生视角:只展示课程信息,不展示选课名单(保护其他学生隐私)。
|
||||
selections 传空数组,showEditButton=false(学生无编辑权限)。
|
||||
*/}
|
||||
<ElectiveCourseDetail
|
||||
course={course}
|
||||
selections={[]}
|
||||
backHref="/student/elective"
|
||||
showEditButton={false}
|
||||
/>
|
||||
</ElectivePageLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { JSX } from "react"
|
||||
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
|
||||
import { StudentAvailableCoursesSection } from "@/modules/elective/components/student-selection-view"
|
||||
import { ElectiveFilters } from "@/modules/elective/components/elective-filters"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
/**
|
||||
* 可选课程 - 异步 RSC loader
|
||||
* 独立 Suspense 边界,可流式渲染
|
||||
* 同时获取 mySelections(通过 React cache() 自动去重)以计算 selectedCourseIds
|
||||
* 过滤逻辑(搜索关键词 + 选课模式)在本 loader 内完成,保持组件纯净
|
||||
*/
|
||||
export async function AvailableCoursesLoader({
|
||||
studentId,
|
||||
searchParams,
|
||||
}: {
|
||||
studentId: string
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const [sp, availableCourses, mySelections] = await Promise.all([
|
||||
searchParams,
|
||||
getAvailableCoursesForStudent(studentId),
|
||||
// 通过 React cache() 去重:若 MySelectionsLoader 已先一步获取,则此处直接返回缓存
|
||||
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
|
||||
})
|
||||
|
||||
const selectedCourseIds = new Set(
|
||||
mySelections
|
||||
.filter((s) => ["selected", "enrolled", "waitlist"].includes(s.status))
|
||||
.map((s) => s.courseId)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{availableCourses.length > 0 && <ElectiveFilters />}
|
||||
<StudentAvailableCoursesSection
|
||||
availableCourses={filteredCourses}
|
||||
selectedCourseIds={selectedCourseIds}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { JSX } from "react"
|
||||
import { getStudentSelections } from "@/modules/elective/data-access-selections"
|
||||
import { StudentMySelectionsSection } from "@/modules/elective/components/student-selection-view"
|
||||
|
||||
/**
|
||||
* 我的选课 - 异步 RSC loader
|
||||
* 独立 Suspense 边界,可流式渲染
|
||||
* 数据获取通过 React cache() 自动去重
|
||||
*/
|
||||
export async function MySelectionsLoader({
|
||||
studentId,
|
||||
}: {
|
||||
studentId: string
|
||||
}): Promise<JSX.Element> {
|
||||
const mySelections = await getStudentSelections(studentId)
|
||||
return <StudentMySelectionsSection mySelections={mySelections} />
|
||||
}
|
||||
@@ -11,8 +11,8 @@ export default function StudentElectiveError({ reset }: { error: Error & { diges
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
title={t("errors.title")}
|
||||
description={t("errors.description")}
|
||||
action={{
|
||||
label: t("actions.retry"),
|
||||
onClick: () => reset(),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* 全页骨架屏 - 路由级 loading.tsx 默认导出
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -8,6 +11,51 @@ export default function Loading() {
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<MySelectionsSkeleton />
|
||||
<AvailableCoursesSkeleton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 我的选课区块骨架屏
|
||||
* 用于独立 Suspense 边界 fallback
|
||||
*/
|
||||
export function MySelectionsSkeleton() {
|
||||
return (
|
||||
<section className="space-y-4" aria-busy="true" aria-label="Loading my selections">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="mt-2 h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-9 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选课程区块骨架屏
|
||||
* 用于独立 Suspense 边界 fallback
|
||||
*/
|
||||
export function AvailableCoursesSkeleton() {
|
||||
return (
|
||||
<section className="space-y-4" aria-busy="true" aria-label="Loading available courses">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-8" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
@@ -21,6 +69,6 @@ export default function Loading() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { getAvailableCoursesForStudent, getStudentSelections } from "@/modules/elective/data-access-selections"
|
||||
import { StudentSelectionView } from "@/modules/elective/components/student-selection-view"
|
||||
import { ElectiveFilters } from "@/modules/elective/components/elective-filters"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { MySelectionsLoader } from "./_components/my-selections-loader"
|
||||
import { AvailableCoursesLoader } from "./_components/available-courses-loader"
|
||||
import { MySelectionsSkeleton, AvailableCoursesSkeleton } from "./loading"
|
||||
import type { SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -14,35 +16,24 @@ export default async function StudentElectivePage({
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const t = await getTranslations("elective")
|
||||
const ctx = await getAuthContext()
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
||||
const studentId = ctx.userId
|
||||
|
||||
const [sp, availableCourses, mySelections] = await Promise.all([
|
||||
searchParams,
|
||||
getAvailableCoursesForStudent(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 (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("title.student")}</h2>
|
||||
<p className="text-muted-foreground">{t("description.student")}</p>
|
||||
</div>
|
||||
{availableCourses.length > 0 && <ElectiveFilters />}
|
||||
<StudentSelectionView
|
||||
availableCourses={filteredCourses}
|
||||
mySelections={mySelections}
|
||||
/>
|
||||
<Suspense fallback={<MySelectionsSkeleton />}>
|
||||
<MySelectionsLoader studentId={studentId} />
|
||||
</Suspense>
|
||||
<Suspense fallback={<AvailableCoursesSkeleton />}>
|
||||
<AvailableCoursesLoader
|
||||
studentId={studentId}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback } from "react"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog"
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
import { extractQuestionPreview } from "@/shared/lib/question-content"
|
||||
|
||||
/**
|
||||
* 手动添加错题对话框包装组件(位于 app 层)。
|
||||
*
|
||||
* 负责从 questions 模块加载题目列表并注入到 AddErrorBookDialog,
|
||||
* 避免 error-book 模块直接 import questions 模块(解耦)。
|
||||
* app 层可以 import 任何模块,因此跨模块依赖在此合法。
|
||||
*/
|
||||
export function AddErrorBookDialogWithQuestions(): JSX.Element {
|
||||
const onLoadQuestions = useCallback(async (): Promise<
|
||||
Array<{ id: string; preview: string }>
|
||||
> => {
|
||||
const res = await getQuestionsAction({ pageSize: 100 })
|
||||
if (res.success && res.data) {
|
||||
return res.data.data.map((q) => ({
|
||||
id: q.id,
|
||||
preview: extractQuestionPreview(q.content, "", 60),
|
||||
}))
|
||||
}
|
||||
return []
|
||||
}, [])
|
||||
|
||||
return <AddErrorBookDialog onLoadQuestions={onLoadQuestions} />
|
||||
}
|
||||
@@ -6,54 +6,40 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
|
||||
import { getErrorBookItems, getErrorBookStats } from "@/modules/error-book/data-access"
|
||||
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
|
||||
import { ErrorBookFilters } from "@/modules/error-book/components/error-book-filters"
|
||||
import { ErrorBookList } from "@/modules/error-book/components/error-book-list"
|
||||
import { AddErrorBookDialog } from "@/modules/error-book/components/add-error-book-dialog"
|
||||
import type { ErrorBookStatusValue, ErrorBookSourceTypeValue } from "@/modules/error-book/types"
|
||||
import {
|
||||
AiClientProvider,
|
||||
type AiClientService,
|
||||
} from "@/modules/ai/context/ai-client-provider"
|
||||
import {
|
||||
aiChatAction,
|
||||
suggestSimilarQuestionsAction,
|
||||
suggestGradingAction,
|
||||
generateLessonContentAction,
|
||||
generateQuestionVariantAction,
|
||||
analyzeWeaknessAction,
|
||||
} from "@/modules/ai/actions"
|
||||
import { createCoreAiClientService } from "@/modules/ai/context/create-ai-client-service"
|
||||
import { StudentErrorBookListClient } from "./student-error-book-list-client"
|
||||
import { AddErrorBookDialogWithQuestions } from "./add-error-book-dialog-with-questions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const VALID_STATUS = new Set(["new", "learning", "mastered", "archived"])
|
||||
const VALID_SOURCE = new Set(["exam", "homework", "manual"])
|
||||
|
||||
function isErrorBookStatus(v: string): v is ErrorBookStatusValue {
|
||||
return VALID_STATUS.has(v)
|
||||
}
|
||||
|
||||
function isErrorBookSource(v: string): v is ErrorBookSourceTypeValue {
|
||||
return VALID_SOURCE.has(v)
|
||||
}
|
||||
|
||||
function parseStatus(v?: string): ErrorBookStatusValue | undefined {
|
||||
return v && VALID_STATUS.has(v) ? (v as ErrorBookStatusValue) : undefined
|
||||
if (!v || !isErrorBookStatus(v)) return undefined
|
||||
return v
|
||||
}
|
||||
|
||||
function parseSource(v?: string): ErrorBookSourceTypeValue | undefined {
|
||||
return v && VALID_SOURCE.has(v) ? (v as ErrorBookSourceTypeValue) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 AI 客户端服务(Server Action 引用集合)
|
||||
*
|
||||
* 通过 React Context 注入,客户端组件不直接 import actions,
|
||||
* 遵循依赖注入模式,便于测试时替换为 mock。
|
||||
*/
|
||||
function createAiClientService(): AiClientService {
|
||||
return {
|
||||
chat: aiChatAction,
|
||||
suggestSimilarQuestions: suggestSimilarQuestionsAction,
|
||||
suggestGrading: suggestGradingAction,
|
||||
generateLessonContent: generateLessonContentAction,
|
||||
generateQuestionVariant: generateQuestionVariantAction,
|
||||
analyzeWeakness: analyzeWeaknessAction,
|
||||
}
|
||||
if (!v || !isErrorBookSource(v)) return undefined
|
||||
return v
|
||||
}
|
||||
|
||||
async function ErrorBookResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||
@@ -74,7 +60,7 @@ async function ErrorBookResults({ searchParams }: { searchParams: Promise<Search
|
||||
pageSize: 50,
|
||||
})
|
||||
|
||||
return <ErrorBookList items={items} studentId={ctx.userId} errorItems={items} />
|
||||
return <StudentErrorBookListClient items={items} studentId={ctx.userId} />
|
||||
}
|
||||
|
||||
function ErrorBookResultsFallback() {
|
||||
@@ -95,7 +81,7 @@ export default async function StudentErrorBookPage({
|
||||
const ctx = await requirePermission(Permissions.ERROR_BOOK_READ)
|
||||
const t = await getTranslations("student")
|
||||
const stats = await getErrorBookStats(ctx.userId)
|
||||
const aiClientService = createAiClientService()
|
||||
const aiClientService = createCoreAiClientService()
|
||||
|
||||
return (
|
||||
<AiClientProvider service={aiClientService}>
|
||||
@@ -108,20 +94,24 @@ export default async function StudentErrorBookPage({
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AddErrorBookDialog />
|
||||
<AddErrorBookDialogWithQuestions />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ErrorBookStatsCards stats={stats} />
|
||||
<WidgetBoundary title={t("errorBook.title")} skeletonHeight={120}>
|
||||
<ErrorBookStatsCards stats={stats} />
|
||||
</WidgetBoundary>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
|
||||
<ErrorBookFilters />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<ErrorBookResultsFallback />}>
|
||||
<ErrorBookResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
<WidgetBoundary title={t("errorBook.title")} skeletonHeight={360}>
|
||||
<Suspense fallback={<ErrorBookResultsFallback />}>
|
||||
<ErrorBookResults searchParams={searchParams} />
|
||||
</Suspense>
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</AiClientProvider>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
|
||||
import { useTransition } from "react"
|
||||
import type { JSX } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { ErrorBookList } from "@/modules/error-book/components/error-book-list"
|
||||
import { AiErrorBookAnalysis } from "@/modules/ai/components/ai-error-book-analysis"
|
||||
import { createPracticeSessionAction } from "@/modules/adaptive-practice/actions"
|
||||
import type { ErrorBookItem } from "@/modules/error-book/types"
|
||||
|
||||
/**
|
||||
* 从题目内容中提取纯文本(用于 AI 分析)
|
||||
*/
|
||||
function extractQuestionText(content: unknown): string {
|
||||
if (!content) return ""
|
||||
if (typeof content === "string") return content
|
||||
if (typeof content === "object" && content !== null && "text" in content) {
|
||||
const textValue = (content as Record<string, unknown>).text
|
||||
if (typeof textValue === "string") return textValue
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将错题条目转换为 AI 薄弱点分析所需的输入格式
|
||||
*/
|
||||
function mapErrorItemsForAnalysis(items: ErrorBookItem[]): Array<{
|
||||
questionText: string
|
||||
questionType: string
|
||||
knowledgePointIds?: string[]
|
||||
errorCount: number
|
||||
masteryLevel: number
|
||||
}> {
|
||||
return items.map((it) => ({
|
||||
questionText: extractQuestionText(it.question?.content),
|
||||
questionType: it.question?.type ?? "unknown",
|
||||
knowledgePointIds: it.knowledgePointIds ?? undefined,
|
||||
errorCount: it.reviewCount > 0 ? it.reviewCount : 1,
|
||||
masteryLevel: it.masteryLevel,
|
||||
}))
|
||||
}
|
||||
|
||||
interface StudentErrorBookListClientProps {
|
||||
items: ErrorBookItem[]
|
||||
studentId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生端错题本列表客户端包装组件(位于 app 层)。
|
||||
*
|
||||
* 负责将 AI 分析和变式练习功能通过 props 注入到 ErrorBookList,
|
||||
* 避免 error-book 模块直接 import ai / adaptive-practice 模块(解耦)。
|
||||
* app 层可以 import 任何模块,因此跨模块依赖在此合法。
|
||||
*/
|
||||
export function StudentErrorBookListClient({
|
||||
items,
|
||||
studentId,
|
||||
}: StudentErrorBookListClientProps): JSX.Element {
|
||||
const router = useRouter()
|
||||
const t = useTranslations("error-book")
|
||||
const tPractice = useTranslations("practice")
|
||||
const [, startTransition] = useTransition()
|
||||
|
||||
const aiAnalysisSlot = (item: ErrorBookItem): React.ReactNode => {
|
||||
const currentQuestionText = extractQuestionText(item.question?.content)
|
||||
if (!currentQuestionText) return null
|
||||
|
||||
const aiErrorItems = mapErrorItemsForAnalysis(items)
|
||||
|
||||
return (
|
||||
<AiErrorBookAnalysis
|
||||
studentId={studentId}
|
||||
subjectId={item.subjectId ?? undefined}
|
||||
currentQuestionText={currentQuestionText}
|
||||
currentQuestionType={item.question?.type}
|
||||
errorItems={aiErrorItems}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const onStartVariantPractice = (item: ErrorBookItem): void => {
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append(
|
||||
"json",
|
||||
JSON.stringify({
|
||||
practiceType: "error_variant",
|
||||
subjectId: item.subjectId ?? undefined,
|
||||
sourceMeta: {
|
||||
errorBookItemIds: [item.id],
|
||||
sourceQuestionIds: [item.questionId],
|
||||
},
|
||||
questionCount: 10,
|
||||
}),
|
||||
)
|
||||
const res = await createPracticeSessionAction(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
toast.success(res.message ?? tPractice("starter.title"))
|
||||
router.push(`/student/practice/${res.data.sessionId}`)
|
||||
} else {
|
||||
toast.error(res.message ?? t("messages.saveFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBookList
|
||||
items={items}
|
||||
aiAnalysisSlot={aiAnalysisSlot}
|
||||
onStartVariantPractice={onStartVariantPractice}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
@@ -10,7 +11,7 @@ export default function StudentGradesError({
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
}): JSX.Element {
|
||||
const t = useTranslations("student")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { JSX } from "react"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
export default function Loading(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -3,11 +3,14 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||
import { getRankingTrend, getClassAverageTrend } from "@/modules/grades/data-access-ranking"
|
||||
import { getStudentPositionInClassDistribution, getStudentGrowthArchive } from "@/modules/grades/data-access-analytics"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
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 { RankingTrendCard } from "@/modules/grades/components/ranking-trend-card"
|
||||
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
|
||||
import { GrowthArchiveChart } from "@/modules/grades/components/growth-archive-chart"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { UserX } from "lucide-react"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
@@ -21,7 +24,7 @@ export default async function StudentGradesPage({
|
||||
}) {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
const [sp, summary, rankingTrend, classAverageTrend, subjectOptions] = await Promise.all([
|
||||
const [sp, summary, rankingTrend, classAverageTrend, subjectOptions, classDistribution, growthArchive] = await Promise.all([
|
||||
searchParams,
|
||||
getStudentGradeSummary(ctx.userId, ctx.dataScope),
|
||||
// v3-P1-3:接入排名趋势图
|
||||
@@ -30,6 +33,10 @@ export default async function StudentGradesPage({
|
||||
getClassAverageTrend(ctx.userId, undefined, undefined, ctx.dataScope),
|
||||
// v3-P2-1:获取科目列表用于过滤器
|
||||
getSubjectOptions(),
|
||||
// P3-9:获取学生所在班级的分布 + 本人位置标注
|
||||
getStudentPositionInClassDistribution(ctx.userId, ctx.dataScope),
|
||||
// P3-4:获取学生纵向成长档案(跨学年/学期聚合)
|
||||
getStudentGrowthArchive(ctx.userId, ctx.dataScope),
|
||||
])
|
||||
|
||||
if (!summary) {
|
||||
@@ -82,6 +89,16 @@ export default async function StudentGradesPage({
|
||||
<RankingTrendCard trend={rankingTrend} />
|
||||
</div>
|
||||
)}
|
||||
{/* P3-9:班级分布 + 学生位置标注(隐私保护视图) */}
|
||||
{classDistribution && (
|
||||
<GradeDistributionChart
|
||||
data={classDistribution.distribution}
|
||||
highlightBucketIndex={classDistribution.studentBucketIndex}
|
||||
studentScore={classDistribution.studentNormalizedScore}
|
||||
/>
|
||||
)}
|
||||
{/* P3-4:学生纵向成长档案(跨学年/学期聚合) */}
|
||||
<GrowthArchiveChart data={growthArchive} />
|
||||
<StudentGradeSummary summary={filteredSummary} />
|
||||
</div>
|
||||
)
|
||||
|
||||
38
src/app/(dashboard)/student/grades/report-card/error.tsx
Normal file
38
src/app/(dashboard)/student/grades/report-card/error.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { useEffect } from "react"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
/**
|
||||
* P3-1: 学生报告卡路由错误边界。
|
||||
*/
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
useEffect(() => {
|
||||
console.error("[ReportCard] Route error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-12">
|
||||
<AlertTriangle className="h-10 w-10 text-destructive" aria-hidden="true" />
|
||||
<div className="text-center">
|
||||
<h2 className="text-lg font-semibold">{t("reportCard.errorTitle")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("reportCard.errorDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={reset} variant="outline">
|
||||
{t("reportCard.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
15
src/app/(dashboard)/student/grades/report-card/loading.tsx
Normal file
15
src/app/(dashboard)/student/grades/report-card/loading.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
/**
|
||||
* P3-1: 学生报告卡路由加载状态。
|
||||
*/
|
||||
export default async function Loading() {
|
||||
const t = await getTranslations("grades")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-sm text-muted-foreground">{t("reportCard.loading")}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
src/app/(dashboard)/student/grades/report-card/page.tsx
Normal file
96
src/app/(dashboard)/student/grades/report-card/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { JSX } from "react"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
import { getReportCardData } from "@/modules/grades/lib/report-card"
|
||||
import { ReportCardView } from "@/modules/grades/components/report-card-view"
|
||||
import { ReportCardPrintAction } from "@/modules/grades/components/report-card-print-action"
|
||||
import { getAcademicYears } from "@/modules/school/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* P3-1: 学生视角的成绩报告卡页面。
|
||||
*
|
||||
* 路由:/student/grades/report-card
|
||||
* 查询参数:
|
||||
* - academicYearId?: 指定学年(不传则使用当前活跃学年)
|
||||
* - semester?: "1" | "2"(不传则全部学期)
|
||||
*
|
||||
* 权限:GRADE_RECORD_READ(学生 scope 自动限制为本人)
|
||||
*/
|
||||
export default async function StudentReportCardPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const sp = await searchParams
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const t = await getTranslations("grades")
|
||||
|
||||
const academicYearIdParam = getParam(sp, "academicYearId")
|
||||
const semesterParam = getParam(sp, "semester")
|
||||
const academicYearId =
|
||||
academicYearIdParam && academicYearIdParam !== "all"
|
||||
? academicYearIdParam
|
||||
: undefined
|
||||
const semester =
|
||||
semesterParam === "1" || semesterParam === "2" ? semesterParam : undefined
|
||||
|
||||
// 学生视角下 studentId 即为 ctx.userId(class_members scope)
|
||||
const [data, academicYears] = await Promise.all([
|
||||
getReportCardData(ctx.userId, ctx.dataScope, {
|
||||
academicYearId,
|
||||
semester,
|
||||
}),
|
||||
getAcademicYears(),
|
||||
])
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
|
||||
<Button asChild variant="ghost" size="sm" className="w-fit">
|
||||
<Link href="/student/grades">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("reportCard.backToGrades")}
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyState
|
||||
title={t("reportCard.emptyTitle")}
|
||||
description={t("reportCard.emptyDescription")}
|
||||
icon={ArrowLeft}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href="/student/grades">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("reportCard.backToGrades")}
|
||||
</Link>
|
||||
</Button>
|
||||
<ReportCardPrintAction />
|
||||
</div>
|
||||
|
||||
<ReportCardView data={data} />
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
{t("reportCard.academicYearsCount", {
|
||||
count: academicYears.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentHomeworkTakeData } from "@/modules/homework/data-access"
|
||||
import { getStudentHomeworkTakeData } from "@/modules/homework/data-access-student"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { HomeworkTakeView } from "@/modules/homework/components/homework-take-view"
|
||||
import { HomeworkReviewView } from "@/modules/homework/components/student-homework-review-view"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* student/learning/assignments/[assignmentId]/result/error.tsx
|
||||
* 学生作业结果页错误边界。
|
||||
*/
|
||||
export default function StudentResultError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("homework.error.submissionNotFound")}
|
||||
description={t("homework.error.loadFailed")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
/**
|
||||
* student/learning/assignments/[assignmentId]/result/loading.tsx
|
||||
* 学生作业结果页骨架屏:标题 + 分数汇总卡片 + 错题预览骨架。
|
||||
*/
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getHomeworkAssignmentById, getStudentSubmissionResult } from "@/modules/homework/data-access"
|
||||
import { getHomeworkAssignmentById } from "@/modules/homework/data-access"
|
||||
import { getStudentSubmissionResult } from "@/modules/homework/data-access-student"
|
||||
import { HomeworkSubmissionResult } from "@/modules/homework/components/homework-submission-result"
|
||||
import { getSession } from "@/shared/lib/session"
|
||||
|
||||
|
||||
28
src/app/(dashboard)/student/learning/assignments/error.tsx
Normal file
28
src/app/(dashboard)/student/learning/assignments/error.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
/**
|
||||
* student/learning/assignments/error.tsx
|
||||
* 学生作业列表错误边界。
|
||||
*/
|
||||
export default function StudentAssignmentsListError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("examHomework")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("homework.error.notFound")}
|
||||
description={t("homework.error.loadFailed")}
|
||||
action={{
|
||||
label: t("common.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui
|
||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { formatDate, cn } from "@/shared/lib/utils"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { AssignmentFilters } from "@/modules/homework/components/assignment-filters"
|
||||
import { Inbox, UserX, TriangleAlert } from "lucide-react"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { ErrorState } from "@/shared/components/error-state"
|
||||
|
||||
export default function StudentClassDetailError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return <ErrorState error={error} reset={reset} namespace="student" />
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import {
|
||||
@@ -14,6 +15,8 @@ import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { getStudentClassById, getStudentSchedule } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -21,6 +24,14 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("classes")
|
||||
return {
|
||||
title: `${t("metadata.studentClassDetail")} - Next_Edu`,
|
||||
description: t("metadata.studentClassDetail"),
|
||||
}
|
||||
}
|
||||
|
||||
const WEEKDAY_KEYS: Record<number, string> = {
|
||||
1: "mon",
|
||||
2: "tue",
|
||||
@@ -37,6 +48,7 @@ export default async function StudentClassDetailPage({
|
||||
params: Promise<{ classId: string }>
|
||||
}) {
|
||||
const { classId } = await params
|
||||
await requirePermission(Permissions.CLASS_READ)
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) return notFound()
|
||||
|
||||
|
||||
13
src/app/(dashboard)/student/learning/courses/error.tsx
Normal file
13
src/app/(dashboard)/student/learning/courses/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { ErrorState } from "@/shared/components/error-state"
|
||||
|
||||
export default function StudentCoursesError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return <ErrorState error={error} reset={reset} namespace="student" />
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next"
|
||||
import { UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
@@ -5,17 +6,28 @@ import { getStudentClasses } from "@/modules/classes/data-access"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
|
||||
import { CourseFilters } from "@/modules/student/components/course-filters"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("classes")
|
||||
return {
|
||||
title: `${t("metadata.studentCourses")} - Next_Edu`,
|
||||
description: t("metadata.studentCourses"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function StudentCoursesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const t = await getTranslations("student")
|
||||
await requirePermission(Permissions.CLASS_READ)
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
|
||||
13
src/app/(dashboard)/student/learning/error.tsx
Normal file
13
src/app/(dashboard)/student/learning/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { ErrorState } from "@/shared/components/error-state"
|
||||
|
||||
export default function StudentLearningError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return <ErrorState error={error} reset={reset} namespace="student" />
|
||||
}
|
||||
34
src/app/(dashboard)/student/learning/loading.tsx
Normal file
34
src/app/(dashboard)/student/learning/loading.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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-7 w-40" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-5 w-24" />
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-5 w-28" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-lg border bg-card p-6 space-y-3">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,30 @@
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
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"
|
||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access-student"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { getTextbooks } from "@/modules/textbooks/data-access"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("classes")
|
||||
return {
|
||||
title: `${t("metadata.studentLearning")} - Next_Edu`,
|
||||
description: t("metadata.studentLearning"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function StudentLearningPage() {
|
||||
const t = await getTranslations("student")
|
||||
await requirePermission(Permissions.CLASS_READ)
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
|
||||
13
src/app/(dashboard)/student/learning/study-path/error.tsx
Normal file
13
src/app/(dashboard)/student/learning/study-path/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { RouteError } from "@/shared/components/route-error"
|
||||
|
||||
export default function StudentStudyPathError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return <RouteError error={error} reset={reset} namespace="student" />
|
||||
}
|
||||
30
src/app/(dashboard)/student/learning/study-path/loading.tsx
Normal file
30
src/app/(dashboard)/student/learning/study-path/loading.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="加载中"
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-72" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="space-y-2">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-10 w-full rounded-md" />
|
||||
<Skeleton className="h-20 w-full rounded-md" />
|
||||
<Skeleton className="h-20 w-full rounded-md" />
|
||||
<Skeleton className="h-10 w-32 rounded-md" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
src/app/(dashboard)/student/learning/study-path/page.tsx
Normal file
50
src/app/(dashboard)/student/learning/study-path/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
import { UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { AiStudyPath } from "@/modules/ai/components/ai-study-path"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("student.studyPath")
|
||||
return {
|
||||
title: t("title"),
|
||||
description: t("description"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function StudentStudyPathPage(): Promise<JSX.Element> {
|
||||
const t = await getTranslations("student.studyPath")
|
||||
// P0 显式权限校验:与 AI_CHAT 一致,防止 student 角色被关闭后页面仍可访问
|
||||
await requirePermission(Permissions.AI_CHAT)
|
||||
const student = await getCurrentStudentUser()
|
||||
|
||||
if (!student) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<EmptyState
|
||||
title={t("noUser")}
|
||||
description={t("noUserDesc")}
|
||||
icon={UserX}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
</div>
|
||||
{/* AiStudyPath 是客户端组件,AiClientProvider 由 dashboard layout 注入 */}
|
||||
<AiStudyPath studentId={student.id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { RouteError } from "@/shared/components/route-error"
|
||||
|
||||
export default function StudentTextbookDetailError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("textbooks")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem-3rem)] flex-col overflow-hidden">
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="加载中"
|
||||
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 gap-3">
|
||||
<Skeleton className="h-6 w-48" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
@@ -10,6 +11,8 @@ import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { getGradeNameById } from "@/modules/school/data-access"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -17,8 +20,10 @@ export default async function StudentTextbookDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("textbooks")
|
||||
// P0 显式权限校验:与教师端一致,校验 TEXTBOOK_READ 权限点
|
||||
await requirePermission(Permissions.TEXTBOOK_READ)
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) return notFound()
|
||||
|
||||
|
||||
@@ -1,29 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { RouteError } from "@/shared/components/route-error"
|
||||
|
||||
export default function StudentTextbooksError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("textbooks")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("error.loadFailed")}
|
||||
description={t("error.loadFailedDesc")}
|
||||
action={{
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return <RouteError error={error} reset={reset} namespace="textbooks" />
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label="加载中"
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-40" />
|
||||
<Skeleton className="h-4 w-56" />
|
||||
@@ -10,7 +16,22 @@ export default function Loading() {
|
||||
<Skeleton className="h-10 w-full max-w-md" />
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full" />
|
||||
<Card key={i} className="h-full overflow-hidden">
|
||||
<div className="aspect-[4/3] w-full bg-muted/30 p-6 flex items-center justify-center">
|
||||
<Skeleton className="h-24 w-20 rounded-sm" />
|
||||
</div>
|
||||
<CardHeader className="p-4 pb-2 space-y-2">
|
||||
<Skeleton className="h-5 w-16" />
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0 mt-auto">
|
||||
<Skeleton className="h-6 w-full rounded-md" />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { JSX } from "react"
|
||||
import { BookOpen, UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
@@ -8,6 +9,8 @@ import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { getGradeNameById } from "@/modules/school/data-access"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -15,8 +18,10 @@ export default async function StudentTextbooksPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
}): Promise<JSX.Element> {
|
||||
const t = await getTranslations("textbooks")
|
||||
// P0 显式权限校验:与教师端一致,校验 TEXTBOOK_READ 权限点
|
||||
await requirePermission(Permissions.TEXTBOOK_READ)
|
||||
const [student, sp] = await Promise.all([getCurrentStudentUser(), searchParams])
|
||||
|
||||
if (!student) {
|
||||
|
||||
7
src/app/(dashboard)/student/leave/error.tsx
Normal file
7
src/app/(dashboard)/student/leave/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
|
||||
export default function StudentLeaveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return <RouteErrorBoundary reset={reset} namespace="leave" />
|
||||
}
|
||||
25
src/app/(dashboard)/student/leave/loading.tsx
Normal file
25
src/app/(dashboard)/student/leave/loading.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-32" />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
src/app/(dashboard)/student/leave/page.tsx
Normal file
87
src/app/(dashboard)/student/leave/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { ArrowLeft, CalendarDays } from "lucide-react"
|
||||
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { getStudentActiveClass } from "@/modules/classes/data-access"
|
||||
import { getLeaveRequests } from "@/modules/leave-requests/data-access"
|
||||
import { LeaveRequestForm } from "@/modules/leave-requests/components/leave-request-form"
|
||||
import { LeaveRequestList } from "@/modules/leave-requests/components/leave-request-list"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 学生在线请假页面。
|
||||
*
|
||||
* L-5 功能:
|
||||
* - 顶部:请假申请表单(学生本人提交,classId 自动写入当前活跃班级)
|
||||
* - 底部:学生本人提交的请假申请列表
|
||||
*
|
||||
* 学生 scope 为 class_members,data-access 中 buildScopeFilter 返回 1=0,
|
||||
* 但通过 currentUserId = requesterId 过滤出本人提交的记录。
|
||||
*/
|
||||
export default async function StudentLeavePage() {
|
||||
const t = await getTranslations("leave")
|
||||
const ctx = await getAuthContext()
|
||||
|
||||
// 并行:学生当前活跃班级 + 本人请假记录
|
||||
const [activeClass, leaveResult] = await Promise.all([
|
||||
getStudentActiveClass(ctx.userId),
|
||||
getLeaveRequests({
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.student")}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("description.student")}</p>
|
||||
</div>
|
||||
|
||||
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
|
||||
<Link href="/student/dashboard">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t("backToDashboard")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{activeClass ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{t("onlineLeave")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LeaveRequestForm
|
||||
defaultStudentId={ctx.userId}
|
||||
defaultClassId={activeClass.classId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-sm text-muted-foreground">
|
||||
{t("empty.studentDesc")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("onlineLeave")}</h2>
|
||||
<LeaveRequestList
|
||||
items={leaveResult.items}
|
||||
emptyTitle={t("empty.studentTitle")}
|
||||
emptyDescription={t("empty.studentDesc")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { notFound } from "next/navigation"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { assertPlanInScope } from "@/modules/lesson-preparation/lib/scope-check"
|
||||
import { getTextbookById, getChaptersByTextbookId, findChapterById } from "@/modules/textbooks/data-access"
|
||||
import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
@@ -16,17 +19,17 @@ export default async function StudentLessonPlanViewPage({
|
||||
}): Promise<JSX.Element> {
|
||||
const { planId } = await params
|
||||
const t = await getTranslations("lessonPreparation")
|
||||
const ctx = await getAuthContext()
|
||||
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ)
|
||||
|
||||
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) notFound()
|
||||
|
||||
// V4 P0-1 修复:学生仅可查看本年级已发布课案,防止跨年级信息泄露
|
||||
try {
|
||||
assertPlanInScope(plan, ctx)
|
||||
} catch {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// 学生只能查看已发布的课案
|
||||
@@ -44,21 +47,14 @@ export default async function StudentLessonPlanViewPage({
|
||||
let textbookTitle: string | undefined
|
||||
let chapterTitle: string | undefined
|
||||
if (plan.textbookId) {
|
||||
const textbook = await getTextbookById(plan.textbookId)
|
||||
// V4 P2-1 修复:textbook 和 chapters 查询无依赖关系,改为 Promise.all 并行查询
|
||||
const [textbook, chapters] = await Promise.all([
|
||||
getTextbookById(plan.textbookId),
|
||||
getChaptersByTextbookId(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)
|
||||
const chapter = findChapterById(chapters, plan.chapterId)
|
||||
chapterTitle = chapter?.title
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
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 { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup"
|
||||
import { STUDENT_ROLE_CONFIG } from "@/modules/lesson-preparation/providers/lesson-plan-provider"
|
||||
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()
|
||||
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ)
|
||||
|
||||
const [items, subjects] = await Promise.all([
|
||||
getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId),
|
||||
@@ -24,21 +28,24 @@ export default async function StudentLessonPlansPage(): Promise<JSX.Element> {
|
||||
<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>
|
||||
{/* P0-13 修复:包裹 LessonPlanProviderSetup,注入 student 角色配置,使筛选功能生效 */}
|
||||
<LessonPlanProviderSetup roleConfig={STUDENT_ROLE_CONFIG}>
|
||||
<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>
|
||||
</LessonPlanProviderSetup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
import { getPracticeSessionById } from "@/modules/adaptive-practice/data-access"
|
||||
import { PracticeSessionView } from "@/modules/adaptive-practice/components/practice-session-view"
|
||||
import { PracticeServiceProvider } from "@/modules/adaptive-practice/services/practice-service"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -24,8 +25,10 @@ export default async function PracticeSessionPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<PracticeSessionView session={session} />
|
||||
</div>
|
||||
<PracticeServiceProvider>
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<PracticeSessionView session={session} />
|
||||
</div>
|
||||
</PracticeServiceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ 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 { PracticeStarterWithNav } from "@/modules/adaptive-practice/components/practice-starter-with-nav"
|
||||
import { PracticeHistory } from "@/modules/adaptive-practice/components/practice-history"
|
||||
import { PracticeStatsCards } from "@/modules/adaptive-practice/components/practice-stats-cards"
|
||||
import { PracticeServiceProvider } from "@/modules/adaptive-practice/services/practice-service"
|
||||
import { getKnowledgePointOptions } from "@/modules/questions/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -23,23 +24,31 @@ export default async function StudentPracticePage(): Promise<JSX.Element> {
|
||||
])
|
||||
|
||||
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} />
|
||||
<PracticeServiceProvider>
|
||||
<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>
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h2 className="text-lg font-semibold">{t("history.title")}</h2>
|
||||
<PracticeHistory sessions={sessionsResult.data} />
|
||||
|
||||
<PracticeStatsCards stats={stats} />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-1">
|
||||
<PracticeStarterWithNav
|
||||
knowledgePoints={knowledgePoints}
|
||||
routePrefix="/student/practice"
|
||||
/>
|
||||
</div>
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<h2 className="text-lg font-semibold">{t("history.title")}</h2>
|
||||
<PracticeHistory
|
||||
sessions={sessionsResult.data}
|
||||
routePrefix="/student/practice"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PracticeServiceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
13
src/app/(dashboard)/student/schedule/error.tsx
Normal file
13
src/app/(dashboard)/student/schedule/error.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { ErrorState } from "@/shared/components/error-state"
|
||||
|
||||
export default function StudentScheduleError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
return <ErrorState error={error} reset={reset} namespace="student" />
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Metadata } from "next"
|
||||
import { UserX } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
@@ -5,17 +6,28 @@ import { getStudentClasses, getStudentSchedule } from "@/modules/classes/data-ac
|
||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||
import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
|
||||
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations("classes")
|
||||
return {
|
||||
title: `${t("metadata.studentSchedule")} - Next_Edu`,
|
||||
description: t("metadata.studentSchedule"),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function StudentSchedulePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}) {
|
||||
const t = await getTranslations("student")
|
||||
await requirePermission(Permissions.CLASS_READ)
|
||||
const student = await getCurrentStudentUser()
|
||||
if (!student) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user