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:
SpecialX
2026-07-03 10:26:25 +08:00
parent e9a5264fe7
commit 21142f9b99
280 changed files with 7137 additions and 1855 deletions

View File

@@ -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" />
}

View File

@@ -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"
/>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View File

@@ -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} />
}

View File

@@ -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(),

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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} />
}

View File

@@ -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>

View File

@@ -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}
/>
)
}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>
)

View 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>
)
}

View 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>
)
}

View 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.userIdclass_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>
)
}

View File

@@ -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"

View 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/[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>
)
}

View File

@@ -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>
)
}

View File

@@ -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"

View 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>
)
}

View File

@@ -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"

View File

@@ -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" />
}

View File

@@ -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()

View 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" />
}

View File

@@ -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 (

View 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" />
}

View 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>
)
}

View File

@@ -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 (

View 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" />
}

View 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>
)
}

View 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>
)
}

View File

@@ -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" />
}

View File

@@ -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" />

View File

@@ -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()

View File

@@ -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" />
}

View File

@@ -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>

View File

@@ -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) {

View 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" />
}

View 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>
)
}

View 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_membersdata-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>
)
}

View File

@@ -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
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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" />
}

View File

@@ -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 (