feat(app): add error/loading boundaries and update dashboard routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add dashboard-error-fallback and dashboard-loading-skeleton components - Add student/learning page, parent/leave routes, teacher textbook components - Update existing app routes across auth, dashboard, and API endpoints - Update proxy middleware and next-auth type declarations
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
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"
|
||||
|
||||
@@ -11,6 +13,7 @@ export default async function TeacherCoursePlanDetailPage({
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const { id } = await params
|
||||
const plan = await getCoursePlanById(id)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { JSX } from "react"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||
@@ -23,7 +24,7 @@ export default async function TeacherCoursePlansPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await getAuthContext()
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const teacherId = ctx.userId
|
||||
|
||||
const sp = await searchParams
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("dashboard")
|
||||
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>
|
||||
)
|
||||
export default function TeacherDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
return <DashboardErrorFallback error={error} reset={reset} />
|
||||
}
|
||||
|
||||
@@ -1,38 +1,5 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
|
||||
|
||||
export default function TeacherDashboardLoading() {
|
||||
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>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="mt-2 h-3 w-28" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
return <DashboardLoadingSkeleton />
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
|
||||
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
|
||||
import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -41,7 +42,9 @@ export default async function ClassDiagnosticPage({
|
||||
Class-level knowledge point mastery overview and student attention list.
|
||||
</p>
|
||||
</div>
|
||||
<ClassDiagnosticView summary={summary} />
|
||||
<WidgetBoundary title="班级学情诊断" skeletonHeight={400}>
|
||||
<ClassDiagnosticView summary={summary} />
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,16 +39,19 @@ export default async function TeacherDiagnosticPage({
|
||||
const reportType = getParam(sp, "reportType")
|
||||
const status = getParam(sp, "status")
|
||||
|
||||
const reports = await getDiagnosticReports({
|
||||
reportType: reportType && reportType !== "all" ? parseReportType(reportType) : undefined,
|
||||
status: status && status !== "all" ? parseReportStatus(status) : undefined,
|
||||
})
|
||||
const reports = await getDiagnosticReports(
|
||||
{
|
||||
reportType: reportType && reportType !== "all" ? parseReportType(reportType) : undefined,
|
||||
status: status && status !== "all" ? parseReportStatus(status) : undefined,
|
||||
},
|
||||
ctx.dataScope,
|
||||
)
|
||||
|
||||
// 学生角色仅查看自己的报告;其他角色查看全部
|
||||
const visibleReports =
|
||||
ctx.dataScope.type === "class_members"
|
||||
? reports.filter((r) => r.studentId === ctx.userId)
|
||||
: reports
|
||||
? reports.reports.filter((r) => r.studentId === ctx.userId)
|
||||
: reports.reports
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
getKnowledgePointStats,
|
||||
} from "@/modules/diagnostic/data-access"
|
||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||
import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
||||
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
||||
import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"
|
||||
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -29,11 +31,26 @@ export default async function StudentDiagnosticPage({
|
||||
notFound()
|
||||
}
|
||||
|
||||
const [summary, reports, classStats] = await Promise.all([
|
||||
// v4-P1-2: class_taught scope 校验师生关系
|
||||
// 教师只能查看自己所教班级的学生诊断
|
||||
if (ctx.dataScope.type === "class_taught") {
|
||||
const studentClassId = await getStudentActiveClassId(studentId)
|
||||
if (!studentClassId || !ctx.dataScope.classIds.includes(studentClassId)) {
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
// 先查询学生所属班级,再用 classId 调用 getKnowledgePointStats
|
||||
// 否则无参调用会导致 studentIds=[] 直接返回空数组,班级平均对比功能失效
|
||||
const studentClassId = await getStudentActiveClassId(studentId)
|
||||
|
||||
const [summary, reportsResult, classStats] = await Promise.all([
|
||||
getStudentMasterySummary(studentId),
|
||||
getDiagnosticReports({ studentId }),
|
||||
getKnowledgePointStats(),
|
||||
// v4-P1-3: 教师视角可查看所有状态报告(含草稿),便于审核
|
||||
getDiagnosticReports({ studentId }, ctx.dataScope),
|
||||
studentClassId ? getKnowledgePointStats(studentClassId) : Promise.resolve([]),
|
||||
])
|
||||
const reports = reportsResult.reports
|
||||
|
||||
// 班级平均掌握度(用于雷达图对比)
|
||||
let classAverageMastery: MasteryRadarPoint[] | undefined
|
||||
@@ -56,11 +73,14 @@ export default async function StudentDiagnosticPage({
|
||||
Knowledge point mastery analysis and diagnostic reports.
|
||||
</p>
|
||||
</div>
|
||||
<StudentDiagnosticView
|
||||
summary={summary}
|
||||
reports={reports}
|
||||
classAverageMastery={classAverageMastery}
|
||||
/>
|
||||
<WidgetBoundary title="学生学情诊断" skeletonHeight={400}>
|
||||
<StudentDiagnosticView
|
||||
summary={summary}
|
||||
reports={reports}
|
||||
classAverageMastery={classAverageMastery}
|
||||
practiceHrefBase="/teacher/questions"
|
||||
/>
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
24
src/app/(dashboard)/teacher/elective/error.tsx
Normal file
24
src/app/(dashboard)/teacher/elective/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function TeacherElectiveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("elective")
|
||||
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.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
|
||||
import {
|
||||
getClassComparison,
|
||||
getExamOptionsForGrades,
|
||||
getGradeDistribution,
|
||||
getGradeTrend,
|
||||
getSubjectComparison,
|
||||
@@ -22,6 +23,7 @@ import { ClassComparisonChart } from "@/modules/grades/components/class-comparis
|
||||
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
|
||||
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
|
||||
import { AnalyticsFilters } from "@/modules/grades/components/analytics-filters"
|
||||
import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -36,6 +38,9 @@ export default async function GradeAnalyticsPage({
|
||||
const classId = getParam(sp, "classId")
|
||||
const subjectId = getParam(sp, "subjectId")
|
||||
const gradeId = getParam(sp, "gradeId")
|
||||
// v3-P2-7: 学期和考试筛选
|
||||
const examId = getParam(sp, "examId")
|
||||
const semester = getParam(sp, "semester")
|
||||
|
||||
const [classes, allGrades, allSubjects] = await Promise.all([
|
||||
getTeacherClasses(),
|
||||
@@ -66,33 +71,50 @@ export default async function GradeAnalyticsPage({
|
||||
const targetSubjectId =
|
||||
subjectId && subjectId !== "all" ? subjectId : undefined
|
||||
const targetGradeId = gradeId ?? allGrades[0]?.id
|
||||
// v3-P2-7: 解析 semester 和 examId
|
||||
const targetSemester: "1" | "2" | undefined =
|
||||
semester === "1" || semester === "2" ? semester : undefined
|
||||
const targetExamId = examId && examId !== "all" ? examId : undefined
|
||||
|
||||
// Run analytics queries in parallel
|
||||
const [trend, distribution, subjectComparison, classComparison] =
|
||||
const [trend, distribution, subjectComparison, classComparison, examOptions] =
|
||||
await Promise.all([
|
||||
getGradeTrend({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
semester: targetSemester,
|
||||
examId: targetExamId,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
}),
|
||||
getGradeDistribution({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
examId: targetExamId,
|
||||
semester: targetSemester,
|
||||
scope: ctx.dataScope,
|
||||
currentUserId: ctx.userId,
|
||||
}),
|
||||
getSubjectComparison({
|
||||
classId: targetClassId,
|
||||
examId: targetExamId,
|
||||
semester: targetSemester,
|
||||
scope: ctx.dataScope,
|
||||
}),
|
||||
targetGradeId
|
||||
? getClassComparison({
|
||||
gradeId: targetGradeId,
|
||||
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
|
||||
examId: targetExamId,
|
||||
semester: targetSemester,
|
||||
scope: ctx.dataScope,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
getExamOptionsForGrades({
|
||||
classId: targetClassId,
|
||||
subjectId: targetSubjectId,
|
||||
scope: ctx.dataScope,
|
||||
}),
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -116,16 +138,63 @@ export default async function GradeAnalyticsPage({
|
||||
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
||||
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
|
||||
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
|
||||
exams={examOptions}
|
||||
currentClassId={targetClassId}
|
||||
currentSubjectId={subjectId ?? "all"}
|
||||
currentGradeId={targetGradeId ?? ""}
|
||||
currentExamId={examId ?? "all"}
|
||||
currentSemester={semester ?? "all"}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<GradeTrendChart data={trend} />
|
||||
<GradeDistributionChart data={distribution} />
|
||||
<SubjectComparisonChart data={subjectComparison} />
|
||||
<ClassComparisonChart data={classComparison} />
|
||||
<WidgetBoundary title="成绩趋势">
|
||||
{trend ? (
|
||||
<GradeTrendChart data={trend} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无趋势数据"
|
||||
description="当前筛选条件下没有可显示的成绩趋势。"
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
<WidgetBoundary title="分数分布">
|
||||
{distribution.totalCount > 0 ? (
|
||||
<GradeDistributionChart data={distribution} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无分布数据"
|
||||
description="当前筛选条件下没有可显示的分数分布。"
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
<WidgetBoundary title="科目对比">
|
||||
{subjectComparison.length > 0 ? (
|
||||
<SubjectComparisonChart data={subjectComparison} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无科目对比数据"
|
||||
description="当前筛选条件下没有可显示的科目对比。"
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
<WidgetBoundary title="班级对比">
|
||||
{classComparison.length > 0 ? (
|
||||
<ClassComparisonChart data={classComparison} />
|
||||
) : (
|
||||
<EmptyState
|
||||
title="暂无班级对比数据"
|
||||
description="当前筛选条件下没有可显示的班级对比。"
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,11 @@ import { getTeacherClasses } from "@/modules/classes/data-access"
|
||||
import { getClassStudentsForEntry } from "@/modules/grades/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -12,22 +16,52 @@ export default async function BatchEntryPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||
const sp = await searchParams
|
||||
|
||||
const defaultClassId = getParam(sp, "classId")
|
||||
const defaultSubjectId = getParam(sp, "subjectId")
|
||||
|
||||
// P3 修复:添加 scope 校验,对 class_taught scope 限制可录入的班级
|
||||
const [classes, allSubjects, students] = await Promise.all([
|
||||
getTeacherClasses(),
|
||||
getSubjectOptions(),
|
||||
defaultClassId
|
||||
? getClassStudentsForEntry(defaultClassId)
|
||||
? getClassStudentsForEntry(defaultClassId, ctx.dataScope)
|
||||
: Promise.resolve([] as Awaited<ReturnType<typeof getClassStudentsForEntry>>),
|
||||
])
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
// 对 class_taught scope,过滤掉不在 scope 中的班级
|
||||
const allowedClassIds =
|
||||
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : null
|
||||
const scopedClasses = allowedClassIds
|
||||
? classes.filter((c) => allowedClassIds.includes(c.id))
|
||||
: classes
|
||||
|
||||
const classOptions = scopedClasses.map((c) => ({ id: c.id, name: c.name }))
|
||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||
|
||||
// 如果指定了 classId 但 scope 不允许,显示提示
|
||||
if (defaultClassId && students.length === 0 && scopedClasses.length > 0) {
|
||||
const classExists = scopedClasses.some((c) => c.id === defaultClassId)
|
||||
if (!classExists) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h1>
|
||||
<p className="text-muted-foreground">Enter grades for all students in a class at once.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="无权访问该班级"
|
||||
description="您没有权限为该班级录入成绩。"
|
||||
icon={ClipboardList}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import Link from "next/link"
|
||||
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination"
|
||||
import { ListPagination, computePagination } from "@/shared/components/ui/list-pagination"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
@@ -43,7 +43,11 @@ export default async function TeacherGradesPage({
|
||||
const type = getParam(sp, "type")
|
||||
const semester = getParam(sp, "semester")
|
||||
|
||||
const [classes, allSubjects, records] = await Promise.all([
|
||||
// P3 修复:使用 DB 层分页,移除重复计算
|
||||
const { page } = computePagination(sp, PAGE_SIZE)
|
||||
const offset = (page - 1) * PAGE_SIZE
|
||||
|
||||
const [classes, allSubjects, result] = await Promise.all([
|
||||
getTeacherClasses(),
|
||||
getSubjectOptions(),
|
||||
getGradeRecords({
|
||||
@@ -53,18 +57,19 @@ export default async function TeacherGradesPage({
|
||||
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
|
||||
type: type && type !== "all" ? parseGradeType(type) : undefined,
|
||||
semester: semester && semester !== "all" ? parseSemester(semester) : undefined,
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
}),
|
||||
])
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||
|
||||
// 分页计算
|
||||
const { page } = computePagination(sp, PAGE_SIZE)
|
||||
const total = records.length
|
||||
// 使用 DB 返回的 total 和 totalPages,移除重复计算
|
||||
const total = result.total
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
const currentPage = Math.min(page, totalPages)
|
||||
const pagedRecords = paginate(records, currentPage, PAGE_SIZE)
|
||||
const pagedRecords = result.records
|
||||
const hasFilters = Boolean(classId || subjectId || type || semester)
|
||||
|
||||
return (
|
||||
@@ -103,7 +108,7 @@ export default async function TeacherGradesPage({
|
||||
|
||||
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
|
||||
|
||||
{records.length === 0 && !hasFilters ? (
|
||||
{total === 0 && !hasFilters ? (
|
||||
<EmptyState
|
||||
title="暂无成绩记录"
|
||||
description="开始为您的班级录入成绩。"
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ClassGradeReport } from "@/modules/grades/components/class-grade-report
|
||||
import { ExportButton } from "@/modules/grades/components/export-button"
|
||||
import { StatsClassSelector } from "@/modules/grades/components/stats-class-selector"
|
||||
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 { BarChart3 } from "lucide-react"
|
||||
|
||||
@@ -16,6 +18,7 @@ export default async function StatsPage({
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||
const sp = await searchParams
|
||||
|
||||
const classId = getParam(sp, "classId")
|
||||
@@ -43,15 +46,52 @@ export default async function StatsPage({
|
||||
)
|
||||
}
|
||||
|
||||
const targetClassId = classId ?? classes[0].id
|
||||
// P3 修复:对 class_taught scope 过滤可选班级
|
||||
const allowedClassIds =
|
||||
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : null
|
||||
const scopedClasses = allowedClassIds
|
||||
? classes.filter((c) => allowedClassIds.includes(c.id))
|
||||
: classes
|
||||
|
||||
if (scopedClasses.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Grade Statistics</h1>
|
||||
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
title="No accessible classes"
|
||||
description="You don't have permission to view any classes."
|
||||
icon={BarChart3}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const targetClassId = classId ?? scopedClasses[0].id
|
||||
const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined
|
||||
|
||||
// P3 修复:传递 scope 到 data-access 层
|
||||
const [stats, ranking] = await Promise.all([
|
||||
getClassGradeStatsWithMeta(targetClassId, targetSubjectId),
|
||||
getClassRanking(targetClassId, targetSubjectId),
|
||||
getClassGradeStatsWithMeta(
|
||||
targetClassId,
|
||||
targetSubjectId,
|
||||
undefined,
|
||||
ctx.dataScope,
|
||||
ctx.userId
|
||||
),
|
||||
getClassRanking(
|
||||
targetClassId,
|
||||
targetSubjectId,
|
||||
undefined,
|
||||
ctx.dataScope,
|
||||
ctx.userId
|
||||
),
|
||||
])
|
||||
|
||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||
const classOptions = scopedClasses.map((c) => ({ id: c.id, name: c.name }))
|
||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -20,7 +22,20 @@ export default async function NewLessonPlanPage(): Promise<JSX.Element> {
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.new")}</h1>
|
||||
</div>
|
||||
<TemplatePicker />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TemplatePicker />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { JSX } from "react"
|
||||
import { Suspense } from "react"
|
||||
import Link from "next/link"
|
||||
import { Plus } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||
@@ -35,7 +37,24 @@ export default async function LessonPlansPage(): Promise<JSX.Element> {
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<LessonPlanList initialItems={items} subjects={subjects} />
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
<Skeleton className="h-9 w-[240px]" />
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
<Skeleton className="h-9 w-[160px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[180px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LessonPlanList initialItems={items} subjects={subjects} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
import { getQuestions } from "@/modules/questions/data-access"
|
||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { QuestionType } from "@/modules/questions/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -27,6 +29,8 @@ function parseQuestionType(v?: string): QuestionType | undefined {
|
||||
}
|
||||
|
||||
async function QuestionBankResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.QUESTION_READ)
|
||||
|
||||
const params = await searchParams
|
||||
|
||||
const q = getParam(params, "q")
|
||||
@@ -36,10 +40,13 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
||||
|
||||
const questionType = parseQuestionType(type)
|
||||
|
||||
const difficultyNum = difficulty && difficulty !== "all" ? Number(difficulty) : undefined
|
||||
const safeDifficulty = difficultyNum !== undefined && Number.isFinite(difficultyNum) ? difficultyNum : undefined
|
||||
|
||||
const { data: questions } = await getQuestions({
|
||||
q: q || undefined,
|
||||
type: questionType,
|
||||
difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined,
|
||||
difficulty: safeDifficulty,
|
||||
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
|
||||
pageSize: 200,
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { JSX } from "react"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
getAdminClassesForScheduling,
|
||||
getScheduleChanges,
|
||||
@@ -14,10 +15,10 @@ import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-cha
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function TeacherScheduleChangesPage(): Promise<JSX.Element> {
|
||||
const ctx = await getAuthContext()
|
||||
const ctx = await requirePermission(Permissions.SCHEDULE_ADJUST)
|
||||
|
||||
// Teachers see only their own requests; admins landing here see all.
|
||||
const requesterId = ctx.roles.includes("admin") ? undefined : ctx.userId
|
||||
const requesterId = ctx.dataScope.type === "all" ? undefined : ctx.userId
|
||||
|
||||
const [classes, teachers, items] = await Promise.all([
|
||||
getAdminClassesForScheduling(),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
TextbookReader,
|
||||
type TextbookReaderProps,
|
||||
} from "@/modules/textbooks/components/textbook-reader"
|
||||
import type { KnowledgePoint } from "@/modules/textbooks/types"
|
||||
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||
|
||||
/**
|
||||
* 教师端 TextbookReader 包装组件。
|
||||
*
|
||||
* 教师详情页是 Server Component,不能直接向 Client Component(TextbookReader)
|
||||
* 传递函数 prop(renderQuestionCreator)。此包装组件在 app 层组装跨模块依赖,
|
||||
* 避免 textbooks 模块直接依赖 questions 模块(模块间只通过 data-access 通信)。
|
||||
*/
|
||||
export function TeacherTextbookReader({
|
||||
chapters,
|
||||
textbookId,
|
||||
}: {
|
||||
chapters: TextbookReaderProps["chapters"]
|
||||
textbookId: string
|
||||
}) {
|
||||
const t = useTranslations("textbooks")
|
||||
const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetKp,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
targetKp: KnowledgePoint | null
|
||||
}): ReactNode => (
|
||||
<CreateQuestionDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
|
||||
defaultContent={targetKp ? t("reader.questionCreatorDefaultContent", { name: targetKp.name }) : ""}
|
||||
defaultType="text"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<TextbookReader
|
||||
key={textbookId}
|
||||
chapters={chapters}
|
||||
textbookId={textbookId}
|
||||
renderQuestionCreator={renderQuestionCreator}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { JSX, ReactNode } from "react"
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { TextbookReader, type TextbookReaderProps } from "@/modules/textbooks/components/textbook-reader"
|
||||
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||
import { TeacherTextbookReader } from "./_components/teacher-textbook-reader"
|
||||
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"
|
||||
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||
import type { KnowledgePoint } from "@/modules/textbooks/types"
|
||||
import { getSubjectLabelKey, getGradeLabelKey } from "@/modules/textbooks/constants"
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
@@ -18,38 +19,20 @@ export default async function TextbookDetailPage({
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.TEXTBOOK_READ)
|
||||
|
||||
const { id } = await params
|
||||
const t = await getTranslations("textbooks")
|
||||
|
||||
const [textbook, chapters, knowledgePoints] = await Promise.all([
|
||||
const [textbook, chapters] = await Promise.all([
|
||||
getTextbookById(id),
|
||||
getChaptersByTextbookId(id),
|
||||
getKnowledgePointsByTextbookId(id),
|
||||
])
|
||||
|
||||
if (!textbook) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
// P0-1 在页面层注入 questions 模块的 CreateQuestionDialog 实现
|
||||
const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetKp,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
targetKp: KnowledgePoint | null
|
||||
}): ReactNode => (
|
||||
<CreateQuestionDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
|
||||
defaultContent={targetKp ? `Please explain the knowledge point: ${targetKp.name}` : ""}
|
||||
defaultType="text"
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
||||
{/* Header / Nav (Fixed height) */}
|
||||
@@ -62,9 +45,9 @@ export default async function TextbookDetailPage({
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{textbook.subject}</Badge>
|
||||
<Badge variant="outline">{t(`subject.${getSubjectLabelKey(textbook.subject)}`)}</Badge>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
||||
{textbook.grade}
|
||||
{textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : ""}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
|
||||
@@ -76,12 +59,7 @@ export default async function TextbookDetailPage({
|
||||
|
||||
{/* Main Content Layout (Flex grow) */}
|
||||
<div className="flex-1 overflow-hidden pt-6">
|
||||
<TextbookReader
|
||||
chapters={chapters}
|
||||
knowledgePoints={knowledgePoints}
|
||||
textbookId={id}
|
||||
renderQuestionCreator={renderQuestionCreator}
|
||||
/>
|
||||
<TeacherTextbookReader chapters={chapters} textbookId={id} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ import { getTextbooks } from "@/modules/textbooks/data-access"
|
||||
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
||||
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"
|
||||
|
||||
@@ -18,6 +20,8 @@ async function TextbooksResults({
|
||||
searchParams: Promise<SearchParams>
|
||||
t: Awaited<ReturnType<typeof getTranslations<"textbooks">>>
|
||||
}): Promise<JSX.Element> {
|
||||
await requirePermission(Permissions.TEXTBOOK_READ)
|
||||
|
||||
const params = await searchParams
|
||||
|
||||
const q = getParam(params, "q") || undefined
|
||||
|
||||
Reference in New Issue
Block a user