feat(app): add error/loading boundaries and update dashboard routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add dashboard-error-fallback and dashboard-loading-skeleton components - Add student/learning page, parent/leave routes, teacher textbook components - Update existing app routes across auth, dashboard, and API endpoints - Update proxy middleware and next-auth type declarations
This commit is contained in:
@@ -5,8 +5,7 @@ import { getTranslations } from "next-intl/server"
|
|||||||
|
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getAnnouncementById } from "@/modules/announcements/data-access"
|
import { getEditAnnouncementPageData } from "@/modules/announcements/data-access"
|
||||||
import { getGrades } from "@/modules/school/data-access"
|
|
||||||
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
|
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -28,10 +27,7 @@ export default async function EditAnnouncementPage({
|
|||||||
const { id } = await params
|
const { id } = await params
|
||||||
const t = await getTranslations("announcements")
|
const t = await getTranslations("announcements")
|
||||||
|
|
||||||
const [announcement, grades] = await Promise.all([
|
const { announcement, grades } = await getEditAnnouncementPageData(id)
|
||||||
getAnnouncementById(id),
|
|
||||||
getGrades(),
|
|
||||||
])
|
|
||||||
|
|
||||||
if (!announcement) notFound()
|
if (!announcement) notFound()
|
||||||
|
|
||||||
@@ -44,7 +40,7 @@ export default async function EditAnnouncementPage({
|
|||||||
<AnnouncementForm
|
<AnnouncementForm
|
||||||
mode="edit"
|
mode="edit"
|
||||||
announcement={announcement}
|
announcement={announcement}
|
||||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
grades={grades}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import { getTranslations } from "next-intl/server"
|
|||||||
|
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getAnnouncements } from "@/modules/announcements/data-access"
|
import { getAdminAnnouncementsPageData } from "@/modules/announcements/data-access"
|
||||||
import { getGrades } from "@/modules/school/data-access"
|
|
||||||
import { getAdminClasses } from "@/modules/classes/data-access"
|
|
||||||
import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
|
import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
|
||||||
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
||||||
import type { AnnouncementStatus } from "@/modules/announcements/types"
|
import type { AnnouncementStatus } from "@/modules/announcements/types"
|
||||||
@@ -34,17 +32,13 @@ export default async function AdminAnnouncementsPage({
|
|||||||
const statusParam = getSearchParam(sp, "status")
|
const statusParam = getSearchParam(sp, "status")
|
||||||
const status = isValidStatus(statusParam) ? statusParam : undefined
|
const status = isValidStatus(statusParam) ? statusParam : undefined
|
||||||
|
|
||||||
const [announcements, grades, classes] = await Promise.all([
|
const { announcements, grades, classes } = await getAdminAnnouncementsPageData(status)
|
||||||
getAnnouncements({ status }),
|
|
||||||
getGrades(),
|
|
||||||
getAdminClasses(),
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminAnnouncementsView
|
<AdminAnnouncementsView
|
||||||
announcements={announcements}
|
announcements={announcements}
|
||||||
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
|
grades={grades}
|
||||||
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
classes={classes}
|
||||||
initialStatus={status}
|
initialStatus={status}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
7
src/app/(dashboard)/admin/dashboard/error.tsx
Normal file
7
src/app/(dashboard)/admin/dashboard/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
|
||||||
|
|
||||||
|
export default function AdminDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return <DashboardErrorFallback error={error} reset={reset} />
|
||||||
|
}
|
||||||
5
src/app/(dashboard)/admin/dashboard/loading.tsx
Normal file
5
src/app/(dashboard)/admin/dashboard/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
|
||||||
|
|
||||||
|
export default function AdminDashboardLoading() {
|
||||||
|
return <DashboardLoadingSkeleton />
|
||||||
|
}
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
export default function AdminError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
export default function AdminError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={AlertCircle}
|
icon={AlertCircle}
|
||||||
title="页面加载失败"
|
title={t("error.loadFailed")}
|
||||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
description={t("error.loadFailedDesc")}
|
||||||
action={{
|
action={{
|
||||||
label: "重试",
|
label: t("error.retry"),
|
||||||
onClick: () => reset(),
|
onClick: () => reset(),
|
||||||
}}
|
}}
|
||||||
className="border-none shadow-none h-auto"
|
className="border-none shadow-none h-auto"
|
||||||
|
|||||||
27
src/app/(dashboard)/admin/school/grades/insights/error.tsx
Normal file
27
src/app/(dashboard)/admin/school/grades/insights/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function AdminGradesInsightsError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="成绩洞察页面加载失败"
|
||||||
|
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||||
|
action={{
|
||||||
|
label: "重试",
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/app/(dashboard)/admin/school/grades/insights/loading.tsx
Normal file
22
src/app/(dashboard)/admin/school/grades/insights/loading.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function AdminGradesInsightsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-4 w-96" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-32" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-80" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { BarChart3 } from "lucide-react"
|
import { BarChart3 } from "lucide-react"
|
||||||
@@ -7,13 +7,16 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
|||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getGrades } from "@/modules/school/data-access"
|
import { getGrades } from "@/modules/school/data-access"
|
||||||
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
import { getGradeHomeworkInsights } from "@/modules/classes/data-access"
|
||||||
|
import { getSchoolWideGradeSummary } from "@/modules/grades/data-access-analytics"
|
||||||
|
import { SchoolWideSummaryCard } from "@/modules/grades/components/school-wide-summary-card"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||||
import { formatDate, formatNumber, getSearchParam, type SearchParams } from "@/shared/lib/utils"
|
import { formatDate, formatNumber } from "@/shared/lib/utils"
|
||||||
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "年级作业洞察 - Next_Edu",
|
title: "年级作业洞察 - Next_Edu",
|
||||||
@@ -27,15 +30,17 @@ export default async function AdminGradeInsightsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
await requirePermission(Permissions.SCHOOL_MANAGE)
|
const ctx = await requirePermission(Permissions.SCHOOL_MANAGE)
|
||||||
const params = await searchParams
|
const params = await searchParams
|
||||||
const gradeId = getSearchParam(params, "gradeId")
|
const gradeId = getParam(params, "gradeId")
|
||||||
const selected = gradeId && gradeId !== "all" ? gradeId : ""
|
const selected = gradeId && gradeId !== "all" ? gradeId : ""
|
||||||
|
|
||||||
// grades 与 insights 无数据依赖,并行查询
|
// grades、insights、全校汇总无数据依赖,并行查询
|
||||||
const [grades, insights] = await Promise.all([
|
const [grades, insights, schoolWideSummary] = await Promise.all([
|
||||||
getGrades(),
|
getGrades(),
|
||||||
selected ? getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : Promise.resolve(null),
|
selected ? getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : Promise.resolve(null),
|
||||||
|
// v3-P2-9:管理员全校成绩汇总视图
|
||||||
|
getSchoolWideGradeSummary(ctx.dataScope),
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,6 +55,11 @@ export default async function AdminGradeInsightsPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* v3-P2-9: 全校成绩汇总视图 */}
|
||||||
|
{schoolWideSummary.grades.length > 0 && (
|
||||||
|
<SchoolWideSummaryCard summary={schoolWideSummary} />
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="shadow-none">
|
<Card className="shadow-none">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<CardTitle className="text-base">筛选</CardTitle>
|
<CardTitle className="text-base">筛选</CardTitle>
|
||||||
|
|||||||
@@ -3,5 +3,5 @@ import { redirect } from "next/navigation"
|
|||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default function AdminSchoolPage(): never {
|
export default function AdminSchoolPage(): never {
|
||||||
redirect("/admin/school/classes")
|
redirect("/admin/school/schools")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default async function AnnouncementsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<AnnouncementList
|
<AnnouncementList
|
||||||
announcements={announcements}
|
announcements={announcements}
|
||||||
detailHrefBuilder={(id) => `/announcements/${id}`}
|
detailHrefPrefix="/announcements"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,24 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return <DashboardErrorFallback error={error} reset={reset} />
|
||||||
export default function DashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
|
||||||
const t = useTranslations("dashboard")
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
|
||||||
<EmptyState
|
|
||||||
icon={AlertCircle}
|
|
||||||
title={t("error.loadFailed")}
|
|
||||||
description={t("error.loadFailedDesc")}
|
|
||||||
action={{
|
|
||||||
label: t("error.retry"),
|
|
||||||
onClick: () => reset(),
|
|
||||||
}}
|
|
||||||
className="border-none shadow-none h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,5 @@
|
|||||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
|
||||||
|
|
||||||
export default function DashboardLoading() {
|
export default function DashboardLoading() {
|
||||||
return (
|
return <DashboardLoadingSkeleton />
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<Skeleton className="h-4 w-64" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
<Skeleton className="mt-2 h-3 w-28" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-32" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-12 w-full" />
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/app/(dashboard)/management/grade/classes/error.tsx
Normal file
27
src/app/(dashboard)/management/grade/classes/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function ManagementGradeClassesError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="年级班级页面加载失败"
|
||||||
|
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||||
|
action={{
|
||||||
|
label: "重试",
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/app/(dashboard)/management/grade/classes/loading.tsx
Normal file
24
src/app/(dashboard)/management/grade/classes/loading.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function ManagementGradeClassesLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/app/(dashboard)/management/grade/error.tsx
Normal file
22
src/app/(dashboard)/management/grade/error.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function ManagementGradeError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="页面加载失败"
|
||||||
|
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||||
|
action={{
|
||||||
|
label: "重试",
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/app/(dashboard)/management/grade/insights/error.tsx
Normal file
27
src/app/(dashboard)/management/grade/insights/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function ManagementGradeInsightsError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="年级成绩洞察页面加载失败"
|
||||||
|
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||||
|
action={{
|
||||||
|
label: "重试",
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/app/(dashboard)/management/grade/insights/loading.tsx
Normal file
38
src/app/(dashboard)/management/grade/insights/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function ManagementGradeInsightsLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-28" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
src/app/(dashboard)/management/grade/loading.tsx
Normal file
38
src/app/(dashboard)/management/grade/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function ManagementGradeLoading() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-28" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import type { Metadata } from "next"
|
import type { Metadata } from "next"
|
||||||
|
import type { JSX } from "react"
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|||||||
24
src/app/(dashboard)/parent/attendance/error.tsx
Normal file
24
src/app/(dashboard)/parent/attendance/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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/app/(dashboard)/parent/attendance/loading.tsx
Normal file
32
src/app/(dashboard)/parent/attendance/loading.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 p-6 md:p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, j) => (
|
||||||
|
<Skeleton key={j} className="h-20 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
src/app/(dashboard)/parent/children/[studentId]/loading.tsx
Normal file
45
src/app/(dashboard)/parent/children/[studentId]/loading.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Skeleton className="h-16 w-16 rounded-full" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-7 w-40" />
|
||||||
|
<Skeleton className="h-4 w-56" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Skeleton className="h-9 w-full max-w-md" />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card className="lg:col-span-2">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
import { verifyParentChildRelation, getChildDashboardData } from "@/modules/parent/data-access"
|
import { getSearchParam } from "@/shared/lib/utils"
|
||||||
|
import {
|
||||||
|
verifyParentChildRelation,
|
||||||
|
getChildDashboardData,
|
||||||
|
getChildNameList,
|
||||||
|
} from "@/modules/parent/data-access"
|
||||||
import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header"
|
import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header"
|
||||||
import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel"
|
import {
|
||||||
|
ChildDetailPanel,
|
||||||
|
SiblingSwitcher,
|
||||||
|
} from "@/modules/parent/components/child-detail-panel"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { ShieldAlert } from "lucide-react"
|
import { ShieldAlert } from "lucide-react"
|
||||||
|
|
||||||
@@ -11,10 +19,13 @@ export const dynamic = "force-dynamic"
|
|||||||
|
|
||||||
export default async function ChildDetailPage({
|
export default async function ChildDetailPage({
|
||||||
params,
|
params,
|
||||||
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ studentId: string }>
|
params: Promise<{ studentId: string }>
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
|
||||||
}) {
|
}) {
|
||||||
const { studentId } = await params
|
const { studentId } = await params
|
||||||
|
const sp = await searchParams
|
||||||
const ctx = await requireAuth()
|
const ctx = await requireAuth()
|
||||||
|
|
||||||
// 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露)
|
// 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露)
|
||||||
@@ -38,15 +49,29 @@ export default async function ChildDetailPage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = await getChildDashboardData(studentId, relation)
|
const [child, siblings] = await Promise.all([
|
||||||
|
getChildDashboardData(studentId, relation),
|
||||||
|
getChildNameList(ctx.userId),
|
||||||
|
])
|
||||||
if (!child) {
|
if (!child) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const initialTab = getSearchParam(sp, "tab")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-8 space-y-6">
|
<div className="p-6 md:p-8 space-y-6">
|
||||||
<ChildDetailHeader child={child} />
|
<ChildDetailHeader child={child} />
|
||||||
<ChildDetailPanel child={child} />
|
<ChildDetailPanel
|
||||||
|
child={child}
|
||||||
|
initialTab={initialTab}
|
||||||
|
siblingSwitcher={
|
||||||
|
<SiblingSwitcher
|
||||||
|
current={{ id: child.basicInfo.id, name: child.basicInfo.name }}
|
||||||
|
siblings={siblings}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
export default function ParentDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return <DashboardErrorFallback error={error} reset={reset} />
|
||||||
export default function ParentDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
|
||||||
const t = useTranslations("dashboard")
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
|
||||||
<EmptyState
|
|
||||||
icon={AlertCircle}
|
|
||||||
title={t("error.loadFailed")}
|
|
||||||
description={t("error.loadFailedDesc")}
|
|
||||||
action={{
|
|
||||||
label: t("error.retry"),
|
|
||||||
onClick: () => reset(),
|
|
||||||
}}
|
|
||||||
className="border-none shadow-none h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Stethoscope } from "lucide-react"
|
import { Stethoscope, AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
@@ -9,9 +9,29 @@ import {
|
|||||||
ParentChildrenDataPage,
|
ParentChildrenDataPage,
|
||||||
ParentNoChildrenPage,
|
ParentNoChildrenPage,
|
||||||
} from "@/modules/parent/components/parent-children-data-page"
|
} from "@/modules/parent/components/parent-children-data-page"
|
||||||
|
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||||
|
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
/** v4-P1-9: 单个子女的诊断数据(成功) */
|
||||||
|
interface ChildDiagnosticSuccessItem {
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
status: "success"
|
||||||
|
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
|
||||||
|
reports: Awaited<ReturnType<typeof getDiagnosticReports>>["reports"]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v4-P1-9: 单个子女的诊断数据(失败) */
|
||||||
|
interface ChildDiagnosticErrorItem {
|
||||||
|
studentId: string
|
||||||
|
studentName: string
|
||||||
|
status: "error"
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChildDiagnosticItem = ChildDiagnosticSuccessItem | ChildDiagnosticErrorItem
|
||||||
|
|
||||||
export default async function ParentDiagnosticPage() {
|
export default async function ParentDiagnosticPage() {
|
||||||
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||||
|
|
||||||
@@ -27,25 +47,44 @@ export default async function ParentDiagnosticPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
|
// 预先查询所有子女的姓名,用于错误卡片展示
|
||||||
|
const childrenIds = ctx.dataScope.childrenIds
|
||||||
|
const nameMap = await getUserNamesByIds(childrenIds)
|
||||||
|
|
||||||
|
// v4-P1-9: 使用 allSettled 容错,但保留 rejected 项渲染错误卡片
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
ctx.dataScope.childrenIds.map(async (id) => {
|
childrenIds.map(async (id) => {
|
||||||
const [summary, reports] = await Promise.all([
|
const [summary, reportsResult] = await Promise.all([
|
||||||
getStudentMasterySummary(id),
|
getStudentMasterySummary(id),
|
||||||
getDiagnosticReports({ studentId: id }),
|
// v4-P1-3: 家长仅可见已发布报告,避免草稿泄露
|
||||||
|
getDiagnosticReports(
|
||||||
|
{ studentId: id, status: "published" },
|
||||||
|
ctx.dataScope,
|
||||||
|
),
|
||||||
])
|
])
|
||||||
return { summary, reports, studentId: id }
|
return { summary, reports: reportsResult.reports, studentId: id }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const validItems = results
|
|
||||||
.filter(
|
const items: ChildDiagnosticItem[] = results.map((r, idx) => {
|
||||||
(r): r is PromiseFulfilledResult<{
|
const studentId = childrenIds[idx]
|
||||||
summary: Awaited<ReturnType<typeof getStudentMasterySummary>>
|
const studentName = nameMap.get(studentId)?.name ?? "Unknown student"
|
||||||
reports: Awaited<ReturnType<typeof getDiagnosticReports>>
|
if (r.status === "fulfilled") {
|
||||||
studentId: string
|
return {
|
||||||
}> => r.status === "fulfilled",
|
studentId,
|
||||||
)
|
studentName,
|
||||||
.map((r) => r.value)
|
status: "success" as const,
|
||||||
|
summary: r.value.summary,
|
||||||
|
reports: r.value.reports,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v4-P1-9: rejected 项不再静默丢弃,渲染错误卡片
|
||||||
|
return {
|
||||||
|
studentId,
|
||||||
|
studentName,
|
||||||
|
status: "error" as const,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParentChildrenDataPage
|
<ParentChildrenDataPage
|
||||||
@@ -54,15 +93,32 @@ export default async function ParentDiagnosticPage() {
|
|||||||
icon={Stethoscope}
|
icon={Stethoscope}
|
||||||
noRecordsTitle="No diagnostic data"
|
noRecordsTitle="No diagnostic data"
|
||||||
noRecordsDescription="Your children don't have any diagnostic data yet."
|
noRecordsDescription="Your children don't have any diagnostic data yet."
|
||||||
items={validItems}
|
items={items}
|
||||||
renderItem={({ summary, reports }) => (
|
renderItem={(item) => (
|
||||||
<>
|
<>
|
||||||
<div className="border-b pb-2">
|
<div className="border-b pb-2">
|
||||||
<h3 className="text-lg font-semibold">
|
<h3 className="text-lg font-semibold">{item.studentName}</h3>
|
||||||
{summary?.studentName ?? "Unknown student"}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<StudentDiagnosticView summary={summary} reports={reports} />
|
{item.status === "success" ? (
|
||||||
|
<StudentDiagnosticView
|
||||||
|
summary={item.summary}
|
||||||
|
reports={item.reports}
|
||||||
|
practiceHrefBase={null}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// v4-P1-9: 错误卡片,提示家长该子女数据加载失败
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Failed to load diagnostic data for {item.studentName}.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Please refresh the page or contact the school administrator if the problem persists.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
23
src/app/(dashboard)/parent/error.tsx
Normal file
23
src/app/(dashboard)/parent/error.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { AlertTriangle } from "lucide-react"
|
||||||
|
|
||||||
|
export default function ParentError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertTriangle}
|
||||||
|
title="Something went wrong"
|
||||||
|
description={error.message || "An unexpected error occurred. Please try again."}
|
||||||
|
action={{ label: "Try again", onClick: reset }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
src/app/(dashboard)/parent/grades/error.tsx
Normal file
27
src/app/(dashboard)/parent/grades/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function ParentGradesError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="子女成绩页面加载失败"
|
||||||
|
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||||
|
action={{
|
||||||
|
label: "重试",
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/app/(dashboard)/parent/grades/loading.tsx
Normal file
32
src/app/(dashboard)/parent/grades/loading.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8 p-6 md:p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{Array.from({ length: 2 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, j) => (
|
||||||
|
<Skeleton key={j} className="h-20 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,27 @@
|
|||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||||
|
import { getClassAverageTrend } from "@/modules/grades/data-access-ranking"
|
||||||
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||||
|
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
|
||||||
import {
|
import {
|
||||||
ParentChildrenDataPage,
|
ParentChildrenDataPage,
|
||||||
ParentNoChildrenPage,
|
ParentNoChildrenPage,
|
||||||
} from "@/modules/parent/components/parent-children-data-page"
|
} from "@/modules/parent/components/parent-children-data-page"
|
||||||
|
import { ParentExportButton } from "@/modules/parent/components/parent-export-button"
|
||||||
import { GraduationCap } from "lucide-react"
|
import { GraduationCap } from "lucide-react"
|
||||||
|
import type { ClassAverageTrendResult } from "@/modules/grades/types"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
interface ChildGradeItem {
|
||||||
|
studentId: string
|
||||||
|
summary: NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>
|
||||||
|
classAverageTrend: ClassAverageTrendResult | null
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ParentGradesPage() {
|
export default async function ParentGradesPage() {
|
||||||
const ctx = await getAuthContext()
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
|
|
||||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -26,26 +37,49 @@ export default async function ParentGradesPage() {
|
|||||||
|
|
||||||
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
|
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id)),
|
ctx.dataScope.childrenIds.map(async (id) => {
|
||||||
|
const [summary, classAverageTrend] = await Promise.all([
|
||||||
|
getStudentGradeSummary(id, ctx.dataScope),
|
||||||
|
// v3-P2-8:家长页面补齐趋势图,复用班级平均对比线
|
||||||
|
getClassAverageTrend(id, undefined, undefined, ctx.dataScope),
|
||||||
|
])
|
||||||
|
return { summary, classAverageTrend, studentId: id }
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
const validSummaries = results
|
const validItems: ChildGradeItem[] = results
|
||||||
.filter(
|
.filter(
|
||||||
(r): r is PromiseFulfilledResult<NonNullable<Awaited<ReturnType<typeof getStudentGradeSummary>>>> =>
|
(
|
||||||
r.status === "fulfilled" && r.value !== null,
|
r,
|
||||||
|
): r is PromiseFulfilledResult<{
|
||||||
|
summary: Awaited<ReturnType<typeof getStudentGradeSummary>>
|
||||||
|
classAverageTrend: ClassAverageTrendResult | null
|
||||||
|
studentId: string
|
||||||
|
}> => r.status === "fulfilled" && r.value.summary !== null,
|
||||||
)
|
)
|
||||||
.map((r) => r.value)
|
.map((r) => ({
|
||||||
|
studentId: r.value.studentId,
|
||||||
|
summary: r.value.summary as NonNullable<typeof r.value.summary>,
|
||||||
|
classAverageTrend: r.value.classAverageTrend,
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ParentChildrenDataPage
|
<ParentChildrenDataPage
|
||||||
title="Children Grades"
|
title="Children Grades"
|
||||||
description="View your children's grade records."
|
description="Compare grades across all your children. For single-child analysis, open the child's detail page."
|
||||||
icon={GraduationCap}
|
icon={GraduationCap}
|
||||||
noRecordsTitle="No grade records"
|
noRecordsTitle="No grade records"
|
||||||
noRecordsDescription="Your children don't have any grade records yet."
|
noRecordsDescription="Your children don't have any grade records yet."
|
||||||
items={validSummaries}
|
items={validItems}
|
||||||
renderItem={(summary) => (
|
renderItem={({ studentId, summary, classAverageTrend }) => (
|
||||||
<>
|
<>
|
||||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
<div className="flex items-center justify-between border-b pb-2">
|
||||||
|
<h3 className="text-lg font-semibold">{summary.studentName}</h3>
|
||||||
|
{/* v4-P1-12: 接入 exportGradesAction,支持按 studentId 导出 */}
|
||||||
|
<ParentExportButton studentId={studentId} studentName={summary.studentName} />
|
||||||
|
</div>
|
||||||
|
{summary.records.length > 0 && (
|
||||||
|
<GradeTrendCard summary={summary} classAverageData={classAverageTrend} />
|
||||||
|
)}
|
||||||
<StudentGradeSummary summary={summary} />
|
<StudentGradeSummary summary={summary} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
24
src/app/(dashboard)/parent/leave/loading.tsx
Normal file
24
src/app/(dashboard)/parent/leave/loading.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
<Skeleton className="h-4 w-40" />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-24 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
src/app/(dashboard)/parent/leave/page.tsx
Normal file
66
src/app/(dashboard)/parent/leave/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { CalendarDays, ArrowLeft, Phone, Mail } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function ParentLeavePage() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 md:p-8 space-y-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Leave Request</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Submit a leave request for your child.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button asChild variant="ghost" size="sm" className="gap-2 -ml-2">
|
||||||
|
<Link href="/parent/dashboard" aria-label="Back to Dashboard">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<CalendarDays className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
Online Leave Request
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<EmptyState
|
||||||
|
icon={CalendarDays}
|
||||||
|
title="Coming soon"
|
||||||
|
description="The online leave request feature is being developed and will be available soon. For now, please contact the homeroom teacher or school office directly."
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
<div className="rounded-md border bg-muted/30 p-4 space-y-2">
|
||||||
|
<div className="text-sm font-medium">Contact options</div>
|
||||||
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Phone className="h-4 w-4" aria-hidden />
|
||||||
|
<span>Call the school office during working hours</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Mail className="h-4 w-4" aria-hidden />
|
||||||
|
<span>Send a message to the homeroom teacher via the Messages page</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
href="/messages"
|
||||||
|
className="inline-flex h-9 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
|
Go to Messages
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@ import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react
|
|||||||
|
|
||||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
import { getUserProfile } from "@/modules/users/data-access"
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
|
import { AvatarUpload } from "@/modules/settings/components/avatar-upload"
|
||||||
import { ProfileStudentOverview, ProfileStudentOverviewSkeleton } from "@/modules/settings/components/profile-student-overview"
|
import { ProfileStudentOverview, ProfileStudentOverviewSkeleton } from "@/modules/settings/components/profile-student-overview"
|
||||||
import { ProfileTeacherOverview, ProfileTeacherOverviewSkeleton } from "@/modules/settings/components/profile-teacher-overview"
|
import { ProfileTeacherOverview, ProfileTeacherOverviewSkeleton } from "@/modules/settings/components/profile-teacher-overview"
|
||||||
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
|
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
@@ -50,18 +50,11 @@ export default async function ProfilePage(): Promise<ReactElement> {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<AvatarUpload
|
||||||
<Avatar className="h-20 w-20">
|
currentImage={userProfile.image}
|
||||||
{userProfile.image ? <AvatarImage src={userProfile.image} alt={userProfile.name ?? t("title")} /> : null}
|
name={userProfile.name}
|
||||||
<AvatarFallback className="text-xl font-semibold">
|
email={userProfile.email}
|
||||||
{(userProfile.name ?? userProfile.email).slice(0, 2).toUpperCase()}
|
/>
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xl font-semibold tracking-tight">{userProfile.name ?? "-"}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{userProfile.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { redirect } from "next/navigation"
|
import { redirect } from "next/navigation"
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { headers } from "next/headers"
|
||||||
|
|
||||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
import { SettingsView } from "@/modules/settings/components/settings-view"
|
import { SettingsView } from "@/modules/settings/components/settings-view"
|
||||||
import { SettingsServiceProvider } from "@/modules/settings/components/settings-service-context"
|
import { SettingsServiceProvider } from "@/modules/settings/components/settings-service-context"
|
||||||
import { resolveRoleSettingsConfig } from "@/modules/settings/config/role-settings-config"
|
import { resolveRoleSettingsConfig } from "@/modules/settings/config/role-settings-config"
|
||||||
import type { SettingsService } from "@/modules/settings/types"
|
import type { SettingsService } from "@/modules/settings/types"
|
||||||
|
import {
|
||||||
|
updateProfileAction,
|
||||||
|
updateNotificationPreferencesAction,
|
||||||
|
} from "@/modules/settings/actions-service"
|
||||||
import { getUserProfile } from "@/modules/users/data-access"
|
import { getUserProfile } from "@/modules/users/data-access"
|
||||||
import { updateUserProfile } from "@/modules/users/actions"
|
|
||||||
import { getNotificationPreferences } from "@/modules/notifications/preferences"
|
import { getNotificationPreferences } from "@/modules/notifications/preferences"
|
||||||
import { updateNotificationPreferencesAction } from "@/modules/messaging/actions"
|
|
||||||
import type { UpdateNotificationPreferencesInput } from "@/modules/notifications/types"
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -18,32 +20,6 @@ export const metadata = {
|
|||||||
title: "Settings",
|
title: "Settings",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 将通知偏好输入对象转换为 FormData,适配 updateNotificationPreferencesAction 的签名。
|
|
||||||
* Action 内部通过 formData.get(key) === "on" 解析布尔值。
|
|
||||||
*/
|
|
||||||
function buildNotificationFormData(input: UpdateNotificationPreferencesInput): FormData {
|
|
||||||
const formData = new FormData()
|
|
||||||
const booleanFields: Array<keyof UpdateNotificationPreferencesInput> = [
|
|
||||||
"emailEnabled",
|
|
||||||
"smsEnabled",
|
|
||||||
"pushEnabled",
|
|
||||||
"homeworkNotifications",
|
|
||||||
"gradeNotifications",
|
|
||||||
"announcementNotifications",
|
|
||||||
"messageNotifications",
|
|
||||||
"attendanceNotifications",
|
|
||||||
"quietHoursEnabled",
|
|
||||||
]
|
|
||||||
for (const field of booleanFields) {
|
|
||||||
const value = input[field]
|
|
||||||
if (value === true) formData.set(field, "on")
|
|
||||||
}
|
|
||||||
if (input.quietHoursStart) formData.set("quietHoursStart", input.quietHoursStart)
|
|
||||||
if (input.quietHoursEnd) formData.set("quietHoursEnd", input.quietHoursEnd)
|
|
||||||
return formData
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const ctx = await requireAuth()
|
const ctx = await requireAuth()
|
||||||
|
|
||||||
@@ -56,22 +32,23 @@ export default async function SettingsPage() {
|
|||||||
const notificationPrefs = await getNotificationPreferences(userId)
|
const notificationPrefs = await getNotificationPreferences(userId)
|
||||||
const t = await getTranslations("settings")
|
const t = await getTranslations("settings")
|
||||||
|
|
||||||
|
// 获取当前请求的 User-Agent,用于安全中心标记当前会话
|
||||||
|
const headerList = await headers()
|
||||||
|
const currentUserAgent = headerList.get("user-agent") ?? ""
|
||||||
|
|
||||||
const config = resolveRoleSettingsConfig(roles)
|
const config = resolveRoleSettingsConfig(roles)
|
||||||
const description = t(config?.descriptionKey ?? "title")
|
const description = t(config?.descriptionKey ?? "title")
|
||||||
const backHref = config?.backHref ?? "/dashboard"
|
const backHref = config?.backHref ?? "/dashboard"
|
||||||
const generalExtra = config?.generalExtra
|
const generalExtra = config?.generalExtra
|
||||||
|
|
||||||
// 构建 SettingsService 实现,注入到 SettingsServiceProvider
|
// 构建 SettingsService:仅传递 Server Action 引用
|
||||||
// 组件层通过 useSettingsService() 消费,不直接 import users/messaging actions
|
// (Next.js 要求传递给 Client Component 的函数必须是 "use server" 标记的 Server Action)
|
||||||
const service: SettingsService = {
|
const service: SettingsService = {
|
||||||
profile: {
|
profile: {
|
||||||
getProfile: async () => getUserProfile(userId),
|
updateProfile: updateProfileAction,
|
||||||
updateProfile: async (input) => updateUserProfile(input),
|
|
||||||
},
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
getPreferences: async () => getNotificationPreferences(userId),
|
updatePreferences: updateNotificationPreferencesAction,
|
||||||
updatePreferences: async (input) =>
|
|
||||||
updateNotificationPreferencesAction(null, buildNotificationFormData(input)),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +60,7 @@ export default async function SettingsPage() {
|
|||||||
user={userProfile}
|
user={userProfile}
|
||||||
notificationPreferences={notificationPrefs}
|
notificationPreferences={notificationPrefs}
|
||||||
generalExtra={generalExtra}
|
generalExtra={generalExtra}
|
||||||
|
currentUserAgent={currentUserAgent}
|
||||||
/>
|
/>
|
||||||
</SettingsServiceProvider>
|
</SettingsServiceProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
24
src/app/(dashboard)/student/attendance/error.tsx
Normal file
24
src/app/(dashboard)/student/attendance/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 StudentAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("attendance")
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title={t("errors.unexpected")}
|
||||||
|
description={t("errors.unexpected")}
|
||||||
|
action={{
|
||||||
|
label: t("actions.save"),
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/app/(dashboard)/student/dashboard/error.tsx
Normal file
7
src/app/(dashboard)/student/dashboard/error.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
|
||||||
|
|
||||||
|
export default function StudentDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return <DashboardErrorFallback error={error} reset={reset} />
|
||||||
|
}
|
||||||
@@ -1,61 +1,5 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
|
||||||
|
|
||||||
export default function Loading() {
|
export default function StudentDashboardLoading() {
|
||||||
return (
|
return <DashboardLoadingSkeleton />
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-9 w-48" />
|
|
||||||
<Skeleton className="h-4 w-56" />
|
|
||||||
</div>
|
|
||||||
<Skeleton className="h-10 w-40" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
<Skeleton className="h-4 w-4 rounded-full" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
<Skeleton className="mt-2 h-3 w-28" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
|
||||||
<Card className="lg:col-span-3">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-sm">
|
|
||||||
<Skeleton className="h-4 w-40" />
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="lg:col-span-4">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-sm">
|
|
||||||
<Skeleton className="h-4 w-44" />
|
|
||||||
</CardTitle>
|
|
||||||
<Skeleton className="h-9 w-24" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-2">
|
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-10 w-full" />
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
27
src/app/(dashboard)/student/diagnostic/error.tsx
Normal file
27
src/app/(dashboard)/student/diagnostic/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function StudentDiagnosticError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="学情诊断页面加载失败"
|
||||||
|
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||||
|
action={{
|
||||||
|
label: "重试",
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Stethoscope } from "lucide-react"
|
import { Stethoscope } from "lucide-react"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
|
import { getStudentMasterySummary } from "@/modules/diagnostic/data-access"
|
||||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||||
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
||||||
@@ -7,15 +8,20 @@ import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-d
|
|||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function StudentDiagnosticPage() {
|
export default async function StudentDiagnosticPage() {
|
||||||
const ctx = await getAuthContext()
|
const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ)
|
||||||
|
|
||||||
const [summary, reports] = await Promise.all([
|
const [summary, reportsResult] = await Promise.all([
|
||||||
getStudentMasterySummary(ctx.userId),
|
getStudentMasterySummary(ctx.userId),
|
||||||
getDiagnosticReports({ studentId: ctx.userId }),
|
// v4-P1-3: 学生仅可见已发布报告,避免草稿泄露
|
||||||
|
getDiagnosticReports(
|
||||||
|
{ studentId: ctx.userId, status: "published" },
|
||||||
|
ctx.dataScope,
|
||||||
|
),
|
||||||
])
|
])
|
||||||
|
const reports = reportsResult.reports
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
<Stethoscope className="h-6 w-6" />
|
<Stethoscope className="h-6 w-6" />
|
||||||
|
|||||||
27
src/app/(dashboard)/student/grades/error.tsx
Normal file
27
src/app/(dashboard)/student/grades/error.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function StudentGradesError({
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string }
|
||||||
|
reset: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="成绩查询页面加载失败"
|
||||||
|
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||||
|
action={{
|
||||||
|
label: "重试",
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||||
|
import { getRankingTrend, getClassAverageTrend } from "@/modules/grades/data-access-ranking"
|
||||||
|
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||||
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary"
|
||||||
import { GradeFilters } from "@/modules/grades/components/grade-filters"
|
import { GradeFilters } from "@/modules/grades/components/grade-filters"
|
||||||
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
|
import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card"
|
||||||
|
import { RankingTrendCard } from "@/modules/grades/components/ranking-trend-card"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { UserX } from "lucide-react"
|
import { UserX } from "lucide-react"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
@@ -16,21 +20,28 @@ export default async function StudentGradesPage({
|
|||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}) {
|
}) {
|
||||||
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
const [sp, summary] = await Promise.all([
|
const [sp, summary, rankingTrend, classAverageTrend, subjectOptions] = await Promise.all([
|
||||||
searchParams,
|
searchParams,
|
||||||
getStudentGradeSummary(ctx.userId),
|
getStudentGradeSummary(ctx.userId, ctx.dataScope),
|
||||||
|
// v3-P1-3:接入排名趋势图
|
||||||
|
getRankingTrend(ctx.userId, undefined, undefined, ctx.dataScope),
|
||||||
|
// v3-P2-2:接入班级平均趋势对比线
|
||||||
|
getClassAverageTrend(ctx.userId, undefined, undefined, ctx.dataScope),
|
||||||
|
// v3-P2-1:获取科目列表用于过滤器
|
||||||
|
getSubjectOptions(),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!summary) {
|
if (!summary) {
|
||||||
|
const t = await getTranslations("grades")
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{t("title.myGrades")}</h2>
|
||||||
<p className="text-muted-foreground">View your grade records.</p>
|
<p className="text-muted-foreground">{t("summary.noDataDescription")}</p>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No user found"
|
title={t("summary.noDataTitle")}
|
||||||
description="Unable to load your student profile."
|
description={t("summary.noDataDescription")}
|
||||||
icon={UserX}
|
icon={UserX}
|
||||||
className="border-none shadow-none"
|
className="border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
@@ -46,7 +57,8 @@ export default async function StudentGradesPage({
|
|||||||
|
|
||||||
const filteredRecords = summary.records.filter((r) => {
|
const filteredRecords = summary.records.filter((r) => {
|
||||||
if (q && !r.title.toLowerCase().includes(q)) return false
|
if (q && !r.title.toLowerCase().includes(q)) return false
|
||||||
if (subjectFilter !== "all" && r.subjectName !== subjectFilter) return false
|
// v3-P2-1 修复:按 subjectId 而非 subjectName 过滤
|
||||||
|
if (subjectFilter !== "all" && r.subjectId !== subjectFilter) return false
|
||||||
if (typeFilter !== "all" && r.type !== typeFilter) return false
|
if (typeFilter !== "all" && r.type !== typeFilter) return false
|
||||||
if (semesterFilter !== "all" && r.semester !== semesterFilter) return false
|
if (semesterFilter !== "all" && r.semester !== semesterFilter) return false
|
||||||
return true
|
return true
|
||||||
@@ -60,11 +72,16 @@ export default async function StudentGradesPage({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">My Grades</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{summary.studentName}</h2>
|
||||||
<p className="text-muted-foreground">View your grade records.</p>
|
<p className="text-muted-foreground">{summary.records.length} 条成绩记录</p>
|
||||||
</div>
|
</div>
|
||||||
<GradeFilters />
|
<GradeFilters subjects={subjectOptions.map((s) => ({ id: s.id, name: s.name }))} />
|
||||||
{filteredSummary.records.length > 0 && <GradeTrendCard summary={filteredSummary} />}
|
{filteredSummary.records.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<GradeTrendCard summary={filteredSummary} classAverageData={classAverageTrend} />
|
||||||
|
<RankingTrendCard trend={rankingTrend} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<StudentGradeSummary summary={filteredSummary} />
|
<StudentGradeSummary summary={filteredSummary} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default async function StudentAssignmentTakePage({
|
|||||||
const status = data.submission?.status
|
const status = data.submission?.status
|
||||||
if (status === "graded" || status === "submitted") {
|
if (status === "graded" || status === "submitted") {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
@@ -38,7 +38,7 @@ export default async function StudentAssignmentTakePage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-4 p-6">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
<h2 className="text-2xl font-bold tracking-tight">{data.assignment.title}</h2>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from "@/shared/components/ui/button"
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||||
import { formatDate, cn } from "@/shared/lib/utils"
|
import { formatDate, cn } from "@/shared/lib/utils"
|
||||||
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||||
import { AssignmentFilters } from "@/modules/homework/components/assignment-filters"
|
import { AssignmentFilters } from "@/modules/homework/components/assignment-filters"
|
||||||
@@ -20,13 +21,6 @@ import {
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
|
||||||
|
|
||||||
const getParam = (params: SearchParams, key: string) => {
|
|
||||||
const v = params[key]
|
|
||||||
return Array.isArray(v) ? v[0] : v
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActionLabel = (status: StudentHomeworkProgressStatus, t: (key: string) => string): string => {
|
const getActionLabel = (status: StudentHomeworkProgressStatus, t: (key: string) => string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "graded":
|
case "graded":
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
|
|||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="space-y-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-8 w-40" />
|
<Skeleton className="h-8 w-40" />
|
||||||
<Skeleton className="h-4 w-52" />
|
<Skeleton className="h-4 w-52" />
|
||||||
|
|||||||
@@ -5,16 +5,10 @@ import { getCurrentStudentUser } from "@/modules/users/data-access"
|
|||||||
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
|
import { StudentCoursesView } from "@/modules/student/components/student-courses-view"
|
||||||
import { CourseFilters } from "@/modules/student/components/course-filters"
|
import { CourseFilters } from "@/modules/student/components/course-filters"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
|
||||||
|
|
||||||
const getParam = (params: SearchParams, key: string) => {
|
|
||||||
const v = params[key]
|
|
||||||
return Array.isArray(v) ? v[0] : v
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function StudentCoursesPage({
|
export default async function StudentCoursesPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
93
src/app/(dashboard)/student/learning/page.tsx
Normal file
93
src/app/(dashboard)/student/learning/page.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import Link from "next/link"
|
||||||
|
import { BookOpen, PenTool, Library, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
|
import { getStudentClasses } from "@/modules/classes/data-access"
|
||||||
|
import { getStudentHomeworkAssignments } from "@/modules/homework/data-access"
|
||||||
|
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||||
|
import { getTextbooks } from "@/modules/textbooks/data-access"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { UserX } from "lucide-react"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
|
export default async function StudentLearningPage() {
|
||||||
|
const student = await getCurrentStudentUser()
|
||||||
|
if (!student) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<EmptyState title="No user found" description="Create a student user to see learning." icon={UserX} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [classes, assignments, textbooks] = await Promise.all([
|
||||||
|
getStudentClasses(student.id),
|
||||||
|
getStudentHomeworkAssignments(student.id),
|
||||||
|
getTextbooks(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const pendingCount = assignments.filter((a) => a.progressStatus !== "submitted" && a.progressStatus !== "graded").length
|
||||||
|
const dueSoonCount = assignments.filter((a) => {
|
||||||
|
if (a.progressStatus === "submitted" || a.progressStatus === "graded") return false
|
||||||
|
if (!a.dueAt) return false
|
||||||
|
const due = new Date(a.dueAt)
|
||||||
|
const in7Days = new Date(now)
|
||||||
|
in7Days.setDate(in7Days.getDate() + 7)
|
||||||
|
return due >= now && due <= in7Days
|
||||||
|
}).length
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: "Courses",
|
||||||
|
description: "Your enrolled classes.",
|
||||||
|
icon: BookOpen,
|
||||||
|
href: "/student/learning/courses",
|
||||||
|
stat: `${classes.length} enrolled`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Assignments",
|
||||||
|
description: "Homework and practice.",
|
||||||
|
icon: PenTool,
|
||||||
|
href: "/student/learning/assignments",
|
||||||
|
stat: `${pendingCount} pending${dueSoonCount > 0 ? ` · ${dueSoonCount} due soon` : ""}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Textbooks",
|
||||||
|
description: "Browse course materials.",
|
||||||
|
icon: Library,
|
||||||
|
href: "/student/learning/textbooks",
|
||||||
|
stat: `${textbooks.length} available`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">My Learning</h2>
|
||||||
|
<p className="text-muted-foreground">Your learning hub: courses, assignments, and textbooks.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{cards.map((c) => (
|
||||||
|
<Link key={c.href} href={c.href}>
|
||||||
|
<Card className="h-full transition-all hover:shadow-md hover:border-primary/50">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-base font-medium">{c.title}</CardTitle>
|
||||||
|
<c.icon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{c.description}</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{c.stat}</span>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import { getTranslations } from "next-intl/server"
|
|||||||
|
|
||||||
import { BookOpen } from "lucide-react"
|
import { BookOpen } from "lucide-react"
|
||||||
|
|
||||||
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
|
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||||
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
import { TextbookReader } from "@/modules/textbooks/components/textbook-reader"
|
||||||
|
import { getSubjectLabelKey, getGradeLabelKey } from "@/modules/textbooks/constants"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||||
@@ -23,10 +24,9 @@ export default async function StudentTextbookDetailPage({
|
|||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
|
||||||
const [textbook, chapters, knowledgePoints] = await Promise.all([
|
const [textbook, chapters] = await Promise.all([
|
||||||
getTextbookById(id),
|
getTextbookById(id),
|
||||||
getChaptersByTextbookId(id),
|
getChaptersByTextbookId(id),
|
||||||
getKnowledgePointsByTextbookId(id)
|
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!textbook) notFound()
|
if (!textbook) notFound()
|
||||||
@@ -45,9 +45,9 @@ export default async function StudentTextbookDetailPage({
|
|||||||
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
|
<h1 className="text-lg font-bold tracking-tight truncate">{textbook.title}</h1>
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span className="hidden sm:inline-block w-px h-4 bg-border" aria-hidden="true" />
|
<span className="hidden sm:inline-block w-px h-4 bg-border" aria-hidden="true" />
|
||||||
<Badge variant="outline" className="font-normal text-xs">{textbook.subject}</Badge>
|
<Badge variant="outline" className="font-normal text-xs">{t(`subject.${getSubjectLabelKey(textbook.subject)}`)}</Badge>
|
||||||
{textbook.grade && (
|
{textbook.grade && (
|
||||||
<Badge variant="secondary" className="font-normal text-xs">{textbook.grade}</Badge>
|
<Badge variant="secondary" className="font-normal text-xs">{t(`grade.${getGradeLabelKey(textbook.grade)}`)}</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,7 +66,7 @@ export default async function StudentTextbookDetailPage({
|
|||||||
) : (
|
) : (
|
||||||
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
<div className="h-full min-h-0 max-w-[1600px] mx-auto w-full">
|
||||||
{/* 学生端不传 renderQuestionCreator,无题目创建权限 */}
|
{/* 学生端不传 renderQuestionCreator,无题目创建权限 */}
|
||||||
<TextbookReader chapters={chapters} knowledgePoints={knowledgePoints} />
|
<TextbookReader key={id} chapters={chapters} textbookId={id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,16 +7,10 @@ import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters
|
|||||||
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
import { getCurrentStudentUser } from "@/modules/users/data-access"
|
||||||
import { getGradeNameById } from "@/modules/school/data-access"
|
import { getGradeNameById } from "@/modules/school/data-access"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
|
||||||
|
|
||||||
const getParam = (params: SearchParams, key: string): string | undefined => {
|
|
||||||
const v = params[key]
|
|
||||||
return Array.isArray(v) ? v[0] : v
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function StudentTextbooksPage({
|
export default async function StudentTextbooksPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Skeleton } from "@/shared/components/ui/skeleton"
|
|||||||
|
|
||||||
export default function Loading() {
|
export default function Loading() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-8 w-40" />
|
<Skeleton className="h-8 w-40" />
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import { getCurrentStudentUser } from "@/modules/users/data-access"
|
|||||||
import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
|
import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters"
|
||||||
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
|
import { StudentScheduleView } from "@/modules/student/components/student-schedule-view"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
type SearchParams = { [key: string]: string | string[] | undefined }
|
|
||||||
|
|
||||||
export default async function StudentSchedulePage({
|
export default async function StudentSchedulePage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -18,7 +17,7 @@ export default async function StudentSchedulePage({
|
|||||||
const student = await getCurrentStudentUser()
|
const student = await getCurrentStudentUser()
|
||||||
if (!student) {
|
if (!student) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
||||||
<p className="text-muted-foreground">Your weekly timetable.</p>
|
<p className="text-muted-foreground">Your weekly timetable.</p>
|
||||||
@@ -34,18 +33,12 @@ export default async function StudentSchedulePage({
|
|||||||
getStudentSchedule(student.id),
|
getStudentSchedule(student.id),
|
||||||
])
|
])
|
||||||
|
|
||||||
const classIdParam = sp.classId
|
const classId = getParam(sp, "classId") ?? "all"
|
||||||
const resolveClassId = (param: string | string[] | undefined): string => {
|
|
||||||
if (typeof param === "string") return param
|
|
||||||
if (Array.isArray(param)) return param[0] ?? "all"
|
|
||||||
return "all"
|
|
||||||
}
|
|
||||||
const classId = resolveClassId(classIdParam)
|
|
||||||
const filteredItems =
|
const filteredItems =
|
||||||
classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule
|
classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
<div className="space-y-8">
|
||||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
<h2 className="text-2xl font-bold tracking-tight">Schedule</h2>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { notFound } from "next/navigation"
|
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 { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||||
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||||
|
|
||||||
@@ -11,6 +13,7 @@ export default async function TeacherCoursePlanDetailPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
|
await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const plan = await getCoursePlanById(id)
|
const plan = await getCoursePlanById(id)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||||
@@ -23,7 +24,7 @@ export default async function TeacherCoursePlansPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
const ctx = await getAuthContext()
|
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||||
const teacherId = ctx.userId
|
const teacherId = ctx.userId
|
||||||
|
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
|
|||||||
@@ -1,24 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { AlertCircle } from "lucide-react"
|
import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback"
|
||||||
import { useTranslations } from "next-intl"
|
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
export default function TeacherDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
return <DashboardErrorFallback error={error} reset={reset} />
|
||||||
export default function TeacherDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
|
||||||
const t = useTranslations("dashboard")
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
|
||||||
<EmptyState
|
|
||||||
icon={AlertCircle}
|
|
||||||
title={t("error.loadFailed")}
|
|
||||||
description={t("error.loadFailedDesc")}
|
|
||||||
action={{
|
|
||||||
label: t("error.retry"),
|
|
||||||
onClick: () => reset(),
|
|
||||||
}}
|
|
||||||
className="border-none shadow-none h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,5 @@
|
|||||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton"
|
||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
|
||||||
|
|
||||||
export default function TeacherDashboardLoading() {
|
export default function TeacherDashboardLoading() {
|
||||||
return (
|
return <DashboardLoadingSkeleton />
|
||||||
<div className="flex h-full flex-col space-y-8 p-8">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Skeleton className="h-8 w-48" />
|
|
||||||
<Skeleton className="h-4 w-64" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Card key={i}>
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<Skeleton className="h-4 w-24" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Skeleton className="h-8 w-16" />
|
|
||||||
<Skeleton className="mt-2 h-3 w-28" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<Skeleton className="h-5 w-32" />
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-12 w-full" />
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { requirePermission } from "@/shared/lib/auth-guard"
|
|||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
|
import { getClassMasterySummary } from "@/modules/diagnostic/data-access"
|
||||||
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
|
import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view"
|
||||||
|
import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -41,7 +42,9 @@ export default async function ClassDiagnosticPage({
|
|||||||
Class-level knowledge point mastery overview and student attention list.
|
Class-level knowledge point mastery overview and student attention list.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ClassDiagnosticView summary={summary} />
|
<WidgetBoundary title="班级学情诊断" skeletonHeight={400}>
|
||||||
|
<ClassDiagnosticView summary={summary} />
|
||||||
|
</WidgetBoundary>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,16 +39,19 @@ export default async function TeacherDiagnosticPage({
|
|||||||
const reportType = getParam(sp, "reportType")
|
const reportType = getParam(sp, "reportType")
|
||||||
const status = getParam(sp, "status")
|
const status = getParam(sp, "status")
|
||||||
|
|
||||||
const reports = await getDiagnosticReports({
|
const reports = await getDiagnosticReports(
|
||||||
reportType: reportType && reportType !== "all" ? parseReportType(reportType) : undefined,
|
{
|
||||||
status: status && status !== "all" ? parseReportStatus(status) : undefined,
|
reportType: reportType && reportType !== "all" ? parseReportType(reportType) : undefined,
|
||||||
})
|
status: status && status !== "all" ? parseReportStatus(status) : undefined,
|
||||||
|
},
|
||||||
|
ctx.dataScope,
|
||||||
|
)
|
||||||
|
|
||||||
// 学生角色仅查看自己的报告;其他角色查看全部
|
// 学生角色仅查看自己的报告;其他角色查看全部
|
||||||
const visibleReports =
|
const visibleReports =
|
||||||
ctx.dataScope.type === "class_members"
|
ctx.dataScope.type === "class_members"
|
||||||
? reports.filter((r) => r.studentId === ctx.userId)
|
? reports.reports.filter((r) => r.studentId === ctx.userId)
|
||||||
: reports
|
: reports.reports
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
getKnowledgePointStats,
|
getKnowledgePointStats,
|
||||||
} from "@/modules/diagnostic/data-access"
|
} from "@/modules/diagnostic/data-access"
|
||||||
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports"
|
||||||
|
import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
||||||
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view"
|
||||||
|
import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"
|
||||||
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
|
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -29,11 +31,26 @@ export default async function StudentDiagnosticPage({
|
|||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const [summary, reports, classStats] = await Promise.all([
|
// v4-P1-2: class_taught scope 校验师生关系
|
||||||
|
// 教师只能查看自己所教班级的学生诊断
|
||||||
|
if (ctx.dataScope.type === "class_taught") {
|
||||||
|
const studentClassId = await getStudentActiveClassId(studentId)
|
||||||
|
if (!studentClassId || !ctx.dataScope.classIds.includes(studentClassId)) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先查询学生所属班级,再用 classId 调用 getKnowledgePointStats
|
||||||
|
// 否则无参调用会导致 studentIds=[] 直接返回空数组,班级平均对比功能失效
|
||||||
|
const studentClassId = await getStudentActiveClassId(studentId)
|
||||||
|
|
||||||
|
const [summary, reportsResult, classStats] = await Promise.all([
|
||||||
getStudentMasterySummary(studentId),
|
getStudentMasterySummary(studentId),
|
||||||
getDiagnosticReports({ studentId }),
|
// v4-P1-3: 教师视角可查看所有状态报告(含草稿),便于审核
|
||||||
getKnowledgePointStats(),
|
getDiagnosticReports({ studentId }, ctx.dataScope),
|
||||||
|
studentClassId ? getKnowledgePointStats(studentClassId) : Promise.resolve([]),
|
||||||
])
|
])
|
||||||
|
const reports = reportsResult.reports
|
||||||
|
|
||||||
// 班级平均掌握度(用于雷达图对比)
|
// 班级平均掌握度(用于雷达图对比)
|
||||||
let classAverageMastery: MasteryRadarPoint[] | undefined
|
let classAverageMastery: MasteryRadarPoint[] | undefined
|
||||||
@@ -56,11 +73,14 @@ export default async function StudentDiagnosticPage({
|
|||||||
Knowledge point mastery analysis and diagnostic reports.
|
Knowledge point mastery analysis and diagnostic reports.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<StudentDiagnosticView
|
<WidgetBoundary title="学生学情诊断" skeletonHeight={400}>
|
||||||
summary={summary}
|
<StudentDiagnosticView
|
||||||
reports={reports}
|
summary={summary}
|
||||||
classAverageMastery={classAverageMastery}
|
reports={reports}
|
||||||
/>
|
classAverageMastery={classAverageMastery}
|
||||||
|
practiceHrefBase="/teacher/questions"
|
||||||
|
/>
|
||||||
|
</WidgetBoundary>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/app/(dashboard)/teacher/elective/error.tsx
Normal file
24
src/app/(dashboard)/teacher/elective/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
export default function TeacherElectiveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("elective")
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title={t("errors.unexpected")}
|
||||||
|
description={t("errors.unexpected")}
|
||||||
|
action={{
|
||||||
|
label: t("actions.save"),
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { getSubjectOptions } from "@/modules/school/data-access"
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getClassComparison,
|
getClassComparison,
|
||||||
|
getExamOptionsForGrades,
|
||||||
getGradeDistribution,
|
getGradeDistribution,
|
||||||
getGradeTrend,
|
getGradeTrend,
|
||||||
getSubjectComparison,
|
getSubjectComparison,
|
||||||
@@ -22,6 +23,7 @@ import { ClassComparisonChart } from "@/modules/grades/components/class-comparis
|
|||||||
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
|
import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart"
|
||||||
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
|
import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart"
|
||||||
import { AnalyticsFilters } from "@/modules/grades/components/analytics-filters"
|
import { AnalyticsFilters } from "@/modules/grades/components/analytics-filters"
|
||||||
|
import { WidgetBoundary } from "@/modules/grades/components/widget-boundary"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -36,6 +38,9 @@ export default async function GradeAnalyticsPage({
|
|||||||
const classId = getParam(sp, "classId")
|
const classId = getParam(sp, "classId")
|
||||||
const subjectId = getParam(sp, "subjectId")
|
const subjectId = getParam(sp, "subjectId")
|
||||||
const gradeId = getParam(sp, "gradeId")
|
const gradeId = getParam(sp, "gradeId")
|
||||||
|
// v3-P2-7: 学期和考试筛选
|
||||||
|
const examId = getParam(sp, "examId")
|
||||||
|
const semester = getParam(sp, "semester")
|
||||||
|
|
||||||
const [classes, allGrades, allSubjects] = await Promise.all([
|
const [classes, allGrades, allSubjects] = await Promise.all([
|
||||||
getTeacherClasses(),
|
getTeacherClasses(),
|
||||||
@@ -66,33 +71,50 @@ export default async function GradeAnalyticsPage({
|
|||||||
const targetSubjectId =
|
const targetSubjectId =
|
||||||
subjectId && subjectId !== "all" ? subjectId : undefined
|
subjectId && subjectId !== "all" ? subjectId : undefined
|
||||||
const targetGradeId = gradeId ?? allGrades[0]?.id
|
const targetGradeId = gradeId ?? allGrades[0]?.id
|
||||||
|
// v3-P2-7: 解析 semester 和 examId
|
||||||
|
const targetSemester: "1" | "2" | undefined =
|
||||||
|
semester === "1" || semester === "2" ? semester : undefined
|
||||||
|
const targetExamId = examId && examId !== "all" ? examId : undefined
|
||||||
|
|
||||||
// Run analytics queries in parallel
|
// Run analytics queries in parallel
|
||||||
const [trend, distribution, subjectComparison, classComparison] =
|
const [trend, distribution, subjectComparison, classComparison, examOptions] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
getGradeTrend({
|
getGradeTrend({
|
||||||
classId: targetClassId,
|
classId: targetClassId,
|
||||||
subjectId: targetSubjectId,
|
subjectId: targetSubjectId,
|
||||||
|
semester: targetSemester,
|
||||||
|
examId: targetExamId,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
currentUserId: ctx.userId,
|
currentUserId: ctx.userId,
|
||||||
}),
|
}),
|
||||||
getGradeDistribution({
|
getGradeDistribution({
|
||||||
classId: targetClassId,
|
classId: targetClassId,
|
||||||
subjectId: targetSubjectId,
|
subjectId: targetSubjectId,
|
||||||
|
examId: targetExamId,
|
||||||
|
semester: targetSemester,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
currentUserId: ctx.userId,
|
currentUserId: ctx.userId,
|
||||||
}),
|
}),
|
||||||
getSubjectComparison({
|
getSubjectComparison({
|
||||||
classId: targetClassId,
|
classId: targetClassId,
|
||||||
|
examId: targetExamId,
|
||||||
|
semester: targetSemester,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
}),
|
}),
|
||||||
targetGradeId
|
targetGradeId
|
||||||
? getClassComparison({
|
? getClassComparison({
|
||||||
gradeId: targetGradeId,
|
gradeId: targetGradeId,
|
||||||
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
|
subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "",
|
||||||
|
examId: targetExamId,
|
||||||
|
semester: targetSemester,
|
||||||
scope: ctx.dataScope,
|
scope: ctx.dataScope,
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
getExamOptionsForGrades({
|
||||||
|
classId: targetClassId,
|
||||||
|
subjectId: targetSubjectId,
|
||||||
|
scope: ctx.dataScope,
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -116,16 +138,63 @@ export default async function GradeAnalyticsPage({
|
|||||||
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
|
grades={allGrades.map((g) => ({ id: g.id, name: g.name }))}
|
||||||
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
|
subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))}
|
||||||
|
exams={examOptions}
|
||||||
currentClassId={targetClassId}
|
currentClassId={targetClassId}
|
||||||
currentSubjectId={subjectId ?? "all"}
|
currentSubjectId={subjectId ?? "all"}
|
||||||
currentGradeId={targetGradeId ?? ""}
|
currentGradeId={targetGradeId ?? ""}
|
||||||
|
currentExamId={examId ?? "all"}
|
||||||
|
currentSemester={semester ?? "all"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<GradeTrendChart data={trend} />
|
<WidgetBoundary title="成绩趋势">
|
||||||
<GradeDistributionChart data={distribution} />
|
{trend ? (
|
||||||
<SubjectComparisonChart data={subjectComparison} />
|
<GradeTrendChart data={trend} />
|
||||||
<ClassComparisonChart data={classComparison} />
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无趋势数据"
|
||||||
|
description="当前筛选条件下没有可显示的成绩趋势。"
|
||||||
|
icon={BarChart3}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WidgetBoundary>
|
||||||
|
<WidgetBoundary title="分数分布">
|
||||||
|
{distribution.totalCount > 0 ? (
|
||||||
|
<GradeDistributionChart data={distribution} />
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无分布数据"
|
||||||
|
description="当前筛选条件下没有可显示的分数分布。"
|
||||||
|
icon={BarChart3}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WidgetBoundary>
|
||||||
|
<WidgetBoundary title="科目对比">
|
||||||
|
{subjectComparison.length > 0 ? (
|
||||||
|
<SubjectComparisonChart data={subjectComparison} />
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无科目对比数据"
|
||||||
|
description="当前筛选条件下没有可显示的科目对比。"
|
||||||
|
icon={BarChart3}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WidgetBoundary>
|
||||||
|
<WidgetBoundary title="班级对比">
|
||||||
|
{classComparison.length > 0 ? (
|
||||||
|
<ClassComparisonChart data={classComparison} />
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无班级对比数据"
|
||||||
|
description="当前筛选条件下没有可显示的班级对比。"
|
||||||
|
icon={BarChart3}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WidgetBoundary>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { getTeacherClasses } from "@/modules/classes/data-access"
|
|||||||
import { getClassStudentsForEntry } from "@/modules/grades/data-access"
|
import { getClassStudentsForEntry } from "@/modules/grades/data-access"
|
||||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||||
import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry"
|
import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
import { ClipboardList } from "lucide-react"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -12,22 +16,52 @@ export default async function BatchEntryPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE)
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
|
|
||||||
const defaultClassId = getParam(sp, "classId")
|
const defaultClassId = getParam(sp, "classId")
|
||||||
const defaultSubjectId = getParam(sp, "subjectId")
|
const defaultSubjectId = getParam(sp, "subjectId")
|
||||||
|
|
||||||
|
// P3 修复:添加 scope 校验,对 class_taught scope 限制可录入的班级
|
||||||
const [classes, allSubjects, students] = await Promise.all([
|
const [classes, allSubjects, students] = await Promise.all([
|
||||||
getTeacherClasses(),
|
getTeacherClasses(),
|
||||||
getSubjectOptions(),
|
getSubjectOptions(),
|
||||||
defaultClassId
|
defaultClassId
|
||||||
? getClassStudentsForEntry(defaultClassId)
|
? getClassStudentsForEntry(defaultClassId, ctx.dataScope)
|
||||||
: Promise.resolve([] as Awaited<ReturnType<typeof getClassStudentsForEntry>>),
|
: Promise.resolve([] as Awaited<ReturnType<typeof getClassStudentsForEntry>>),
|
||||||
])
|
])
|
||||||
|
|
||||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
// 对 class_taught scope,过滤掉不在 scope 中的班级
|
||||||
|
const allowedClassIds =
|
||||||
|
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : null
|
||||||
|
const scopedClasses = allowedClassIds
|
||||||
|
? classes.filter((c) => allowedClassIds.includes(c.id))
|
||||||
|
: classes
|
||||||
|
|
||||||
|
const classOptions = scopedClasses.map((c) => ({ id: c.id, name: c.name }))
|
||||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||||
|
|
||||||
|
// 如果指定了 classId 但 scope 不允许,显示提示
|
||||||
|
if (defaultClassId && students.length === 0 && scopedClasses.length > 0) {
|
||||||
|
const classExists = scopedClasses.some((c) => c.id === defaultClassId)
|
||||||
|
if (!classExists) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Batch Grade Entry</h1>
|
||||||
|
<p className="text-muted-foreground">Enter grades for all students in a class at once.</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="无权访问该班级"
|
||||||
|
description="您没有权限为该班级录入成绩。"
|
||||||
|
icon={ClipboardList}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Link from "next/link"
|
|||||||
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
import { PlusCircle, BarChart3, ClipboardList } from "lucide-react"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination"
|
import { ListPagination, computePagination } from "@/shared/components/ui/list-pagination"
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
@@ -43,7 +43,11 @@ export default async function TeacherGradesPage({
|
|||||||
const type = getParam(sp, "type")
|
const type = getParam(sp, "type")
|
||||||
const semester = getParam(sp, "semester")
|
const semester = getParam(sp, "semester")
|
||||||
|
|
||||||
const [classes, allSubjects, records] = await Promise.all([
|
// P3 修复:使用 DB 层分页,移除重复计算
|
||||||
|
const { page } = computePagination(sp, PAGE_SIZE)
|
||||||
|
const offset = (page - 1) * PAGE_SIZE
|
||||||
|
|
||||||
|
const [classes, allSubjects, result] = await Promise.all([
|
||||||
getTeacherClasses(),
|
getTeacherClasses(),
|
||||||
getSubjectOptions(),
|
getSubjectOptions(),
|
||||||
getGradeRecords({
|
getGradeRecords({
|
||||||
@@ -53,18 +57,19 @@ export default async function TeacherGradesPage({
|
|||||||
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
|
subjectId: subjectId && subjectId !== "all" ? subjectId : undefined,
|
||||||
type: type && type !== "all" ? parseGradeType(type) : undefined,
|
type: type && type !== "all" ? parseGradeType(type) : undefined,
|
||||||
semester: semester && semester !== "all" ? parseSemester(semester) : undefined,
|
semester: semester && semester !== "all" ? parseSemester(semester) : undefined,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
||||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||||
|
|
||||||
// 分页计算
|
// 使用 DB 返回的 total 和 totalPages,移除重复计算
|
||||||
const { page } = computePagination(sp, PAGE_SIZE)
|
const total = result.total
|
||||||
const total = records.length
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
const currentPage = Math.min(page, totalPages)
|
const currentPage = Math.min(page, totalPages)
|
||||||
const pagedRecords = paginate(records, currentPage, PAGE_SIZE)
|
const pagedRecords = result.records
|
||||||
const hasFilters = Boolean(classId || subjectId || type || semester)
|
const hasFilters = Boolean(classId || subjectId || type || semester)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -103,7 +108,7 @@ export default async function TeacherGradesPage({
|
|||||||
|
|
||||||
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
|
<GradeQueryFilters classes={classOptions} subjects={subjectOptions} />
|
||||||
|
|
||||||
{records.length === 0 && !hasFilters ? (
|
{total === 0 && !hasFilters ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="暂无成绩记录"
|
title="暂无成绩记录"
|
||||||
description="开始为您的班级录入成绩。"
|
description="开始为您的班级录入成绩。"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { ClassGradeReport } from "@/modules/grades/components/class-grade-report
|
|||||||
import { ExportButton } from "@/modules/grades/components/export-button"
|
import { ExportButton } from "@/modules/grades/components/export-button"
|
||||||
import { StatsClassSelector } from "@/modules/grades/components/stats-class-selector"
|
import { StatsClassSelector } from "@/modules/grades/components/stats-class-selector"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
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 { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
import { BarChart3 } from "lucide-react"
|
import { BarChart3 } from "lucide-react"
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ export default async function StatsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
|
const ctx = await requirePermission(Permissions.GRADE_RECORD_READ)
|
||||||
const sp = await searchParams
|
const sp = await searchParams
|
||||||
|
|
||||||
const classId = getParam(sp, "classId")
|
const classId = getParam(sp, "classId")
|
||||||
@@ -43,15 +46,52 @@ export default async function StatsPage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetClassId = classId ?? classes[0].id
|
// P3 修复:对 class_taught scope 过滤可选班级
|
||||||
|
const allowedClassIds =
|
||||||
|
ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : null
|
||||||
|
const scopedClasses = allowedClassIds
|
||||||
|
? classes.filter((c) => allowedClassIds.includes(c.id))
|
||||||
|
: classes
|
||||||
|
|
||||||
|
if (scopedClasses.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Grade Statistics</h1>
|
||||||
|
<p className="text-muted-foreground">View class grade statistics and rankings.</p>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
title="No accessible classes"
|
||||||
|
description="You don't have permission to view any classes."
|
||||||
|
icon={BarChart3}
|
||||||
|
className="border-none shadow-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetClassId = classId ?? scopedClasses[0].id
|
||||||
const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined
|
const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined
|
||||||
|
|
||||||
|
// P3 修复:传递 scope 到 data-access 层
|
||||||
const [stats, ranking] = await Promise.all([
|
const [stats, ranking] = await Promise.all([
|
||||||
getClassGradeStatsWithMeta(targetClassId, targetSubjectId),
|
getClassGradeStatsWithMeta(
|
||||||
getClassRanking(targetClassId, targetSubjectId),
|
targetClassId,
|
||||||
|
targetSubjectId,
|
||||||
|
undefined,
|
||||||
|
ctx.dataScope,
|
||||||
|
ctx.userId
|
||||||
|
),
|
||||||
|
getClassRanking(
|
||||||
|
targetClassId,
|
||||||
|
targetSubjectId,
|
||||||
|
undefined,
|
||||||
|
ctx.dataScope,
|
||||||
|
ctx.userId
|
||||||
|
),
|
||||||
])
|
])
|
||||||
|
|
||||||
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
|
const classOptions = scopedClasses.map((c) => ({ id: c.id, name: c.name }))
|
||||||
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { Suspense } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker"
|
import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -20,7 +22,20 @@ export default async function NewLessonPlanPage(): Promise<JSX.Element> {
|
|||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{t("title.new")}</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t("title.new")}</h1>
|
||||||
</div>
|
</div>
|
||||||
<TemplatePicker />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="max-w-3xl mx-auto p-6 space-y-6">
|
||||||
|
<Skeleton className="h-10 w-full" />
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[100px] w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TemplatePicker />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
|
import { Suspense } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Plus } from "lucide-react"
|
import { Plus } from "lucide-react"
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
import { getLessonPlans } from "@/modules/lesson-preparation/data-access"
|
||||||
import { getSubjectOptions } from "@/modules/school/data-access"
|
import { getSubjectOptions } from "@/modules/school/data-access"
|
||||||
@@ -35,7 +37,24 @@ export default async function LessonPlansPage(): Promise<JSX.Element> {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<LessonPlanList initialItems={items} subjects={subjects} />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2 flex-wrap items-center">
|
||||||
|
<Skeleton className="h-9 w-[240px]" />
|
||||||
|
<Skeleton className="h-9 w-[160px]" />
|
||||||
|
<Skeleton className="h-9 w-[160px]" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-[180px] w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LessonPlanList initialItems={items} subjects={subjects} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
|||||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
import { getQuestions } from "@/modules/questions/data-access"
|
import { getQuestions } from "@/modules/questions/data-access"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import type { QuestionType } from "@/modules/questions/types"
|
import type { QuestionType } from "@/modules/questions/types"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
@@ -27,6 +29,8 @@ function parseQuestionType(v?: string): QuestionType | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function QuestionBankResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
async function QuestionBankResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
|
||||||
|
await requirePermission(Permissions.QUESTION_READ)
|
||||||
|
|
||||||
const params = await searchParams
|
const params = await searchParams
|
||||||
|
|
||||||
const q = getParam(params, "q")
|
const q = getParam(params, "q")
|
||||||
@@ -36,10 +40,13 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise<Sea
|
|||||||
|
|
||||||
const questionType = parseQuestionType(type)
|
const questionType = parseQuestionType(type)
|
||||||
|
|
||||||
|
const difficultyNum = difficulty && difficulty !== "all" ? Number(difficulty) : undefined
|
||||||
|
const safeDifficulty = difficultyNum !== undefined && Number.isFinite(difficultyNum) ? difficultyNum : undefined
|
||||||
|
|
||||||
const { data: questions } = await getQuestions({
|
const { data: questions } = await getQuestions({
|
||||||
q: q || undefined,
|
q: q || undefined,
|
||||||
type: questionType,
|
type: questionType,
|
||||||
difficulty: difficulty && difficulty !== "all" ? Number(difficulty) : undefined,
|
difficulty: safeDifficulty,
|
||||||
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
|
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
|
||||||
pageSize: 200,
|
pageSize: 200,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { JSX } from "react"
|
|||||||
import { ClipboardList } from "lucide-react"
|
import { ClipboardList } from "lucide-react"
|
||||||
|
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import {
|
import {
|
||||||
getAdminClassesForScheduling,
|
getAdminClassesForScheduling,
|
||||||
getScheduleChanges,
|
getScheduleChanges,
|
||||||
@@ -14,10 +15,10 @@ import { ScheduleChangeList } from "@/modules/scheduling/components/schedule-cha
|
|||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export default async function TeacherScheduleChangesPage(): Promise<JSX.Element> {
|
export default async function TeacherScheduleChangesPage(): Promise<JSX.Element> {
|
||||||
const ctx = await getAuthContext()
|
const ctx = await requirePermission(Permissions.SCHEDULE_ADJUST)
|
||||||
|
|
||||||
// Teachers see only their own requests; admins landing here see all.
|
// Teachers see only their own requests; admins landing here see all.
|
||||||
const requesterId = ctx.roles.includes("admin") ? undefined : ctx.userId
|
const requesterId = ctx.dataScope.type === "all" ? undefined : ctx.userId
|
||||||
|
|
||||||
const [classes, teachers, items] = await Promise.all([
|
const [classes, teachers, items] = await Promise.all([
|
||||||
getAdminClassesForScheduling(),
|
getAdminClassesForScheduling(),
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import {
|
||||||
|
TextbookReader,
|
||||||
|
type TextbookReaderProps,
|
||||||
|
} from "@/modules/textbooks/components/textbook-reader"
|
||||||
|
import type { KnowledgePoint } from "@/modules/textbooks/types"
|
||||||
|
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 教师端 TextbookReader 包装组件。
|
||||||
|
*
|
||||||
|
* 教师详情页是 Server Component,不能直接向 Client Component(TextbookReader)
|
||||||
|
* 传递函数 prop(renderQuestionCreator)。此包装组件在 app 层组装跨模块依赖,
|
||||||
|
* 避免 textbooks 模块直接依赖 questions 模块(模块间只通过 data-access 通信)。
|
||||||
|
*/
|
||||||
|
export function TeacherTextbookReader({
|
||||||
|
chapters,
|
||||||
|
textbookId,
|
||||||
|
}: {
|
||||||
|
chapters: TextbookReaderProps["chapters"]
|
||||||
|
textbookId: string
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("textbooks")
|
||||||
|
const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
targetKp,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
targetKp: KnowledgePoint | null
|
||||||
|
}): ReactNode => (
|
||||||
|
<CreateQuestionDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
|
||||||
|
defaultContent={targetKp ? t("reader.questionCreatorDefaultContent", { name: targetKp.name }) : ""}
|
||||||
|
defaultType="text"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextbookReader
|
||||||
|
key={textbookId}
|
||||||
|
chapters={chapters}
|
||||||
|
textbookId={textbookId}
|
||||||
|
renderQuestionCreator={renderQuestionCreator}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
import type { JSX, ReactNode } from "react"
|
import type { JSX } from "react"
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import { ArrowLeft } from "lucide-react"
|
import { ArrowLeft } from "lucide-react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations } from "next-intl/server"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access"
|
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
|
||||||
import { TextbookReader, type TextbookReaderProps } from "@/modules/textbooks/components/textbook-reader"
|
import { TeacherTextbookReader } from "./_components/teacher-textbook-reader"
|
||||||
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"
|
import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog"
|
||||||
import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog"
|
import { getSubjectLabelKey, getGradeLabelKey } from "@/modules/textbooks/constants"
|
||||||
import type { KnowledgePoint } from "@/modules/textbooks/types"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -18,38 +19,20 @@ export default async function TextbookDetailPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
|
await requirePermission(Permissions.TEXTBOOK_READ)
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
const t = await getTranslations("textbooks")
|
const t = await getTranslations("textbooks")
|
||||||
|
|
||||||
const [textbook, chapters, knowledgePoints] = await Promise.all([
|
const [textbook, chapters] = await Promise.all([
|
||||||
getTextbookById(id),
|
getTextbookById(id),
|
||||||
getChaptersByTextbookId(id),
|
getChaptersByTextbookId(id),
|
||||||
getKnowledgePointsByTextbookId(id),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
if (!textbook) {
|
if (!textbook) {
|
||||||
notFound()
|
notFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
// P0-1 在页面层注入 questions 模块的 CreateQuestionDialog 实现
|
|
||||||
const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
targetKp,
|
|
||||||
}: {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
targetKp: KnowledgePoint | null
|
|
||||||
}): ReactNode => (
|
|
||||||
<CreateQuestionDialog
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
defaultKnowledgePointIds={targetKp ? [targetKp.id] : []}
|
|
||||||
defaultContent={targetKp ? `Please explain the knowledge point: ${targetKp.name}` : ""}
|
|
||||||
defaultType="text"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
|
||||||
{/* Header / Nav (Fixed height) */}
|
{/* Header / Nav (Fixed height) */}
|
||||||
@@ -62,9 +45,9 @@ export default async function TextbookDetailPage({
|
|||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<Badge variant="outline">{textbook.subject}</Badge>
|
<Badge variant="outline">{t(`subject.${getSubjectLabelKey(textbook.subject)}`)}</Badge>
|
||||||
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
<span className="text-xs text-muted-foreground uppercase tracking-wider font-medium">
|
||||||
{textbook.grade}
|
{textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
|
<h1 className="text-xl font-bold tracking-tight line-clamp-1">{textbook.title}</h1>
|
||||||
@@ -76,12 +59,7 @@ export default async function TextbookDetailPage({
|
|||||||
|
|
||||||
{/* Main Content Layout (Flex grow) */}
|
{/* Main Content Layout (Flex grow) */}
|
||||||
<div className="flex-1 overflow-hidden pt-6">
|
<div className="flex-1 overflow-hidden pt-6">
|
||||||
<TextbookReader
|
<TeacherTextbookReader chapters={chapters} textbookId={id} />
|
||||||
chapters={chapters}
|
|
||||||
knowledgePoints={knowledgePoints}
|
|
||||||
textbookId={id}
|
|
||||||
renderQuestionCreator={renderQuestionCreator}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { getTextbooks } from "@/modules/textbooks/data-access"
|
|||||||
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
import { getParam, type SearchParams } from "@/shared/lib/search-params"
|
||||||
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ async function TextbooksResults({
|
|||||||
searchParams: Promise<SearchParams>
|
searchParams: Promise<SearchParams>
|
||||||
t: Awaited<ReturnType<typeof getTranslations<"textbooks">>>
|
t: Awaited<ReturnType<typeof getTranslations<"textbooks">>>
|
||||||
}): Promise<JSX.Element> {
|
}): Promise<JSX.Element> {
|
||||||
|
await requirePermission(Permissions.TEXTBOOK_READ)
|
||||||
|
|
||||||
const params = await searchParams
|
const params = await searchParams
|
||||||
|
|
||||||
const q = getParam(params, "q") || undefined
|
const q = getParam(params, "q") || undefined
|
||||||
|
|||||||
@@ -1,138 +1,20 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { eq, inArray } from "drizzle-orm"
|
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
|
||||||
import { db } from "@/shared/db"
|
|
||||||
import { classes, classSubjectTeachers, roles, users, usersToRoles, subjects } from "@/shared/db/schema"
|
|
||||||
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
|
|
||||||
import { enrollStudentByInvitationCode } from "@/modules/classes/data-access"
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 已迁移至 Server Action `completeOnboardingAction`(modules/onboarding/actions.ts)。
|
||||||
|
* 旧实现存在多项安全漏洞(角色自选越权、教师覆盖任课、无事务、无 Zod),
|
||||||
|
* 新实现采用 Server Action + requireAuth + Zod + db.transaction + classes data-access 强校验。
|
||||||
|
* 保留此文件仅为兼容性,返回 410 Gone 指示调用方迁移。
|
||||||
|
*/
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
function parseCodes(input: string) {
|
export async function POST() {
|
||||||
const raw = input
|
return NextResponse.json(
|
||||||
.split(/[\s,,;;]+/g)
|
{
|
||||||
.map((s) => s.trim())
|
success: false,
|
||||||
.filter(Boolean)
|
message: "此 API 已废弃,请使用 completeOnboardingAction Server Action",
|
||||||
return Array.from(new Set(raw))
|
redirect: "/onboarding",
|
||||||
}
|
},
|
||||||
|
{ status: 410 }
|
||||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
)
|
||||||
return typeof v === "object" && v !== null
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
const session = await auth()
|
|
||||||
const userId = String(session?.user?.id ?? "").trim()
|
|
||||||
if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 })
|
|
||||||
|
|
||||||
const body = await req.json().catch(() => null)
|
|
||||||
if (!isRecord(body)) return NextResponse.json({ success: false, message: "Invalid payload" }, { status: 400 })
|
|
||||||
|
|
||||||
const roleRaw = String(body.role ?? "").trim()
|
|
||||||
const allowedRoles = ["student", "teacher", "parent", "admin"] as const
|
|
||||||
const role = (allowedRoles as readonly string[]).includes(roleRaw) ? roleRaw : null
|
|
||||||
if (!role) return NextResponse.json({ success: false, message: "Invalid role" }, { status: 400 })
|
|
||||||
|
|
||||||
const currentRoleRows = await db
|
|
||||||
.select({ name: roles.name })
|
|
||||||
.from(usersToRoles)
|
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
|
||||||
.where(eq(usersToRoles.userId, userId))
|
|
||||||
const currentMapped = currentRoleRows.map((r) => String(r.name ?? "").trim().toLowerCase())
|
|
||||||
|
|
||||||
if (role === "admin" && !currentMapped.includes("admin")) {
|
|
||||||
return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = String(body.name ?? "").trim()
|
|
||||||
if (!name) return NextResponse.json({ success: false, message: "Name is required" }, { status: 400 })
|
|
||||||
|
|
||||||
const phone = String(body.phone ?? "").trim()
|
|
||||||
const address = String(body.address ?? "").trim()
|
|
||||||
|
|
||||||
const classCodesText = String(body.classCodes ?? "").trim()
|
|
||||||
const codes = classCodesText.length ? parseCodes(classCodesText) : []
|
|
||||||
|
|
||||||
const teacherSubjectsRaw = Array.isArray(body.teacherSubjects) ? body.teacherSubjects : []
|
|
||||||
const teacherSubjects = teacherSubjectsRaw
|
|
||||||
.map((s) => String(s).trim())
|
|
||||||
.filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject))
|
|
||||||
|
|
||||||
const roleRow = await db.query.roles.findFirst({
|
|
||||||
where: eq(roles.name, role),
|
|
||||||
columns: { id: true },
|
|
||||||
})
|
|
||||||
if (!roleRow) {
|
|
||||||
await db.insert(roles).values({ name: role })
|
|
||||||
}
|
|
||||||
const resolvedRole = roleRow
|
|
||||||
?? (await db.query.roles.findFirst({ where: eq(roles.name, role), columns: { id: true } }))
|
|
||||||
const roleId = resolvedRole?.id
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({
|
|
||||||
name,
|
|
||||||
phone: phone.length ? phone : null,
|
|
||||||
address: address.length ? address : null,
|
|
||||||
})
|
|
||||||
.where(eq(users.id, userId))
|
|
||||||
|
|
||||||
if (roleId) {
|
|
||||||
await db
|
|
||||||
.insert(usersToRoles)
|
|
||||||
.values({ userId, roleId })
|
|
||||||
.onDuplicateKeyUpdate({ set: { roleId } })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === "student" && codes.length) {
|
|
||||||
for (const code of codes) {
|
|
||||||
await enrollStudentByInvitationCode(userId, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role === "teacher" && codes.length && teacherSubjects.length) {
|
|
||||||
const classRows = await db
|
|
||||||
.select({ id: classes.id, invitationCode: classes.invitationCode })
|
|
||||||
.from(classes)
|
|
||||||
.where(inArray(classes.invitationCode, codes))
|
|
||||||
|
|
||||||
const byCode = new Map<string, string>()
|
|
||||||
for (const r of classRows) {
|
|
||||||
if (typeof r.invitationCode === "string") {
|
|
||||||
byCode.set(r.invitationCode, r.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve subject ids when possible (by name exact match)
|
|
||||||
const subjectsFound = await db
|
|
||||||
.select({ id: subjects.id, name: subjects.name })
|
|
||||||
.from(subjects)
|
|
||||||
.where(inArray(subjects.name, teacherSubjects))
|
|
||||||
const subjectIdByName = new Map<string, string>()
|
|
||||||
for (const s of subjectsFound) {
|
|
||||||
if (s.name && s.id) subjectIdByName.set(String(s.name), String(s.id))
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const code of codes) {
|
|
||||||
const classId = byCode.get(code)
|
|
||||||
if (!classId) continue
|
|
||||||
for (const subject of teacherSubjects) {
|
|
||||||
const subjectId = subjectIdByName.get(subject)
|
|
||||||
if (!subjectId) continue
|
|
||||||
await db
|
|
||||||
.insert(classSubjectTeachers)
|
|
||||||
.values({ classId, subjectId, teacherId: userId })
|
|
||||||
.onDuplicateKeyUpdate({ set: { teacherId: userId, subjectId, updatedAt: new Date() } })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({ onboardedAt: new Date() })
|
|
||||||
.where(eq(users.id, userId))
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,19 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { eq } from "drizzle-orm"
|
|
||||||
|
|
||||||
import { auth } from "@/auth"
|
|
||||||
import { db } from "@/shared/db"
|
|
||||||
import { roles, users, usersToRoles } from "@/shared/db/schema"
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated 已迁移至 Server Action `getOnboardingStatusAction`(modules/onboarding/actions.ts)。
|
||||||
|
* 引导流程改为独立路由 /onboarding + middleware 重定向,不再使用客户端 Dialog 拉取此 API。
|
||||||
|
* 保留此文件仅为兼容性,返回 410 Gone 指示调用方迁移。
|
||||||
|
*/
|
||||||
export const dynamic = "force-dynamic"
|
export const dynamic = "force-dynamic"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth()
|
return NextResponse.json(
|
||||||
const userId = String(session?.user?.id ?? "").trim()
|
{
|
||||||
if (!userId) {
|
success: false,
|
||||||
return NextResponse.json({ required: false })
|
message: "此 API 已废弃,请使用 /onboarding 路由或 getOnboardingStatusAction",
|
||||||
}
|
redirect: "/onboarding",
|
||||||
|
},
|
||||||
const [row, roleRows] = await Promise.all([
|
{ status: 410 }
|
||||||
db.query.users.findFirst({
|
)
|
||||||
where: eq(users.id, userId),
|
|
||||||
columns: { onboardedAt: true },
|
|
||||||
}),
|
|
||||||
db
|
|
||||||
.select({ name: roles.name })
|
|
||||||
.from(usersToRoles)
|
|
||||||
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
|
|
||||||
.where(eq(usersToRoles.userId, userId)),
|
|
||||||
])
|
|
||||||
|
|
||||||
const normalizeRole = (value: string) => {
|
|
||||||
const role = value.trim().toLowerCase()
|
|
||||||
if (role === "grade_head" || role === "teaching_head") return "teacher"
|
|
||||||
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
const mappedRoles = roleRows.map((r) => normalizeRole(r.name)).filter(Boolean)
|
|
||||||
const resolvedRole = mappedRoles.find((r) => r === "admin")
|
|
||||||
?? mappedRoles.find((r) => r === "teacher")
|
|
||||||
?? mappedRoles.find((r) => r === "parent")
|
|
||||||
?? mappedRoles.find((r) => r === "student")
|
|
||||||
?? "student"
|
|
||||||
|
|
||||||
const required = !row?.onboardedAt
|
|
||||||
return NextResponse.json({ required, role: resolvedRole })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
import { and, desc, eq, like, or, sql } from "drizzle-orm"
|
import { and, desc, eq, like, or, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import {
|
import {
|
||||||
announcements,
|
announcements,
|
||||||
@@ -41,10 +41,12 @@ interface SearchResponse {
|
|||||||
/**
|
/**
|
||||||
* GET /api/search?q=keyword&type=all&page=1
|
* GET /api/search?q=keyword&type=all&page=1
|
||||||
* 全文检索:questions / textbooks / exams / announcements
|
* 全文检索:questions / textbooks / exams / announcements
|
||||||
|
* 按角色过滤:学生只能搜索 textbook 和 announcement
|
||||||
*/
|
*/
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
try {
|
try {
|
||||||
await requireAuth()
|
const ctx = await getAuthContext()
|
||||||
|
const isStudent = ctx.roles.includes("student") && !ctx.roles.includes("admin") && !ctx.roles.includes("teacher")
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
const q = (searchParams.get("q") ?? "").trim()
|
const q = (searchParams.get("q") ?? "").trim()
|
||||||
@@ -72,16 +74,18 @@ export async function GET(req: Request) {
|
|||||||
const offset = (page - 1) * pageSize
|
const offset = (page - 1) * pageSize
|
||||||
const results: SearchResultItem[] = []
|
const results: SearchResultItem[] = []
|
||||||
|
|
||||||
// 并行查询各类型
|
// 并行查询各类型(按角色过滤)
|
||||||
const tasks: Promise<SearchResultItem[]>[] = []
|
const tasks: Promise<SearchResultItem[]>[] = []
|
||||||
|
|
||||||
if (type === "all" || type === "question") {
|
// 学生不能搜索题目和考试
|
||||||
|
if (!isStudent && (type === "all" || type === "question")) {
|
||||||
tasks.push(searchQuestions(kw, pageSize))
|
tasks.push(searchQuestions(kw, pageSize))
|
||||||
}
|
}
|
||||||
if (type === "all" || type === "textbook") {
|
if (type === "all" || type === "textbook") {
|
||||||
tasks.push(searchTextbooks(kw, pageSize))
|
tasks.push(searchTextbooks(kw, pageSize))
|
||||||
}
|
}
|
||||||
if (type === "all" || type === "exam") {
|
// 学生不能搜索考试
|
||||||
|
if (!isStudent && (type === "all" || type === "exam")) {
|
||||||
tasks.push(searchExams(kw, pageSize))
|
tasks.push(searchExams(kw, pageSize))
|
||||||
}
|
}
|
||||||
if (type === "all" || type === "announcement") {
|
if (type === "all" || type === "announcement") {
|
||||||
|
|||||||
@@ -190,3 +190,54 @@
|
|||||||
font-feature-settings: "rlig" 1, "calt" 1;
|
font-feature-settings: "rlig" 1, "calt" 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- 备课画布:锚点与连线透明度规则 ---- */
|
||||||
|
/* 范围锚定文本背景:默认透明(opacity 0),选中关联节点时显示(opacity 0.3) */
|
||||||
|
.range-anchor {
|
||||||
|
background-color: var(--node-color, #1976d2);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 1px;
|
||||||
|
}
|
||||||
|
.range-anchor.active {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.range-anchor:hover {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 点锚定占位符:默认半透明(opacity 0.3),选中关联节点时不透明(opacity 1) */
|
||||||
|
.point-anchor {
|
||||||
|
background-color: var(--node-color, #1976d2);
|
||||||
|
color: #fff;
|
||||||
|
opacity: 0.3;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
margin: 0 1px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 1.2em;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.point-anchor.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.point-anchor:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 锚点连线:默认 10% 透明度,选中关联节点时完整显示 */
|
||||||
|
.anchor-edge {
|
||||||
|
opacity: 0.1;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.anchor-edge.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
import { cache } from "react"
|
|
||||||
|
|
||||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
import { getClassSchedule, getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access"
|
import { getClassSchedule, getStudentClasses, getStudentSchedule, getTeacherClasses, getTeacherIdForMutations } from "@/modules/classes/data-access"
|
||||||
import {
|
import {
|
||||||
getHomeworkAssignments,
|
getHomeworkAssignments,
|
||||||
@@ -19,6 +19,7 @@ import { getAdminDashboardData } from "./data-access"
|
|||||||
import type {
|
import type {
|
||||||
AdminDashboardData,
|
AdminDashboardData,
|
||||||
StudentDashboardProps,
|
StudentDashboardProps,
|
||||||
|
StudentTodayScheduleItem,
|
||||||
TeacherDashboardData,
|
TeacherDashboardData,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
import type { ParentDashboardData } from "@/modules/parent/types"
|
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||||
@@ -35,47 +36,59 @@ import {
|
|||||||
* 获取管理员仪表盘数据。
|
* 获取管理员仪表盘数据。
|
||||||
* 权限:DASHBOARD_ADMIN_READ
|
* 权限:DASHBOARD_ADMIN_READ
|
||||||
*/
|
*/
|
||||||
export async function getAdminDashboardAction(): Promise<AdminDashboardData> {
|
export async function getAdminDashboardAction(): Promise<ActionState<AdminDashboardData>> {
|
||||||
const ctx = await requirePermission(Permissions.DASHBOARD_ADMIN_READ)
|
try {
|
||||||
return getAdminDashboardData(ctx.dataScope)
|
const ctx = await requirePermission(Permissions.DASHBOARD_ADMIN_READ)
|
||||||
|
const data = await getAdminDashboardData(ctx.dataScope)
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取教师仪表盘数据(含派生指标)。
|
* 获取教师仪表盘数据(含派生指标)。
|
||||||
* 权限:DASHBOARD_TEACHER_READ
|
* 权限:DASHBOARD_TEACHER_READ
|
||||||
*/
|
*/
|
||||||
export async function getTeacherDashboardAction(): Promise<TeacherDashboardData & {
|
export async function getTeacherDashboardAction(): Promise<ActionState<TeacherDashboardData & {
|
||||||
metrics: TeacherDashboardMetrics
|
metrics: TeacherDashboardMetrics
|
||||||
}> {
|
}>> {
|
||||||
await requirePermission(Permissions.DASHBOARD_TEACHER_READ)
|
try {
|
||||||
const teacherId = await getTeacherIdForMutations()
|
await requirePermission(Permissions.DASHBOARD_TEACHER_READ)
|
||||||
|
const teacherId = await getTeacherIdForMutations()
|
||||||
|
|
||||||
const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
|
const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([
|
||||||
getTeacherClasses({ teacherId }),
|
getTeacherClasses({ teacherId }),
|
||||||
getClassSchedule({ teacherId }),
|
getClassSchedule({ teacherId }),
|
||||||
getHomeworkAssignments({ creatorId: teacherId }),
|
getHomeworkAssignments({ creatorId: teacherId }),
|
||||||
getHomeworkSubmissions({ creatorId: teacherId }),
|
getHomeworkSubmissions({ creatorId: teacherId }),
|
||||||
getUserBasicInfo(teacherId),
|
getUserBasicInfo(teacherId),
|
||||||
getTeacherGradeTrends(teacherId),
|
getTeacherGradeTrends(teacherId),
|
||||||
])
|
])
|
||||||
|
|
||||||
const metrics = computeTeacherMetrics(
|
const metrics = computeTeacherMetrics(
|
||||||
classes,
|
classes,
|
||||||
schedule,
|
schedule,
|
||||||
assignments,
|
assignments,
|
||||||
submissions,
|
submissions,
|
||||||
gradeTrends,
|
gradeTrends,
|
||||||
new Date(),
|
new Date(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
classes,
|
success: true,
|
||||||
schedule,
|
data: {
|
||||||
assignments,
|
classes,
|
||||||
submissions,
|
schedule,
|
||||||
teacherName: teacherProfile?.name ?? "Teacher",
|
assignments,
|
||||||
gradeTrends,
|
submissions,
|
||||||
metrics,
|
teacherName: teacherProfile?.name ?? "Teacher",
|
||||||
|
gradeTrends,
|
||||||
|
metrics,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,40 +96,47 @@ export async function getTeacherDashboardAction(): Promise<TeacherDashboardData
|
|||||||
* 获取学生仪表盘数据(含派生指标)。
|
* 获取学生仪表盘数据(含派生指标)。
|
||||||
* 权限:DASHBOARD_STUDENT_READ
|
* 权限:DASHBOARD_STUDENT_READ
|
||||||
*/
|
*/
|
||||||
export async function getStudentDashboardAction(): Promise<{
|
export async function getStudentDashboardAction(): Promise<ActionState<{
|
||||||
student: { id: string; name: string } | null
|
student: { id: string; name: string } | null
|
||||||
dashboardProps: Omit<StudentDashboardProps, "studentName"> | null
|
dashboardProps: Omit<StudentDashboardProps, "studentName"> | null
|
||||||
}> {
|
}>> {
|
||||||
await requirePermission(Permissions.DASHBOARD_STUDENT_READ)
|
try {
|
||||||
const student = await getCurrentStudentUser()
|
await requirePermission(Permissions.DASHBOARD_STUDENT_READ)
|
||||||
if (!student) {
|
const student = await getCurrentStudentUser()
|
||||||
return { student: null, dashboardProps: null }
|
if (!student) {
|
||||||
}
|
return { success: true, data: { student: null, dashboardProps: null } }
|
||||||
|
}
|
||||||
|
|
||||||
const [classes, schedule, assignments, grades] = await Promise.all([
|
const [classes, schedule, assignments, grades] = await Promise.all([
|
||||||
getStudentClasses(student.id),
|
getStudentClasses(student.id),
|
||||||
getStudentSchedule(student.id),
|
getStudentSchedule(student.id),
|
||||||
getStudentHomeworkAssignments(student.id),
|
getStudentHomeworkAssignments(student.id),
|
||||||
getStudentDashboardGrades(student.id),
|
getStudentDashboardGrades(student.id),
|
||||||
])
|
])
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const stats = countStudentAssignments(assignments, now)
|
const stats = countStudentAssignments(assignments, now)
|
||||||
const todayWeekday = toWeekday(now)
|
const todayWeekday = toWeekday(now)
|
||||||
const todayScheduleItems = filterTodaySchedule(schedule, todayWeekday)
|
const todayScheduleItems = filterTodaySchedule<StudentTodayScheduleItem>(schedule, todayWeekday)
|
||||||
const upcomingAssignments = sortUpcomingAssignments(assignments, 6)
|
const upcomingAssignments = sortUpcomingAssignments(assignments, 6)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
student: { id: student.id, name: student.name },
|
success: true,
|
||||||
dashboardProps: {
|
data: {
|
||||||
enrolledClassCount: classes.length,
|
student: { id: student.id, name: student.name },
|
||||||
dueSoonCount: stats.dueSoonCount,
|
dashboardProps: {
|
||||||
overdueCount: stats.overdueCount,
|
enrolledClassCount: classes.length,
|
||||||
gradedCount: stats.gradedCount,
|
dueSoonCount: stats.dueSoonCount,
|
||||||
todayScheduleItems,
|
overdueCount: stats.overdueCount,
|
||||||
upcomingAssignments,
|
gradedCount: stats.gradedCount,
|
||||||
grades,
|
todayScheduleItems,
|
||||||
},
|
upcomingAssignments,
|
||||||
|
grades,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,23 +144,25 @@ export async function getStudentDashboardAction(): Promise<{
|
|||||||
* 获取家长仪表盘数据。
|
* 获取家长仪表盘数据。
|
||||||
* 权限:DASHBOARD_PARENT_READ
|
* 权限:DASHBOARD_PARENT_READ
|
||||||
*/
|
*/
|
||||||
export async function getParentDashboardAction(): Promise<{
|
export async function getParentDashboardAction(): Promise<ActionState<{
|
||||||
data: ParentDashboardData | null
|
data: ParentDashboardData | null
|
||||||
hasChildren: boolean
|
hasChildren: boolean
|
||||||
}> {
|
}>> {
|
||||||
const ctx = await requirePermission(Permissions.DASHBOARD_PARENT_READ)
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.DASHBOARD_PARENT_READ)
|
||||||
|
|
||||||
// 非 admin 且 dataScope 非 children 类型时,无孩子数据
|
// 非 admin 且 dataScope 非 children 类型时,无孩子数据
|
||||||
if (
|
if (
|
||||||
ctx.dataScope.type !== "all" &&
|
ctx.dataScope.type !== "all" &&
|
||||||
!(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0)
|
!(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0)
|
||||||
) {
|
) {
|
||||||
return { data: null, hasChildren: false }
|
return { success: true, data: { data: null, hasChildren: false } }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getParentDashboardData(ctx.userId)
|
||||||
|
return { success: true, data: { data, hasChildren: data.children.length > 0 } }
|
||||||
|
} catch (e) {
|
||||||
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await getParentDashboardData(ctx.userId)
|
|
||||||
return { data, hasChildren: data.children.length > 0 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 缓存版本(用于 RSC 直接调用,不走 Server Action 协议) */
|
|
||||||
export const getCachedAdminDashboard = cache(getAdminDashboardAction)
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
|
import { BarChart3 } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -11,13 +12,28 @@ import {
|
|||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts"
|
} from "recharts"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
interface UserGrowthChartProps {
|
interface UserGrowthChartProps {
|
||||||
data: Array<{ date: string; count: number }>
|
data: Array<{ date: string; count: number }>
|
||||||
|
/** Translation key for the line/tooltip label (e.g. "chart.newUsers" or "chart.newSubmissions") */
|
||||||
|
labelKey?: "chart.newUsers" | "chart.newSubmissions"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserGrowthChart({ data }: UserGrowthChartProps) {
|
export function UserGrowthChart({ data, labelKey = "chart.newUsers" }: UserGrowthChartProps) {
|
||||||
const t = useTranslations("dashboard")
|
const t = useTranslations("dashboard")
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={BarChart3}
|
||||||
|
title={t("empty.noData")}
|
||||||
|
description={t("empty.noDataDesc")}
|
||||||
|
className="border-none h-[240px]"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={240}>
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||||
@@ -41,7 +57,7 @@ export function UserGrowthChart({ data }: UserGrowthChartProps) {
|
|||||||
stroke="hsl(var(--primary))"
|
stroke="hsl(var(--primary))"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={{ fill: "hsl(var(--primary))", r: 3 }}
|
dot={{ fill: "hsl(var(--primary))", r: 3 }}
|
||||||
name={t("chart.newUsers")}
|
name={t(labelKey)}
|
||||||
/>
|
/>
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AlertCircle } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘通用错误边界回退 UI。
|
||||||
|
*
|
||||||
|
* 用于各角色 dashboard 路由的 `error.tsx`,消除重复代码。
|
||||||
|
* 接收 Next.js 注入的 `reset` 函数用于重试。
|
||||||
|
*/
|
||||||
|
export function DashboardErrorFallback({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||||
|
const t = useTranslations("dashboard")
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||||
|
<EmptyState
|
||||||
|
icon={AlertCircle}
|
||||||
|
title={t("error.loadFailed")}
|
||||||
|
description={t("error.loadFailedDesc")}
|
||||||
|
action={{
|
||||||
|
label: t("error.retry"),
|
||||||
|
onClick: () => reset(),
|
||||||
|
}}
|
||||||
|
className="border-none shadow-none h-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { getLocale, getTranslations } from "next-intl/server"
|
||||||
import { formatLongDate } from "@/shared/lib/utils"
|
import { formatLongDate } from "@/shared/lib/utils"
|
||||||
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
||||||
|
|
||||||
@@ -11,15 +9,16 @@ import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
|
|||||||
* 教师与学生仪表盘头部 90% 重复,统一抽象为此组件。
|
* 教师与学生仪表盘头部 90% 重复,统一抽象为此组件。
|
||||||
* 通过 `actions` slot 注入角色专属快捷操作。
|
* 通过 `actions` slot 注入角色专属快捷操作。
|
||||||
*/
|
*/
|
||||||
export function DashboardGreetingHeader({
|
export async function DashboardGreetingHeader({
|
||||||
userName,
|
userName,
|
||||||
actions,
|
actions,
|
||||||
}: {
|
}: {
|
||||||
userName: string
|
userName: string
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("dashboard")
|
const t = await getTranslations("dashboard")
|
||||||
const today = formatLongDate(new Date())
|
const locale = await getLocale()
|
||||||
|
const today = formatLongDate(new Date(), locale)
|
||||||
const greetingKey = getGreetingKey(new Date())
|
const greetingKey = getGreetingKey(new Date())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘通用加载骨架屏。
|
||||||
|
*
|
||||||
|
* 用于各角色 dashboard 路由的 `loading.tsx`,消除重复代码。
|
||||||
|
* 布局:页头 + 4 列统计卡片 + 列表骨架。
|
||||||
|
*/
|
||||||
|
export function DashboardLoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
<Skeleton className="mt-2 h-3 w-28" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-12 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||||
|
|
||||||
interface StudentDashboardHeaderProps {
|
interface StudentDashboardHeaderProps {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { BarChart3 } from "lucide-react"
|
import { BarChart3 } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations, useLocale } from "next-intl"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||||
@@ -13,6 +13,7 @@ import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
|||||||
|
|
||||||
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
|
export function StudentGradesCard({ grades }: { grades: StudentDashboardGradeProps }) {
|
||||||
const t = useTranslations("dashboard")
|
const t = useTranslations("dashboard")
|
||||||
|
const locale = useLocale()
|
||||||
const hasGradeTrend = grades.trend.length > 0
|
const hasGradeTrend = grades.trend.length > 0
|
||||||
const hasRecentGrades = grades.recent.length > 0
|
const hasRecentGrades = grades.recent.length > 0
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
title: item.assignmentTitle,
|
title: item.assignmentTitle,
|
||||||
score: Math.round(item.percentage),
|
score: Math.round(item.percentage),
|
||||||
fullTitle: item.assignmentTitle,
|
fullTitle: item.assignmentTitle,
|
||||||
submittedAt: formatDate(item.submittedAt),
|
submittedAt: formatDate(item.submittedAt, locale),
|
||||||
rawScore: item.score,
|
rawScore: item.score,
|
||||||
maxScore: item.maxScore,
|
maxScore: item.maxScore,
|
||||||
}))
|
}))
|
||||||
@@ -102,7 +103,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
|||||||
<TableCell className="tabular-nums">
|
<TableCell className="tabular-nums">
|
||||||
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
|
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt)}</TableCell>
|
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt, locale)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PenTool } from "lucide-react"
|
import { PenTool } from "lucide-react"
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getLocale, getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -27,7 +27,7 @@ const getActionVariant = (status: string): "default" | "secondary" | "outline" =
|
|||||||
return "default"
|
return "default"
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDueUrgency = (dueAt: string | null) => {
|
const getDueUrgency = (dueAt: string | null): "overdue" | "urgent" | "warning" | "normal" | null => {
|
||||||
if (!dueAt) return null
|
if (!dueAt) return null
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const due = new Date(dueAt)
|
const due = new Date(dueAt)
|
||||||
@@ -41,6 +41,7 @@ const getDueUrgency = (dueAt: string | null) => {
|
|||||||
|
|
||||||
export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) {
|
||||||
const t = await getTranslations("dashboard")
|
const t = await getTranslations("dashboard")
|
||||||
|
const locale = await getLocale()
|
||||||
const hasAssignments = upcomingAssignments.length > 0
|
const hasAssignments = upcomingAssignments.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -60,6 +61,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: {
|
|||||||
icon={PenTool}
|
icon={PenTool}
|
||||||
title={t("empty.noAssignmentsStudent")}
|
title={t("empty.noAssignmentsStudent")}
|
||||||
description={t("empty.noAssignmentsStudentDesc")}
|
description={t("empty.noAssignmentsStudentDesc")}
|
||||||
|
action={{ label: t("quickActions.viewAll"), href: "/student/learning/assignments" }}
|
||||||
className="border-none h-72"
|
className="border-none h-72"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -103,7 +105,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: {
|
|||||||
!isGraded && urgency === "overdue" && "text-destructive font-medium",
|
!isGraded && urgency === "overdue" && "text-destructive font-medium",
|
||||||
!isGraded && urgency === "urgent" && "text-orange-500 font-medium"
|
!isGraded && urgency === "urgent" && "text-orange-500 font-medium"
|
||||||
)}>
|
)}>
|
||||||
{a.dueAt ? formatDate(a.dueAt) : "-"}
|
{a.dueAt ? formatDate(a.dueAt, locale) : "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
<TableCell className="tabular-nums">{a.latestScore ?? "-"}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { getTranslations } from "next-intl/server"
|
import { getTranslations, getLocale } from "next-intl/server"
|
||||||
import { Inbox, ArrowRight } from "lucide-react"
|
import { Inbox, ArrowRight } from "lucide-react"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/shared/components/ui/avatar"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
@@ -32,6 +32,7 @@ export async function RecentSubmissions({
|
|||||||
emptyDescription,
|
emptyDescription,
|
||||||
}: RecentSubmissionsProps) {
|
}: RecentSubmissionsProps) {
|
||||||
const t = await getTranslations("dashboard")
|
const t = await getTranslations("dashboard")
|
||||||
|
const locale = await getLocale()
|
||||||
const hasSubmissions = submissions.length > 0
|
const hasSubmissions = submissions.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,7 +74,6 @@ export async function RecentSubmissions({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar className="h-8 w-8 border">
|
<Avatar className="h-8 w-8 border">
|
||||||
<AvatarImage src={undefined} alt={item.studentName} />
|
|
||||||
<AvatarFallback className="bg-primary/10 text-primary text-xs">
|
<AvatarFallback className="bg-primary/10 text-primary text-xs">
|
||||||
{item.studentName.charAt(0)}
|
{item.studentName.charAt(0)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
@@ -93,7 +93,7 @@ export async function RecentSubmissions({
|
|||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{item.submittedAt ? formatDate(item.submittedAt) : "-"}
|
{item.submittedAt ? formatDate(item.submittedAt, locale) : "-"}
|
||||||
</span>
|
</span>
|
||||||
{item.isLate && (
|
{item.isLate && (
|
||||||
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
|
<Badge variant="destructive" className="w-fit text-[10px] h-4 px-1.5 font-normal">
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
import { DashboardGreetingHeader } from "../dashboard-greeting-header"
|
||||||
import { TeacherQuickActions } from "./teacher-quick-actions"
|
import { TeacherQuickActions } from "./teacher-quick-actions"
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[]
|
|||||||
.reverse()
|
.reverse()
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((item, i) => (
|
.map((item, i) => (
|
||||||
<div key={i} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
|
<div key={item.fullTitle || `item-${i}`} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
|
||||||
<div className="text-xs text-muted-foreground truncate" title={item.fullTitle}>
|
<div className="text-xs text-muted-foreground truncate" title={item.fullTitle}>
|
||||||
{item.fullTitle}
|
{item.fullTitle}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { PlusCircle, CheckSquare, Users } from "lucide-react"
|
import { PlusCircle, CheckSquare, Users } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
|
||||||
export function TeacherQuickActions() {
|
export async function TeacherQuickActions() {
|
||||||
const t = useTranslations("dashboard")
|
const t = await getTranslations("dashboard")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleIt
|
|||||||
const t = await getTranslations("dashboard")
|
const t = await getTranslations("dashboard")
|
||||||
const hasSchedule = items.length > 0
|
const hasSchedule = items.length > 0
|
||||||
|
|
||||||
const getStatus = (start: string, end: string) => {
|
const getStatus = (start: string, end: string): "live" | "upcoming" | "past" => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const currentTime = now.getHours() * 60 + now.getMinutes()
|
const currentTime = now.getHours() * 60 + now.getMinutes()
|
||||||
|
|
||||||
const [startH, startM] = start.split(":").map(Number)
|
const [startH, startM] = start.split(":").map(Number)
|
||||||
const [endH, endM] = end.split(":").map(Number)
|
const [endH, endM] = end.split(":").map(Number)
|
||||||
const startTime = (startH ?? 0) * 60 + (startM ?? 0)
|
const startTime = (Number.isFinite(startH) ? startH : 0) * 60 + (Number.isFinite(startM) ? startM : 0)
|
||||||
const endTime = (endH ?? 0) * 60 + (endM ?? 0)
|
const endTime = (Number.isFinite(endH) ? endH : 0) * 60 + (Number.isFinite(endM) ? endM : 0)
|
||||||
|
|
||||||
if (currentTime >= startTime && currentTime <= endTime) return "live"
|
if (currentTime >= startTime && currentTime <= endTime) return "live"
|
||||||
if (currentTime < startTime) return "upcoming"
|
if (currentTime < startTime) return "upcoming"
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ interface TeacherStatsProps {
|
|||||||
activeAssignmentsCount: number
|
activeAssignmentsCount: number
|
||||||
averageScore: number
|
averageScore: number
|
||||||
submissionRate: number
|
submissionRate: number
|
||||||
isLoading?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function TeacherStats({
|
export async function TeacherStats({
|
||||||
@@ -15,7 +14,6 @@ export async function TeacherStats({
|
|||||||
activeAssignmentsCount,
|
activeAssignmentsCount,
|
||||||
averageScore,
|
averageScore,
|
||||||
submissionRate,
|
submissionRate,
|
||||||
isLoading = false,
|
|
||||||
}: TeacherStatsProps) {
|
}: TeacherStatsProps) {
|
||||||
const t = await getTranslations("dashboard")
|
const t = await getTranslations("dashboard")
|
||||||
|
|
||||||
@@ -29,7 +27,6 @@ export async function TeacherStats({
|
|||||||
href="/teacher/homework/submissions?status=submitted"
|
href="/teacher/homework/submissions?status=submitted"
|
||||||
highlight={toGradeCount > 0}
|
highlight={toGradeCount > 0}
|
||||||
color="text-amber-500"
|
color="text-amber-500"
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t("stats.activeAssignments")}
|
title={t("stats.activeAssignments")}
|
||||||
@@ -38,7 +35,6 @@ export async function TeacherStats({
|
|||||||
icon={PenTool}
|
icon={PenTool}
|
||||||
href="/teacher/homework/assignments?status=published"
|
href="/teacher/homework/assignments?status=published"
|
||||||
color="text-blue-500"
|
color="text-blue-500"
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t("stats.averageScore")}
|
title={t("stats.averageScore")}
|
||||||
@@ -47,7 +43,6 @@ export async function TeacherStats({
|
|||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
href="#grade-trends"
|
href="#grade-trends"
|
||||||
color="text-emerald-500"
|
color="text-emerald-500"
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title={t("stats.submissionRate")}
|
title={t("stats.submissionRate")}
|
||||||
@@ -56,7 +51,6 @@ export async function TeacherStats({
|
|||||||
icon={BarChart}
|
icon={BarChart}
|
||||||
href="#grade-trends"
|
href="#grade-trends"
|
||||||
color="text-purple-500"
|
color="text-purple-500"
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,13 +49,17 @@ export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{items
|
{items
|
||||||
.filter((item) => item.count > 0)
|
.filter((item) => item.count > 0)
|
||||||
.sort((a, b) => (a.variant === "urgent" ? -1 : 1) - (b.variant === "urgent" ? -1 : 1))
|
.sort((a, b) => {
|
||||||
|
if (a.variant === "urgent" && b.variant !== "urgent") return -1
|
||||||
|
if (a.variant !== "urgent" && b.variant === "urgent") return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
.map((item, idx) => {
|
.map((item, idx) => {
|
||||||
const style = VARIANT_STYLES[item.variant]
|
const style = VARIANT_STYLES[item.variant]
|
||||||
const Icon = style.icon
|
const Icon = style.icon
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={idx}
|
key={item.href || item.label || `item-${idx}`}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="group flex items-center justify-between rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 hover:border-border transition-colors"
|
className="group flex items-center justify-between rounded-md border border-transparent px-3 py-2 hover:bg-muted/50 hover:border-border transition-colors"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export function countStudentAssignments(
|
|||||||
}
|
}
|
||||||
if (!a.dueAt) continue
|
if (!a.dueAt) continue
|
||||||
const due = new Date(a.dueAt)
|
const due = new Date(a.dueAt)
|
||||||
|
if (Number.isNaN(due.getTime())) continue
|
||||||
if (due >= now && due <= in7Days) {
|
if (due >= now && due <= in7Days) {
|
||||||
dueSoonCount++
|
dueSoonCount++
|
||||||
} else if (due < now) {
|
} else if (due < now) {
|
||||||
@@ -85,8 +86,10 @@ export function sortUpcomingAssignments(
|
|||||||
): StudentHomeworkAssignmentListItem[] {
|
): StudentHomeworkAssignmentListItem[] {
|
||||||
return [...assignments]
|
return [...assignments]
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
const aTime = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||||
const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
const bTime = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY
|
||||||
|
const aDue = Number.isNaN(aTime) ? Number.POSITIVE_INFINITY : aTime
|
||||||
|
const bDue = Number.isNaN(bTime) ? Number.POSITIVE_INFINITY : bTime
|
||||||
return aDue - bDue
|
return aDue - bDue
|
||||||
})
|
})
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
@@ -94,12 +97,15 @@ export function sortUpcomingAssignments(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从课表中筛选指定周几的课程,按开始时间升序排序。
|
* 从课表中筛选指定周几的课程,按开始时间升序排序。
|
||||||
|
*
|
||||||
|
* 泛型 T 允许调用方指定返回的课表项类型(StudentTodayScheduleItem 或
|
||||||
|
* TeacherTodayScheduleItem)。两者结构完全相同,泛型仅用于类型层面。
|
||||||
*/
|
*/
|
||||||
export function filterTodaySchedule(
|
export function filterTodaySchedule<T extends StudentTodayScheduleItem | TeacherTodayScheduleItem = StudentTodayScheduleItem | TeacherTodayScheduleItem>(
|
||||||
schedule: readonly ClassScheduleItem[],
|
schedule: readonly ClassScheduleItem[],
|
||||||
weekday: Weekday,
|
weekday: Weekday,
|
||||||
classNameById?: ReadonlyMap<string, string>,
|
classNameById?: ReadonlyMap<string, string>,
|
||||||
): StudentTodayScheduleItem[] | TeacherTodayScheduleItem[] {
|
): T[] {
|
||||||
return schedule
|
return schedule
|
||||||
.filter((s) => s.weekday === weekday)
|
.filter((s) => s.weekday === weekday)
|
||||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||||
@@ -111,7 +117,7 @@ export function filterTodaySchedule(
|
|||||||
startTime: s.startTime,
|
startTime: s.startTime,
|
||||||
endTime: s.endTime,
|
endTime: s.endTime,
|
||||||
location: s.location ?? null,
|
location: s.location ?? null,
|
||||||
})) as StudentTodayScheduleItem[] | TeacherTodayScheduleItem[]
|
})) as T[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 教师仪表盘派生指标 */
|
/** 教师仪表盘派生指标 */
|
||||||
@@ -138,22 +144,24 @@ export function computeTeacherMetrics(
|
|||||||
const todayWeekday = toWeekday(now)
|
const todayWeekday = toWeekday(now)
|
||||||
const classNameById = new Map(classes.map((c) => [c.id, c.name] as const))
|
const classNameById = new Map(classes.map((c) => [c.id, c.name] as const))
|
||||||
|
|
||||||
const todayScheduleItems = filterTodaySchedule(
|
const todayScheduleItems = filterTodaySchedule<TeacherTodayScheduleItem>(
|
||||||
schedule,
|
schedule,
|
||||||
todayWeekday,
|
todayWeekday,
|
||||||
classNameById,
|
classNameById,
|
||||||
) as TeacherTodayScheduleItem[]
|
)
|
||||||
|
|
||||||
const submittedSubmissions = submissions.filter((s) => Boolean(s.submittedAt))
|
const submittedSubmissions = submissions.filter((s) => Boolean(s.submittedAt))
|
||||||
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length
|
||||||
|
|
||||||
const submissionsToGrade = submittedSubmissions
|
const submissionsToGrade = submittedSubmissions
|
||||||
.filter((s) => s.status === "submitted")
|
.filter((s) => s.status === "submitted")
|
||||||
.sort(
|
.sort((a, b) => {
|
||||||
(a, b) =>
|
const aTime = a.submittedAt ? new Date(a.submittedAt).getTime() : 0
|
||||||
(a.submittedAt ? new Date(a.submittedAt).getTime() : 0) -
|
const bTime = b.submittedAt ? new Date(b.submittedAt).getTime() : 0
|
||||||
(b.submittedAt ? new Date(b.submittedAt).getTime() : 0),
|
const aDue = Number.isNaN(aTime) ? 0 : aTime
|
||||||
)
|
const bDue = Number.isNaN(bTime) ? 0 : bTime
|
||||||
|
return aDue - bDue
|
||||||
|
})
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
|
|
||||||
const activeAssignmentsCount = assignments.filter((a) => a.status === "published").length
|
const activeAssignmentsCount = assignments.filter((a) => a.status === "published").length
|
||||||
|
|||||||
2
src/next-auth.d.ts
vendored
2
src/next-auth.d.ts
vendored
@@ -8,6 +8,7 @@ declare module "next-auth" {
|
|||||||
role: string // kept for backward compatibility
|
role: string // kept for backward compatibility
|
||||||
roles: Role[]
|
roles: Role[]
|
||||||
permissions: Permission[]
|
permissions: Permission[]
|
||||||
|
onboarded: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,5 +19,6 @@ declare module "next-auth/jwt" {
|
|||||||
role: string // kept for backward compatibility
|
role: string // kept for backward compatibility
|
||||||
roles: Role[]
|
roles: Role[]
|
||||||
permissions: Permission[]
|
permissions: Permission[]
|
||||||
|
onboarded: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/proxy.ts
45
src/proxy.ts
@@ -7,14 +7,26 @@ import { Permissions } from "@/shared/types/permissions"
|
|||||||
// Route prefix → minimum required permission
|
// Route prefix → minimum required permission
|
||||||
// Note: /admin/announcements is covered by /admin prefix (requires school:manage)
|
// Note: /admin/announcements is covered by /admin prefix (requires school:manage)
|
||||||
// Note: /announcements is accessible to all authenticated users (no permission entry needed)
|
// Note: /announcements is accessible to all authenticated users (no permission entry needed)
|
||||||
|
// P0 修复:原先 /teacher 和 /parent 都使用 EXAM_READ,但 student/parent 也有 EXAM_READ,
|
||||||
|
// 导致跨角色访问漏洞(学生可访问 /teacher/*,教师可访问 /parent/*)。
|
||||||
|
// 改为使用各角色独有的权限点,确保跨角色访问被拒绝。
|
||||||
const ROUTE_PERMISSIONS: Record<string, string> = {
|
const ROUTE_PERMISSIONS: Record<string, string> = {
|
||||||
"/admin": Permissions.SCHOOL_MANAGE,
|
"/admin": Permissions.SCHOOL_MANAGE,
|
||||||
"/teacher": Permissions.EXAM_READ,
|
"/teacher": Permissions.EXAM_CREATE,
|
||||||
"/student": Permissions.HOMEWORK_SUBMIT,
|
"/student": Permissions.HOMEWORK_SUBMIT,
|
||||||
"/parent": Permissions.EXAM_READ,
|
"/parent": Permissions.DASHBOARD_PARENT_READ,
|
||||||
"/management": Permissions.GRADE_MANAGE,
|
"/management": Permissions.GRADE_MANAGE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仪表盘路由的细粒度权限(覆盖 ROUTE_PERMISSIONS 的前缀匹配)
|
||||||
|
// 防止拥有 EXAM_READ 的学生/家长访问 /teacher/dashboard 等
|
||||||
|
const DASHBOARD_ROUTE_PERMISSIONS: Record<string, string> = {
|
||||||
|
"/admin/dashboard": Permissions.DASHBOARD_ADMIN_READ,
|
||||||
|
"/teacher/dashboard": Permissions.DASHBOARD_TEACHER_READ,
|
||||||
|
"/student/dashboard": Permissions.DASHBOARD_STUDENT_READ,
|
||||||
|
"/parent/dashboard": Permissions.DASHBOARD_PARENT_READ,
|
||||||
|
}
|
||||||
|
|
||||||
// API route prefix → required permission
|
// API route prefix → required permission
|
||||||
const API_PERMISSIONS: Record<string, string> = {
|
const API_PERMISSIONS: Record<string, string> = {
|
||||||
"/api/ai/chat": Permissions.AI_CHAT,
|
"/api/ai/chat": Permissions.AI_CHAT,
|
||||||
@@ -57,6 +69,22 @@ export async function proxy(request: NextRequest) {
|
|||||||
return NextResponse.redirect(loginUrl)
|
return NextResponse.redirect(loginUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Onboarding gate: 未完成引导的用户只能访问 /onboarding 与白名单路径
|
||||||
|
// 修复 P2-1:用 middleware 强制重定向替代客户端 Dialog
|
||||||
|
const onboarded = Boolean(token.onboarded)
|
||||||
|
const isOnboardingPath = pathname === "/onboarding" || pathname.startsWith("/onboarding/")
|
||||||
|
const isWhitelistedApi = pathname.startsWith("/api/auth") || pathname.startsWith("/api/onboarding")
|
||||||
|
if (!onboarded && !isOnboardingPath && !isWhitelistedApi) {
|
||||||
|
const onboardingUrl = new URL("/onboarding", request.url)
|
||||||
|
return NextResponse.redirect(onboardingUrl)
|
||||||
|
}
|
||||||
|
// 已完成 onboarding 的用户不应停留在 /onboarding
|
||||||
|
if (onboarded && isOnboardingPath) {
|
||||||
|
const roles: string[] = (token.roles as string[]) ?? []
|
||||||
|
const defaultPath = resolveDefaultPath(roles)
|
||||||
|
return NextResponse.redirect(new URL(defaultPath, request.url))
|
||||||
|
}
|
||||||
|
|
||||||
const permissions: string[] = (token.permissions as string[]) ?? []
|
const permissions: string[] = (token.permissions as string[]) ?? []
|
||||||
const roles: string[] = (token.roles as string[]) ?? []
|
const roles: string[] = (token.roles as string[]) ?? []
|
||||||
|
|
||||||
@@ -71,6 +99,19 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check page route permissions
|
// Check page route permissions
|
||||||
|
// 优先检查仪表盘路由的细粒度权限(防止跨角色访问仪表盘)
|
||||||
|
if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) {
|
||||||
|
const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname]
|
||||||
|
if (!permissions.includes(requiredPerm)) {
|
||||||
|
const defaultPath = resolveDefaultPath(roles)
|
||||||
|
const redirectUrl = new URL(defaultPath, request.url)
|
||||||
|
redirectUrl.searchParams.set("from", pathname)
|
||||||
|
redirectUrl.searchParams.set("reason", "forbidden")
|
||||||
|
return NextResponse.redirect(redirectUrl)
|
||||||
|
}
|
||||||
|
return NextResponse.next()
|
||||||
|
}
|
||||||
|
|
||||||
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
|
for (const [prefix, requiredPerm] of Object.entries(ROUTE_PERMISSIONS)) {
|
||||||
if (pathname.startsWith(prefix)) {
|
if (pathname.startsWith(prefix)) {
|
||||||
if (!permissions.includes(requiredPerm)) {
|
if (!permissions.includes(requiredPerm)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user