diff --git a/src/app/(dashboard)/admin/announcements/[id]/page.tsx b/src/app/(dashboard)/admin/announcements/[id]/page.tsx index 437d176..825f688 100644 --- a/src/app/(dashboard)/admin/announcements/[id]/page.tsx +++ b/src/app/(dashboard)/admin/announcements/[id]/page.tsx @@ -5,8 +5,7 @@ import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { getAnnouncementById } from "@/modules/announcements/data-access" -import { getGrades } from "@/modules/school/data-access" +import { getEditAnnouncementPageData } from "@/modules/announcements/data-access" import { AnnouncementForm } from "@/modules/announcements/components/announcement-form" export const dynamic = "force-dynamic" @@ -28,10 +27,7 @@ export default async function EditAnnouncementPage({ const { id } = await params const t = await getTranslations("announcements") - const [announcement, grades] = await Promise.all([ - getAnnouncementById(id), - getGrades(), - ]) + const { announcement, grades } = await getEditAnnouncementPageData(id) if (!announcement) notFound() @@ -44,7 +40,7 @@ export default async function EditAnnouncementPage({ ({ id: g.id, name: g.name }))} + grades={grades} /> ) diff --git a/src/app/(dashboard)/admin/announcements/page.tsx b/src/app/(dashboard)/admin/announcements/page.tsx index 762ec12..e0229a4 100644 --- a/src/app/(dashboard)/admin/announcements/page.tsx +++ b/src/app/(dashboard)/admin/announcements/page.tsx @@ -4,9 +4,7 @@ import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { getAnnouncements } from "@/modules/announcements/data-access" -import { getGrades } from "@/modules/school/data-access" -import { getAdminClasses } from "@/modules/classes/data-access" +import { getAdminAnnouncementsPageData } from "@/modules/announcements/data-access" import { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view" import { getSearchParam, type SearchParams } from "@/shared/lib/utils" import type { AnnouncementStatus } from "@/modules/announcements/types" @@ -34,17 +32,13 @@ export default async function AdminAnnouncementsPage({ const statusParam = getSearchParam(sp, "status") const status = isValidStatus(statusParam) ? statusParam : undefined - const [announcements, grades, classes] = await Promise.all([ - getAnnouncements({ status }), - getGrades(), - getAdminClasses(), - ]) + const { announcements, grades, classes } = await getAdminAnnouncementsPageData(status) return ( ({ id: g.id, name: g.name }))} - classes={classes.map((c) => ({ id: c.id, name: c.name }))} + grades={grades} + classes={classes} initialStatus={status} /> ) diff --git a/src/app/(dashboard)/admin/dashboard/error.tsx b/src/app/(dashboard)/admin/dashboard/error.tsx new file mode 100644 index 0000000..b5243b8 --- /dev/null +++ b/src/app/(dashboard)/admin/dashboard/error.tsx @@ -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 +} diff --git a/src/app/(dashboard)/admin/dashboard/loading.tsx b/src/app/(dashboard)/admin/dashboard/loading.tsx new file mode 100644 index 0000000..ba4d093 --- /dev/null +++ b/src/app/(dashboard)/admin/dashboard/loading.tsx @@ -0,0 +1,5 @@ +import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton" + +export default function AdminDashboardLoading() { + return +} diff --git a/src/app/(dashboard)/admin/error.tsx b/src/app/(dashboard)/admin/error.tsx index d55a9e8..d21d018 100644 --- a/src/app/(dashboard)/admin/error.tsx +++ b/src/app/(dashboard)/admin/error.tsx @@ -1,18 +1,20 @@ "use client" import { AlertCircle } from "lucide-react" +import { useTranslations } from "next-intl" import { EmptyState } from "@/shared/components/ui/empty-state" export default function AdminError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations("dashboard") return (
reset(), }} className="border-none shadow-none h-auto" diff --git a/src/app/(dashboard)/admin/school/grades/insights/error.tsx b/src/app/(dashboard)/admin/school/grades/insights/error.tsx new file mode 100644 index 0000000..958b584 --- /dev/null +++ b/src/app/(dashboard)/admin/school/grades/insights/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/admin/school/grades/insights/loading.tsx b/src/app/(dashboard)/admin/school/grades/insights/loading.tsx new file mode 100644 index 0000000..bc1ab65 --- /dev/null +++ b/src/app/(dashboard)/admin/school/grades/insights/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from "@/shared/components/ui/skeleton" + +export default function AdminGradesInsightsLoading() { + return ( +
+
+ + +
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/admin/school/grades/insights/page.tsx b/src/app/(dashboard)/admin/school/grades/insights/page.tsx index 687f7c1..0e7d3c1 100644 --- a/src/app/(dashboard)/admin/school/grades/insights/page.tsx +++ b/src/app/(dashboard)/admin/school/grades/insights/page.tsx @@ -1,4 +1,4 @@ -import Link from "next/link" +import Link from "next/link" import type { Metadata } from "next" import type { JSX } from "react" import { BarChart3 } from "lucide-react" @@ -7,13 +7,16 @@ import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getGrades } from "@/modules/school/data-access" import { getGradeHomeworkInsights } from "@/modules/classes/data-access" +import { getSchoolWideGradeSummary } from "@/modules/grades/data-access-analytics" +import { SchoolWideSummaryCard } from "@/modules/grades/components/school-wide-summary-card" import { EmptyState } from "@/shared/components/ui/empty-state" import { StatCard } from "@/shared/components/ui/stat-card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table" -import { formatDate, formatNumber, getSearchParam, type SearchParams } from "@/shared/lib/utils" +import { formatDate, formatNumber } from "@/shared/lib/utils" +import { getParam, type SearchParams } from "@/shared/lib/search-params" export const metadata: Metadata = { title: "年级作业洞察 - Next_Edu", @@ -27,15 +30,17 @@ export default async function AdminGradeInsightsPage({ }: { searchParams: Promise }): Promise { - await requirePermission(Permissions.SCHOOL_MANAGE) + const ctx = await requirePermission(Permissions.SCHOOL_MANAGE) const params = await searchParams - const gradeId = getSearchParam(params, "gradeId") + const gradeId = getParam(params, "gradeId") const selected = gradeId && gradeId !== "all" ? gradeId : "" - // grades 与 insights 无数据依赖,并行查询 - const [grades, insights] = await Promise.all([ + // grades、insights、全校汇总无数据依赖,并行查询 + const [grades, insights, schoolWideSummary] = await Promise.all([ getGrades(), selected ? getGradeHomeworkInsights({ gradeId: selected, limit: 50 }) : Promise.resolve(null), + // v3-P2-9:管理员全校成绩汇总视图 + getSchoolWideGradeSummary(ctx.dataScope), ]) return ( @@ -50,6 +55,11 @@ export default async function AdminGradeInsightsPage({
+ {/* v3-P2-9: 全校成绩汇总视图 */} + {schoolWideSummary.grades.length > 0 && ( + + )} + 筛选 diff --git a/src/app/(dashboard)/admin/school/page.tsx b/src/app/(dashboard)/admin/school/page.tsx index 6dd3ab2..7a1b4f7 100644 --- a/src/app/(dashboard)/admin/school/page.tsx +++ b/src/app/(dashboard)/admin/school/page.tsx @@ -3,5 +3,5 @@ import { redirect } from "next/navigation" export const dynamic = "force-dynamic" export default function AdminSchoolPage(): never { - redirect("/admin/school/classes") + redirect("/admin/school/schools") } diff --git a/src/app/(dashboard)/announcements/page.tsx b/src/app/(dashboard)/announcements/page.tsx index 1abfd0c..3fca5ac 100644 --- a/src/app/(dashboard)/announcements/page.tsx +++ b/src/app/(dashboard)/announcements/page.tsx @@ -97,7 +97,7 @@ export default async function AnnouncementsPage() { `/announcements/${id}`} + detailHrefPrefix="/announcements" /> ) diff --git a/src/app/(dashboard)/dashboard/error.tsx b/src/app/(dashboard)/dashboard/error.tsx index bbbeef7..656eacf 100644 --- a/src/app/(dashboard)/dashboard/error.tsx +++ b/src/app/(dashboard)/dashboard/error.tsx @@ -1,24 +1,7 @@ "use client" -import { AlertCircle } from "lucide-react" -import { useTranslations } from "next-intl" +import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback" -import { EmptyState } from "@/shared/components/ui/empty-state" - -export default function DashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { - const t = useTranslations("dashboard") - return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
- ) +export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + return } diff --git a/src/app/(dashboard)/dashboard/loading.tsx b/src/app/(dashboard)/dashboard/loading.tsx index e93c717..dc464d6 100644 --- a/src/app/(dashboard)/dashboard/loading.tsx +++ b/src/app/(dashboard)/dashboard/loading.tsx @@ -1,38 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" -import { Skeleton } from "@/shared/components/ui/skeleton" +import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton" export default function DashboardLoading() { - return ( -
-
- - -
- -
- {Array.from({ length: 4 }).map((_, i) => ( - - - - - - - - - - ))} -
- - - - - - - {Array.from({ length: 6 }).map((_, i) => ( - - ))} - - -
- ) + return } diff --git a/src/app/(dashboard)/management/grade/classes/error.tsx b/src/app/(dashboard)/management/grade/classes/error.tsx new file mode 100644 index 0000000..0b40391 --- /dev/null +++ b/src/app/(dashboard)/management/grade/classes/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/management/grade/classes/loading.tsx b/src/app/(dashboard)/management/grade/classes/loading.tsx new file mode 100644 index 0000000..5f3e115 --- /dev/null +++ b/src/app/(dashboard)/management/grade/classes/loading.tsx @@ -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 ( +
+
+ + +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/management/grade/error.tsx b/src/app/(dashboard)/management/grade/error.tsx new file mode 100644 index 0000000..24a712f --- /dev/null +++ b/src/app/(dashboard)/management/grade/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/management/grade/insights/error.tsx b/src/app/(dashboard)/management/grade/insights/error.tsx new file mode 100644 index 0000000..2b4ff36 --- /dev/null +++ b/src/app/(dashboard)/management/grade/insights/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/management/grade/insights/loading.tsx b/src/app/(dashboard)/management/grade/insights/loading.tsx new file mode 100644 index 0000000..f5e3106 --- /dev/null +++ b/src/app/(dashboard)/management/grade/insights/loading.tsx @@ -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 ( +
+
+ + +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/management/grade/loading.tsx b/src/app/(dashboard)/management/grade/loading.tsx new file mode 100644 index 0000000..6184825 --- /dev/null +++ b/src/app/(dashboard)/management/grade/loading.tsx @@ -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 ( +
+
+ + +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/app/(dashboard)/messages/[id]/page.tsx b/src/app/(dashboard)/messages/[id]/page.tsx index c00506a..1133985 100644 --- a/src/app/(dashboard)/messages/[id]/page.tsx +++ b/src/app/(dashboard)/messages/[id]/page.tsx @@ -1,5 +1,6 @@ import { notFound } from "next/navigation" import type { Metadata } from "next" +import type { JSX } from "react" import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" diff --git a/src/app/(dashboard)/parent/attendance/error.tsx b/src/app/(dashboard)/parent/attendance/error.tsx new file mode 100644 index 0000000..326af9b --- /dev/null +++ b/src/app/(dashboard)/parent/attendance/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/attendance/loading.tsx b/src/app/(dashboard)/parent/attendance/loading.tsx new file mode 100644 index 0000000..0d963fb --- /dev/null +++ b/src/app/(dashboard)/parent/attendance/loading.tsx @@ -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 ( +
+
+ + +
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + + + + + + +
+ {Array.from({ length: 3 }).map((_, j) => ( + + ))} +
+ +
+
+ ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/children/[studentId]/loading.tsx b/src/app/(dashboard)/parent/children/[studentId]/loading.tsx new file mode 100644 index 0000000..04449d9 --- /dev/null +++ b/src/app/(dashboard)/parent/children/[studentId]/loading.tsx @@ -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 ( +
+
+ +
+ + +
+
+ + + +
+ + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + + + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/children/[studentId]/page.tsx b/src/app/(dashboard)/parent/children/[studentId]/page.tsx index 7c719b8..95a3381 100644 --- a/src/app/(dashboard)/parent/children/[studentId]/page.tsx +++ b/src/app/(dashboard)/parent/children/[studentId]/page.tsx @@ -1,9 +1,17 @@ import { notFound } from "next/navigation" import { requireAuth } from "@/shared/lib/auth-guard" -import { verifyParentChildRelation, getChildDashboardData } from "@/modules/parent/data-access" +import { getSearchParam } from "@/shared/lib/utils" +import { + verifyParentChildRelation, + getChildDashboardData, + getChildNameList, +} from "@/modules/parent/data-access" import { ChildDetailHeader } from "@/modules/parent/components/child-detail-header" -import { ChildDetailPanel } from "@/modules/parent/components/child-detail-panel" +import { + ChildDetailPanel, + SiblingSwitcher, +} from "@/modules/parent/components/child-detail-panel" import { EmptyState } from "@/shared/components/ui/empty-state" import { ShieldAlert } from "lucide-react" @@ -11,10 +19,13 @@ export const dynamic = "force-dynamic" export default async function ChildDetailPage({ params, + searchParams, }: { params: Promise<{ studentId: string }> + searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { const { studentId } = await params + const sp = await searchParams const ctx = await requireAuth() // 校验当前家长与该子女存在关系(同时按 parentId + studentId 过滤,防止跨家庭信息泄露) @@ -38,15 +49,29 @@ export default async function ChildDetailPage({ ) } - const child = await getChildDashboardData(studentId, relation) + const [child, siblings] = await Promise.all([ + getChildDashboardData(studentId, relation), + getChildNameList(ctx.userId), + ]) if (!child) { notFound() } + const initialTab = getSearchParam(sp, "tab") + return (
- + + } + />
) } diff --git a/src/app/(dashboard)/parent/dashboard/error.tsx b/src/app/(dashboard)/parent/dashboard/error.tsx index 98cb9c3..926e7f7 100644 --- a/src/app/(dashboard)/parent/dashboard/error.tsx +++ b/src/app/(dashboard)/parent/dashboard/error.tsx @@ -1,24 +1,7 @@ "use client" -import { AlertCircle } from "lucide-react" -import { useTranslations } from "next-intl" +import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback" -import { EmptyState } from "@/shared/components/ui/empty-state" - -export default function ParentDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { - const t = useTranslations("dashboard") - return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
- ) +export default function ParentDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + return } diff --git a/src/app/(dashboard)/parent/diagnostic/page.tsx b/src/app/(dashboard)/parent/diagnostic/page.tsx index 5772871..eadb3ff 100644 --- a/src/app/(dashboard)/parent/diagnostic/page.tsx +++ b/src/app/(dashboard)/parent/diagnostic/page.tsx @@ -1,4 +1,4 @@ -import { Stethoscope } from "lucide-react" +import { Stethoscope, AlertCircle } from "lucide-react" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" @@ -9,9 +9,29 @@ import { ParentChildrenDataPage, ParentNoChildrenPage, } from "@/modules/parent/components/parent-children-data-page" +import { getUserNamesByIds } from "@/modules/users/data-access" +import { Card, CardContent } from "@/shared/components/ui/card" export const dynamic = "force-dynamic" +/** v4-P1-9: 单个子女的诊断数据(成功) */ +interface ChildDiagnosticSuccessItem { + studentId: string + studentName: string + status: "success" + summary: Awaited> + reports: Awaited>["reports"] +} + +/** v4-P1-9: 单个子女的诊断数据(失败) */ +interface ChildDiagnosticErrorItem { + studentId: string + studentName: string + status: "error" +} + +type ChildDiagnosticItem = ChildDiagnosticSuccessItem | ChildDiagnosticErrorItem + export default async function ParentDiagnosticPage() { const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ) @@ -27,25 +47,44 @@ export default async function ParentDiagnosticPage() { ) } - // 使用 allSettled 容错:单个子女查询失败不影响其他子女展示 + // 预先查询所有子女的姓名,用于错误卡片展示 + const childrenIds = ctx.dataScope.childrenIds + const nameMap = await getUserNamesByIds(childrenIds) + + // v4-P1-9: 使用 allSettled 容错,但保留 rejected 项渲染错误卡片 const results = await Promise.allSettled( - ctx.dataScope.childrenIds.map(async (id) => { - const [summary, reports] = await Promise.all([ + childrenIds.map(async (id) => { + const [summary, reportsResult] = await Promise.all([ getStudentMasterySummary(id), - getDiagnosticReports({ studentId: id }), + // v4-P1-3: 家长仅可见已发布报告,避免草稿泄露 + getDiagnosticReports( + { studentId: id, status: "published" }, + ctx.dataScope, + ), ]) - return { summary, reports, studentId: id } + return { summary, reports: reportsResult.reports, studentId: id } }), ) - const validItems = results - .filter( - (r): r is PromiseFulfilledResult<{ - summary: Awaited> - reports: Awaited> - studentId: string - }> => r.status === "fulfilled", - ) - .map((r) => r.value) + + const items: ChildDiagnosticItem[] = results.map((r, idx) => { + const studentId = childrenIds[idx] + const studentName = nameMap.get(studentId)?.name ?? "Unknown student" + if (r.status === "fulfilled") { + return { + studentId, + studentName, + status: "success" as const, + summary: r.value.summary, + reports: r.value.reports, + } + } + // v4-P1-9: rejected 项不再静默丢弃,渲染错误卡片 + return { + studentId, + studentName, + status: "error" as const, + } + }) return ( ( + items={items} + renderItem={(item) => ( <>
-

- {summary?.studentName ?? "Unknown student"} -

+

{item.studentName}

- + {item.status === "success" ? ( + + ) : ( + // v4-P1-9: 错误卡片,提示家长该子女数据加载失败 + + + + + )} )} /> diff --git a/src/app/(dashboard)/parent/error.tsx b/src/app/(dashboard)/parent/error.tsx new file mode 100644 index 0000000..539423a --- /dev/null +++ b/src/app/(dashboard)/parent/error.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/src/app/(dashboard)/parent/grades/error.tsx b/src/app/(dashboard)/parent/grades/error.tsx new file mode 100644 index 0000000..be5c1ae --- /dev/null +++ b/src/app/(dashboard)/parent/grades/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/parent/grades/loading.tsx b/src/app/(dashboard)/parent/grades/loading.tsx new file mode 100644 index 0000000..bb1a447 --- /dev/null +++ b/src/app/(dashboard)/parent/grades/loading.tsx @@ -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 ( +
+
+ + +
+
+ {Array.from({ length: 2 }).map((_, i) => ( + + + + + + + +
+ {Array.from({ length: 4 }).map((_, j) => ( + + ))} +
+ +
+
+ ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/parent/grades/page.tsx b/src/app/(dashboard)/parent/grades/page.tsx index bab2ce1..ed7c9df 100644 --- a/src/app/(dashboard)/parent/grades/page.tsx +++ b/src/app/(dashboard)/parent/grades/page.tsx @@ -1,16 +1,27 @@ -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getStudentGradeSummary } from "@/modules/grades/data-access" +import { getClassAverageTrend } from "@/modules/grades/data-access-ranking" import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary" +import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card" import { ParentChildrenDataPage, ParentNoChildrenPage, } from "@/modules/parent/components/parent-children-data-page" +import { ParentExportButton } from "@/modules/parent/components/parent-export-button" import { GraduationCap } from "lucide-react" +import type { ClassAverageTrendResult } from "@/modules/grades/types" export const dynamic = "force-dynamic" +interface ChildGradeItem { + studentId: string + summary: NonNullable>> + classAverageTrend: ClassAverageTrendResult | null +} + export default async function ParentGradesPage() { - const ctx = await getAuthContext() + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) { return ( @@ -26,26 +37,49 @@ export default async function ParentGradesPage() { // 使用 allSettled 容错:单个子女查询失败不影响其他子女展示 const results = await Promise.allSettled( - ctx.dataScope.childrenIds.map((id) => getStudentGradeSummary(id)), + ctx.dataScope.childrenIds.map(async (id) => { + const [summary, classAverageTrend] = await Promise.all([ + getStudentGradeSummary(id, ctx.dataScope), + // v3-P2-8:家长页面补齐趋势图,复用班级平均对比线 + getClassAverageTrend(id, undefined, undefined, ctx.dataScope), + ]) + return { summary, classAverageTrend, studentId: id } + }), ) - const validSummaries = results + const validItems: ChildGradeItem[] = results .filter( - (r): r is PromiseFulfilledResult>>> => - r.status === "fulfilled" && r.value !== null, + ( + r, + ): r is PromiseFulfilledResult<{ + summary: Awaited> + 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, + classAverageTrend: r.value.classAverageTrend, + })) return ( ( + items={validItems} + renderItem={({ studentId, summary, classAverageTrend }) => ( <> -

{summary.studentName}

+
+

{summary.studentName}

+ {/* v4-P1-12: 接入 exportGradesAction,支持按 studentId 导出 */} + +
+ {summary.records.length > 0 && ( + + )} )} diff --git a/src/app/(dashboard)/parent/leave/loading.tsx b/src/app/(dashboard)/parent/leave/loading.tsx new file mode 100644 index 0000000..045a4e5 --- /dev/null +++ b/src/app/(dashboard)/parent/leave/loading.tsx @@ -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 ( +
+
+ + +
+ + + + + + + + + + + +
+ ) +} diff --git a/src/app/(dashboard)/parent/leave/page.tsx b/src/app/(dashboard)/parent/leave/page.tsx new file mode 100644 index 0000000..c80c13c --- /dev/null +++ b/src/app/(dashboard)/parent/leave/page.tsx @@ -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 ( +
+
+

Leave Request

+

+ Submit a leave request for your child. +

+
+ + + + + + + + Online Leave Request + + + + +
+
Contact options
+
    +
  • + + Call the school office during working hours +
  • +
  • + + Send a message to the homeroom teacher via the Messages page +
  • +
  • + + Go to Messages + +
  • +
+
+
+
+
+ ) +} diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx index d234318..99876d7 100644 --- a/src/app/(dashboard)/profile/page.tsx +++ b/src/app/(dashboard)/profile/page.tsx @@ -6,10 +6,10 @@ import { User, Mail, Phone, MapPin, Calendar, Clock, Shield } from "lucide-react import { requireAuth } from "@/shared/lib/auth-guard" import { getUserProfile } from "@/modules/users/data-access" +import { AvatarUpload } from "@/modules/settings/components/avatar-upload" import { ProfileStudentOverview, ProfileStudentOverviewSkeleton } from "@/modules/settings/components/profile-student-overview" import { ProfileTeacherOverview, ProfileTeacherOverviewSkeleton } from "@/modules/settings/components/profile-teacher-overview" import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary" -import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar" import { Badge } from "@/shared/components/ui/badge" import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" @@ -50,18 +50,11 @@ export default async function ProfilePage(): Promise { } /> -
- - {userProfile.image ? : null} - - {(userProfile.name ?? userProfile.email).slice(0, 2).toUpperCase()} - - -
-
{userProfile.name ?? "-"}
-
{userProfile.email}
-
-
+
diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 6562ad0..9826581 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -1,16 +1,18 @@ import { redirect } from "next/navigation" import { getTranslations } from "next-intl/server" +import { headers } from "next/headers" import { requireAuth } from "@/shared/lib/auth-guard" import { SettingsView } from "@/modules/settings/components/settings-view" import { SettingsServiceProvider } from "@/modules/settings/components/settings-service-context" import { resolveRoleSettingsConfig } from "@/modules/settings/config/role-settings-config" import type { SettingsService } from "@/modules/settings/types" +import { + updateProfileAction, + updateNotificationPreferencesAction, +} from "@/modules/settings/actions-service" import { getUserProfile } from "@/modules/users/data-access" -import { updateUserProfile } from "@/modules/users/actions" import { getNotificationPreferences } from "@/modules/notifications/preferences" -import { updateNotificationPreferencesAction } from "@/modules/messaging/actions" -import type { UpdateNotificationPreferencesInput } from "@/modules/notifications/types" export const dynamic = "force-dynamic" @@ -18,32 +20,6 @@ export const metadata = { title: "Settings", } -/** - * 将通知偏好输入对象转换为 FormData,适配 updateNotificationPreferencesAction 的签名。 - * Action 内部通过 formData.get(key) === "on" 解析布尔值。 - */ -function buildNotificationFormData(input: UpdateNotificationPreferencesInput): FormData { - const formData = new FormData() - const booleanFields: Array = [ - "emailEnabled", - "smsEnabled", - "pushEnabled", - "homeworkNotifications", - "gradeNotifications", - "announcementNotifications", - "messageNotifications", - "attendanceNotifications", - "quietHoursEnabled", - ] - for (const field of booleanFields) { - const value = input[field] - if (value === true) formData.set(field, "on") - } - if (input.quietHoursStart) formData.set("quietHoursStart", input.quietHoursStart) - if (input.quietHoursEnd) formData.set("quietHoursEnd", input.quietHoursEnd) - return formData -} - export default async function SettingsPage() { const ctx = await requireAuth() @@ -56,22 +32,23 @@ export default async function SettingsPage() { const notificationPrefs = await getNotificationPreferences(userId) const t = await getTranslations("settings") + // 获取当前请求的 User-Agent,用于安全中心标记当前会话 + const headerList = await headers() + const currentUserAgent = headerList.get("user-agent") ?? "" + const config = resolveRoleSettingsConfig(roles) const description = t(config?.descriptionKey ?? "title") const backHref = config?.backHref ?? "/dashboard" const generalExtra = config?.generalExtra - // 构建 SettingsService 实现,注入到 SettingsServiceProvider - // 组件层通过 useSettingsService() 消费,不直接 import users/messaging actions + // 构建 SettingsService:仅传递 Server Action 引用 + // (Next.js 要求传递给 Client Component 的函数必须是 "use server" 标记的 Server Action) const service: SettingsService = { profile: { - getProfile: async () => getUserProfile(userId), - updateProfile: async (input) => updateUserProfile(input), + updateProfile: updateProfileAction, }, notifications: { - getPreferences: async () => getNotificationPreferences(userId), - updatePreferences: async (input) => - updateNotificationPreferencesAction(null, buildNotificationFormData(input)), + updatePreferences: updateNotificationPreferencesAction, }, } @@ -83,6 +60,7 @@ export default async function SettingsPage() { user={userProfile} notificationPreferences={notificationPrefs} generalExtra={generalExtra} + currentUserAgent={currentUserAgent} /> ) diff --git a/src/app/(dashboard)/student/attendance/error.tsx b/src/app/(dashboard)/student/attendance/error.tsx new file mode 100644 index 0000000..5cfd446 --- /dev/null +++ b/src/app/(dashboard)/student/attendance/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/dashboard/error.tsx b/src/app/(dashboard)/student/dashboard/error.tsx new file mode 100644 index 0000000..d36b16e --- /dev/null +++ b/src/app/(dashboard)/student/dashboard/error.tsx @@ -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 +} diff --git a/src/app/(dashboard)/student/dashboard/loading.tsx b/src/app/(dashboard)/student/dashboard/loading.tsx index d6ad44c..d9e7c4a 100644 --- a/src/app/(dashboard)/student/dashboard/loading.tsx +++ b/src/app/(dashboard)/student/dashboard/loading.tsx @@ -1,61 +1,5 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" -import { Skeleton } from "@/shared/components/ui/skeleton" +import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton" -export default function Loading() { - return ( -
-
-
- - -
- -
- -
- {Array.from({ length: 4 }).map((_, i) => ( - - - - - - - - - - - ))} -
- -
- - - - - - - - {Array.from({ length: 4 }).map((_, i) => ( - - ))} - - - - - - - - - - - - {Array.from({ length: 5 }).map((_, i) => ( - - ))} - - -
-
- ) +export default function StudentDashboardLoading() { + return } - diff --git a/src/app/(dashboard)/student/diagnostic/error.tsx b/src/app/(dashboard)/student/diagnostic/error.tsx new file mode 100644 index 0000000..5bd3d0e --- /dev/null +++ b/src/app/(dashboard)/student/diagnostic/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/diagnostic/page.tsx b/src/app/(dashboard)/student/diagnostic/page.tsx index 2aee2a8..6b6d4cb 100644 --- a/src/app/(dashboard)/student/diagnostic/page.tsx +++ b/src/app/(dashboard)/student/diagnostic/page.tsx @@ -1,5 +1,6 @@ import { Stethoscope } from "lucide-react" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getStudentMasterySummary } from "@/modules/diagnostic/data-access" import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports" import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view" @@ -7,15 +8,20 @@ import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-d export const dynamic = "force-dynamic" export default async function StudentDiagnosticPage() { - const ctx = await getAuthContext() + const ctx = await requirePermission(Permissions.DIAGNOSTIC_READ) - const [summary, reports] = await Promise.all([ + const [summary, reportsResult] = await Promise.all([ getStudentMasterySummary(ctx.userId), - getDiagnosticReports({ studentId: ctx.userId }), + // v4-P1-3: 学生仅可见已发布报告,避免草稿泄露 + getDiagnosticReports( + { studentId: ctx.userId, status: "published" }, + ctx.dataScope, + ), ]) + const reports = reportsResult.reports return ( -
+

diff --git a/src/app/(dashboard)/student/grades/error.tsx b/src/app/(dashboard)/student/grades/error.tsx new file mode 100644 index 0000000..4f0086d --- /dev/null +++ b/src/app/(dashboard)/student/grades/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/student/grades/page.tsx b/src/app/(dashboard)/student/grades/page.tsx index 812abcc..a593c0c 100644 --- a/src/app/(dashboard)/student/grades/page.tsx +++ b/src/app/(dashboard)/student/grades/page.tsx @@ -1,9 +1,13 @@ +import { getTranslations } from "next-intl/server" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getStudentGradeSummary } from "@/modules/grades/data-access" +import { getRankingTrend, getClassAverageTrend } from "@/modules/grades/data-access-ranking" +import { getSubjectOptions } from "@/modules/school/data-access" import { StudentGradeSummary } from "@/modules/grades/components/student-grade-summary" import { GradeFilters } from "@/modules/grades/components/grade-filters" import { GradeTrendCard } from "@/modules/grades/components/grade-trend-card" +import { RankingTrendCard } from "@/modules/grades/components/ranking-trend-card" import { EmptyState } from "@/shared/components/ui/empty-state" import { UserX } from "lucide-react" import { getParam, type SearchParams } from "@/shared/lib/search-params" @@ -16,21 +20,28 @@ export default async function StudentGradesPage({ searchParams: Promise }) { const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) - const [sp, summary] = await Promise.all([ + const [sp, summary, rankingTrend, classAverageTrend, subjectOptions] = await Promise.all([ searchParams, - getStudentGradeSummary(ctx.userId), + getStudentGradeSummary(ctx.userId, ctx.dataScope), + // v3-P1-3:接入排名趋势图 + getRankingTrend(ctx.userId, undefined, undefined, ctx.dataScope), + // v3-P2-2:接入班级平均趋势对比线 + getClassAverageTrend(ctx.userId, undefined, undefined, ctx.dataScope), + // v3-P2-1:获取科目列表用于过滤器 + getSubjectOptions(), ]) if (!summary) { + const t = await getTranslations("grades") return (
-

My Grades

-

View your grade records.

+

{t("title.myGrades")}

+

{t("summary.noDataDescription")}

@@ -46,7 +57,8 @@ export default async function StudentGradesPage({ const filteredRecords = summary.records.filter((r) => { if (q && !r.title.toLowerCase().includes(q)) return false - if (subjectFilter !== "all" && r.subjectName !== subjectFilter) return false + // v3-P2-1 修复:按 subjectId 而非 subjectName 过滤 + if (subjectFilter !== "all" && r.subjectId !== subjectFilter) return false if (typeFilter !== "all" && r.type !== typeFilter) return false if (semesterFilter !== "all" && r.semester !== semesterFilter) return false return true @@ -60,11 +72,16 @@ export default async function StudentGradesPage({ return (
-

My Grades

-

View your grade records.

+

{summary.studentName}

+

{summary.records.length} 条成绩记录

- - {filteredSummary.records.length > 0 && } + ({ id: s.id, name: s.name }))} /> + {filteredSummary.records.length > 0 && ( +
+ + +
+ )}
) diff --git a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx index 4224608..33015ac 100644 --- a/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx +++ b/src/app/(dashboard)/student/learning/assignments/[assignmentId]/page.tsx @@ -24,7 +24,7 @@ export default async function StudentAssignmentTakePage({ const status = data.submission?.status if (status === "graded" || status === "submitted") { return ( -
+

{data.assignment.title}

@@ -38,7 +38,7 @@ export default async function StudentAssignmentTakePage({ } return ( -
+

{data.assignment.title}

diff --git a/src/app/(dashboard)/student/learning/assignments/page.tsx b/src/app/(dashboard)/student/learning/assignments/page.tsx index cb6375a..11f01a1 100644 --- a/src/app/(dashboard)/student/learning/assignments/page.tsx +++ b/src/app/(dashboard)/student/learning/assignments/page.tsx @@ -6,6 +6,7 @@ import { Button } from "@/shared/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { StatusBadge } from "@/shared/components/ui/status-badge" import { formatDate, cn } from "@/shared/lib/utils" +import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getStudentHomeworkAssignments } from "@/modules/homework/data-access" import { getCurrentStudentUser } from "@/modules/users/data-access" import { AssignmentFilters } from "@/modules/homework/components/assignment-filters" @@ -20,13 +21,6 @@ import { export const dynamic = "force-dynamic" -type SearchParams = { [key: string]: string | string[] | undefined } - -const getParam = (params: SearchParams, key: string) => { - const v = params[key] - return Array.isArray(v) ? v[0] : v -} - const getActionLabel = (status: StudentHomeworkProgressStatus, t: (key: string) => string): string => { switch (status) { case "graded": diff --git a/src/app/(dashboard)/student/learning/courses/loading.tsx b/src/app/(dashboard)/student/learning/courses/loading.tsx index c4a76bd..c7010a9 100644 --- a/src/app/(dashboard)/student/learning/courses/loading.tsx +++ b/src/app/(dashboard)/student/learning/courses/loading.tsx @@ -3,7 +3,7 @@ import { Skeleton } from "@/shared/components/ui/skeleton" export default function Loading() { return ( -
+
diff --git a/src/app/(dashboard)/student/learning/courses/page.tsx b/src/app/(dashboard)/student/learning/courses/page.tsx index daefa38..0974d1e 100644 --- a/src/app/(dashboard)/student/learning/courses/page.tsx +++ b/src/app/(dashboard)/student/learning/courses/page.tsx @@ -5,16 +5,10 @@ import { getCurrentStudentUser } from "@/modules/users/data-access" import { StudentCoursesView } from "@/modules/student/components/student-courses-view" import { CourseFilters } from "@/modules/student/components/course-filters" import { EmptyState } from "@/shared/components/ui/empty-state" +import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" -type SearchParams = { [key: string]: string | string[] | undefined } - -const getParam = (params: SearchParams, key: string) => { - const v = params[key] - return Array.isArray(v) ? v[0] : v -} - export default async function StudentCoursesPage({ searchParams, }: { diff --git a/src/app/(dashboard)/student/learning/page.tsx b/src/app/(dashboard)/student/learning/page.tsx new file mode 100644 index 0000000..227c786 --- /dev/null +++ b/src/app/(dashboard)/student/learning/page.tsx @@ -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 ( +
+ +
+ ) + } + + 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 ( +
+
+

My Learning

+

Your learning hub: courses, assignments, and textbooks.

+
+ +
+ {cards.map((c) => ( + + + + {c.title} + + + +

{c.description}

+
+ {c.stat} + +
+
+
+ + ))} +
+
+ ) +} diff --git a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx index 73b208e..d530257 100644 --- a/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/[id]/page.tsx @@ -3,8 +3,9 @@ import { getTranslations } from "next-intl/server" import { BookOpen } from "lucide-react" -import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access" +import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" import { TextbookReader } from "@/modules/textbooks/components/textbook-reader" +import { getSubjectLabelKey, getGradeLabelKey } from "@/modules/textbooks/constants" import { Badge } from "@/shared/components/ui/badge" import { EmptyState } from "@/shared/components/ui/empty-state" import { getCurrentStudentUser } from "@/modules/users/data-access" @@ -23,10 +24,9 @@ export default async function StudentTextbookDetailPage({ const { id } = await params - const [textbook, chapters, knowledgePoints] = await Promise.all([ + const [textbook, chapters] = await Promise.all([ getTextbookById(id), getChaptersByTextbookId(id), - getKnowledgePointsByTextbookId(id) ]) if (!textbook) notFound() @@ -45,9 +45,9 @@ export default async function StudentTextbookDetailPage({

{textbook.title}

@@ -66,7 +66,7 @@ export default async function StudentTextbookDetailPage({ ) : (
{/* 学生端不传 renderQuestionCreator,无题目创建权限 */} - +
)}
diff --git a/src/app/(dashboard)/student/learning/textbooks/page.tsx b/src/app/(dashboard)/student/learning/textbooks/page.tsx index 0140dba..bd79b9a 100644 --- a/src/app/(dashboard)/student/learning/textbooks/page.tsx +++ b/src/app/(dashboard)/student/learning/textbooks/page.tsx @@ -7,16 +7,10 @@ import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters import { getCurrentStudentUser } from "@/modules/users/data-access" import { getGradeNameById } from "@/modules/school/data-access" import { EmptyState } from "@/shared/components/ui/empty-state" +import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" -type SearchParams = { [key: string]: string | string[] | undefined } - -const getParam = (params: SearchParams, key: string): string | undefined => { - const v = params[key] - return Array.isArray(v) ? v[0] : v -} - export default async function StudentTextbooksPage({ searchParams, }: { diff --git a/src/app/(dashboard)/student/schedule/loading.tsx b/src/app/(dashboard)/student/schedule/loading.tsx index e59c48e..3199de9 100644 --- a/src/app/(dashboard)/student/schedule/loading.tsx +++ b/src/app/(dashboard)/student/schedule/loading.tsx @@ -3,7 +3,7 @@ import { Skeleton } from "@/shared/components/ui/skeleton" export default function Loading() { return ( -
+
diff --git a/src/app/(dashboard)/student/schedule/page.tsx b/src/app/(dashboard)/student/schedule/page.tsx index 6be0b63..a011fde 100644 --- a/src/app/(dashboard)/student/schedule/page.tsx +++ b/src/app/(dashboard)/student/schedule/page.tsx @@ -5,11 +5,10 @@ import { getCurrentStudentUser } from "@/modules/users/data-access" import { StudentScheduleFilters } from "@/modules/student/components/student-schedule-filters" import { StudentScheduleView } from "@/modules/student/components/student-schedule-view" import { EmptyState } from "@/shared/components/ui/empty-state" +import { getParam, type SearchParams } from "@/shared/lib/search-params" export const dynamic = "force-dynamic" -type SearchParams = { [key: string]: string | string[] | undefined } - export default async function StudentSchedulePage({ searchParams, }: { @@ -18,7 +17,7 @@ export default async function StudentSchedulePage({ const student = await getCurrentStudentUser() if (!student) { return ( -
+

Schedule

Your weekly timetable.

@@ -34,18 +33,12 @@ export default async function StudentSchedulePage({ getStudentSchedule(student.id), ]) - const classIdParam = sp.classId - const resolveClassId = (param: string | string[] | undefined): string => { - if (typeof param === "string") return param - if (Array.isArray(param)) return param[0] ?? "all" - return "all" - } - const classId = resolveClassId(classIdParam) + const classId = getParam(sp, "classId") ?? "all" const filteredItems = classId !== "all" ? schedule.filter((s) => s.classId === classId) : schedule return ( -
+

Schedule

diff --git a/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx b/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx index d3c7501..51d92c1 100644 --- a/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/course-plans/[id]/page.tsx @@ -1,6 +1,8 @@ import type { JSX } from "react" import { notFound } from "next/navigation" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getCoursePlanById } from "@/modules/course-plans/data-access" import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail" @@ -11,6 +13,7 @@ export default async function TeacherCoursePlanDetailPage({ }: { params: Promise<{ id: string }> }): Promise { + await requirePermission(Permissions.COURSE_PLAN_READ) const { id } = await params const plan = await getCoursePlanById(id) diff --git a/src/app/(dashboard)/teacher/course-plans/page.tsx b/src/app/(dashboard)/teacher/course-plans/page.tsx index e0ed1ca..1fbdbc0 100644 --- a/src/app/(dashboard)/teacher/course-plans/page.tsx +++ b/src/app/(dashboard)/teacher/course-plans/page.tsx @@ -1,5 +1,6 @@ import type { JSX } from "react" -import { getAuthContext } from "@/shared/lib/auth-guard" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" import { getCoursePlans } from "@/modules/course-plans/data-access" import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list" @@ -23,7 +24,7 @@ export default async function TeacherCoursePlansPage({ }: { searchParams: Promise }): Promise { - const ctx = await getAuthContext() + const ctx = await requirePermission(Permissions.COURSE_PLAN_READ) const teacherId = ctx.userId const sp = await searchParams diff --git a/src/app/(dashboard)/teacher/dashboard/error.tsx b/src/app/(dashboard)/teacher/dashboard/error.tsx index d6d5d56..ccc4c1d 100644 --- a/src/app/(dashboard)/teacher/dashboard/error.tsx +++ b/src/app/(dashboard)/teacher/dashboard/error.tsx @@ -1,24 +1,7 @@ "use client" -import { AlertCircle } from "lucide-react" -import { useTranslations } from "next-intl" +import { DashboardErrorFallback } from "@/modules/dashboard/components/dashboard-error-fallback" -import { EmptyState } from "@/shared/components/ui/empty-state" - -export default function TeacherDashboardError({ reset }: { error: Error & { digest?: string }; reset: () => void }) { - const t = useTranslations("dashboard") - return ( -
- reset(), - }} - className="border-none shadow-none h-auto" - /> -
- ) +export default function TeacherDashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + return } diff --git a/src/app/(dashboard)/teacher/dashboard/loading.tsx b/src/app/(dashboard)/teacher/dashboard/loading.tsx index 12cdafb..0ac89b1 100644 --- a/src/app/(dashboard)/teacher/dashboard/loading.tsx +++ b/src/app/(dashboard)/teacher/dashboard/loading.tsx @@ -1,38 +1,5 @@ -import { Card, CardContent, CardHeader } from "@/shared/components/ui/card" -import { Skeleton } from "@/shared/components/ui/skeleton" +import { DashboardLoadingSkeleton } from "@/modules/dashboard/components/dashboard-loading-skeleton" export default function TeacherDashboardLoading() { - return ( -
-
- - -
- -
- {Array.from({ length: 4 }).map((_, i) => ( - - - - - - - - - - ))} -
- - - - - - - {Array.from({ length: 6 }).map((_, i) => ( - - ))} - - -
- ) + return } diff --git a/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx b/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx index cffaaf3..224e7e3 100644 --- a/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx +++ b/src/app/(dashboard)/teacher/diagnostic/class/[classId]/page.tsx @@ -5,6 +5,7 @@ import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getClassMasterySummary } from "@/modules/diagnostic/data-access" import { ClassDiagnosticView } from "@/modules/diagnostic/components/class-diagnostic-view" +import { WidgetBoundary } from "@/modules/grades/components/widget-boundary" export const dynamic = "force-dynamic" @@ -41,7 +42,9 @@ export default async function ClassDiagnosticPage({ Class-level knowledge point mastery overview and student attention list.

- + + +
) } diff --git a/src/app/(dashboard)/teacher/diagnostic/page.tsx b/src/app/(dashboard)/teacher/diagnostic/page.tsx index d372471..09d4734 100644 --- a/src/app/(dashboard)/teacher/diagnostic/page.tsx +++ b/src/app/(dashboard)/teacher/diagnostic/page.tsx @@ -39,16 +39,19 @@ export default async function TeacherDiagnosticPage({ const reportType = getParam(sp, "reportType") const status = getParam(sp, "status") - const reports = await getDiagnosticReports({ - reportType: reportType && reportType !== "all" ? parseReportType(reportType) : undefined, - status: status && status !== "all" ? parseReportStatus(status) : undefined, - }) + const reports = await getDiagnosticReports( + { + reportType: reportType && reportType !== "all" ? parseReportType(reportType) : undefined, + status: status && status !== "all" ? parseReportStatus(status) : undefined, + }, + ctx.dataScope, + ) // 学生角色仅查看自己的报告;其他角色查看全部 const visibleReports = ctx.dataScope.type === "class_members" - ? reports.filter((r) => r.studentId === ctx.userId) - : reports + ? reports.reports.filter((r) => r.studentId === ctx.userId) + : reports.reports return (
diff --git a/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx b/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx index caff7f7..5029e73 100644 --- a/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx +++ b/src/app/(dashboard)/teacher/diagnostic/student/[studentId]/page.tsx @@ -8,7 +8,9 @@ import { getKnowledgePointStats, } from "@/modules/diagnostic/data-access" import { getDiagnosticReports } from "@/modules/diagnostic/data-access-reports" +import { getStudentActiveClassId } from "@/modules/classes/data-access" import { StudentDiagnosticView } from "@/modules/diagnostic/components/student-diagnostic-view" +import { WidgetBoundary } from "@/modules/grades/components/widget-boundary" import type { MasteryRadarPoint } from "@/modules/diagnostic/types" export const dynamic = "force-dynamic" @@ -29,11 +31,26 @@ export default async function StudentDiagnosticPage({ notFound() } - const [summary, reports, classStats] = await Promise.all([ + // v4-P1-2: class_taught scope 校验师生关系 + // 教师只能查看自己所教班级的学生诊断 + if (ctx.dataScope.type === "class_taught") { + const studentClassId = await getStudentActiveClassId(studentId) + if (!studentClassId || !ctx.dataScope.classIds.includes(studentClassId)) { + notFound() + } + } + + // 先查询学生所属班级,再用 classId 调用 getKnowledgePointStats + // 否则无参调用会导致 studentIds=[] 直接返回空数组,班级平均对比功能失效 + const studentClassId = await getStudentActiveClassId(studentId) + + const [summary, reportsResult, classStats] = await Promise.all([ getStudentMasterySummary(studentId), - getDiagnosticReports({ studentId }), - getKnowledgePointStats(), + // v4-P1-3: 教师视角可查看所有状态报告(含草稿),便于审核 + getDiagnosticReports({ studentId }, ctx.dataScope), + studentClassId ? getKnowledgePointStats(studentClassId) : Promise.resolve([]), ]) + const reports = reportsResult.reports // 班级平均掌握度(用于雷达图对比) let classAverageMastery: MasteryRadarPoint[] | undefined @@ -56,11 +73,14 @@ export default async function StudentDiagnosticPage({ Knowledge point mastery analysis and diagnostic reports.

- + + +
) } diff --git a/src/app/(dashboard)/teacher/elective/error.tsx b/src/app/(dashboard)/teacher/elective/error.tsx new file mode 100644 index 0000000..3bfcda0 --- /dev/null +++ b/src/app/(dashboard)/teacher/elective/error.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/app/(dashboard)/teacher/grades/analytics/page.tsx b/src/app/(dashboard)/teacher/grades/analytics/page.tsx index 662e200..6610de5 100644 --- a/src/app/(dashboard)/teacher/grades/analytics/page.tsx +++ b/src/app/(dashboard)/teacher/grades/analytics/page.tsx @@ -13,6 +13,7 @@ import { getSubjectOptions } from "@/modules/school/data-access" import { getClassComparison, + getExamOptionsForGrades, getGradeDistribution, getGradeTrend, getSubjectComparison, @@ -22,6 +23,7 @@ import { ClassComparisonChart } from "@/modules/grades/components/class-comparis import { SubjectComparisonChart } from "@/modules/grades/components/subject-comparison-chart" import { GradeDistributionChart } from "@/modules/grades/components/grade-distribution-chart" import { AnalyticsFilters } from "@/modules/grades/components/analytics-filters" +import { WidgetBoundary } from "@/modules/grades/components/widget-boundary" export const dynamic = "force-dynamic" @@ -36,6 +38,9 @@ export default async function GradeAnalyticsPage({ const classId = getParam(sp, "classId") const subjectId = getParam(sp, "subjectId") const gradeId = getParam(sp, "gradeId") + // v3-P2-7: 学期和考试筛选 + const examId = getParam(sp, "examId") + const semester = getParam(sp, "semester") const [classes, allGrades, allSubjects] = await Promise.all([ getTeacherClasses(), @@ -66,33 +71,50 @@ export default async function GradeAnalyticsPage({ const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined const targetGradeId = gradeId ?? allGrades[0]?.id + // v3-P2-7: 解析 semester 和 examId + const targetSemester: "1" | "2" | undefined = + semester === "1" || semester === "2" ? semester : undefined + const targetExamId = examId && examId !== "all" ? examId : undefined // Run analytics queries in parallel - const [trend, distribution, subjectComparison, classComparison] = + const [trend, distribution, subjectComparison, classComparison, examOptions] = await Promise.all([ getGradeTrend({ classId: targetClassId, subjectId: targetSubjectId, + semester: targetSemester, + examId: targetExamId, scope: ctx.dataScope, currentUserId: ctx.userId, }), getGradeDistribution({ classId: targetClassId, subjectId: targetSubjectId, + examId: targetExamId, + semester: targetSemester, scope: ctx.dataScope, currentUserId: ctx.userId, }), getSubjectComparison({ classId: targetClassId, + examId: targetExamId, + semester: targetSemester, scope: ctx.dataScope, }), targetGradeId ? getClassComparison({ gradeId: targetGradeId, subjectId: targetSubjectId ?? allSubjects[0]?.id ?? "", + examId: targetExamId, + semester: targetSemester, scope: ctx.dataScope, }) : Promise.resolve([]), + getExamOptionsForGrades({ + classId: targetClassId, + subjectId: targetSubjectId, + scope: ctx.dataScope, + }), ]) return ( @@ -116,16 +138,63 @@ export default async function GradeAnalyticsPage({ classes={classes.map((c) => ({ id: c.id, name: c.name }))} grades={allGrades.map((g) => ({ id: g.id, name: g.name }))} subjects={allSubjects.map((s) => ({ id: s.id, name: s.name ?? "Unknown" }))} + exams={examOptions} currentClassId={targetClassId} currentSubjectId={subjectId ?? "all"} currentGradeId={targetGradeId ?? ""} + currentExamId={examId ?? "all"} + currentSemester={semester ?? "all"} />
- - - - + + {trend ? ( + + ) : ( + + )} + + + {distribution.totalCount > 0 ? ( + + ) : ( + + )} + + + {subjectComparison.length > 0 ? ( + + ) : ( + + )} + + + {classComparison.length > 0 ? ( + + ) : ( + + )} +
) diff --git a/src/app/(dashboard)/teacher/grades/entry/page.tsx b/src/app/(dashboard)/teacher/grades/entry/page.tsx index 1003b00..0a771af 100644 --- a/src/app/(dashboard)/teacher/grades/entry/page.tsx +++ b/src/app/(dashboard)/teacher/grades/entry/page.tsx @@ -3,7 +3,11 @@ import { getTeacherClasses } from "@/modules/classes/data-access" import { getClassStudentsForEntry } from "@/modules/grades/data-access" import { getSubjectOptions } from "@/modules/school/data-access" import { BatchGradeEntry } from "@/modules/grades/components/batch-grade-entry" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { EmptyState } from "@/shared/components/ui/empty-state" +import { ClipboardList } from "lucide-react" export const dynamic = "force-dynamic" @@ -12,22 +16,52 @@ export default async function BatchEntryPage({ }: { searchParams: Promise }): Promise { + const ctx = await requirePermission(Permissions.GRADE_RECORD_MANAGE) const sp = await searchParams const defaultClassId = getParam(sp, "classId") const defaultSubjectId = getParam(sp, "subjectId") + // P3 修复:添加 scope 校验,对 class_taught scope 限制可录入的班级 const [classes, allSubjects, students] = await Promise.all([ getTeacherClasses(), getSubjectOptions(), defaultClassId - ? getClassStudentsForEntry(defaultClassId) + ? getClassStudentsForEntry(defaultClassId, ctx.dataScope) : Promise.resolve([] as Awaited>), ]) - const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) + // 对 class_taught scope,过滤掉不在 scope 中的班级 + const allowedClassIds = + ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : null + const scopedClasses = allowedClassIds + ? classes.filter((c) => allowedClassIds.includes(c.id)) + : classes + + const classOptions = scopedClasses.map((c) => ({ id: c.id, name: c.name })) const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name })) + // 如果指定了 classId 但 scope 不允许,显示提示 + if (defaultClassId && students.length === 0 && scopedClasses.length > 0) { + const classExists = scopedClasses.some((c) => c.id === defaultClassId) + if (!classExists) { + return ( +
+
+

Batch Grade Entry

+

Enter grades for all students in a class at once.

+
+ +
+ ) + } + } + return (
diff --git a/src/app/(dashboard)/teacher/grades/page.tsx b/src/app/(dashboard)/teacher/grades/page.tsx index 4d4c1ff..d4b9040 100644 --- a/src/app/(dashboard)/teacher/grades/page.tsx +++ b/src/app/(dashboard)/teacher/grades/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link" import { PlusCircle, BarChart3, ClipboardList } from "lucide-react" import { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" -import { ListPagination, computePagination, paginate } from "@/shared/components/ui/list-pagination" +import { ListPagination, computePagination } from "@/shared/components/ui/list-pagination" import { requirePermission } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { getParam, type SearchParams } from "@/shared/lib/search-params" @@ -43,7 +43,11 @@ export default async function TeacherGradesPage({ const type = getParam(sp, "type") const semester = getParam(sp, "semester") - const [classes, allSubjects, records] = await Promise.all([ + // P3 修复:使用 DB 层分页,移除重复计算 + const { page } = computePagination(sp, PAGE_SIZE) + const offset = (page - 1) * PAGE_SIZE + + const [classes, allSubjects, result] = await Promise.all([ getTeacherClasses(), getSubjectOptions(), getGradeRecords({ @@ -53,18 +57,19 @@ export default async function TeacherGradesPage({ subjectId: subjectId && subjectId !== "all" ? subjectId : undefined, type: type && type !== "all" ? parseGradeType(type) : undefined, semester: semester && semester !== "all" ? parseSemester(semester) : undefined, + limit: PAGE_SIZE, + offset, }), ]) const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name })) - // 分页计算 - const { page } = computePagination(sp, PAGE_SIZE) - const total = records.length + // 使用 DB 返回的 total 和 totalPages,移除重复计算 + const total = result.total const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const currentPage = Math.min(page, totalPages) - const pagedRecords = paginate(records, currentPage, PAGE_SIZE) + const pagedRecords = result.records const hasFilters = Boolean(classId || subjectId || type || semester) return ( @@ -103,7 +108,7 @@ export default async function TeacherGradesPage({ - {records.length === 0 && !hasFilters ? ( + {total === 0 && !hasFilters ? ( }): Promise { + const ctx = await requirePermission(Permissions.GRADE_RECORD_READ) const sp = await searchParams const classId = getParam(sp, "classId") @@ -43,15 +46,52 @@ export default async function StatsPage({ ) } - const targetClassId = classId ?? classes[0].id + // P3 修复:对 class_taught scope 过滤可选班级 + const allowedClassIds = + ctx.dataScope.type === "class_taught" ? ctx.dataScope.classIds : null + const scopedClasses = allowedClassIds + ? classes.filter((c) => allowedClassIds.includes(c.id)) + : classes + + if (scopedClasses.length === 0) { + return ( +
+
+

Grade Statistics

+

View class grade statistics and rankings.

+
+ +
+ ) + } + + const targetClassId = classId ?? scopedClasses[0].id const targetSubjectId = subjectId && subjectId !== "all" ? subjectId : undefined + // P3 修复:传递 scope 到 data-access 层 const [stats, ranking] = await Promise.all([ - getClassGradeStatsWithMeta(targetClassId, targetSubjectId), - getClassRanking(targetClassId, targetSubjectId), + getClassGradeStatsWithMeta( + targetClassId, + targetSubjectId, + undefined, + ctx.dataScope, + ctx.userId + ), + getClassRanking( + targetClassId, + targetSubjectId, + undefined, + ctx.dataScope, + ctx.userId + ), ]) - const classOptions = classes.map((c) => ({ id: c.id, name: c.name })) + const classOptions = scopedClasses.map((c) => ({ id: c.id, name: c.name })) const subjectOptions = allSubjects.map((s) => ({ id: s.id, name: s.name })) return ( diff --git a/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx index e36a232..582c72b 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/new/page.tsx @@ -1,8 +1,10 @@ import type { JSX } from "react" +import { Suspense } from "react" import Link from "next/link" import { ArrowLeft } from "lucide-react" import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" +import { Skeleton } from "@/shared/components/ui/skeleton" import { TemplatePicker } from "@/modules/lesson-preparation/components/template-picker" export const dynamic = "force-dynamic" @@ -20,7 +22,20 @@ export default async function NewLessonPlanPage(): Promise {

{t("title.new")}

- + + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ } + > + +
) } diff --git a/src/app/(dashboard)/teacher/lesson-plans/page.tsx b/src/app/(dashboard)/teacher/lesson-plans/page.tsx index c7643c6..a27141b 100644 --- a/src/app/(dashboard)/teacher/lesson-plans/page.tsx +++ b/src/app/(dashboard)/teacher/lesson-plans/page.tsx @@ -1,8 +1,10 @@ import type { JSX } from "react" +import { Suspense } from "react" import Link from "next/link" import { Plus } from "lucide-react" import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" +import { Skeleton } from "@/shared/components/ui/skeleton" import { getAuthContext } from "@/shared/lib/auth-guard" import { getLessonPlans } from "@/modules/lesson-preparation/data-access" import { getSubjectOptions } from "@/modules/school/data-access" @@ -35,7 +37,24 @@ export default async function LessonPlansPage(): Promise {
- + +
+ + + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ } + > + +
) } diff --git a/src/app/(dashboard)/teacher/questions/page.tsx b/src/app/(dashboard)/teacher/questions/page.tsx index cc45681..610b2ec 100644 --- a/src/app/(dashboard)/teacher/questions/page.tsx +++ b/src/app/(dashboard)/teacher/questions/page.tsx @@ -10,6 +10,8 @@ import { EmptyState } from "@/shared/components/ui/empty-state" import { Skeleton } from "@/shared/components/ui/skeleton" import { getQuestions } from "@/modules/questions/data-access" import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" import type { QuestionType } from "@/modules/questions/types" export const dynamic = "force-dynamic" @@ -27,6 +29,8 @@ function parseQuestionType(v?: string): QuestionType | undefined { } async function QuestionBankResults({ searchParams }: { searchParams: Promise }): Promise { + await requirePermission(Permissions.QUESTION_READ) + const params = await searchParams const q = getParam(params, "q") @@ -36,10 +40,13 @@ async function QuestionBankResults({ searchParams }: { searchParams: Promise { - const ctx = await getAuthContext() + const ctx = await requirePermission(Permissions.SCHEDULE_ADJUST) // Teachers see only their own requests; admins landing here see all. - const requesterId = ctx.roles.includes("admin") ? undefined : ctx.userId + const requesterId = ctx.dataScope.type === "all" ? undefined : ctx.userId const [classes, teachers, items] = await Promise.all([ getAdminClassesForScheduling(), diff --git a/src/app/(dashboard)/teacher/textbooks/[id]/_components/teacher-textbook-reader.tsx b/src/app/(dashboard)/teacher/textbooks/[id]/_components/teacher-textbook-reader.tsx new file mode 100644 index 0000000..0d2b8f3 --- /dev/null +++ b/src/app/(dashboard)/teacher/textbooks/[id]/_components/teacher-textbook-reader.tsx @@ -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 => ( + + ) + + return ( + + ) +} diff --git a/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx index f022ce4..9dc514c 100644 --- a/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx +++ b/src/app/(dashboard)/teacher/textbooks/[id]/page.tsx @@ -1,15 +1,16 @@ -import type { JSX, ReactNode } from "react" +import type { JSX } from "react" import { notFound } from "next/navigation" import { ArrowLeft } from "lucide-react" import Link from "next/link" import { getTranslations } from "next-intl/server" import { Button } from "@/shared/components/ui/button" import { Badge } from "@/shared/components/ui/badge" -import { getTextbookById, getChaptersByTextbookId, getKnowledgePointsByTextbookId } from "@/modules/textbooks/data-access" -import { TextbookReader, type TextbookReaderProps } from "@/modules/textbooks/components/textbook-reader" +import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access" +import { TeacherTextbookReader } from "./_components/teacher-textbook-reader" import { TextbookSettingsDialog } from "@/modules/textbooks/components/textbook-settings-dialog" -import { CreateQuestionDialog } from "@/modules/questions/components/create-question-dialog" -import type { KnowledgePoint } from "@/modules/textbooks/types" +import { getSubjectLabelKey, getGradeLabelKey } from "@/modules/textbooks/constants" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" @@ -18,38 +19,20 @@ export default async function TextbookDetailPage({ }: { params: Promise<{ id: string }> }): Promise { + await requirePermission(Permissions.TEXTBOOK_READ) + const { id } = await params const t = await getTranslations("textbooks") - const [textbook, chapters, knowledgePoints] = await Promise.all([ + const [textbook, chapters] = await Promise.all([ getTextbookById(id), getChaptersByTextbookId(id), - getKnowledgePointsByTextbookId(id), ]) if (!textbook) { notFound() } - // P0-1 在页面层注入 questions 模块的 CreateQuestionDialog 实现 - const renderQuestionCreator: TextbookReaderProps["renderQuestionCreator"] = ({ - open, - onOpenChange, - targetKp, - }: { - open: boolean - onOpenChange: (open: boolean) => void - targetKp: KnowledgePoint | null - }): ReactNode => ( - - ) - return (
{/* Header / Nav (Fixed height) */} @@ -62,9 +45,9 @@ export default async function TextbookDetailPage({
- {textbook.subject} + {t(`subject.${getSubjectLabelKey(textbook.subject)}`)} - {textbook.grade} + {textbook.grade ? t(`grade.${getGradeLabelKey(textbook.grade)}`) : ""}

{textbook.title}

@@ -76,12 +59,7 @@ export default async function TextbookDetailPage({ {/* Main Content Layout (Flex grow) */}
- +
) diff --git a/src/app/(dashboard)/teacher/textbooks/page.tsx b/src/app/(dashboard)/teacher/textbooks/page.tsx index a416f13..5d7f421 100644 --- a/src/app/(dashboard)/teacher/textbooks/page.tsx +++ b/src/app/(dashboard)/teacher/textbooks/page.tsx @@ -8,6 +8,8 @@ import { getTextbooks } from "@/modules/textbooks/data-access" import { TextbookFilters } from "@/modules/textbooks/components/textbook-filters" import { EmptyState } from "@/shared/components/ui/empty-state" import { getParam, type SearchParams } from "@/shared/lib/search-params" +import { requirePermission } from "@/shared/lib/auth-guard" +import { Permissions } from "@/shared/types/permissions" export const dynamic = "force-dynamic" @@ -18,6 +20,8 @@ async function TextbooksResults({ searchParams: Promise t: Awaited>> }): Promise { + await requirePermission(Permissions.TEXTBOOK_READ) + const params = await searchParams const q = getParam(params, "q") || undefined diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 31db1d4..797333d 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -1,138 +1,20 @@ 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" -function parseCodes(input: string) { - const raw = input - .split(/[\s,,;;]+/g) - .map((s) => s.trim()) - .filter(Boolean) - return Array.from(new Set(raw)) -} - -function isRecord(v: unknown): v is Record { - 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() - 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() - 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 }) +export async function POST() { + return NextResponse.json( + { + success: false, + message: "此 API 已废弃,请使用 completeOnboardingAction Server Action", + redirect: "/onboarding", + }, + { status: 410 } + ) } diff --git a/src/app/api/onboarding/status/route.ts b/src/app/api/onboarding/status/route.ts index 5f61aac..241115a 100644 --- a/src/app/api/onboarding/status/route.ts +++ b/src/app/api/onboarding/status/route.ts @@ -1,45 +1,19 @@ 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 async function GET() { - const session = await auth() - const userId = String(session?.user?.id ?? "").trim() - if (!userId) { - return NextResponse.json({ required: false }) - } - - const [row, roleRows] = await Promise.all([ - 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 }) + return NextResponse.json( + { + success: false, + message: "此 API 已废弃,请使用 /onboarding 路由或 getOnboardingStatusAction", + redirect: "/onboarding", + }, + { status: 410 } + ) } diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 1e93d40..af9ea4e 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server" 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 { announcements, @@ -41,10 +41,12 @@ interface SearchResponse { /** * GET /api/search?q=keyword&type=all&page=1 * 全文检索:questions / textbooks / exams / announcements + * 按角色过滤:学生只能搜索 textbook 和 announcement */ export async function GET(req: Request) { 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 q = (searchParams.get("q") ?? "").trim() @@ -72,16 +74,18 @@ export async function GET(req: Request) { const offset = (page - 1) * pageSize const results: SearchResultItem[] = [] - // 并行查询各类型 + // 并行查询各类型(按角色过滤) const tasks: Promise[] = [] - if (type === "all" || type === "question") { + // 学生不能搜索题目和考试 + if (!isStudent && (type === "all" || type === "question")) { tasks.push(searchQuestions(kw, pageSize)) } if (type === "all" || type === "textbook") { tasks.push(searchTextbooks(kw, pageSize)) } - if (type === "all" || type === "exam") { + // 学生不能搜索考试 + if (!isStudent && (type === "all" || type === "exam")) { tasks.push(searchExams(kw, pageSize)) } if (type === "all" || type === "announcement") { diff --git a/src/app/globals.css b/src/app/globals.css index 8a4530e..87217eb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -190,3 +190,54 @@ 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; +} diff --git a/src/modules/dashboard/actions.ts b/src/modules/dashboard/actions.ts index beef6e8..d5b1977 100644 --- a/src/modules/dashboard/actions.ts +++ b/src/modules/dashboard/actions.ts @@ -1,9 +1,9 @@ "use server" -import { cache } from "react" - import { requirePermission } from "@/shared/lib/auth-guard" 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 { getHomeworkAssignments, @@ -19,6 +19,7 @@ import { getAdminDashboardData } from "./data-access" import type { AdminDashboardData, StudentDashboardProps, + StudentTodayScheduleItem, TeacherDashboardData, } from "./types" import type { ParentDashboardData } from "@/modules/parent/types" @@ -35,47 +36,59 @@ import { * 获取管理员仪表盘数据。 * 权限:DASHBOARD_ADMIN_READ */ -export async function getAdminDashboardAction(): Promise { - const ctx = await requirePermission(Permissions.DASHBOARD_ADMIN_READ) - return getAdminDashboardData(ctx.dataScope) +export async function getAdminDashboardAction(): Promise> { + try { + 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 */ -export async function getTeacherDashboardAction(): Promise { - await requirePermission(Permissions.DASHBOARD_TEACHER_READ) - const teacherId = await getTeacherIdForMutations() +}>> { + try { + await requirePermission(Permissions.DASHBOARD_TEACHER_READ) + const teacherId = await getTeacherIdForMutations() - const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([ - getTeacherClasses({ teacherId }), - getClassSchedule({ teacherId }), - getHomeworkAssignments({ creatorId: teacherId }), - getHomeworkSubmissions({ creatorId: teacherId }), - getUserBasicInfo(teacherId), - getTeacherGradeTrends(teacherId), - ]) + const [classes, schedule, assignments, submissions, teacherProfile, gradeTrends] = await Promise.all([ + getTeacherClasses({ teacherId }), + getClassSchedule({ teacherId }), + getHomeworkAssignments({ creatorId: teacherId }), + getHomeworkSubmissions({ creatorId: teacherId }), + getUserBasicInfo(teacherId), + getTeacherGradeTrends(teacherId), + ]) - const metrics = computeTeacherMetrics( - classes, - schedule, - assignments, - submissions, - gradeTrends, - new Date(), - ) + const metrics = computeTeacherMetrics( + classes, + schedule, + assignments, + submissions, + gradeTrends, + new Date(), + ) - return { - classes, - schedule, - assignments, - submissions, - teacherName: teacherProfile?.name ?? "Teacher", - gradeTrends, - metrics, + return { + success: true, + data: { + classes, + schedule, + assignments, + submissions, + teacherName: teacherProfile?.name ?? "Teacher", + gradeTrends, + metrics, + }, + } + } catch (e) { + return handleActionError(e) } } @@ -83,40 +96,47 @@ export async function getTeacherDashboardAction(): Promise | null -}> { - await requirePermission(Permissions.DASHBOARD_STUDENT_READ) - const student = await getCurrentStudentUser() - if (!student) { - return { student: null, dashboardProps: null } - } +}>> { + try { + await requirePermission(Permissions.DASHBOARD_STUDENT_READ) + const student = await getCurrentStudentUser() + if (!student) { + return { success: true, data: { student: null, dashboardProps: null } } + } - const [classes, schedule, assignments, grades] = await Promise.all([ - getStudentClasses(student.id), - getStudentSchedule(student.id), - getStudentHomeworkAssignments(student.id), - getStudentDashboardGrades(student.id), - ]) + const [classes, schedule, assignments, grades] = await Promise.all([ + getStudentClasses(student.id), + getStudentSchedule(student.id), + getStudentHomeworkAssignments(student.id), + getStudentDashboardGrades(student.id), + ]) - const now = new Date() - const stats = countStudentAssignments(assignments, now) - const todayWeekday = toWeekday(now) - const todayScheduleItems = filterTodaySchedule(schedule, todayWeekday) - const upcomingAssignments = sortUpcomingAssignments(assignments, 6) + const now = new Date() + const stats = countStudentAssignments(assignments, now) + const todayWeekday = toWeekday(now) + const todayScheduleItems = filterTodaySchedule(schedule, todayWeekday) + const upcomingAssignments = sortUpcomingAssignments(assignments, 6) - return { - student: { id: student.id, name: student.name }, - dashboardProps: { - enrolledClassCount: classes.length, - dueSoonCount: stats.dueSoonCount, - overdueCount: stats.overdueCount, - gradedCount: stats.gradedCount, - todayScheduleItems, - upcomingAssignments, - grades, - }, + return { + success: true, + data: { + student: { id: student.id, name: student.name }, + dashboardProps: { + enrolledClassCount: classes.length, + dueSoonCount: stats.dueSoonCount, + overdueCount: stats.overdueCount, + gradedCount: stats.gradedCount, + todayScheduleItems, + upcomingAssignments, + grades, + }, + }, + } + } catch (e) { + return handleActionError(e) } } @@ -124,23 +144,25 @@ export async function getStudentDashboardAction(): Promise<{ * 获取家长仪表盘数据。 * 权限:DASHBOARD_PARENT_READ */ -export async function getParentDashboardAction(): Promise<{ +export async function getParentDashboardAction(): Promise { - const ctx = await requirePermission(Permissions.DASHBOARD_PARENT_READ) +}>> { + try { + const ctx = await requirePermission(Permissions.DASHBOARD_PARENT_READ) - // 非 admin 且 dataScope 非 children 类型时,无孩子数据 - if ( - ctx.dataScope.type !== "all" && - !(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0) - ) { - return { data: null, hasChildren: false } + // 非 admin 且 dataScope 非 children 类型时,无孩子数据 + if ( + ctx.dataScope.type !== "all" && + !(ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0) + ) { + 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) diff --git a/src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx b/src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx index 4b2608f..52f6eb1 100644 --- a/src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx +++ b/src/modules/dashboard/components/admin-dashboard/user-growth-chart.tsx @@ -1,6 +1,7 @@ "use client" import { useTranslations } from "next-intl" +import { BarChart3 } from "lucide-react" import { LineChart, Line, @@ -11,13 +12,28 @@ import { ResponsiveContainer, } from "recharts" +import { EmptyState } from "@/shared/components/ui/empty-state" + interface UserGrowthChartProps { 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") + if (data.length === 0) { + return ( + + ) + } + return ( @@ -41,7 +57,7 @@ export function UserGrowthChart({ data }: UserGrowthChartProps) { stroke="hsl(var(--primary))" strokeWidth={2} dot={{ fill: "hsl(var(--primary))", r: 3 }} - name={t("chart.newUsers")} + name={t(labelKey)} /> diff --git a/src/modules/dashboard/components/dashboard-error-fallback.tsx b/src/modules/dashboard/components/dashboard-error-fallback.tsx new file mode 100644 index 0000000..fd34767 --- /dev/null +++ b/src/modules/dashboard/components/dashboard-error-fallback.tsx @@ -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 ( +
+ reset(), + }} + className="border-none shadow-none h-auto" + /> +
+ ) +} diff --git a/src/modules/dashboard/components/dashboard-greeting-header.tsx b/src/modules/dashboard/components/dashboard-greeting-header.tsx index 9ea8bd8..4bba9b2 100644 --- a/src/modules/dashboard/components/dashboard-greeting-header.tsx +++ b/src/modules/dashboard/components/dashboard-greeting-header.tsx @@ -1,7 +1,5 @@ -"use client" - import type { ReactNode } from "react" -import { useTranslations } from "next-intl" +import { getLocale, getTranslations } from "next-intl/server" import { formatLongDate } from "@/shared/lib/utils" import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils" @@ -11,15 +9,16 @@ import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils" * 教师与学生仪表盘头部 90% 重复,统一抽象为此组件。 * 通过 `actions` slot 注入角色专属快捷操作。 */ -export function DashboardGreetingHeader({ +export async function DashboardGreetingHeader({ userName, actions, }: { userName: string actions?: ReactNode }) { - const t = useTranslations("dashboard") - const today = formatLongDate(new Date()) + const t = await getTranslations("dashboard") + const locale = await getLocale() + const today = formatLongDate(new Date(), locale) const greetingKey = getGreetingKey(new Date()) return ( diff --git a/src/modules/dashboard/components/dashboard-loading-skeleton.tsx b/src/modules/dashboard/components/dashboard-loading-skeleton.tsx new file mode 100644 index 0000000..0c867e7 --- /dev/null +++ b/src/modules/dashboard/components/dashboard-loading-skeleton.tsx @@ -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 ( +
+
+ + +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + + + + + + ))} +
+ + + + + + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + +
+ ) +} diff --git a/src/modules/dashboard/components/student-dashboard/student-dashboard-header.tsx b/src/modules/dashboard/components/student-dashboard/student-dashboard-header.tsx index 39aa1ff..4a6def1 100644 --- a/src/modules/dashboard/components/student-dashboard/student-dashboard-header.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-dashboard-header.tsx @@ -1,5 +1,3 @@ -"use client" - import { DashboardGreetingHeader } from "../dashboard-greeting-header" interface StudentDashboardHeaderProps { diff --git a/src/modules/dashboard/components/student-dashboard/student-grades-card.tsx b/src/modules/dashboard/components/student-dashboard/student-grades-card.tsx index b4d2c11..1581414 100644 --- a/src/modules/dashboard/components/student-dashboard/student-grades-card.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-grades-card.tsx @@ -2,7 +2,7 @@ import Link from "next/link" import { BarChart3 } from "lucide-react" -import { useTranslations } from "next-intl" +import { useTranslations, useLocale } from "next-intl" import { Button } from "@/shared/components/ui/button" 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 }) { const t = useTranslations("dashboard") + const locale = useLocale() const hasGradeTrend = grades.trend.length > 0 const hasRecentGrades = grades.recent.length > 0 @@ -20,7 +21,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro title: item.assignmentTitle, score: Math.round(item.percentage), fullTitle: item.assignmentTitle, - submittedAt: formatDate(item.submittedAt), + submittedAt: formatDate(item.submittedAt, locale), rawScore: item.score, maxScore: item.maxScore, })) @@ -102,7 +103,7 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro {r.score}/{r.maxScore} ({Math.round(r.percentage)}%) - {formatDate(r.submittedAt)} + {formatDate(r.submittedAt, locale)} ))} diff --git a/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx b/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx index 7d0a343..1629ae4 100644 --- a/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx +++ b/src/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card.tsx @@ -1,6 +1,6 @@ import Link from "next/link" 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 { Button } from "@/shared/components/ui/button" @@ -27,7 +27,7 @@ const getActionVariant = (status: string): "default" | "secondary" | "outline" = return "default" } -const getDueUrgency = (dueAt: string | null) => { +const getDueUrgency = (dueAt: string | null): "overdue" | "urgent" | "warning" | "normal" | null => { if (!dueAt) return null const now = new Date() const due = new Date(dueAt) @@ -41,6 +41,7 @@ const getDueUrgency = (dueAt: string | null) => { export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { upcomingAssignments: StudentHomeworkAssignmentListItem[] }) { const t = await getTranslations("dashboard") + const locale = await getLocale() const hasAssignments = upcomingAssignments.length > 0 return ( @@ -60,6 +61,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { icon={PenTool} title={t("empty.noAssignmentsStudent")} description={t("empty.noAssignmentsStudentDesc")} + action={{ label: t("quickActions.viewAll"), href: "/student/learning/assignments" }} className="border-none h-72" /> ) : ( @@ -103,7 +105,7 @@ export async function StudentUpcomingAssignmentsCard({ upcomingAssignments }: { !isGraded && urgency === "overdue" && "text-destructive font-medium", !isGraded && urgency === "urgent" && "text-orange-500 font-medium" )}> - {a.dueAt ? formatDate(a.dueAt) : "-"} + {a.dueAt ? formatDate(a.dueAt, locale) : "-"} {a.latestScore ?? "-"} diff --git a/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx b/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx index af11c3c..8d3b518 100644 --- a/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/recent-submissions.tsx @@ -1,9 +1,9 @@ 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 { 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 { Button } from "@/shared/components/ui/button" import { EmptyState } from "@/shared/components/ui/empty-state" @@ -32,6 +32,7 @@ export async function RecentSubmissions({ emptyDescription, }: RecentSubmissionsProps) { const t = await getTranslations("dashboard") + const locale = await getLocale() const hasSubmissions = submissions.length > 0 return ( @@ -73,7 +74,6 @@ export async function RecentSubmissions({
- {item.studentName.charAt(0)} @@ -93,7 +93,7 @@ export async function RecentSubmissions({
- {item.submittedAt ? formatDate(item.submittedAt) : "-"} + {item.submittedAt ? formatDate(item.submittedAt, locale) : "-"} {item.isLate && ( diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx index aa83e28..89f05ba 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-dashboard-header.tsx @@ -1,5 +1,3 @@ -"use client" - import { DashboardGreetingHeader } from "../dashboard-greeting-header" import { TeacherQuickActions } from "./teacher-quick-actions" diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx index 4f6f6ec..257d911 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-grade-trends.tsx @@ -59,7 +59,7 @@ export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] .reverse() .slice(0, 3) .map((item, i) => ( -
+
{item.fullTitle}
diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-quick-actions.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-quick-actions.tsx index 3dd2aa7..6c54801 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-quick-actions.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-quick-actions.tsx @@ -1,13 +1,11 @@ -"use client" - import Link from "next/link" 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" -export function TeacherQuickActions() { - const t = useTranslations("dashboard") +export async function TeacherQuickActions() { + const t = await getTranslations("dashboard") return (
diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx index 2ba3813..3327ae3 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-schedule.tsx @@ -21,14 +21,14 @@ export async function TeacherSchedule({ items }: { items: TeacherTodayScheduleIt const t = await getTranslations("dashboard") 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 currentTime = now.getHours() * 60 + now.getMinutes() const [startH, startM] = start.split(":").map(Number) const [endH, endM] = end.split(":").map(Number) - const startTime = (startH ?? 0) * 60 + (startM ?? 0) - const endTime = (endH ?? 0) * 60 + (endM ?? 0) + const startTime = (Number.isFinite(startH) ? startH : 0) * 60 + (Number.isFinite(startM) ? startM : 0) + const endTime = (Number.isFinite(endH) ? endH : 0) * 60 + (Number.isFinite(endM) ? endM : 0) if (currentTime >= startTime && currentTime <= endTime) return "live" if (currentTime < startTime) return "upcoming" diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx index 8e5fea3..f644d0f 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-stats.tsx @@ -7,7 +7,6 @@ interface TeacherStatsProps { activeAssignmentsCount: number averageScore: number submissionRate: number - isLoading?: boolean } export async function TeacherStats({ @@ -15,7 +14,6 @@ export async function TeacherStats({ activeAssignmentsCount, averageScore, submissionRate, - isLoading = false, }: TeacherStatsProps) { const t = await getTranslations("dashboard") @@ -29,7 +27,6 @@ export async function TeacherStats({ href="/teacher/homework/submissions?status=submitted" highlight={toGradeCount > 0} color="text-amber-500" - isLoading={isLoading} />
) diff --git a/src/modules/dashboard/components/teacher-dashboard/teacher-todo-card.tsx b/src/modules/dashboard/components/teacher-dashboard/teacher-todo-card.tsx index 3536095..8b16c0a 100644 --- a/src/modules/dashboard/components/teacher-dashboard/teacher-todo-card.tsx +++ b/src/modules/dashboard/components/teacher-dashboard/teacher-todo-card.tsx @@ -49,13 +49,17 @@ export async function TeacherTodoCard({ items }: TeacherTodoCardProps) {
{items .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) => { const style = VARIANT_STYLES[item.variant] const Icon = style.icon return ( diff --git a/src/modules/dashboard/lib/dashboard-utils.ts b/src/modules/dashboard/lib/dashboard-utils.ts index 1a0f3db..f0596cd 100644 --- a/src/modules/dashboard/lib/dashboard-utils.ts +++ b/src/modules/dashboard/lib/dashboard-utils.ts @@ -65,6 +65,7 @@ export function countStudentAssignments( } if (!a.dueAt) continue const due = new Date(a.dueAt) + if (Number.isNaN(due.getTime())) continue if (due >= now && due <= in7Days) { dueSoonCount++ } else if (due < now) { @@ -85,8 +86,10 @@ export function sortUpcomingAssignments( ): StudentHomeworkAssignmentListItem[] { return [...assignments] .sort((a, b) => { - const aDue = a.dueAt ? new Date(a.dueAt).getTime() : Number.POSITIVE_INFINITY - const bDue = b.dueAt ? new Date(b.dueAt).getTime() : Number.POSITIVE_INFINITY + const aTime = a.dueAt ? new Date(a.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 }) .slice(0, limit) @@ -94,12 +97,15 @@ export function sortUpcomingAssignments( /** * 从课表中筛选指定周几的课程,按开始时间升序排序。 + * + * 泛型 T 允许调用方指定返回的课表项类型(StudentTodayScheduleItem 或 + * TeacherTodayScheduleItem)。两者结构完全相同,泛型仅用于类型层面。 */ -export function filterTodaySchedule( +export function filterTodaySchedule( schedule: readonly ClassScheduleItem[], weekday: Weekday, classNameById?: ReadonlyMap, -): StudentTodayScheduleItem[] | TeacherTodayScheduleItem[] { +): T[] { return schedule .filter((s) => s.weekday === weekday) .sort((a, b) => a.startTime.localeCompare(b.startTime)) @@ -111,7 +117,7 @@ export function filterTodaySchedule( startTime: s.startTime, endTime: s.endTime, location: s.location ?? null, - })) as StudentTodayScheduleItem[] | TeacherTodayScheduleItem[] + })) as T[] } /** 教师仪表盘派生指标 */ @@ -138,22 +144,24 @@ export function computeTeacherMetrics( const todayWeekday = toWeekday(now) const classNameById = new Map(classes.map((c) => [c.id, c.name] as const)) - const todayScheduleItems = filterTodaySchedule( + const todayScheduleItems = filterTodaySchedule( schedule, todayWeekday, classNameById, - ) as TeacherTodayScheduleItem[] + ) const submittedSubmissions = submissions.filter((s) => Boolean(s.submittedAt)) const toGradeCount = submittedSubmissions.filter((s) => s.status === "submitted").length const submissionsToGrade = submittedSubmissions .filter((s) => s.status === "submitted") - .sort( - (a, b) => - (a.submittedAt ? new Date(a.submittedAt).getTime() : 0) - - (b.submittedAt ? new Date(b.submittedAt).getTime() : 0), - ) + .sort((a, b) => { + const aTime = a.submittedAt ? new Date(a.submittedAt).getTime() : 0 + const bTime = 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) const activeAssignmentsCount = assignments.filter((a) => a.status === "published").length diff --git a/src/next-auth.d.ts b/src/next-auth.d.ts index 670bd23..19e42e7 100644 --- a/src/next-auth.d.ts +++ b/src/next-auth.d.ts @@ -8,6 +8,7 @@ declare module "next-auth" { role: string // kept for backward compatibility roles: Role[] permissions: Permission[] + onboarded: boolean } } } @@ -18,5 +19,6 @@ declare module "next-auth/jwt" { role: string // kept for backward compatibility roles: Role[] permissions: Permission[] + onboarded: boolean } } diff --git a/src/proxy.ts b/src/proxy.ts index 16ae7ac..4a7fb77 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -7,14 +7,26 @@ import { Permissions } from "@/shared/types/permissions" // Route prefix → minimum required permission // Note: /admin/announcements is covered by /admin prefix (requires school:manage) // 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 = { "/admin": Permissions.SCHOOL_MANAGE, - "/teacher": Permissions.EXAM_READ, + "/teacher": Permissions.EXAM_CREATE, "/student": Permissions.HOMEWORK_SUBMIT, - "/parent": Permissions.EXAM_READ, + "/parent": Permissions.DASHBOARD_PARENT_READ, "/management": Permissions.GRADE_MANAGE, } +// 仪表盘路由的细粒度权限(覆盖 ROUTE_PERMISSIONS 的前缀匹配) +// 防止拥有 EXAM_READ 的学生/家长访问 /teacher/dashboard 等 +const DASHBOARD_ROUTE_PERMISSIONS: Record = { + "/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 const API_PERMISSIONS: Record = { "/api/ai/chat": Permissions.AI_CHAT, @@ -57,6 +69,22 @@ export async function proxy(request: NextRequest) { 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 roles: string[] = (token.roles as string[]) ?? [] @@ -71,6 +99,19 @@ export async function proxy(request: NextRequest) { } // 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)) { if (pathname.startsWith(prefix)) { if (!permissions.includes(requiredPerm)) {