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:
SpecialX
2026-06-23 17:38:28 +08:00
parent c4d3433cc9
commit 1a9377222c
90 changed files with 1690 additions and 741 deletions

View File

@@ -5,8 +5,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAnnouncementById } from "@/modules/announcements/data-access"
import { getGrades } from "@/modules/school/data-access"
import { getEditAnnouncementPageData } from "@/modules/announcements/data-access"
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
export const dynamic = "force-dynamic"
@@ -28,10 +27,7 @@ export default async function EditAnnouncementPage({
const { id } = await params
const t = await getTranslations("announcements")
const [announcement, grades] = await Promise.all([
getAnnouncementById(id),
getGrades(),
])
const { announcement, grades } = await getEditAnnouncementPageData(id)
if (!announcement) notFound()
@@ -44,7 +40,7 @@ export default async function EditAnnouncementPage({
<AnnouncementForm
mode="edit"
announcement={announcement}
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
grades={grades}
/>
</div>
)

View File

@@ -4,9 +4,7 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAnnouncements } from "@/modules/announcements/data-access"
import { getGrades } from "@/modules/school/data-access"
import { getAdminClasses } from "@/modules/classes/data-access"
import { getAdminAnnouncementsPageData } from "@/modules/announcements/data-access"
import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import type { AnnouncementStatus } from "@/modules/announcements/types"
@@ -34,17 +32,13 @@ export default async function AdminAnnouncementsPage({
const statusParam = getSearchParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const [announcements, grades, classes] = await Promise.all([
getAnnouncements({ status }),
getGrades(),
getAdminClasses(),
])
const { announcements, grades, classes } = await getAdminAnnouncementsPageData(status)
return (
<AdminAnnouncementsView
announcements={announcements}
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
grades={grades}
classes={classes}
initialStatus={status}
/>
)

View File

@@ -0,0 +1,7 @@
"use client"
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
export default function AdminDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <DashboardErrorFallback error={error} reset={reset} />
}

View File

@@ -0,0 +1,5 @@
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
export default function AdminDashboardLoading() {
return <DashboardLoadingSkeleton />
}

View File

@@ -1,18 +1,20 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function AdminError({ 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="页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
title={t("error.loadFailed")}
description={t("error.loadFailedDesc")}
action={{
label: "重试",
label: t("error.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function AdminGradesInsightsError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="成绩洞察页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,22 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminGradesInsightsLoading() {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-80" />
))}
</div>
</div>
)
}

View File

@@ -1,4 +1,4 @@
import Link from "next/link"
import Link from "next/link"
import type { Metadata } from "next"
import type { JSX } from "react"
import { BarChart3 } from "lucide-react"
@@ -7,13 +7,16 @@ import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getGrades } from "@/modules/school/data-access"
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
import { getSchoolWideGradeSummary } from "@/modules/grades/data-access-analytics"
import { SchoolWideSummaryCard } from "@/modules/grades/components/school-wide-summary-card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatCard } from "@/shared/components/ui/stat-card"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
import { formatDate, formatNumber, getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { formatDate, formatNumber } from "@/shared/lib/utils"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
export const metadata: Metadata = {
title: "年级作业洞察 - Next_Edu",
@@ -27,15 +30,17 @@ export default async function AdminGradeInsightsPage({
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const ctx = await requirePermission(Permissions.SCHOOL_MANAGE)
const params = await searchParams
const gradeId = getSearchParam(params, "gradeId")
const gradeId = getParam(params, "gradeId")
const selected = gradeId && gradeId !== "all" ? gradeId : ""
// gradesinsights 无数据依赖,并行查询
const [grades, insights] = await Promise.all([
// gradesinsights、全校汇总无数据依赖,并行查询
const [grades, insights, schoolWideSummary] = await Promise.all([
getGrades(),
selected ? getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : Promise.resolve(null),
// v3-P2-9管理员全校成绩汇总视图
getSchoolWideGradeSummary(ctx.dataScope),
])
return (
@@ -50,6 +55,11 @@ export default async function AdminGradeInsightsPage({
</Button>
</div>
{/* v3-P2-9: 全校成绩汇总视图 */}
{schoolWideSummary.grades.length > 0 && (
<SchoolWideSummaryCard summary={schoolWideSummary} />
)}
<Card className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="text-base"></CardTitle>

View File

@@ -3,5 +3,5 @@ import { redirect } from "next/navigation"
export const dynamic = "force-dynamic"
export default function AdminSchoolPage(): never {
redirect("/admin/school/classes")
redirect("/admin/school/schools")
}

View File

@@ -97,7 +97,7 @@ export default async function AnnouncementsPage() {
</div>
<AnnouncementList
announcements={announcements}
detailHrefBuilder={(id) => `/announcements/${id}`}
detailHrefPrefix="/announcements"
/>
</div>
)

View File

@@ -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 DashboardError({ 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 DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <DashboardErrorFallback error={error} reset={reset} />
}

View File

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

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ManagementGradeClassesError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="年级班级页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ManagementGradeClassesLoading() {
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-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>
)
}

View File

@@ -0,0 +1,22 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ManagementGradeError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ManagementGradeInsightsError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="年级成绩洞察页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,38 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ManagementGradeInsightsLoading() {
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>
)
}

View File

@@ -0,0 +1,38 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ManagementGradeLoading() {
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>
)
}

View File

@@ -1,5 +1,6 @@
import { notFound } from "next/navigation"
import type { Metadata } from "next"
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"

View 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 ParentAttendanceError({ 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.save"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-8 p-6 md:p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<CardTitle className="text-lg">
<Skeleton className="h-5 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, j) => (
<Skeleton key={j} className="h-20 w-full" />
))}
</div>
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-16 w-16 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-56" />
</div>
</div>
<Skeleton className="h-9 w-full max-w-md" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-base">
<Skeleton className="h-4 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
<Skeleton className="h-4 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,9 +1,17 @@
import { notFound } from "next/navigation"
import { requireAuth } from "@/shared/lib/auth-guard"
import { verifyParentChildRelation, getChildDashboardData } from "@/modules/parent/data-access"
import { getSearchParam } from "@/shared/lib/utils"
import {
verifyParentChildRelation,
getChildDashboardData,
getChildNameList,
} from "@/modules/parent/data-access"
import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header"
import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel"
import {
ChildDetailPanel,
SiblingSwitcher,
} from "@/modules/parent/components/child-detail-panel"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ShieldAlert } from "lucide-react"
@@ -11,10 +19,13 @@ export const dynamic = "force-dynamic"
export default async function ChildDetailPage({
params,
searchParams,
}: {
params: Promise<{ studentId: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
const { studentId } = await params
const sp = await searchParams
const ctx = await requireAuth()
// 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露)
@@ -38,15 +49,29 @@ export default async function ChildDetailPage({
)
}
const child = await getChildDashboardData(studentId, relation)
const [child, siblings] = await Promise.all([
getChildDashboardData(studentId, relation),
getChildNameList(ctx.userId),
])
if (!child) {
notFound()
}
const initialTab = getSearchParam(sp, "tab")
return (
<div className="p-6 md:p-8 space-y-6">
<ChildDetailHeader child={child} />
<ChildDetailPanel child={child} />
<ChildDetailPanel
child={child}
initialTab={initialTab}
siblingSwitcher={
<SiblingSwitcher
current={{ id: child.basicInfo.id, name: child.basicInfo.name }}
siblings={siblings}
/>
}
/>
</div>
)
}

View File

@@ -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 ParentDashboardError({ 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 ParentDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <DashboardErrorFallback error={error} reset={reset} />
}

View File

@@ -1,4 +1,4 @@
import { Stethoscope } from "lucide-react"
import { Stethoscope, AlertCircle } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
@@ -9,9 +9,29 @@ import {
ParentChildrenDataPage,
ParentNoChildrenPage,
} from "@/modules/parent/components/parent-children-data-page"
import { getUserNamesByIds } from "@/modules/users/data-access"
import { Card, CardContent } from "@/shared/components/ui/card"
export const dynamic = "force-dynamic"
/** v4-P1-9: 单个子女的诊断数据(成功) */
interface ChildDiagnosticSuccessItem {
studentId: string
studentName: string
status: "success"
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
reports: Awaited<ReturnType<typeof getDiagnosticReports>>["reports"]
}
/** v4-P1-9: 单个子女的诊断数据(失败) */
interface ChildDiagnosticErrorItem {
studentId: string
studentName: string
status: "error"
}
type ChildDiagnosticItem = ChildDiagnosticSuccessItem | ChildDiagnosticErrorItem
export default async function ParentDiagnosticPage() {
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
@@ -27,25 +47,44 @@ export default async function ParentDiagnosticPage() {
)
}
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
// 预先查询所有子女的姓名,用于错误卡片展示
const childrenIds = ctx.dataScope.childrenIds
const nameMap = await getUserNamesByIds(childrenIds)
// v4-P1-9: 使用 allSettled 容错,但保留 rejected 项渲染错误卡片
const results = await Promise.allSettled(
ctx.dataScope.childrenIds.map(async (id) => {
const [summary, reports] = await Promise.all([
childrenIds.map(async (id) => {
const [summary, reportsResult] = await Promise.all([
getStudentMasterySummary(id),
getDiagnosticReports({ studentId: id }),
// v4-P1-3: 家长仅可见已发布报告,避免草稿泄露
getDiagnosticReports(
{ studentId: id, status: "published" },
ctx.dataScope,
),
])
return { summary, reports, studentId: id }
return { summary, reports: reportsResult.reports, studentId: id }
}),
)
const validItems = results
.filter(
(r): r is PromiseFulfilledResult<{
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
reports: Awaited<ReturnType<typeof getDiagnosticReports>>
studentId: string
}> => r.status === "fulfilled",
)
.map((r) => r.value)
const items: ChildDiagnosticItem[] = results.map((r, idx) => {
const studentId = childrenIds[idx]
const studentName = nameMap.get(studentId)?.name ?? "Unknown student"
if (r.status === "fulfilled") {
return {
studentId,
studentName,
status: "success" as const,
summary: r.value.summary,
reports: r.value.reports,
}
}
// v4-P1-9: rejected 项不再静默丢弃,渲染错误卡片
return {
studentId,
studentName,
status: "error" as const,
}
})
return (
<ParentChildrenDataPage
@@ -54,15 +93,32 @@ export default async function ParentDiagnosticPage() {
icon={Stethoscope}
noRecordsTitle="No diagnostic data"
noRecordsDescription="Your children don't have any diagnostic data yet."
items={validItems}
renderItem={({ summary, reports }) => (
items={items}
renderItem={(item) => (
<>
<div className="border-b pb-2">
<h3 className="text-lg font-semibold">
{summary?.studentName ?? "Unknown student"}
</h3>
<h3 className="text-lg font-semibold">{item.studentName}</h3>
</div>
<StudentDiagnosticView summary={summary} reports={reports} />
{item.status === "success" ? (
<StudentDiagnosticView
summary={item.summary}
reports={item.reports}
practiceHrefBase={null}
/>
) : (
// v4-P1-9: 错误卡片,提示家长该子女数据加载失败
<Card className="border-destructive/50">
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
<p className="text-sm font-medium text-destructive">
Failed to load diagnostic data for {item.studentName}.
</p>
<p className="text-xs text-muted-foreground">
Please refresh the page or contact the school administrator if the problem persists.
</p>
</CardContent>
</Card>
)}
</>
)}
/>

View File

@@ -0,0 +1,23 @@
"use client"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { AlertTriangle } from "lucide-react"
export default function ParentError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="p-6 md:p-8">
<EmptyState
icon={AlertTriangle}
title="Something went wrong"
description={error.message || "An unexpected error occurred. Please try again."}
action={{ label: "Try again", onClick: reset }}
/>
</div>
)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentGradesError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="子女成绩页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-8 p-6 md:p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<CardTitle className="text-lg">
<Skeleton className="h-5 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{Array.from({ length: 4 }).map((_, j) => (
<Skeleton key={j} className="h-20 w-full" />
))}
</div>
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,16 +1,27 @@
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getStudentGradeSummary } from "@/modules/grades/data-access"
import { getClassAverageTrend } from "@/modules/grades/data-access-ranking"
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
import {
ParentChildrenDataPage,
ParentNoChildrenPage,
} from "@/modules/parent/components/parent-children-data-page"
import { ParentExportButton } from "@/modules/parent/components/parent-export-button"
import { GraduationCap } from "lucide-react"
import type { ClassAverageTrendResult } from "@/modules/grades/types"
export const dynamic = "force-dynamic"
interface ChildGradeItem {
studentId: string
summary: NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>
classAverageTrend: ClassAverageTrendResult | null
}
export default async function ParentGradesPage() {
const ctx = await getAuthContext()
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
@@ -26,26 +37,49 @@ export default async function ParentGradesPage() {
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
const results = await Promise.allSettled(
ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id)),
ctx.dataScope.childrenIds.map(async (id) => {
const [summary, classAverageTrend] = await Promise.all([
getStudentGradeSummary(id, ctx.dataScope),
// v3-P2-8家长页面补齐趋势图复用班级平均对比线
getClassAverageTrend(id, undefined, undefined, ctx.dataScope),
])
return { summary, classAverageTrend, studentId: id }
}),
)
const validSummaries = results
const validItems: ChildGradeItem[] = results
.filter(
(r): r is PromiseFulfilledResult<NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>> =>
r.status === "fulfilled" && r.value !== null,
(
r,
): r is PromiseFulfilledResult<{
summary: Awaited<ReturnType<typeof getStudentGradeSummary>>
classAverageTrend: ClassAverageTrendResult | null
studentId: string
}> => r.status === "fulfilled" && r.value.summary !== null,
)
.map((r) => r.value)
.map((r) => ({
studentId: r.value.studentId,
summary: r.value.summary as NonNullable<typeof r.value.summary>,
classAverageTrend: r.value.classAverageTrend,
}))
return (
<ParentChildrenDataPage
title="Children Grades"
description="View your children's grade records."
description="Compare grades across all your children. For single-child analysis, open the child's detail page."
icon={GraduationCap}
noRecordsTitle="No grade records"
noRecordsDescription="Your children don't have any grade records yet."
items={validSummaries}
renderItem={(summary) => (
items={validItems}
renderItem={({ studentId, summary, classAverageTrend }) => (
<>
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
<div className="flex items-center justify-between border-b pb-2">
<h3 className="text-lg font-semibold">{summary.studentName}</h3>
{/* v4-P1-12: 接入 exportGradesAction支持按 studentId 导出 */}
<ParentExportButton studentId={studentId} studentName={summary.studentName} />
</div>
{summary.records.length > 0 && (
<GradeTrendCard summary={summary} classAverageData={classAverageTrend} />
)}
<StudentGradeSummary summary={summary} />
</>
)}

View File

@@ -0,0 +1,24 @@
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>
<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,66 @@
import Link from "next/link"
import { CalendarDays, ArrowLeft, Phone, Mail } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
export const dynamic = "force-dynamic"
export default async function ParentLeavePage() {
return (
<div className="p-6 md:p-8 space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">Leave Request</h1>
<p className="text-sm text-muted-foreground">
Submit a leave request for your child.
</p>
</div>
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
<Link href="/parent/dashboard" aria-label="Back to Dashboard">
<ArrowLeft className="h-4 w-4" />
Back to Dashboard
</Link>
</Button>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
Online Leave Request
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EmptyState
icon={CalendarDays}
title="Coming soon"
description="The online leave request feature is being developed and will be available soon. For now, please contact the homeroom teacher or school office directly."
className="border-none shadow-none"
/>
<div className="rounded-md border bg-muted/30 p-4 space-y-2">
<div className="text-sm font-medium">Contact options</div>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-center gap-2">
<Phone className="h-4 w-4" aria-hidden />
<span>Call the school office during working hours</span>
</li>
<li className="flex items-center gap-2">
<Mail className="h-4 w-4" aria-hidden />
<span>Send a message to the homeroom teacher via the Messages page</span>
</li>
<li>
<Link
href="/messages"
className="inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Go to Messages
</Link>
</li>
</ul>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -6,10 +6,10 @@ import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react
import { requireAuth } from "@/shared/lib/auth-guard"
import { getUserProfile } from "@/modules/users/data-access"
import { AvatarUpload } from "@/modules/settings/components/avatar-upload"
import { ProfileStudentOverview, ProfileStudentOverviewSkeleton } from "@/modules/settings/components/profile-student-overview"
import { ProfileTeacherOverview, ProfileTeacherOverviewSkeleton } from "@/modules/settings/components/profile-teacher-overview"
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -50,18 +50,11 @@ export default async function ProfilePage(): Promise<ReactElement> {
}
/>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
{userProfile.image ? <AvatarImage src={userProfile.image} alt={userProfile.name ?? t("title")} /> : null}
<AvatarFallback className="text-xl font-semibold">
{(userProfile.name ?? userProfile.email).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<div className="text-xl font-semibold tracking-tight">{userProfile.name ?? "-"}</div>
<div className="text-sm text-muted-foreground">{userProfile.email}</div>
</div>
</div>
<AvatarUpload
currentImage={userProfile.image}
name={userProfile.name}
email={userProfile.email}
/>
<div className="grid gap-6 md:grid-cols-2">
<Card>

View File

@@ -1,16 +1,18 @@
import { redirect } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { headers } from "next/headers"
import { requireAuth } from "@/shared/lib/auth-guard"
import { SettingsView } from "@/modules/settings/components/settings-view"
import { SettingsServiceProvider } from "@/modules/settings/components/settings-service-context"
import { resolveRoleSettingsConfig } from "@/modules/settings/config/role-settings-config"
import type { SettingsService } from "@/modules/settings/types"
import {
updateProfileAction,
updateNotificationPreferencesAction,
} from "@/modules/settings/actions-service"
import { getUserProfile } from "@/modules/users/data-access"
import { updateUserProfile } from "@/modules/users/actions"
import { getNotificationPreferences } from "@/modules/notifications/preferences"
import { updateNotificationPreferencesAction } from "@/modules/messaging/actions"
import type { UpdateNotificationPreferencesInput } from "@/modules/notifications/types"
export const dynamic = "force-dynamic"
@@ -18,32 +20,6 @@ export const metadata = {
title: "Settings",
}
/**
* 将通知偏好输入对象转换为 FormData适配 updateNotificationPreferencesAction 的签名。
* Action 内部通过 formData.get(key) === "on" 解析布尔值。
*/
function buildNotificationFormData(input: UpdateNotificationPreferencesInput): FormData {
const formData = new FormData()
const booleanFields: Array<keyof UpdateNotificationPreferencesInput> = [
"emailEnabled",
"smsEnabled",
"pushEnabled",
"homeworkNotifications",
"gradeNotifications",
"announcementNotifications",
"messageNotifications",
"attendanceNotifications",
"quietHoursEnabled",
]
for (const field of booleanFields) {
const value = input[field]
if (value === true) formData.set(field, "on")
}
if (input.quietHoursStart) formData.set("quietHoursStart", input.quietHoursStart)
if (input.quietHoursEnd) formData.set("quietHoursEnd", input.quietHoursEnd)
return formData
}
export default async function SettingsPage() {
const ctx = await requireAuth()
@@ -56,22 +32,23 @@ export default async function SettingsPage() {
const notificationPrefs = await getNotificationPreferences(userId)
const t = await getTranslations("settings")
// 获取当前请求的 User-Agent用于安全中心标记当前会话
const headerList = await headers()
const currentUserAgent = headerList.get("user-agent") ?? ""
const config = resolveRoleSettingsConfig(roles)
const description = t(config?.descriptionKey ?? "title")
const backHref = config?.backHref ?? "/dashboard"
const generalExtra = config?.generalExtra
// 构建 SettingsService 实现,注入到 SettingsServiceProvider
// 组件层通过 useSettingsService() 消费,不直接 import users/messaging actions
// 构建 SettingsService:仅传递 Server Action 引用
// Next.js 要求传递给 Client Component 的函数必须是 "use server" 标记的 Server Action
const service: SettingsService = {
profile: {
getProfile: async () => getUserProfile(userId),
updateProfile: async (input) => updateUserProfile(input),
updateProfile: updateProfileAction,
},
notifications: {
getPreferences: async () => getNotificationPreferences(userId),
updatePreferences: async (input) =>
updateNotificationPreferencesAction(null, buildNotificationFormData(input)),
updatePreferences: updateNotificationPreferencesAction,
},
}
@@ -83,6 +60,7 @@ export default async function SettingsPage() {
user={userProfile}
notificationPreferences={notificationPrefs}
generalExtra={generalExtra}
currentUserAgent={currentUserAgent}
/>
</SettingsServiceProvider>
)

View 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 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.save"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,7 @@
"use client"
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
export default function StudentDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
return <DashboardErrorFallback error={error} reset={reset} />
}

View File

@@ -1,61 +1,5 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-4 w-56" />
</div>
<Skeleton className="h-10 w-40" />
</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="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-4 rounded-full" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle className="text-sm">
<Skeleton className="h-4 w-40" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card className="lg:col-span-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-sm">
<Skeleton className="h-4 w-44" />
</CardTitle>
<Skeleton className="h-9 w-24" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
export default function StudentDashboardLoading() {
return <DashboardLoadingSkeleton />
}

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function StudentDiagnosticError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="学情诊断页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { Stethoscope } from "lucide-react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
@@ -7,15 +8,20 @@ import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-d
export const dynamic = "force-dynamic"
export default async function StudentDiagnosticPage() {
const ctx = await getAuthContext()
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
const [summary, reports] = await Promise.all([
const [summary, reportsResult] = await Promise.all([
getStudentMasterySummary(ctx.userId),
getDiagnosticReports({ studentId: ctx.userId }),
// v4-P1-3: 学生仅可见已发布报告,避免草稿泄露
getDiagnosticReports(
{ studentId: ctx.userId, status: "published" },
ctx.dataScope,
),
])
const reports = reportsResult.reports
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="space-y-8">
<div>
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Stethoscope className="h-6 w-6" />

View File

@@ -0,0 +1,27 @@
"use client"
import { AlertCircle } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function StudentGradesError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="成绩查询页面加载失败"
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
action={{
label: "重试",
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -1,9 +1,13 @@
import { getTranslations } from "next-intl/server"
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 { 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 { EmptyState } from "@/shared/components/ui/empty-state"
import { UserX } from "lucide-react"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
@@ -16,21 +20,28 @@ export default async function StudentGradesPage({
searchParams: Promise<SearchParams>
}) {
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const [sp, summary] = await Promise.all([
const [sp, summary, rankingTrend, classAverageTrend, subjectOptions] = await Promise.all([
searchParams,
getStudentGradeSummary(ctx.userId),
getStudentGradeSummary(ctx.userId, ctx.dataScope),
// v3-P1-3接入排名趋势图
getRankingTrend(ctx.userId, undefined, undefined, ctx.dataScope),
// v3-P2-2接入班级平均趋势对比线
getClassAverageTrend(ctx.userId, undefined, undefined, ctx.dataScope),
// v3-P2-1获取科目列表用于过滤器
getSubjectOptions(),
])
if (!summary) {
const t = await getTranslations("grades")
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
<p className="text-muted-foreground">View your grade records.</p>
<h2 className="text-2xl font-bold tracking-tight">{t("title.myGrades")}</h2>
<p className="text-muted-foreground">{t("summary.noDataDescription")}</p>
</div>
<EmptyState
title="No user found"
description="Unable to load your student profile."
title={t("summary.noDataTitle")}
description={t("summary.noDataDescription")}
icon={UserX}
className="border-none shadow-none"
/>
@@ -46,7 +57,8 @@ export default async function StudentGradesPage({
const filteredRecords = summary.records.filter((r) => {
if (q && !r.title.toLowerCase().includes(q)) return false
if (subjectFilter !== "all" && r.subjectName !== subjectFilter) return false
// v3-P2-1 修复:按 subjectId 而非 subjectName 过滤
if (subjectFilter !== "all" && r.subjectId !== subjectFilter) return false
if (typeFilter !== "all" && r.type !== typeFilter) return false
if (semesterFilter !== "all" && r.semester !== semesterFilter) return false
return true
@@ -60,11 +72,16 @@ export default async function StudentGradesPage({
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
<p className="text-muted-foreground">View your grade records.</p>
<h2 className="text-2xl font-bold tracking-tight">{summary.studentName}</h2>
<p className="text-muted-foreground">{summary.records.length} </p>
</div>
<GradeFilters />
{filteredSummary.records.length > 0 && <GradeTrendCard summary={filteredSummary} />}
<GradeFilters subjects={subjectOptions.map((s) => ({ id: s.id, name: s.name }))} />
{filteredSummary.records.length > 0 && (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<GradeTrendCard summary={filteredSummary} classAverageData={classAverageTrend} />
<RankingTrendCard trend={rankingTrend} />
</div>
)}
<StudentGradeSummary summary={filteredSummary} />
</div>
)

View File

@@ -24,7 +24,7 @@ export default async function StudentAssignmentTakePage({
const status = data.submission?.status
if (status === "graded" || status === "submitted") {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="space-y-8">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
<div className="text-sm text-muted-foreground">
@@ -38,7 +38,7 @@ export default async function StudentAssignmentTakePage({
}
return (
<div className="flex h-full flex-col space-y-4 p-6">
<div className="space-y-4">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
<div className="text-sm text-muted-foreground">

View File

@@ -6,6 +6,7 @@ import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
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 { getCurrentStudentUser } from "@/modules/users/data-access"
import { AssignmentFilters } from "@/modules/homework/components/assignment-filters"
@@ -20,13 +21,6 @@ import {
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
const getActionLabel = (status: StudentHomeworkProgressStatus, t: (key: string) => string): string => {
switch (status) {
case "graded":

View File

@@ -3,7 +3,7 @@ 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-8">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-52" />

View File

@@ -5,16 +5,10 @@ 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 { EmptyState } from "@/shared/components/ui/empty-state"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string) => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function StudentCoursesPage({
searchParams,
}: {

View File

@@ -0,0 +1,93 @@
import Link from "next/link"
import { BookOpen, PenTool, Library, ArrowRight } from "lucide-react"
import { getStudentClasses } from "@/modules/classes/data-access"
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getCurrentStudentUser } from "@/modules/users/data-access"
import { getTextbooks } from "@/modules/textbooks/data-access"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { UserX } from "lucide-react"
export const dynamic = "force-dynamic"
export default async function StudentLearningPage() {
const student = await getCurrentStudentUser()
if (!student) {
return (
<div className="space-y-8">
<EmptyState title="No user found" description="Create a student user to see learning." icon={UserX} />
</div>
)
}
const [classes, assignments, textbooks] = await Promise.all([
getStudentClasses(student.id),
getStudentHomeworkAssignments(student.id),
getTextbooks(),
])
const now = new Date()
const pendingCount = assignments.filter((a) => a.progressStatus !== "submitted" && a.progressStatus !== "graded").length
const dueSoonCount = assignments.filter((a) => {
if (a.progressStatus === "submitted" || a.progressStatus === "graded") return false
if (!a.dueAt) return false
const due = new Date(a.dueAt)
const in7Days = new Date(now)
in7Days.setDate(in7Days.getDate() + 7)
return due >= now && due <= in7Days
}).length
const cards = [
{
title: "Courses",
description: "Your enrolled classes.",
icon: BookOpen,
href: "/student/learning/courses",
stat: `${classes.length} enrolled`,
},
{
title: "Assignments",
description: "Homework and practice.",
icon: PenTool,
href: "/student/learning/assignments",
stat: `${pendingCount} pending${dueSoonCount > 0 ? ` · ${dueSoonCount} due soon` : ""}`,
},
{
title: "Textbooks",
description: "Browse course materials.",
icon: Library,
href: "/student/learning/textbooks",
stat: `${textbooks.length} available`,
},
]
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">My Learning</h2>
<p className="text-muted-foreground">Your learning hub: courses, assignments, and textbooks.</p>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{cards.map((c) => (
<Link key={c.href} href={c.href}>
<Card className="h-full transition-all hover:shadow-md hover:border-primary/50">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-base font-medium">{c.title}</CardTitle>
<c.icon className="h-5 w-5 text-muted-foreground" />
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">{c.description}</p>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{c.stat}</span>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}

View File

@@ -3,8 +3,9 @@ import { getTranslations } from "next-intl/server"
import { BookOpen } from "lucide-react"
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
import { getSubjectLabelKey, getGradeLabelKey } from "@/modules/textbooks/constants"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getCurrentStudentUser } from "@/modules/users/data-access"
@@ -23,10 +24,9 @@ export default async function StudentTextbookDetailPage({
const { id } = await params
const [textbook, chapters, knowledgePoints] = await Promise.all([
const [textbook, chapters] = await Promise.all([
getTextbookById(id),
getChaptersByTextbookId(id),
getKnowledgePointsByTextbookId(id)
])
if (!textbook) notFound()
@@ -45,9 +45,9 @@ export default async function StudentTextbookDetailPage({
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="hidden sm:inline-block w-px h-4 bg-border" aria-hidden="true" />
<Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
<Badge variant="outline" className="font-normal text-xs">{t(`subject.${getSubjectLabelKey(textbook.subject)}`)}</Badge>
{textbook.grade && (
<Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
<Badge variant="secondary" className="font-normal text-xs">{t(`grade.${getGradeLabelKey(textbook.grade)}`)}</Badge>
)}
</div>
</div>
@@ -66,7 +66,7 @@ export default async function StudentTextbookDetailPage({
) : (
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
{/* 学生端不传 renderQuestionCreator无题目创建权限 */}
<TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
<TextbookReader key={id} chapters={chapters} textbookId={id} />
</div>
)}
</div>

View File

@@ -7,16 +7,10 @@ import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters
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"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
const getParam = (params: SearchParams, key: string): string | undefined => {
const v = params[key]
return Array.isArray(v) ? v[0] : v
}
export default async function StudentTextbooksPage({
searchParams,
}: {

View File

@@ -3,7 +3,7 @@ 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-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />

View File

@@ -5,11 +5,10 @@ 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 { EmptyState } from "@/shared/components/ui/empty-state"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
export const dynamic = "force-dynamic"
type SearchParams = { [key: string]: string | string[] | undefined }
export default async function StudentSchedulePage({
searchParams,
}: {
@@ -18,7 +17,7 @@ export default async function StudentSchedulePage({
const student = await getCurrentStudentUser()
if (!student) {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
<p className="text-muted-foreground">Your weekly timetable.</p>
@@ -34,18 +33,12 @@ export default async function StudentSchedulePage({
getStudentSchedule(student.id),
])
const classIdParam = sp.classId
const resolveClassId = (param: string | string[] | undefined): string => {
if (typeof param === "string") return param
if (Array.isArray(param)) return param[0] ?? "all"
return "all"
}
const classId = resolveClassId(classIdParam)
const classId = getParam(sp, "classId") ?? "all"
const filteredItems =
classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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="开始为您的班级录入成绩。"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ComponentTextbookReader
* 传递函数 proprenderQuestionCreator。此包装组件在 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}
/>
)
}

View File

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

View File

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