feat(app): add error/loading boundaries across all dashboard routes and new routes

- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes

- Add admin announcements edit, audit-logs overview, curriculum-map, invitation-codes, permissions, questions, roles routes

- Add admin elective detail and components, files, course-plans, users, scheduling boundaries

- Add messages group-compose route

- Add parent course-plans, elective, grades report-card, practice routes

- Add student course-plans, elective detail, error-book dialogs, grades report-card, learning study-path, leave, schedule boundaries

- Add teacher attendance report, classes boundaries, course-plans boundaries, elective, exams analytics/edit-rich/all/create/new, grades report-card, homework boundaries, leave, lesson-plans calendar

- Add auth loading, onboarding loading, api cron
This commit is contained in:
SpecialX
2026-07-03 10:26:25 +08:00
parent e9a5264fe7
commit 21142f9b99
280 changed files with 7137 additions and 1855 deletions

View File

@@ -1,24 +1,7 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { RouteErrorBoundary } from "@/shared/components/route-error"
export default function 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>
)
return <RouteErrorBoundary reset={reset} namespace="attendance" />
}

View File

@@ -1,7 +1,9 @@
import { getTranslations } from "next-intl/server"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
import { CalendarCheck } from "lucide-react"
import { createAttendanceReadService } from "@/modules/attendance/services/attendance-data-service"
import type { AttendanceReadService } from "@/modules/attendance/services/types"
import {
ParentChildrenDataPage,
ParentNoChildrenPage,
@@ -9,10 +11,39 @@ import {
import { ParentAttendanceWarning } from "@/modules/parent/components/parent-attendance-warning"
import { ParentAttendanceRateCard } from "@/modules/parent/components/parent-attendance-rate-card"
import { ParentAttendanceCalendar } from "@/modules/parent/components/parent-attendance-calendar"
import { CalendarCheck } from "lucide-react"
import { ParentStudentAttendanceDetail } from "@/modules/parent/components/parent-student-attendance-detail"
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
export const dynamic = "force-dynamic"
/**
* 将 attendance 模块的 StudentAttendanceSummary 映射为 parent 模块的视图模型。
* parent 模块仅消费自身类型,不依赖 attendance 内部类型变更P1-2 解耦)。
*/
function toParentSummary(
summary: Awaited<ReturnType<AttendanceReadService["getStudentSummary"]>>,
): ParentStudentAttendanceSummary | null {
if (!summary) return null
return {
studentId: summary.studentId,
studentName: summary.studentName,
stats: {
total: summary.stats.total,
present: summary.stats.present,
absent: summary.stats.absent,
late: summary.stats.late,
presentRate: summary.stats.presentRate,
},
recentRecords: summary.recentRecords.map((r) => ({
id: r.id,
date: r.date,
status: r.status,
reason: r.reason,
remark: r.remark,
})),
}
}
export default async function ParentAttendancePage() {
const t = await getTranslations("attendance")
const ctx = await getAuthContext()
@@ -29,16 +60,18 @@ export default async function ParentAttendancePage() {
)
}
// 通过接口抽象消费 attendance 数据P1-2 修复:不再直接 import data-access
const attendanceService = createAttendanceReadService(ctx.dataScope)
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
const results = await Promise.allSettled(
ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id)),
ctx.dataScope.childrenIds.map((id) => attendanceService.getStudentSummary(id)),
)
const validSummaries = results
.filter(
(r): r is PromiseFulfilledResult<NonNullable<Awaited<ReturnType<typeof getStudentAttendanceSummary>>>> =>
r.status === "fulfilled" && r.value !== null,
.map((r) =>
r.status === "fulfilled" ? toParentSummary(r.value) : null,
)
.map((r) => r.value)
.filter((s): s is ParentStudentAttendanceSummary => s !== null)
return (
<ParentChildrenDataPage
@@ -52,7 +85,7 @@ export default async function ParentAttendancePage() {
<div className="space-y-6">
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
<ParentAttendanceCalendar summary={summary} />
<StudentAttendanceView summary={summary} />
<ParentStudentAttendanceDetail summary={summary} />
</div>
)}
headerExtra={

View File

@@ -0,0 +1,20 @@
"use client"
import { useTranslations } from "next-intl"
import { ClipboardList } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentCoursePlanDetailError() {
const t = useTranslations("coursePlans")
return (
<div className="p-8">
<EmptyState
icon={ClipboardList}
title={t("errors.loadFailed")}
description={t("errors.loadFailedDesc")}
action={{ label: t("errors.retry"), onClick: () => window.location.reload() }}
className="border-none shadow-none"
/>
</div>
)
}

View File

@@ -0,0 +1,23 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ParentCoursePlanDetailLoading() {
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-[240px]" />
</div>
<Skeleton className="h-4 w-full max-w-md" />
<div className="grid gap-4 md:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-[100px] w-full" />
))}
</div>
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
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"
import { getStudentActiveClassId } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
export default async function ParentCoursePlanDetailPage({
params,
}: {
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
const { id } = await params
// 家长视角:解析所有孩子的班级 ID仅允许查看孩子所在班级的计划
let classIds: string[] = []
if (ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0) {
const results = await Promise.all(
ctx.dataScope.childrenIds.map((sid) => getStudentActiveClassId(sid))
)
classIds = results.filter((cid): cid is string => cid !== null)
}
const plan =
classIds.length > 0
? await getCoursePlanById(id, { userId: ctx.userId, isAdmin: false, classIds })
: null
if (!plan) notFound()
return (
<div className="flex h-full flex-col space-y-6 p-8">
<CoursePlanDetail
plan={plan}
backHref="/parent/course-plans"
successHref="/parent/course-plans"
/>
</div>
)
}

View File

@@ -0,0 +1,20 @@
"use client"
import { useTranslations } from "next-intl"
import { ClipboardList } from "lucide-react"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentCoursePlansError() {
const t = useTranslations("coursePlans")
return (
<div className="p-8">
<EmptyState
icon={ClipboardList}
title={t("errors.loadFailed")}
description={t("errors.loadFailedDesc")}
action={{ label: t("errors.retry"), onClick: () => window.location.reload() }}
className="border-none shadow-none"
/>
</div>
)
}

View File

@@ -0,0 +1,17 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ParentCoursePlansLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-[180px]" />
<Skeleton className="h-4 w-[300px]" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[160px] w-full" />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getCoursePlans } from "@/modules/course-plans/data-access"
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
import { getStudentActiveClassId } from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
export default async function ParentCoursePlansPage(): Promise<JSX.Element> {
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
const t = await getTranslations("coursePlans")
// 家长视角:解析所有孩子的班级 ID用于过滤课程计划
let classIds: string[] = []
if (ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0) {
const results = await Promise.all(
ctx.dataScope.childrenIds.map((sid) => getStudentActiveClassId(sid))
)
classIds = results.filter((id): id is string => id !== null)
}
const plans =
classIds.length > 0
? await getCoursePlans(
{ status: "active" },
{ userId: ctx.userId, isAdmin: false, classIds }
)
: []
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
<p className="text-muted-foreground">{t("parent.description")}</p>
</div>
<CoursePlanList
plans={plans}
detailBaseHref="/parent/course-plans"
/>
</div>
)
}

View File

@@ -5,7 +5,10 @@ import { use } from "react"
import { Users } from "lucide-react"
import { getParentDashboardAction } from "@/modules/dashboard/actions"
import { ParentDashboard } from "@/modules/parent/components/parent-dashboard"
import { ParentDashboard } from "@/modules/dashboard/components/parent-dashboard/parent-dashboard"
import { AiChildSummary } from "@/modules/ai/components/ai-child-summary"
import { ChildCard } from "@/modules/parent/components/child-card"
import { ParentAttentionBanner } from "@/modules/parent/components/parent-attention-banner"
import { ParentNoChildrenPage } from "@/modules/parent/components/parent-children-data-page"
import type { ActionState } from "@/shared/types/action-state"
import type { ParentDashboardData } from "@/modules/parent/types"
@@ -62,9 +65,67 @@ async function ParentDashboardBody({
)
}
const { parentName, children } = data
// 组合注入:子女卡片列表(移动端水平滑动 + 桌面端网格)
const childrenSlot = (
<>
<div
className="flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory sm:hidden"
aria-label={t("title.parent")}
>
{children.map((child) => (
<div key={child.basicInfo.id} className="snap-start shrink-0 w-[85%]">
<ChildCard child={child} />
</div>
))}
</div>
<div className="hidden sm:grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{children.map((child) => (
<ChildCard key={child.basicInfo.id} child={child} />
))}
</div>
</>
)
// AI 学情摘要区域:每个子女一张卡片
// AiChildSummary 是客户端组件,由 AiClientProviderdashboard layout 注入)提供数据服务
// 仅传入已知安全字段studentId / studentName / grade / homeworkCompletionRate
// recentGrades 字段因当前数据源不含 subject 信息暂不传AI 仍可基于基本信息生成摘要
const aiSummarySlot = (
<div className="space-y-4 pt-2">
<h2 className="text-lg font-semibold tracking-tight">{t("title.parent")}</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{children.map((child) => {
const { pendingCount, submittedCount, gradedCount, overdueCount } = child.homeworkSummary
const totalHomework = pendingCount + submittedCount + gradedCount + overdueCount
const homeworkCompletionRate = totalHomework > 0
? (submittedCount + gradedCount) / totalHomework
: undefined
return (
<AiChildSummary
key={child.basicInfo.id}
studentId={child.basicInfo.id}
studentName={child.basicInfo.name ?? undefined}
grade={child.basicInfo.gradeName ?? undefined}
homeworkCompletionRate={homeworkCompletionRate}
/>
)
})}
</div>
</div>
)
return (
<div className="p-6 md:p-8">
<ParentDashboard data={data} />
<ParentDashboard
parentName={parentName ?? ""}
childrenCount={children.length}
childrenSlot={childrenSlot}
attentionBannerSlot={<ParentAttentionBanner data={data} />}
aiSummarySlot={aiSummarySlot}
/>
</div>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -10,14 +11,15 @@ export default function ParentDiagnosticError({
error: Error & { digest?: string }
reset: () => void
}) {
const t = useTranslations("diagnostic")
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.parentLoadFailed")}
description={t("error.parentLoadFailedDesc")}
action={{
label: "重试",
label: t("error.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -93,8 +93,8 @@ export default async function ParentDiagnosticPage() {
title={t("parent.title")}
description={t("parent.description")}
icon={Stethoscope}
noRecordsTitle={t("parent.noReports")}
noRecordsDescription={t("parent.noReports")}
noRecordsTitle={t("parent.noReportsTitle")}
noRecordsDescription={t("parent.noRecordsDescription")}
items={items}
renderItem={(item) => (
<>
@@ -105,7 +105,7 @@ export default async function ParentDiagnosticPage() {
<StudentDiagnosticView
summary={item.summary}
reports={item.reports}
practiceHrefBase={null}
role="parent"
/>
) : (
// v4-P1-9: 错误卡片,提示家长该子女数据加载失败
@@ -113,10 +113,10 @@ export default async function ParentDiagnosticPage() {
<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">
{t("error.loadFailed")} for {item.studentName}.
{t("error.childLoadFailed", { studentName: item.studentName })}
</p>
<p className="text-xs text-muted-foreground">
{t("error.loadFailed")}. Please refresh the page or contact the school administrator if the problem persists.
{t("error.childLoadFailedDesc")}
</p>
</CardContent>
</Card>

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 ParentElectiveError({ 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.title")}
description={t("errors.description")}
action={{
label: t("actions.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,31 @@
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-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, j) => (
<Skeleton key={j} className="h-32 w-full" />
))}
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,81 @@
import { BookOpen } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import {
getChildren,
getChildBasicInfo,
} from "@/modules/parent/data-access"
import { getStudentSelections } from "@/modules/elective/data-access-selections"
import {
ParentChildrenDataPage,
ParentNoChildrenPage,
} from "@/modules/parent/components/parent-children-data-page"
import { ParentSelectionView } from "@/modules/elective/components/parent-selection-view"
export const dynamic = "force-dynamic"
interface ChildSelectionData {
studentId: string
studentName: string
selections: Awaited<ReturnType<typeof getStudentSelections>>
}
export default async function ParentElectivePage() {
const t = await getTranslations("elective")
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
const parentId = ctx.userId
// dataScope 校验:家长必须有关联子女才能查看选课信息
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<ParentNoChildrenPage
title={t("parent.title")}
description={t("parent.description")}
icon={BookOpen}
emptyTitle={t("parent.noChildrenTitle")}
emptyDescription={t("parent.noChildrenDescription")}
/>
)
}
// 并行拉取每个子女的选课记录,使用 allSettled 容错
const relations = await getChildren(parentId)
const results = await Promise.allSettled(
relations.map(async (r): Promise<ChildSelectionData> => {
// 双重校验parentId + studentId 必须存在关联关系,防止跨家庭信息泄露
const basicInfo = await getChildBasicInfo(r.studentId, r.relation)
const selections = await getStudentSelections(r.studentId)
return {
studentId: r.studentId,
studentName: basicInfo?.name ?? r.studentId,
selections,
}
})
)
const validItems = results
.filter(
(r): r is PromiseFulfilledResult<ChildSelectionData> =>
r.status === "fulfilled"
)
.map((r) => r.value)
return (
<ParentChildrenDataPage
title={t("parent.title")}
description={t("parent.description")}
icon={BookOpen}
noRecordsTitle={t("parent.noRecordsTitle")}
noRecordsDescription={t("parent.noRecordsDescription")}
items={validItems}
renderItem={(item) => (
<ParentSelectionView
selections={item.selections}
studentName={item.studentName}
/>
)}
/>
)
}

View File

@@ -7,14 +7,15 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { Progress } from "@/shared/components/ui/progress"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { formatNumber } from "@/shared/lib/utils"
import { getErrorBookStats } from "@/modules/error-book/data-access"
import {
getErrorBookStats,
getStudentNameMap,
getTopWrongQuestionsByStudentIds,
getKnowledgePointWeakness,
} from "@/modules/error-book/data-access"
} from "@/modules/error-book/data-access-analytics"
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
import { getTranslations } from "next-intl/server"
@@ -64,77 +65,85 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
{childrenIds.length === 1 ? (
// 单子女:直接展示统计卡片
<ErrorBookStatsCards stats={childStatsList[0]} />
<WidgetBoundary title={t("parent.title")} skeletonHeight={300}>
<ErrorBookStatsCards stats={childStatsList[0]} />
</WidgetBoundary>
) : (
// 多子女:每个子女一张卡片
<div className="grid gap-4 md:grid-cols-2">
{childrenIds.map((childId, idx) => {
const stats = childStatsList[idx]
const name = nameMap.get(childId) ?? t("parent.unknown")
return (
<Card key={childId}>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>{name}</span>
<Badge variant="outline">
{t("parent.mastery", { rate: formatNumber(stats.masteredRate * 100, 0) })}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-muted-foreground">{t("parent.totalErrors")}</div>
<div className="text-lg font-bold">{stats.totalCount}</div>
</div>
<div>
<div className="text-muted-foreground">{t("parent.dueReview")}</div>
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">
{stats.dueReviewCount}
<WidgetBoundary title={t("parent.title")} skeletonHeight={300}>
<div className="grid gap-4 md:grid-cols-2">
{childrenIds.map((childId, idx) => {
const stats = childStatsList[idx]
const name = nameMap.get(childId) ?? t("parent.unknown")
return (
<Card key={childId}>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>{name}</span>
<Badge variant="outline">
{t("parent.mastery", { rate: formatNumber(stats.masteredRate * 100, 0) })}
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-muted-foreground">{t("parent.totalErrors")}</div>
<div className="text-lg font-bold">{stats.totalCount}</div>
</div>
<div>
<div className="text-muted-foreground">{t("parent.dueReview")}</div>
<div className="text-lg font-bold text-rose-600 dark:text-rose-400">
{stats.dueReviewCount}
</div>
</div>
<div>
<div className="text-muted-foreground">{t("parent.newItems")}</div>
<div className="font-medium text-blue-600 dark:text-blue-400">{stats.newCount}</div>
</div>
<div>
<div className="text-muted-foreground">{t("parent.mastered")}</div>
<div className="font-medium text-emerald-600 dark:text-emerald-400">{stats.masteredCount}</div>
</div>
</div>
<div>
<div className="text-muted-foreground">{t("parent.newItems")}</div>
<div className="font-medium text-blue-600 dark:text-blue-400">{stats.newCount}</div>
</div>
<div>
<div className="text-muted-foreground">{t("parent.mastered")}</div>
<div className="font-medium text-emerald-600 dark:text-emerald-400">{stats.masteredCount}</div>
</div>
</div>
<Progress value={stats.masteredRate * 100} className="h-2" />
</CardContent>
</Card>
)
})}
</div>
<Progress value={stats.masteredRate * 100} className="h-2" />
</CardContent>
</Card>
)
})}
</div>
</WidgetBoundary>
)}
{/* 薄弱知识点 */}
{weakKps.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="text-base">{t("parent.weakPoints")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{weakKps.map((kp) => (
<div key={kp.knowledgePointId} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{kp.knowledgePointName}</span>
<span className="text-muted-foreground">
{t("parent.errorsAndMastery", { count: kp.errorCount, rate: formatNumber(kp.masteryRate * 100, 0) })}
</span>
<WidgetBoundary title={t("parent.weakPoints")} skeletonHeight={300}>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("parent.weakPoints")}</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{weakKps.map((kp) => (
<div key={kp.knowledgePointId} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{kp.knowledgePointName}</span>
<span className="text-muted-foreground">
{t("parent.errorsAndMastery", { count: kp.errorCount, rate: formatNumber(kp.masteryRate * 100, 0) })}
</span>
</div>
<Progress value={kp.masteryRate * 100} className="h-1.5" />
</div>
<Progress value={kp.masteryRate * 100} className="h-1.5" />
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
</WidgetBoundary>
) : null}
<TopWrongQuestions questions={topWrongQuestions} />
<WidgetBoundary title={t("parent.weakPoints")} skeletonHeight={300}>
<TopWrongQuestions questions={topWrongQuestions} />
</WidgetBoundary>
</div>
)
}

View File

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

View File

@@ -1,7 +1,8 @@
import type { JSX } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
export default function Loading(): JSX.Element {
return (
<div className="space-y-8 p-6 md:p-8">
<div className="space-y-2">

View File

@@ -2,15 +2,17 @@ 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 { getStudentGrowthArchive } from "@/modules/grades/data-access-analytics"
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
import { GrowthArchiveChart } from "@/modules/grades/components/growth-archive-chart"
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"
import type { ClassAverageTrendResult, StudentGrowthArchiveResult } from "@/modules/grades/types"
import { getTranslations } from "next-intl/server"
export const dynamic = "force-dynamic"
@@ -19,6 +21,7 @@ interface ChildGradeItem {
studentId: string
summary: NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>
classAverageTrend: ClassAverageTrendResult | null
growthArchive: StudentGrowthArchiveResult | null
}
export default async function ParentGradesPage() {
@@ -40,29 +43,24 @@ export default async function ParentGradesPage() {
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
const results = await Promise.allSettled(
ctx.dataScope.childrenIds.map(async (id) => {
const [summary, classAverageTrend] = await Promise.all([
const [summary, classAverageTrend, growthArchive] = await Promise.all([
getStudentGradeSummary(id, ctx.dataScope),
// v3-P2-8家长页面补齐趋势图复用班级平均对比线
getClassAverageTrend(id, undefined, undefined, ctx.dataScope),
// P3-4子女纵向成长档案
getStudentGrowthArchive(id, ctx.dataScope),
])
return { summary, classAverageTrend, studentId: id }
return { summary, classAverageTrend, growthArchive, studentId: id }
}),
)
const validItems: ChildGradeItem[] = results
.filter(
(
r,
): r is PromiseFulfilledResult<{
summary: Awaited<ReturnType<typeof getStudentGradeSummary>>
classAverageTrend: ClassAverageTrendResult | null
studentId: string
}> => r.status === "fulfilled" && r.value.summary !== null,
)
.map((r) => ({
studentId: r.value.studentId,
summary: r.value.summary as NonNullable<typeof r.value.summary>,
classAverageTrend: r.value.classAverageTrend,
}))
// P1-8 修复:用循环 + 类型守卫替代 `as` 断言
const validItems: ChildGradeItem[] = []
for (const r of results) {
if (r.status !== "fulfilled") continue
const { summary, classAverageTrend, growthArchive, studentId } = r.value
if (summary === null) continue
validItems.push({ studentId, summary, classAverageTrend, growthArchive })
}
return (
<ParentChildrenDataPage
@@ -72,7 +70,7 @@ export default async function ParentGradesPage() {
noRecordsTitle={t("parent.noGrades")}
noRecordsDescription={t("parent.noGradesDesc")}
items={validItems}
renderItem={({ studentId, summary, classAverageTrend }) => (
renderItem={({ studentId, summary, classAverageTrend, growthArchive }) => (
<>
<div className="flex items-center justify-between border-b pb-2">
<h3 className="text-lg font-semibold">{summary.studentName}</h3>
@@ -82,6 +80,8 @@ export default async function ParentGradesPage() {
{summary.records.length > 0 && (
<GradeTrendCard summary={summary} classAverageData={classAverageTrend} />
)}
{/* P3-4子女纵向成长档案 */}
<GrowthArchiveChart data={growthArchive} />
<StudentGradeSummary summary={summary} />
</>
)}

View File

@@ -0,0 +1,35 @@
"use client"
import type { JSX } from "react"
import { useEffect } from "react"
import { AlertTriangle } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { useTranslations } from "next-intl"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}): JSX.Element {
const t = useTranslations("grades")
useEffect(() => {
console.error("[ReportCard] Route error:", error)
}, [error])
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-12">
<AlertTriangle className="h-10 w-10 text-destructive" aria-hidden="true" />
<div className="text-center">
<h2 className="text-lg font-semibold">{t("reportCard.errorTitle")}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("reportCard.errorDescription")}
</p>
</div>
<Button onClick={reset} variant="outline">
{t("reportCard.retry")}
</Button>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { Loader2 } from "lucide-react"
import { getTranslations } from "next-intl/server"
export default async function Loading() {
const t = await getTranslations("grades")
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-muted-foreground">{t("reportCard.loading")}</p>
</div>
)
}

View File

@@ -0,0 +1,116 @@
import type { JSX } from "react"
import { ArrowLeft } from "lucide-react"
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import { getReportCardData } from "@/modules/grades/lib/report-card"
import { ReportCardView } from "@/modules/grades/components/report-card-view"
import { ReportCardPrintAction } from "@/modules/grades/components/report-card-print-action"
import { getAcademicYears } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
/**
* P3-1: 家长视角的子女成绩报告卡页面。
*
* 路由:/parent/grades/report-card?studentId=xxx
* 查询参数:
* - studentId: 必填,目标学生 ID必须在家长子女范围内
* - academicYearId?: 指定学年
* - semester?: "1" | "2"
*
* 权限GRADE_RECORD_READchildren scope 在 data-access 层校验子女归属)
*/
export default async function ParentReportCardPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
const t = await getTranslations("grades")
const studentId = getParam(sp, "studentId")
const academicYearIdParam = getParam(sp, "academicYearId")
const semesterParam = getParam(sp, "semester")
const academicYearId =
academicYearIdParam && academicYearIdParam !== "all"
? academicYearIdParam
: undefined
const semester =
semesterParam === "1" || semesterParam === "2" ? semesterParam : undefined
if (!studentId) {
return (
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
<Button asChild variant="ghost" size="sm" className="w-fit">
<Link href="/parent/grades">
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
{t("reportCard.backToGrades")}
</Link>
</Button>
<EmptyState
title={t("reportCard.missingStudentTitle")}
description={t("reportCard.missingStudentDescription")}
icon={ArrowLeft}
className="border-none shadow-none"
/>
</div>
)
}
const [data, academicYears] = await Promise.all([
getReportCardData(studentId, ctx.dataScope, {
academicYearId,
semester,
}),
getAcademicYears(),
])
if (!data) {
return (
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
<Button asChild variant="ghost" size="sm" className="w-fit">
<Link href="/parent/grades">
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
{t("reportCard.backToGrades")}
</Link>
</Button>
<EmptyState
title={t("reportCard.emptyTitle")}
description={t("reportCard.emptyDescription")}
icon={ArrowLeft}
className="border-none shadow-none"
/>
</div>
)
}
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex items-center justify-between gap-4">
<Button asChild variant="ghost" size="sm">
<Link href="/parent/grades">
<ArrowLeft className="mr-2 h-4 w-4" aria-hidden="true" />
{t("reportCard.backToGrades")}
</Link>
</Button>
<ReportCardPrintAction />
</div>
<ReportCardView data={data} />
<p className="text-center text-xs text-muted-foreground">
{t("reportCard.academicYearsCount", {
count: academicYears.length,
})}
</p>
</div>
)
}

View File

@@ -1,23 +1,80 @@
import Link from "next/link"
import { CalendarDays, ArrowLeft, Phone, Mail } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { ArrowLeft, CalendarDays } from "lucide-react"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getTranslations } from "next-intl/server"
import { getChildren, getChildBasicInfo } from "@/modules/parent/data-access"
import { getLeaveRequests } from "@/modules/leave-requests/data-access"
import {
LeaveRequestForm,
type ChildOption,
} from "@/modules/leave-requests/components/leave-request-form"
import { LeaveRequestList } from "@/modules/leave-requests/components/leave-request-list"
export const dynamic = "force-dynamic"
/**
* 家长在线请假页面。
*
* L-5 功能:
* - 顶部:请假申请表单(下拉选择子女,自动写入 classId
* - 底部:该家长所有子女的请假申请列表(按 dataScope=children 过滤)
*/
export default async function ParentLeavePage() {
const t = await getTranslations("leave")
const ctx = await getAuthContext()
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<div className="p-6 md:p-8 space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
<p className="text-sm text-muted-foreground">{t("description.parent")}</p>
</div>
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
{t("empty.parentDesc")}
</CardContent>
</Card>
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
<Link href="/parent/dashboard">{t("backToDashboard")}</Link>
</Button>
</div>
)
}
// 并行:子女关系列表 + 已有请假申请列表
const [relations, leaveResult] = await Promise.all([
getChildren(ctx.userId),
getLeaveRequests({
scope: ctx.dataScope,
currentUserId: ctx.userId,
page: 1,
pageSize: 50,
}),
])
// 构造子女选项(含 activeClass供表单下拉使用
const childOptions: ChildOption[] = []
for (const r of relations) {
const basic = await getChildBasicInfo(r.studentId, r.relation)
if (basic && basic.classId && basic.className) {
childOptions.push({
id: basic.id,
name: basic.name ?? "Unknown",
classId: basic.classId,
className: basic.className,
})
}
}
return (
<div className="p-6 md:p-8 space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-sm text-muted-foreground">
{t("description")}
</p>
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
<p className="text-sm text-muted-foreground">{t("description.parent")}</p>
</div>
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
@@ -27,43 +84,34 @@ export default async function ParentLeavePage() {
</Link>
</Button>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
{t("onlineLeave")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<EmptyState
icon={CalendarDays}
title={t("comingSoon")}
description={t("comingSoonDesc")}
className="border-none shadow-none"
/>
<div className="rounded-md border bg-muted/30 p-4 space-y-2">
<div className="text-sm font-medium">{t("contactOptions")}</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>{t("callOffice")}</span>
</li>
<li className="flex items-center gap-2">
<Mail className="h-4 w-4" aria-hidden />
<span>{t("sendMessage")}</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"
>
{t("goToMessages")}
</Link>
</li>
</ul>
</div>
</CardContent>
</Card>
{childOptions.length > 0 ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
{t("onlineLeave")}
</CardTitle>
</CardHeader>
<CardContent>
<LeaveRequestForm childOptions={childOptions} />
</CardContent>
</Card>
) : (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
{t("empty.parentDesc")}
</CardContent>
</Card>
)}
<div className="space-y-3">
<h2 className="text-lg font-semibold">{t("onlineLeave")}</h2>
<LeaveRequestList
items={leaveResult.items}
emptyTitle={t("empty.parentTitle")}
emptyDescription={t("empty.parentDesc")}
/>
</div>
</div>
)
}

View File

@@ -1,9 +1,12 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { getTranslations } from "next-intl/server"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { notFound } from "next/navigation"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { assertPlanInScope } from "@/modules/lesson-preparation/lib/scope-check"
import { getTextbookById, getChaptersByTextbookId, findChapterById } from "@/modules/textbooks/data-access"
import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view"
import { Skeleton } from "@/shared/components/ui/skeleton"
@@ -16,17 +19,17 @@ export default async function ParentLessonPlanViewPage({
}): Promise<JSX.Element> {
const { planId } = await params
const t = await getTranslations("lessonPreparation")
const ctx = await getAuthContext()
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ)
const plan = await getLessonPlanById(planId, ctx.userId)
if (!plan) {
return (
<div className="p-6">
<div className="rounded-md border border-outline-variant bg-surface-container-low p-4 text-on-surface-variant">
{t("readonly.notFound")}
</div>
</div>
)
if (!plan) notFound()
// V4 P0-1 修复:家长仅可查看孩子所在年级的已发布课案,防止跨年级信息泄露
try {
assertPlanInScope(plan, ctx)
} catch {
notFound()
}
if (plan.status !== "published") {
@@ -42,21 +45,14 @@ export default async function ParentLessonPlanViewPage({
let textbookTitle: string | undefined
let chapterTitle: string | undefined
if (plan.textbookId) {
const textbook = await getTextbookById(plan.textbookId)
// V4 P2-1 修复textbook 和 chapters 查询无依赖关系,改为 Promise.all 并行查询
const [textbook, chapters] = await Promise.all([
getTextbookById(plan.textbookId),
getChaptersByTextbookId(plan.textbookId),
])
textbookTitle = textbook?.title
if (plan.chapterId) {
const chapters = await getChaptersByTextbookId(plan.textbookId)
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
for (const ch of list) {
if (ch.id === plan.chapterId) return ch
if (ch.children && ch.children.length > 0) {
const found = findChapter(ch.children as typeof chapters)
if (found) return found
}
}
return undefined
}
const chapter = findChapter(chapters)
const chapter = findChapterById(chapters, plan.chapterId)
chapterTitle = chapter?.title
}
}

View File

@@ -1,17 +1,21 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { getTranslations } from "next-intl/server"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup"
import { PARENT_ROLE_CONFIG } from "@/modules/lesson-preparation/providers/lesson-plan-provider"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
export default async function ParentLessonPlansPage(): Promise<JSX.Element> {
const t = await getTranslations("lessonPreparation")
const ctx = await getAuthContext()
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ)
const [items, subjects] = await Promise.all([
getLessonPlans({ status: "published" }, ctx.dataScope, ctx.userId),
@@ -24,21 +28,24 @@ export default async function ParentLessonPlansPage(): Promise<JSX.Element> {
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
<p className="text-muted-foreground">{t("parent.description")}</p>
</div>
<Suspense
fallback={
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[180px] w-full" />
))}
</div>
}
>
<LessonPlanList
initialItems={items}
subjects={subjects}
viewMode="parent"
/>
</Suspense>
{/* P0-13 修复:包裹 LessonPlanProviderSetup注入 parent 角色配置,使筛选功能生效 */}
<LessonPlanProviderSetup roleConfig={PARENT_ROLE_CONFIG}>
<Suspense
fallback={
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[180px] w-full" />
))}
</div>
}
>
<LessonPlanList
initialItems={items}
subjects={subjects}
viewMode="parent"
/>
</Suspense>
</LessonPlanProviderSetup>
</div>
)
}

View File

@@ -0,0 +1,30 @@
"use client"
import type { JSX } from "react"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function ParentPracticeError({
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}): JSX.Element {
const t = useTranslations("practice")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("errors.pageErrorTitleParent")}
description={t("errors.pageErrorParent")}
action={{
label: t("errors.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import type { JSX } from "react"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ParentPracticeLoading(): JSX.Element {
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-72" />
</div>
{/* 默认按多子女布局骨架 */}
<div className="space-y-8">
{Array.from({ length: 2 }).map((_, i) => (
<div key={i} className="space-y-4">
<div className="border-b pb-2">
<Skeleton className="h-5 w-32" />
</div>
<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 rounded-md" />
))}
</div>
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, j) => (
<Skeleton key={j} className="h-20 w-full rounded-md" />
))}
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,127 @@
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { Target, Users } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getPracticeSessions, getPracticeStats } from "@/modules/adaptive-practice/data-access"
import { PracticeHistory } from "@/modules/adaptive-practice/components/practice-history"
import { PracticeStatsCards } from "@/modules/adaptive-practice/components/practice-stats-cards"
import { PracticeServiceProvider } from "@/modules/adaptive-practice/services/practice-service"
import type { PracticeSessionSummary, PracticeStats } from "@/modules/adaptive-practice/types"
import { getUserNamesByIds } from "@/modules/users/data-access"
export const dynamic = "force-dynamic"
interface ChildPracticeItem {
studentId: string
studentName: string
stats: PracticeStats
sessions: PracticeSessionSummary[]
}
export default async function ParentPracticePage(): Promise<JSX.Element> {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
const t = await getTranslations("practice")
// 家长 dataScope 必须为 children否则提示无关联子女
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
return (
<div className="flex h-full flex-col space-y-8 p-6 md:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
<p className="text-muted-foreground">{t("parent.description")}</p>
</div>
<EmptyState
icon={Users}
title={t("parent.noChild")}
description={t("parent.noChildDescription")}
className="h-[360px] bg-card"
/>
</div>
)
}
const childrenIds = ctx.dataScope.childrenIds
// 并行查询:姓名映射 + 每个子女的统计与历史
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
// 注意nameMapPromise 与 childResults 并行启动,但类型上分离以利于类型收窄
const nameMapPromise = getUserNamesByIds(childrenIds)
const childResults = await Promise.allSettled(
childrenIds.map(async (id) => {
const [stats, sessionsResult] = await Promise.all([
getPracticeStats(id),
getPracticeSessions(id, { pageSize: 20 }),
])
return { studentId: id, stats, sessions: sessionsResult.data }
}),
)
const nameMap = await nameMapPromise
// 过滤掉 rejected 的查询结果
const validItems: ChildPracticeItem[] = []
for (const r of childResults) {
if (r.status !== "fulfilled") continue
const { studentId, stats, sessions } = r.value
const studentName = nameMap.get(studentId)?.name ?? t("parent.noChildSelected")
validItems.push({ studentId, studentName, stats, sessions })
}
// 单子女:直接展示;多子女:按卡片分组
const isSingleChild = validItems.length === 1
return (
<PracticeServiceProvider>
<div className="flex h-full flex-col space-y-8 p-6 md:p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
<p className="text-muted-foreground">{t("parent.description")}</p>
</div>
{isSingleChild ? (
<>
<WidgetBoundary title={t("parent.stats")} skeletonHeight={160}>
<PracticeStatsCards stats={validItems[0]!.stats} />
</WidgetBoundary>
<div className="space-y-4">
<h2 className="text-lg font-semibold">{t("parent.history")}</h2>
<WidgetBoundary title={t("parent.history")} skeletonHeight={300}>
{/* 家长无会话详情页,仅展示只读卡片 */}
<PracticeHistory sessions={validItems[0]!.sessions} />
</WidgetBoundary>
</div>
</>
) : (
<div className="space-y-8">
{validItems.map((item) => (
<WidgetBoundary
key={item.studentId}
title={`${t("parent.stats")} · ${item.studentName}`}
skeletonHeight={300}
>
<div className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Target className="h-5 w-5 text-primary" aria-hidden="true" />
<h3 className="text-lg font-semibold">{item.studentName}</h3>
</div>
<PracticeStatsCards stats={item.stats} />
<div className="space-y-3">
<h4 className="text-sm font-medium text-muted-foreground">
{t("parent.history")}
</h4>
{/* 家长无会话详情页,仅展示只读卡片 */}
<PracticeHistory sessions={item.sessions} />
</div>
</div>
</WidgetBoundary>
))}
</div>
)}
</div>
</PracticeServiceProvider>
)
}