fix: patch P0 security vulnerabilities and critical UX issues across 6 modules

Security: Add admin/layout.tsx auth guard; Add requirePermission() to 12 admin pages

Dashboard: Fix StudentStatsGrid rendering; Fix teacher greeting; Add loading/error boundaries; Fix col-span; Add metadata

Announcements: Fix audience filtering; Add user detail page; Trigger notifications on publish; Pass classes data; Add loading.tsx

Messages: Implement soft delete; Add unread badge with polling; Add notification dropdown polling; Add keyword search; Add quiet hours DND

Management: Add loading/error for 9 admin routes; Fix admin-classes-view to use Select for school/grade

Profile/Settings: Add loading/error; Fix parent role routing; Create ParentSettingsView; Integrate AiProviderSettingsCard; Add Tab URL persistence; Add logout confirm; Add avatar; Fix Progress arbitrary class

Schema: Add senderDeletedAt/receiverDeletedAt to messages; Add quietHours to notificationPreferences; Add uniqueIndex import

Docs: Update architecture docs 004/005
This commit is contained in:
SpecialX
2026-06-22 13:57:31 +08:00
parent 5ff7ab9e72
commit a4d096a6fc
81 changed files with 2145 additions and 124 deletions

View File

@@ -2,6 +2,8 @@ import { notFound } from "next/navigation"
import type { Metadata } from "next"
import type { JSX } from "react"
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 { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
@@ -18,6 +20,7 @@ export default async function EditAnnouncementPage({
}: {
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const { id } = await params
const [announcement, grades] = await Promise.all([

View File

@@ -0,0 +1,40 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminAnnouncementsLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex items-center justify-between space-y-2">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-9 w-40" />
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-9 w-[180px]" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-5 w-16" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex items-center gap-2 pt-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-3 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,8 +1,11 @@
import type { Metadata } from "next"
import type { JSX } from "react"
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 { AdminAnnouncementsView } from "@/modules/announcements/components/admin-announcements-view"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import type { AnnouncementStatus } from "@/modules/announcements/types"
@@ -22,19 +25,22 @@ export default async function AdminAnnouncementsPage({
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const sp = await searchParams
const statusParam = getSearchParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const [announcements, grades] = await Promise.all([
const [announcements, grades, classes] = await Promise.all([
getAnnouncements({ status }),
getGrades(),
getAdminClasses(),
])
return (
<AdminAnnouncementsView
announcements={announcements}
grades={grades.map((g) => ({ id: g.id, name: g.name }))}
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
initialStatus={status}
/>
)

View File

@@ -0,0 +1,10 @@
import { getAuthContext } from "@/shared/lib/auth-guard"
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}): Promise<React.ReactNode> {
await getAuthContext()
return <>{children}</>
}

View File

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

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminSchedulingAutoLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -3,6 +3,8 @@ import { CalendarClock, ClipboardList, Settings2 } from "lucide-react"
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { getAdminClassesForScheduling } from "@/modules/scheduling/data-access"
@@ -16,6 +18,7 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"
export default async function AdminSchedulingAutoPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SCHEDULE_AUTO)
const classes = await getAdminClassesForScheduling()
const classOptions = classes.map((c) => ({ id: c.id, name: c.name, grade: c.grade }))

View File

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

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminSchedulingChangesLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

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

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminSchedulingRulesLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -2,6 +2,8 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import {
getAdminClassesForScheduling,
@@ -17,6 +19,7 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"
export default async function AdminSchedulingRulesPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SCHEDULE_ADJUST)
const [classes, existingRules] = await Promise.all([
getAdminClassesForScheduling(),
getSchedulingRules(),

View File

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

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminAcademicYearLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { AcademicYearClient } from "@/modules/school/components/academic-year-view"
import { getAcademicYears } from "@/modules/school/data-access"
@@ -12,6 +14,7 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"
export default async function AdminAcademicYearPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const years = await getAcademicYears()
return (
<div className="flex h-full flex-col space-y-8 p-8">

View File

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

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminClassesLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,7 +1,10 @@
import type { Metadata } from "next"
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAdminClasses, getTeacherOptions } from "@/modules/classes/data-access"
import { getGrades, getSchools } from "@/modules/school/data-access"
import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view"
export const metadata: Metadata = {
@@ -12,7 +15,13 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"
export default async function AdminSchoolClassesPage(): Promise<JSX.Element> {
const [classes, teachers] = await Promise.all([getAdminClasses(), getTeacherOptions()])
await requirePermission(Permissions.SCHOOL_MANAGE)
const [classes, teachers, schools, grades] = await Promise.all([
getAdminClasses(),
getTeacherOptions(),
getSchools(),
getGrades(),
])
return (
<div className="flex h-full flex-col space-y-8 p-8">
@@ -20,7 +29,7 @@ export default async function AdminSchoolClassesPage(): Promise<JSX.Element> {
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<AdminClassesClient classes={classes} teachers={teachers} />
<AdminClassesClient classes={classes} teachers={teachers} schools={schools} grades={grades} />
</div>
)
}

View File

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

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminDepartmentsLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { DepartmentsClient } from "@/modules/school/components/departments-view"
import { getDepartments } from "@/modules/school/data-access"
@@ -12,6 +14,7 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"
export default async function AdminDepartmentsPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const departments = await getDepartments()
return (
<div className="flex h-full flex-col space-y-8 p-8">

View File

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

View File

@@ -3,6 +3,8 @@ import type { Metadata } from "next"
import type { JSX } from "react"
import { BarChart3 } from "lucide-react"
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 { EmptyState } from "@/shared/components/ui/empty-state"
@@ -25,6 +27,7 @@ export default async function AdminGradeInsightsPage({
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const params = await searchParams
const gradeId = getSearchParam(params, "gradeId")
const selected = gradeId && gradeId !== "all" ? gradeId : ""

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminGradesLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { GradesClient } from "@/modules/school/components/grades-view"
import { getGrades, getSchools, getStaffOptions } from "@/modules/school/data-access"
@@ -12,6 +14,7 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"
export default async function AdminGradesPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const [grades, schools, staff] = await Promise.all([getGrades(), getSchools(), getStaffOptions()])
return (

View File

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

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminSchoolsLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { SchoolsClient } from "@/modules/school/components/schools-view"
import { getSchools } from "@/modules/school/data-access"
@@ -12,6 +14,7 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"
export default async function AdminSchoolsPage(): Promise<JSX.Element> {
await requirePermission(Permissions.SCHOOL_MANAGE)
const schools = await getSchools()
return (
<div className="flex h-full flex-col space-y-8 p-8">

View File

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

View File

@@ -0,0 +1,24 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminUsersImportLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -3,6 +3,8 @@ import type { JSX } from "react"
import Link from "next/link"
import { ArrowLeft, Users, FileSpreadsheet, Info } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import {
@@ -22,7 +24,8 @@ export const metadata: Metadata = {
export const dynamic = "force-dynamic"
export default function UserImportPage(): JSX.Element {
export default async function UserImportPage(): Promise<JSX.Element> {
await requirePermission(Permissions.USER_MANAGE)
return (
<div className="h-full flex-1 flex-col space-y-6 p-8 md:flex">
<div className="flex items-center justify-between space-y-2">

View File

@@ -0,0 +1,36 @@
import { notFound } from "next/navigation"
import type { Metadata } from "next"
import type { JSX } from "react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAnnouncementById } from "@/modules/announcements/data-access"
import { AnnouncementDetail } from "@/modules/announcements/components/announcement-detail"
export const metadata: Metadata = {
title: "Announcement - Next_Edu",
}
export const dynamic = "force-dynamic"
export default async function AnnouncementDetailPage({
params,
}: {
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
const { id } = await params
await requirePermission(Permissions.ANNOUNCEMENT_READ)
const announcement = await getAnnouncementById(id)
if (!announcement) notFound()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<AnnouncementDetail
announcement={announcement}
canManage={false}
backHref="/announcements"
/>
</div>
)
}

View File

@@ -0,0 +1,37 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AnnouncementsLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="flex items-center gap-3">
<Skeleton className="h-9 w-[180px]" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-5 w-16" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex items-center gap-2 pt-2">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-3 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -1,17 +1,88 @@
import type { Metadata } from "next"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAnnouncements } from "@/modules/announcements/data-access"
import { AnnouncementList } from "@/modules/announcements/components/announcement-list"
import {
getStudentActiveClassId,
getStudentActiveGradeId,
getClassGradeId,
} from "@/modules/classes/data-access"
export const dynamic = "force-dynamic"
export const metadata = {
export const metadata: Metadata = {
title: "Announcements",
}
/**
* 根据当前用户身份解析受众信息gradeId / classId
* - admin返回 null管理端可见所有公告
* - student / teacher使用首个 classId 并查询其 gradeId
* - grade_head / teaching_head使用首个 gradeId
* - parent使用首个孩子的活跃班级信息
* - 其他:返回 null仅显示 school 类型公告由 audience.gradeId/classId 均缺失时的兜底处理)
*/
async function resolveAudience(ctx: {
userId: string
dataScope:
| { type: "all" }
| { type: "owned"; userId: string }
| { type: "class_members"; classIds: string[] }
| { type: "grade_managed"; gradeIds: string[] }
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] }
| { type: "children"; childrenIds: string[] }
}): Promise<{ gradeId?: string; classId?: string } | null> {
const { dataScope } = ctx
if (dataScope.type === "all") return null
if (dataScope.type === "grade_managed") {
const gradeId = dataScope.gradeIds[0]
return gradeId ? { gradeId } : null
}
if (dataScope.type === "class_members" || dataScope.type === "class_taught") {
const classId = dataScope.classIds[0]
if (!classId) return null
const gradeId = await getClassGradeId(classId)
return { classId, gradeId: gradeId ?? undefined }
}
if (dataScope.type === "children") {
const childId = dataScope.childrenIds[0]
if (!childId) return null
const [classId, gradeId] = await Promise.all([
getStudentActiveClassId(childId),
getStudentActiveGradeId(childId),
])
return {
classId: classId ?? undefined,
gradeId: gradeId ?? undefined,
}
}
// owned / 其他:尝试用当前 userId 查询(兼容 student 角色直接访问)
const [classId, gradeId] = await Promise.all([
getStudentActiveClassId(ctx.userId),
getStudentActiveGradeId(ctx.userId),
])
if (!classId && !gradeId) return null
return {
classId: classId ?? undefined,
gradeId: gradeId ?? undefined,
}
}
export default async function AnnouncementsPage() {
await requirePermission(Permissions.ANNOUNCEMENT_READ)
const announcements = await getAnnouncements({ status: "published" })
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
const audience = await resolveAudience(ctx)
const announcements = await getAnnouncements({
status: "published",
audience: audience ?? undefined,
})
return (
<div className="flex h-full flex-col space-y-8 p-8">
@@ -21,7 +92,10 @@ export default async function AnnouncementsPage() {
Stay up to date with the latest school announcements.
</p>
</div>
<AnnouncementList announcements={announcements} />
<AnnouncementList
announcements={announcements}
detailHrefBuilder={(id) => `/announcements/${id}`}
/>
</div>
)
}

View File

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

View File

@@ -0,0 +1,38 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function DashboardLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Button } from "@/shared/components/ui/button"
export default function MessageDetailLoading() {
return (
<div className="flex h-full flex-col p-8">
<div className="mx-auto w-full max-w-3xl space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-24" />
<div className="space-y-2">
<Skeleton className="h-7 w-64" />
<Skeleton className="h-4 w-48" />
</div>
</div>
<Card>
<CardHeader className="space-y-2">
<Skeleton className="h-5 w-3/4" />
<div className="flex items-center gap-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-32" />
</div>
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" disabled>
<Skeleton className="h-4 w-20" />
</Button>
<Button disabled>
<Skeleton className="h-4 w-24" />
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ComposeLoading() {
return (
<div className="flex h-full flex-col p-8">
<div className="mx-auto w-full max-w-3xl space-y-6">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-32 w-full" />
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-28" />
</div>
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,36 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function MessagesLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-10 w-48" />
<Skeleton className="h-10 w-28" />
</div>
<Skeleton className="h-10 w-full" />
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
<div className="space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-3 w-20" />
</CardHeader>
<CardContent className="pt-0">
<Skeleton className="h-4 w-full" />
<Skeleton className="mt-1 h-4 w-3/4" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,50 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-9 w-48" />
<Skeleton className="h-4 w-56" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<CardTitle className="text-base">
<Skeleton className="h-4 w-32" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -6,6 +6,8 @@ import { Users } from "lucide-react"
export const dynamic = "force-dynamic"
export const metadata = { title: "Dashboard - Next_Edu" }
export default async function ParentDashboardPage() {
const ctx = await requireAuth()

View File

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

View File

@@ -0,0 +1,36 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function ProfileLoading() {
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="flex items-center gap-4">
<Skeleton className="h-20 w-20 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-4 w-24" />
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, j) => (
<Skeleton key={j} className="h-10 w-full" />
))}
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -9,6 +9,7 @@ import { StudentTodayScheduleCard } from "@/modules/dashboard/components/student
import { StudentUpcomingAssignmentsCard } from "@/modules/dashboard/components/student-dashboard/student-upcoming-assignments-card"
import { getStudentDashboardGrades, getStudentHomeworkAssignments } from "@/modules/homework/data-access"
import { getUserProfile } from "@/modules/users/data-access"
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"
@@ -129,6 +130,19 @@ export default async function ProfilePage() {
}
/>
<div className="flex items-center gap-4">
<Avatar className="h-20 w-20">
{userProfile.image ? <AvatarImage src={userProfile.image} alt={userProfile.name ?? "User avatar"} /> : null}
<AvatarFallback className="text-xl font-semibold">
{(userProfile.name ?? userProfile.email).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<div className="text-xl font-semibold tracking-tight">{userProfile.name ?? "-"}</div>
<div className="text-sm text-muted-foreground">{userProfile.email}</div>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>

View File

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

View File

@@ -0,0 +1,31 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function SettingsLoading() {
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-64" />
</div>
<Skeleton className="h-10 w-40" />
</div>
<div className="space-y-6">
<Skeleton className="h-10 w-full max-w-md" />
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,9 +1,10 @@
import { redirect } from "next/navigation"
import { requireAuth } from "@/shared/lib/auth-guard"
import { AdminSettingsView } from "@/modules/settings/components/admin-settings-view"
import { SettingsView } from "@/modules/settings/components/settings-view"
import { StudentSettingsView } from "@/modules/settings/components/student-settings-view"
import { TeacherSettingsView } from "@/modules/settings/components/teacher-settings-view"
import { ParentSettingsView } from "@/modules/settings/components/parent-settings-view"
import { getUserProfile } from "@/modules/users/data-access"
import { getNotificationPreferences } from "@/modules/notifications/preferences"
@@ -25,10 +26,20 @@ export default async function SettingsPage() {
const notificationPrefs = await getNotificationPreferences(userId)
if (roles.includes("admin")) {
return <AdminSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
return (
<SettingsView
description="Manage your admin preferences and account access."
backHref="/admin/dashboard"
user={userProfile}
notificationPreferences={notificationPrefs}
/>
)
}
if (roles.includes("student")) {
return <StudentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
}
if (roles.includes("parent")) {
return <ParentSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
}
return <TeacherSettingsView user={userProfile} notificationPreferences={notificationPrefs} />
}

View File

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

View File

@@ -0,0 +1,37 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function SecuritySettingsLoading() {
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-72" />
</div>
<div className="max-w-2xl space-y-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -8,6 +8,8 @@ import type { StudentHomeworkProgressStatus } from "@/modules/homework/types"
export const dynamic = "force-dynamic"
export const metadata = { title: "Dashboard - Next_Edu" }
const toWeekday = (d: Date): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
// getDay() 返回 0(周日)-6(周六),转换为 1-7周一为 1
const WEEKDAY_MAP = [7, 1, 2, 3, 4, 5, 6] as const
@@ -85,10 +87,6 @@ export default async function StudentDashboardPage() {
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">Dashboard</h2>
<p className="text-muted-foreground">Welcome back, {student.name}.</p>
</div>
<StudentDashboard
studentName={student.name}
enrolledClassCount={classes.length}

View File

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

View File

@@ -0,0 +1,38 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function TeacherDashboardLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-28" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -7,6 +7,8 @@ import { getAuthContext } from "@/shared/lib/auth-guard"
export const dynamic = "force-dynamic"
export const metadata = { title: "Dashboard - Next_Edu" }
export default async function TeacherDashboardPage(): Promise<JSX.Element> {
await getAuthContext()
const teacherId = await getTeacherIdForMutations()