feat(app): add error/loading boundaries across all dashboard routes and new routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add admin announcements edit, audit-logs overview, curriculum-map, invitation-codes, permissions, questions, roles routes - Add admin elective detail and components, files, course-plans, users, scheduling boundaries - Add messages group-compose route - Add parent course-plans, elective, grades report-card, practice routes - Add student course-plans, elective detail, error-book dialogs, grades report-card, learning study-path, leave, schedule boundaries - Add teacher attendance report, classes boundaries, course-plans boundaries, elective, exams analytics/edit-rich/all/create/new, grades report-card, homework boundaries, leave, lesson-plans calendar - Add auth loading, onboarding loading, api cron
This commit is contained in:
@@ -1,24 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
|
||||
export default function 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" />
|
||||
}
|
||||
|
||||
@@ -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={
|
||||
|
||||
20
src/app/(dashboard)/parent/course-plans/[id]/error.tsx
Normal file
20
src/app/(dashboard)/parent/course-plans/[id]/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
23
src/app/(dashboard)/parent/course-plans/[id]/loading.tsx
Normal file
23
src/app/(dashboard)/parent/course-plans/[id]/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/app/(dashboard)/parent/course-plans/[id]/page.tsx
Normal file
45
src/app/(dashboard)/parent/course-plans/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/parent/course-plans/error.tsx
Normal file
20
src/app/(dashboard)/parent/course-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
17
src/app/(dashboard)/parent/course-plans/loading.tsx
Normal file
17
src/app/(dashboard)/parent/course-plans/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
src/app/(dashboard)/parent/course-plans/page.tsx
Normal file
45
src/app/(dashboard)/parent/course-plans/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 是客户端组件,由 AiClientProvider(dashboard 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
24
src/app/(dashboard)/parent/elective/error.tsx
Normal file
24
src/app/(dashboard)/parent/elective/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
31
src/app/(dashboard)/parent/elective/loading.tsx
Normal file
31
src/app/(dashboard)/parent/elective/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
src/app/(dashboard)/parent/elective/page.tsx
Normal file
81
src/app/(dashboard)/parent/elective/page.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
35
src/app/(dashboard)/parent/grades/report-card/error.tsx
Normal file
35
src/app/(dashboard)/parent/grades/report-card/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
src/app/(dashboard)/parent/grades/report-card/loading.tsx
Normal file
12
src/app/(dashboard)/parent/grades/report-card/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/app/(dashboard)/parent/grades/report-card/page.tsx
Normal file
116
src/app/(dashboard)/parent/grades/report-card/page.tsx
Normal 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_READ(children 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
30
src/app/(dashboard)/parent/practice/error.tsx
Normal file
30
src/app/(dashboard)/parent/practice/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/app/(dashboard)/parent/practice/loading.tsx
Normal file
33
src/app/(dashboard)/parent/practice/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
src/app/(dashboard)/parent/practice/page.tsx
Normal file
127
src/app/(dashboard)/parent/practice/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user