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

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

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

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

- Add messages group-compose route

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

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

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

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

View File

@@ -1,5 +1,19 @@
import { AuthLayout } from "@/modules/auth/components/auth-layout"
import { getBrandConfig } from "@/modules/settings/data-access-brand"
export default function Layout({ children }: { children: React.ReactNode }) {
return <AuthLayout>{children}</AuthLayout>
/**
* 认证页面布局audit-P2-6: 注入品牌配置)
*
* Server Component在渲染前从 system_settings 表读取品牌配置,
* 失败时 AuthLayout 使用 DEFAULT_BRAND_CONFIG 默认值。
*/
export default async function Layout({ children }: { children: React.ReactNode }) {
let brand
try {
brand = await getBrandConfig()
} catch {
// 数据库不可用时使用默认品牌配置,不阻断认证页面渲染
}
return <AuthLayout brand={brand}>{children}</AuthLayout>
}

View File

@@ -0,0 +1,19 @@
import { Loader2 } from "lucide-react"
/**
* 认证路由组 loading.tsxaudit-P1-5 新增)
*
* 在 (auth) 路由组下的页面login/register/privacy/terms进行服务端渲染时
* 展示的骨架屏,避免白屏闪烁。
*/
export default function AuthLoading() {
return (
<div
className="flex h-full w-full flex-col items-center justify-center gap-4 p-4"
aria-busy="true"
aria-live="polite"
>
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)
}

View File

@@ -1,167 +1,13 @@
import { Metadata } from "next"
import { hash } from "bcryptjs"
import { createId } from "@paralleldrive/cuid2"
import { eq } from "drizzle-orm"
import type { ActionState } from "@/shared/types/action-state"
import { RegisterForm } from "@/modules/auth/components/register-form"
import { registerAction } from "@/modules/auth/actions"
export const metadata: Metadata = {
title: "Register - Next_Edu",
description: "Create an account",
}
const ADULT_AGE = 18
const normalizeBcryptHash = (value: string) => {
if (value.startsWith("$2")) return value
if (value.startsWith("$")) return `$2b${value}`
return `$2b$${value}`
}
function calcAge(birth: string): number | null {
if (!birth) return null
const birthDate = new Date(birth)
if (Number.isNaN(birthDate.getTime())) return null
const now = new Date()
let age = now.getFullYear() - birthDate.getFullYear()
const monthDiff = now.getMonth() - birthDate.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) {
age -= 1
}
return age >= 0 ? age : null
}
export default function RegisterPage() {
async function registerAction(formData: FormData): Promise<ActionState> {
"use server"
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" }
try {
const [{ db }, { roles, users, usersToRoles }] = await Promise.all([
import("@/shared/db"),
import("@/shared/db/schema"),
])
const name = String(formData.get("name") ?? "").trim()
const email = String(formData.get("email") ?? "").trim().toLowerCase()
const password = String(formData.get("password") ?? "")
const birthDateRaw = String(formData.get("birthDate") ?? "").trim()
const guardianName = String(formData.get("guardianName") ?? "").trim()
const guardianPhone = String(formData.get("guardianPhone") ?? "").trim()
const guardianRelation = String(formData.get("guardianRelation") ?? "").trim()
if (!email) return { success: false, message: "请输入邮箱" }
if (!password) return { success: false, message: "请输入密码" }
if (password.length < 6) return { success: false, message: "密码至少 6 位" }
const age = calcAge(birthDateRaw)
const isMinor = age !== null && age < ADULT_AGE
if (isMinor) {
if (!guardianName) return { success: false, message: "未成年人须填写监护人姓名" }
if (!guardianPhone) return { success: false, message: "未成年人须填写监护人电话" }
if (!guardianRelation) return { success: false, message: "未成年人须选择监护人关系" }
}
const existing = await db.query.users.findFirst({
where: eq(users.email, email),
columns: { id: true },
})
if (existing) return { success: false, message: "该邮箱已注册" }
const hashedPassword = normalizeBcryptHash(await hash(password, 10))
const userId = createId()
await db.insert(users).values({
id: userId,
name: name.length ? name : null,
email,
password: hashedPassword,
birthDate: birthDateRaw ? new Date(birthDateRaw) : null,
age: age ?? null,
guardianName: guardianName || null,
guardianPhone: guardianPhone || null,
guardianRelation: guardianRelation || null,
consentAcceptedAt: new Date(),
})
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, "student"),
columns: { id: true },
})
if (!roleRow) {
await db.insert(roles).values({ name: "student" })
}
const resolvedRole = roleRow
?? (await db.query.roles.findFirst({ where: eq(roles.name, "student"), columns: { id: true } }))
if (resolvedRole?.id) {
await db.insert(usersToRoles).values({ userId, roleId: resolvedRole.id })
}
return { success: true, message: "账户创建成功" }
} catch (error) {
const isProd = process.env.NODE_ENV === "production"
const anyErr = error as unknown as {
code?: string
message?: string
sqlMessage?: string
cause?: unknown
}
const cause1 = anyErr?.cause as
| { code?: string; message?: string; sqlMessage?: string; cause?: unknown }
| undefined
const cause2 = (cause1?.cause ?? undefined) as
| { code?: string; message?: string; sqlMessage?: string }
| undefined
const code = String(cause2?.code ?? cause1?.code ?? anyErr?.code ?? "").trim()
const msg = String(
cause2?.sqlMessage ??
cause1?.sqlMessage ??
anyErr?.sqlMessage ??
cause2?.message ??
cause1?.message ??
anyErr?.message ??
""
).trim()
const msgLower = msg.toLowerCase()
if (
code === "ER_DUP_ENTRY" ||
msgLower.includes("duplicate") ||
msgLower.includes("unique")
) {
return { success: false, message: "该邮箱已注册" }
}
if (
code === "ER_NO_SUCH_TABLE" ||
msgLower.includes("doesn't exist") ||
msgLower.includes("unknown column")
) {
return {
success: false,
message: "数据库未初始化或未迁移,请先运行 npm run db:migrate",
}
}
if (code === "ER_ACCESS_DENIED_ERROR") {
return { success: false, message: "数据库账号/权限错误,请检查 DATABASE_URL" }
}
if (code === "ECONNREFUSED" || code === "ENOTFOUND") {
return { success: false, message: "数据库连接失败,请检查 DATABASE_URL 与网络" }
}
if (!isProd && msg) {
return { success: false, message: `创建账户失败:${msg}` }
}
return { success: false, message: "创建账户失败,请稍后重试" }
}
}
return <RegisterForm registerAction={registerAction} />
}

View File

@@ -0,0 +1,50 @@
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"
import { getEditAnnouncementPageData } from "@/modules/announcements/data-access"
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
import { AnnouncementsServiceProvider } from "@/modules/announcements/components/announcements-service-context"
export const dynamic = "force-dynamic"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("announcements")
return {
title: t("title.edit"),
description: t("description.edit"),
}
}
export default async function EditAnnouncementPage({
params,
}: {
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const { id } = await params
const t = await getTranslations("announcements")
const { announcement, grades } = await getEditAnnouncementPageData(id)
if (!announcement) notFound()
return (
<AnnouncementsServiceProvider>
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title.edit")}</h2>
<p className="text-muted-foreground">{t("description.edit")}</p>
</div>
<AnnouncementForm
mode="edit"
announcement={announcement}
grades={grades}
/>
</div>
</AnnouncementsServiceProvider>
)
}

View File

@@ -5,43 +5,45 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getEditAnnouncementPageData } from "@/modules/announcements/data-access"
import { AnnouncementForm } from "@/modules/announcements/components/announcement-form"
import { getAdminAnnouncementDetailPageData } from "@/modules/announcements/data-access"
import { AnnouncementDetail } from "@/modules/announcements/components/announcement-detail"
import { AnnouncementsServiceProvider } from "@/modules/announcements/components/announcements-service-context"
export const dynamic = "force-dynamic"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("announcements")
return {
title: t("title.edit"),
description: t("description.edit"),
}
return { title: t("title.detail"), description: t("description.detail") }
}
export default async function EditAnnouncementPage({
/**
* P1-2: 新增管理端公告详情页。
*
* 此前 `AnnouncementDetail canManage=true` 分支为死代码(无任何页面使用),
* 管理员只能通过编辑表单间接查看公告,无法在 UI 中执行发布/归档/删除/置顶。
* 本页面启用该分支,提供完整的管理操作入口。
*/
export default async function AdminAnnouncementDetailPage({
params,
}: {
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
const { id } = await params
const t = await getTranslations("announcements")
const { announcement, grades } = await getEditAnnouncementPageData(id)
const { announcement } = await getAdminAnnouncementDetailPageData(id)
if (!announcement) notFound()
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title.edit")}</h2>
<p className="text-muted-foreground">{t("description.edit")}</p>
<AnnouncementsServiceProvider>
<div className="flex h-full flex-col space-y-8 p-8">
<AnnouncementDetail
announcement={announcement}
canManage
editHref={`/admin/announcements/${id}/edit`}
backHref="/admin/announcements"
/>
</div>
<AnnouncementForm
mode="edit"
announcement={announcement}
grades={grades}
/>
</div>
</AnnouncementsServiceProvider>
)
}

View File

@@ -1,40 +1,5 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { AnnouncementListSkeleton } from "@/modules/announcements/components/announcement-list-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>
)
return <AnnouncementListSkeleton showCreateButton />
}

View File

@@ -1,24 +1,7 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { RouteErrorBoundary } from "@/shared/components/route-error"
export default function AdminAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("attendance")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("errors.unexpected")}
description={t("errors.unexpected")}
action={{
label: t("actions.save"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
return <RouteErrorBoundary reset={reset} namespace="attendance" />
}

View File

@@ -1,5 +1,4 @@
import Link from "next/link"
import type { Metadata } from "next"
import type { JSX } from "react"
import { BarChart3, ClipboardList } from "lucide-react"
import { getTranslations } from "next-intl/server"
@@ -10,17 +9,22 @@ import { requirePermission, getAuthContext } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { getAdminClasses } from "@/modules/classes/data-access"
import { getGrades } from "@/modules/school/data-access"
import { getAttendanceRecords, getAttendanceStats } from "@/modules/attendance/data-access"
import { getClassComparison } from "@/modules/attendance/data-access-stats"
import { getAttendanceGradeCorrelation } from "@/modules/attendance/data-access-correlation"
import { AttendanceFilters } from "@/modules/attendance/components/attendance-filters"
import { AttendanceStatsCards } from "@/modules/attendance/components/attendance-stats-cards"
import { AttendanceRecordList } from "@/modules/attendance/components/attendance-record-list"
import { AttendancePageLayout } from "@/modules/attendance/components/attendance-page-layout"
import { ClassComparisonCard } from "@/modules/attendance/components/class-comparison-card"
import { AttendanceGradeCorrelationCard } from "@/modules/attendance/components/attendance-grade-correlation-card"
import type { AttendanceStatus } from "@/modules/attendance/types"
export const dynamic = "force-dynamic"
const isValidAttendanceStatus = (v?: string): v is AttendanceStatus =>
v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused"
v === "present" || v === "absent" || v === "late" || v === "early_leave" || v === "excused" || v === "school_activity"
export default async function AdminAttendancePage({
searchParams,
@@ -37,24 +41,46 @@ export default async function AdminAttendancePage({
const status =
statusParam && statusParam !== "all" && isValidAttendanceStatus(statusParam) ? statusParam : undefined
const date = getSearchParam(sp, "date")
// L-7跨班级对比的年级选择
const gradeId = getSearchParam(sp, "gradeId")
const startDate = getSearchParam(sp, "startDate")
const endDate = getSearchParam(sp, "endDate")
const classes = await getAdminClasses()
const classOptions = classes.map((c) => ({ id: c.id, name: c.name }))
const result = await getAttendanceRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
status,
date: date && date.length > 0 ? date : undefined,
})
// 并行获取:记录、统计、年级列表、对比汇总
const [result, stats, grades, comparison, correlation] = await Promise.all([
getAttendanceRecords({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
status,
date: date && date.length > 0 ? date : undefined,
}),
getAttendanceStats({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
date: date && date.length > 0 ? date : undefined,
}),
getGrades(),
// L-7若指定 gradeId 则获取对比汇总
gradeId
? getClassComparison(gradeId, startDate ?? undefined, endDate ?? undefined)
: Promise.resolve(null),
// L-9考勤-成绩关联分析(仅当指定班级时)
classId && classId !== "all"
? getAttendanceGradeCorrelation(
classId,
startDate ?? undefined,
endDate ?? undefined,
ctx.dataScope
)
: Promise.resolve(null),
])
const stats = await getAttendanceStats({
scope: ctx.dataScope,
currentUserId: ctx.userId,
classId: classId && classId !== "all" ? classId : undefined,
date: date && date.length > 0 ? date : undefined,
})
const gradeOptions = grades.map((g) => ({ id: g.id, name: g.name }))
const header = (
<div className="flex items-center justify-between space-y-2">
@@ -77,15 +103,29 @@ export default async function AdminAttendancePage({
stats={<AttendanceStatsCards stats={stats} />}
filters={<AttendanceFilters classes={classOptions} />}
>
{result.items.length === 0 && !classId && !status && !date ? (
<EmptyState
title={t("list.empty")}
description={t("list.emptyDescription")}
icon={ClipboardList}
<div className="space-y-6">
{result.items.length === 0 && !classId && !status && !date ? (
<EmptyState
title={t("list.empty")}
description={t("list.emptyDescription")}
icon={ClipboardList}
/>
) : (
<AttendanceRecordList records={result.items} />
)}
<ClassComparisonCard
summary={comparison}
grades={gradeOptions}
currentGradeId={gradeId ?? undefined}
/>
) : (
<AttendanceRecordList records={result.items} />
)}
{/* L-9考勤与成绩关联分析仅当指定班级时显示 */}
{classId && classId !== "all" && (
<AttendanceGradeCorrelationCard summary={correlation} />
)}
</div>
</AttendancePageLayout>
)
}

View File

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

View File

@@ -0,0 +1,16 @@
import { AuditLogTableSkeleton } from "@/modules/audit/components/audit-log-table-skeleton"
export default function DataChangeLogsLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8" aria-hidden="true" role="presentation">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
</div>
<div className="h-9 w-32 animate-pulse rounded bg-muted" />
</div>
<AuditLogTableSkeleton />
</div>
)
}

View File

@@ -10,8 +10,9 @@ import {
getDataChangeStats,
getDataChangeTableOptions,
} from "@/modules/audit/data-access"
import { DataChangeLogTable } from "@/modules/audit/components/data-change-log-table"
import { DataChangeLogView } from "@/modules/audit/components/data-change-log-view"
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
import { AuditErrorBoundary } from "@/modules/audit/components/audit-error-boundary"
import type { DataChangeAction } from "@/modules/audit/types"
export async function generateMetadata(): Promise<Metadata> {
@@ -40,11 +41,12 @@ export default async function DataChangeLogsPage({
const tableName = getSearchParam(params, "tableName") ?? undefined
const actionParam = getSearchParam(params, "action")
const action = isValidDataChangeAction(actionParam) ? actionParam : undefined
const userId = getSearchParam(params, "userId") ?? undefined
const startDate = getSearchParam(params, "startDate") ?? undefined
const endDate = getSearchParam(params, "endDate") ?? undefined
const [result, tableOptions, stats] = await Promise.all([
getDataChangeLogs({ page, tableName, action, startDate, endDate }),
getDataChangeLogs({ page, tableName, action, userId, startDate, endDate }),
getDataChangeTableOptions(),
getDataChangeStats(),
])
@@ -52,6 +54,7 @@ export default async function DataChangeLogsPage({
const exportParams: Record<string, string> = {}
if (tableName) exportParams.tableName = tableName
if (action) exportParams.action = action
if (userId) exportParams.userId = userId
if (startDate) exportParams.startDate = startDate
if (endDate) exportParams.endDate = endDate
@@ -64,15 +67,17 @@ export default async function DataChangeLogsPage({
</div>
<AuditLogExportButton exportType="dataChange" params={exportParams} />
</div>
<DataChangeLogTable
items={result.items}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
tableOptions={tableOptions}
stats={stats}
/>
<AuditErrorBoundary>
<DataChangeLogView
items={result.items}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
tableOptions={tableOptions}
stats={stats}
/>
</AuditErrorBoundary>
</div>
)
}

View File

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

View File

@@ -0,0 +1,16 @@
import { AuditLogTableSkeleton } from "@/modules/audit/components/audit-log-table-skeleton"
export default function AuditLogsLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8" aria-hidden="true" role="presentation">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
</div>
<div className="h-9 w-32 animate-pulse rounded bg-muted" />
</div>
<AuditLogTableSkeleton />
</div>
)
}

View File

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

View File

@@ -0,0 +1,16 @@
import { AuditLogTableSkeleton } from "@/modules/audit/components/audit-log-table-skeleton"
export default function LoginLogsLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8" aria-hidden="true" role="presentation">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-4 w-64 animate-pulse rounded bg-muted" />
</div>
<div className="h-9 w-32 animate-pulse rounded bg-muted" />
</div>
<AuditLogTableSkeleton />
</div>
)
}

View File

@@ -8,6 +8,7 @@ import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { getLoginLogs } from "@/modules/audit/data-access"
import { LoginLogView } from "@/modules/audit/components/login-log-view"
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
import { AuditErrorBoundary } from "@/modules/audit/components/audit-error-boundary"
import type { LoginLogAction, LoginLogStatus } from "@/modules/audit/types"
export async function generateMetadata(): Promise<Metadata> {
@@ -40,14 +41,16 @@ export default async function LoginLogsPage({
const action = isValidLoginLogAction(actionParam) ? actionParam : undefined
const statusParam = getSearchParam(params, "status")
const status = isValidLoginLogStatus(statusParam) ? statusParam : undefined
const userId = getSearchParam(params, "userId") ?? undefined
const startDate = getSearchParam(params, "startDate") ?? undefined
const endDate = getSearchParam(params, "endDate") ?? undefined
const result = await getLoginLogs({ page, action, status, startDate, endDate })
const result = await getLoginLogs({ page, action, status, userId, startDate, endDate })
const exportParams: Record<string, string> = {}
if (action) exportParams.action = action
if (status) exportParams.status = status
if (userId) exportParams.userId = userId
if (startDate) exportParams.startDate = startDate
if (endDate) exportParams.endDate = endDate
@@ -62,13 +65,15 @@ export default async function LoginLogsPage({
</div>
<AuditLogExportButton exportType="login" params={exportParams} />
</div>
<LoginLogView
items={result.items}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
/>
<AuditErrorBoundary>
<LoginLogView
items={result.items}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
/>
</AuditErrorBoundary>
</div>
)
}

View File

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

View File

@@ -0,0 +1,40 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
export default function AuditOverviewLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8" aria-hidden="true" role="presentation">
<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>
{/* 图表骨架 */}
<div className="grid gap-6 lg:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-[280px] w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
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"
import {
getAuditOverviewStats,
getAuditTrend,
getDataChangeActionStats,
} from "@/modules/audit/data-access"
import { AuditOverviewView } from "@/modules/audit/components/audit-overview-view"
import { AuditServiceProvider } from "@/modules/audit/services/audit-service"
import { adminAuditService } from "@/modules/audit/services/admin-audit-service"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("audit.overview")
return {
title: `${t("title")} - Next_Edu`,
description: t("description"),
}
}
export const dynamic = "force-dynamic"
export default async function AuditOverviewPage(): Promise<JSX.Element> {
await requirePermission(Permissions.AUDIT_LOG_READ)
const [stats, trend, distribution] = await Promise.all([
getAuditOverviewStats(),
getAuditTrend(7),
getDataChangeActionStats(),
])
return (
<AuditServiceProvider service={adminAuditService}>
<AuditOverviewView stats={stats} trend={trend} distribution={distribution} />
</AuditServiceProvider>
)
}

View File

@@ -8,6 +8,7 @@ import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { getAuditLogs, getAuditModuleOptions } from "@/modules/audit/data-access"
import { AuditLogView } from "@/modules/audit/components/audit-log-view"
import { AuditLogExportButton } from "@/modules/audit/components/audit-log-export-button"
import { AuditErrorBoundary } from "@/modules/audit/components/audit-error-boundary"
import type { AuditLogStatus } from "@/modules/audit/types"
export async function generateMetadata(): Promise<Metadata> {
@@ -37,11 +38,12 @@ export default async function AuditLogsPage({
const action = getSearchParam(params, "action") ?? undefined
const statusParam = getSearchParam(params, "status")
const status = isValidAuditLogStatus(statusParam) ? statusParam : undefined
const userId = getSearchParam(params, "userId") ?? undefined
const startDate = getSearchParam(params, "startDate") ?? undefined
const endDate = getSearchParam(params, "endDate") ?? undefined
const [result, moduleOptions] = await Promise.all([
getAuditLogs({ page, module: moduleFilter, action, status, startDate, endDate }),
getAuditLogs({ page, module: moduleFilter, action, status, userId, startDate, endDate }),
getAuditModuleOptions(),
])
@@ -49,6 +51,7 @@ export default async function AuditLogsPage({
if (moduleFilter) exportParams.module = moduleFilter
if (action) exportParams.action = action
if (status) exportParams.status = status
if (userId) exportParams.userId = userId
if (startDate) exportParams.startDate = startDate
if (endDate) exportParams.endDate = endDate
@@ -61,14 +64,16 @@ export default async function AuditLogsPage({
</div>
<AuditLogExportButton exportType="audit" params={exportParams} />
</div>
<AuditLogView
items={result.items}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
moduleOptions={moduleOptions}
/>
<AuditErrorBoundary>
<AuditLogView
items={result.items}
page={result.page}
pageSize={result.pageSize}
total={result.total}
totalPages={result.totalPages}
moduleOptions={moduleOptions}
/>
</AuditErrorBoundary>
</div>
)
}

View File

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

View File

@@ -0,0 +1,28 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function EditCoursePlanLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-[180px]" />
<Skeleton className="h-4 w-[260px]" />
</div>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<div className="flex justify-end gap-2">
<Skeleton className="h-10 w-[80px]" />
<Skeleton className="h-10 w-[120px]" />
</div>
</div>
</div>
)
}

View File

@@ -3,9 +3,11 @@ import type { Metadata } from "next"
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { getCoursePlanById, getSubjectOptions } from "@/modules/course-plans/data-access"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getCoursePlanById } from "@/modules/course-plans/data-access"
import { getAdminClasses } from "@/modules/classes/data-access"
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
import { getAcademicYears, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
export async function generateMetadata(): Promise<Metadata> {
@@ -24,10 +26,13 @@ export default async function EditCoursePlanPage({
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
const t = await getTranslations("coursePlans")
// P0-2admin 页面补充 requirePermission 校验
const ctx = await requirePermission(Permissions.COURSE_PLAN_MANAGE)
const { id } = await params
const [plan, classes, subjects, teachers, academicYears] = await Promise.all([
getCoursePlanById(id),
// P0-1携带 scope 过滤
getCoursePlanById(id, { userId: ctx.userId, isAdmin: true }),
getAdminClasses(),
getSubjectOptions(),
getStaffOptions(),
@@ -47,9 +52,10 @@ export default async function EditCoursePlanPage({
plan={plan}
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
subjects={subjects}
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
teachers={teachers.map((tch) => ({ id: tch.id, name: tch.name }))}
academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))}
backHref={`/admin/course-plans/${plan.id}`}
successHref={`/admin/course-plans/${plan.id}`}
/>
</div>
)

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ 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"
import { getCoursePlanById } from "@/modules/course-plans/data-access"
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
@@ -21,8 +23,11 @@ export default async function CoursePlanDetailPage({
}: {
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
// P0-2admin 页面补充 requirePermission 校验
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
const { id } = await params
const plan = await getCoursePlanById(id)
// P0-1/P0-3携带 scope 过滤admin 视角为全局
const plan = await getCoursePlanById(id, { userId: ctx.userId, isAdmin: true })
if (!plan) notFound()
@@ -32,6 +37,7 @@ export default async function CoursePlanDetailPage({
plan={plan}
editHref={`/admin/course-plans/${plan.id}/edit`}
backHref="/admin/course-plans"
successHref="/admin/course-plans"
/>
</div>
)

View File

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

View File

@@ -0,0 +1,28 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function CreateCoursePlanLoading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-[180px]" />
<Skeleton className="h-4 w-[260px]" />
</div>
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-10 w-full" />
</div>
))}
</div>
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<div className="flex justify-end gap-2">
<Skeleton className="h-10 w-[80px]" />
<Skeleton className="h-10 w-[120px]" />
</div>
</div>
</div>
)
}

View File

@@ -2,9 +2,10 @@ 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"
import { getAdminClasses } from "@/modules/classes/data-access"
import { getAcademicYears, getStaffOptions } from "@/modules/school/data-access"
import { getSubjectOptions } from "@/modules/course-plans/data-access"
import { getAcademicYears, getStaffOptions, getSubjectOptions } from "@/modules/school/data-access"
import { CoursePlanForm } from "@/modules/course-plans/components/course-plan-form"
export async function generateMetadata(): Promise<Metadata> {
@@ -19,6 +20,9 @@ export const dynamic = "force-dynamic"
export default async function CreateCoursePlanPage(): Promise<JSX.Element> {
const t = await getTranslations("coursePlans")
// P0-2admin 页面补充 requirePermission 校验
await requirePermission(Permissions.COURSE_PLAN_MANAGE)
const [classes, subjects, teachers, academicYears] = await Promise.all([
getAdminClasses(),
getSubjectOptions(),
@@ -36,9 +40,10 @@ export default async function CreateCoursePlanPage(): Promise<JSX.Element> {
mode="create"
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
subjects={subjects}
teachers={teachers.map((t) => ({ id: t.id, name: t.name }))}
teachers={teachers.map((tch) => ({ id: tch.id, name: tch.name }))}
academicYears={academicYears.map((y) => ({ id: y.id, name: y.name }))}
backHref="/admin/course-plans"
successHref="/admin/course-plans"
/>
</div>
)

View File

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

View File

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

View File

@@ -2,10 +2,12 @@ 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"
import { getCoursePlans } from "@/modules/course-plans/data-access"
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import type { CoursePlanStatus } from "@/modules/course-plans/types"
import { isCoursePlanStatus } from "@/modules/course-plans/types"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("coursePlans")
@@ -17,20 +19,21 @@ export async function generateMetadata(): Promise<Metadata> {
export const dynamic = "force-dynamic"
const isValidStatus = (v?: string): v is CoursePlanStatus =>
v === "planning" || v === "active" || v === "completed" || v === "paused"
export default async function AdminCoursePlansPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
// P0-2admin 页面补充 requirePermission 校验
await requirePermission(Permissions.COURSE_PLAN_READ)
const t = await getTranslations("coursePlans")
const sp = await searchParams
const statusParam = getSearchParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const status = isCoursePlanStatus(statusParam) ? statusParam : undefined
const plans = await getCoursePlans({ status })
// admin 视角scope.isAdmin = true全局视图
const plans = await getCoursePlans({ status }, { userId: "", isAdmin: true })
return (
<div className="flex h-full flex-col space-y-8 p-8">

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
import type { JSX } from "react";
import { getTranslations } from "next-intl/server";
import { requirePermission } from "@/shared/lib/auth-guard";
import { Permissions } from "@/shared/types/permissions";
import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access";
import { getStandardsCoverageHeatmapAction, getGlobalLessonPlanStatsAction } from "@/modules/lesson-preparation/actions-analytics";
import { CurriculumMapView } from "@/modules/lesson-preparation/components/curriculum-map-view";
export const dynamic = "force-dynamic";
export default async function CurriculumMapPage(): Promise<JSX.Element> {
const t = await getTranslations("lessonPreparation");
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
void ctx;
const [grades, subjects, heatmapRes, statsRes] = await Promise.all([
getGradeOptions(),
getSubjectOptions(),
getStandardsCoverageHeatmapAction(),
getGlobalLessonPlanStatsAction(),
]);
const heatmap = heatmapRes.success && heatmapRes.data ? heatmapRes.data.items : [];
const stats = statsRes.success && statsRes.data ? statsRes.data.stats : null;
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("analytics.standardsCoverage")}</h1>
<p className="text-muted-foreground">{t("analytics.title")}</p>
</div>
{stats !== null && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<StatCard label={t("analytics.totalTeachers")} value={stats.totalTeachers} />
<StatCard label={t("analytics.totalPlans")} value={stats.totalPlans} />
<StatCard label={t("analytics.totalPublished")} value={stats.totalPublished} />
<StatCard label={t("analytics.totalSubmitted")} value={stats.totalSubmitted} />
<StatCard label={t("analytics.totalStandardsLinked")} value={stats.totalStandardsLinked} />
</div>
)}
<CurriculumMapView
grades={grades}
subjects={subjects}
heatmap={heatmap}
/>
</div>
);
}
function StatCard({ label, value }: { label: string; value: number }): JSX.Element {
return (
<div className="rounded-lg border p-4 bg-card">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-bold mt-1">{value}</p>
</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 Loading() {
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-6 w-48" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 9 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-24" />
</div>
))}
</div>
</CardContent>
</Card>
<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-8 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { notFound } from "next/navigation"
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getElectiveCourseById } from "@/modules/elective/data-access"
import { getCourseSelections } from "@/modules/elective/data-access-selections"
import { ElectiveCourseDetail } from "@/modules/elective/components/elective-course-detail"
import { ElectivePageLayout } from "@/modules/elective/components/elective-page-layout"
export const dynamic = "force-dynamic"
export default async function AdminElectiveDetailPage({
params,
}: {
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
const t = await getTranslations("elective")
await requirePermission(Permissions.ELECTIVE_READ)
const { id } = await params
const [course, selections] = await Promise.all([
getElectiveCourseById(id),
getCourseSelections(id),
])
if (!course) notFound()
const header = (
<div className="space-y-1">
<h2 className="text-2xl font-bold tracking-tight">{t("title.detail")}</h2>
<p className="text-muted-foreground">{t("description.detail")}</p>
</div>
)
return (
<ElectivePageLayout header={header}>
<ElectiveCourseDetail
course={course}
selections={selections}
editHref={`/admin/elective/${course.id}/edit`}
backHref="/admin/elective"
/>
</ElectivePageLayout>
)
}

View File

@@ -0,0 +1,34 @@
import type { JSX } from "react"
import { getElectiveCourses } from "@/modules/elective/data-access"
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import type { ElectiveCourseStatus } from "@/modules/elective/types"
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
/**
* 管理员课程列表 - 异步 RSC loader
* 独立 Suspense 边界,可流式渲染
* 过滤逻辑status在本 loader 内完成,保持组件纯净
*/
export async function CourseListLoader({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const statusParam = getSearchParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const courses = await getElectiveCourses({ status })
return (
<ElectiveCourseList
courses={courses}
canManage
createHref="/admin/elective/create"
editBaseHref="/admin/elective"
/>
)
}

View File

@@ -0,0 +1,12 @@
import type { JSX } from "react"
import { getElectiveOverviewStats } from "@/modules/elective/data-access-stats"
import { ElectiveStatsCards } from "@/modules/elective/components/elective-stats-cards"
/**
* 管理员概览统计卡片 - 异步 RSC loader
* 独立 Suspense 边界,可流式渲染
*/
export async function StatsCardsLoader(): Promise<JSX.Element> {
const stats = await getElectiveOverviewStats()
return <ElectiveStatsCards stats={stats} />
}

View File

@@ -11,10 +11,10 @@ export default function AdminElectiveError({ reset }: { error: Error & { digest?
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("errors.unexpected")}
description={t("errors.unexpected")}
title={t("errors.title")}
description={t("errors.description")}
action={{
label: t("actions.save"),
label: t("actions.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

@@ -1,6 +1,9 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
/**
* 全页骨架屏 - 路由级 loading.tsx 默认导出
*/
export default function Loading() {
return (
<div className="space-y-8 p-8">
@@ -8,20 +11,63 @@ export default function Loading() {
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-8 w-24" />
</CardContent>
</Card>
))}
</div>
<StatsCardsSkeleton />
<CourseListSkeleton />
</div>
)
}
/**
* 统计卡片骨架屏
* 用于独立 Suspense 边界 fallback
*/
export function StatsCardsSkeleton() {
return (
<div
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
role="status"
aria-busy="true"
aria-label="Loading statistics"
>
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="shadow-none">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-8 rounded-md" />
</CardHeader>
<CardContent>
<Skeleton className="h-7 w-16" />
</CardContent>
</Card>
))}
</div>
)
}
/**
* 课程列表骨架屏
* 用于独立 Suspense 边界 fallback
*/
export function CourseListSkeleton() {
return (
<div
className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"
role="status"
aria-busy="true"
aria-label="Loading course list"
>
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-8 w-24" />
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -1,29 +1,26 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { Suspense } from "react"
import { getTranslations } from "next-intl/server"
import { getElectiveCourses } from "@/modules/elective/data-access"
import { ElectiveCourseList } from "@/modules/elective/components/elective-course-list"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { ElectivePageLayout } from "@/modules/elective/components/elective-page-layout"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import type { ElectiveCourseStatus } from "@/modules/elective/types"
import { type SearchParams } from "@/shared/lib/utils"
import { StatsCardsLoader } from "./_components/stats-cards-loader"
import { CourseListLoader } from "./_components/course-list-loader"
import { StatsCardsSkeleton, CourseListSkeleton } from "./loading"
export const dynamic = "force-dynamic"
const isValidStatus = (v?: string): v is ElectiveCourseStatus =>
v === "draft" || v === "open" || v === "closed" || v === "cancelled"
export default async function AdminElectivePage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const sp = await searchParams
const t = await getTranslations("elective")
const statusParam = getSearchParam(sp, "status")
const status = isValidStatus(statusParam) ? statusParam : undefined
const courses = await getElectiveCourses({ status })
// 纵深防御除中间件外Server Component 入口再次校验权限
await requirePermission(Permissions.ELECTIVE_READ)
const header = (
<div className="space-y-1">
@@ -36,12 +33,12 @@ export default async function AdminElectivePage({
return (
<ElectivePageLayout header={header}>
<ElectiveCourseList
courses={courses}
canManage
createHref="/admin/elective/create"
editBaseHref="/admin/elective"
/>
<Suspense fallback={<StatsCardsSkeleton />}>
<StatsCardsLoader />
</Suspense>
<Suspense fallback={<CourseListSkeleton />}>
<CourseListLoader searchParams={searchParams} />
</Suspense>
</ElectivePageLayout>
)
}

View File

@@ -8,6 +8,7 @@ import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { getParam, type SearchParams } from "@/shared/lib/search-params"
import {
@@ -19,7 +20,7 @@ import {
getSubjectErrorDistribution,
getChapterWeakness,
getAllStudentIds,
} from "@/modules/error-book/data-access"
} from "@/modules/error-book/data-access-analytics"
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
import { SubjectTabs } from "@/modules/error-book/components/subject-tabs"
import { AnalyticsStatsCards } from "@/modules/error-book/components/analytics-stats-cards"
@@ -137,24 +138,30 @@ async function AdminErrorBookContent({
) : null}
{/* 统计卡片 */}
<AnalyticsStatsCards
totalStudents={limitedStudentIds.length}
studentsWithErrorBook={studentsWithErrorBook.length}
totalErrorItems={totalErrorItems}
averageMasteryRate={averageMasteryRate}
dueReviewCount={totalDueReview}
knowledgePointCount={knowledgePointCount}
/>
<WidgetBoundary title={t("admin.title")}>
<AnalyticsStatsCards
totalStudents={limitedStudentIds.length}
studentsWithErrorBook={studentsWithErrorBook.length}
totalErrorItems={totalErrorItems}
averageMasteryRate={averageMasteryRate}
dueReviewCount={totalDueReview}
knowledgePointCount={knowledgePointCount}
/>
</WidgetBoundary>
{/* 学科错题分布图(仅在"全部学科"视图下显示) */}
{!subjectParam && subjectDist.length > 0 ? (
<SubjectDistributionChart data={subjectDist} />
<WidgetBoundary title={t("admin.title")}>
<SubjectDistributionChart data={subjectDist} />
</WidgetBoundary>
) : null}
{/* 章节错题分布 + 知识点薄弱度(并排) */}
<div className="grid gap-4 lg:grid-cols-2">
{chapterWeakness.length > 0 ? (
<ChapterWeaknessChart data={chapterWeakness} />
<WidgetBoundary title={t("admin.noChapterDataTitle")}>
<ChapterWeaknessChart data={chapterWeakness} />
</WidgetBoundary>
) : (
<EmptyState
icon={BarChart3}
@@ -164,7 +171,9 @@ async function AdminErrorBookContent({
/>
)}
{weakKps.length > 0 ? (
<KnowledgePointWeaknessChart data={weakKps} />
<WidgetBoundary title={t("admin.noKnowledgePointDataTitle")}>
<KnowledgePointWeaknessChart data={weakKps} />
</WidgetBoundary>
) : (
<EmptyState
icon={BarChart3}
@@ -184,11 +193,13 @@ async function AdminErrorBookContent({
</span>
</div>
{sortedSummaries.length > 0 ? (
<GroupedStudentErrorTable
students={sortedSummaries}
studentNames={nameMap}
basePath="/admin/error-book"
/>
<WidgetBoundary title={t("admin.topStudents")}>
<GroupedStudentErrorTable
students={sortedSummaries}
studentNames={nameMap}
basePath="/admin/error-book"
/>
</WidgetBoundary>
) : (
<EmptyState
icon={BarChart3}
@@ -201,7 +212,9 @@ async function AdminErrorBookContent({
{/* 高频错题 Top 10 */}
{topWrongQuestions.length > 0 ? (
<TopWrongQuestions questions={topWrongQuestions} />
<WidgetBoundary title={t("admin.title")}>
<TopWrongQuestions questions={topWrongQuestions} />
</WidgetBoundary>
) : null}
</div>
)

View File

@@ -0,0 +1,33 @@
"use client"
import { FileWarning } from "lucide-react"
import type { ReactElement } from "react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
export default function AdminFilesError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}): ReactElement {
const t = useTranslations("files")
return (
<div
role="alert"
className="flex h-full flex-col items-center justify-center space-y-4 p-8"
>
<FileWarning className="h-12 w-12 text-destructive" aria-hidden="true" />
<h2 className="text-xl font-semibold">
{t("admin.empty.title")}
</h2>
<p className="max-w-md text-center text-sm text-muted-foreground">
{error.message}
</p>
<Button onClick={reset} aria-label={t("admin.columns.actions")}>
{t("preview.text.load")}
</Button>
</div>
)
}

View File

@@ -0,0 +1,54 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminFilesLoading() {
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-72" />
</div>
{/* 统计卡片骨架 */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<Skeleton className="h-3.5 w-24" />
<Skeleton className="mt-2 h-7 w-16" />
</CardContent>
</Card>
))}
</div>
{/* 上传区骨架 */}
<Skeleton className="h-32 w-full rounded-lg" />
{/* 工具栏骨架 */}
<div className="flex gap-3">
<Skeleton className="h-9 w-[200px]" />
<Skeleton className="h-9 flex-1" />
</div>
{/* 列表骨架 */}
<Card>
<CardHeader className="border-b bg-muted/40 py-2">
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent className="p-0">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 border-b p-3">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-6 w-6" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-8 w-24" />
</div>
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,25 @@
"use client"
import { TicketPlus } from "lucide-react"
import type { ReactElement } from "react"
import { Button } from "@/shared/components/ui/button"
import { useTranslations } from "next-intl"
export default function AdminInvitationCodesError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}): ReactElement {
const t = useTranslations("invitationCodes")
const tCommon = useTranslations("common")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<TicketPlus className="h-12 w-12 text-destructive" aria-hidden="true" />
<h2 className="text-xl font-semibold">{t("errors.loadFailed")}</h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}>{tCommon("actions.retry")}</Button>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { Card, CardContent } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminInvitationCodesLoading() {
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-44" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-9 w-32" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i} className="shadow-none">
<CardContent className="pt-6">
<Skeleton className="h-4 w-24" />
<Skeleton className="mt-2 h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card className="shadow-none">
<CardContent className="pt-6">
<div className="space-y-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-5 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-5 w-16" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-8" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,32 @@
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"
import { listInvitationCodes } from "@/modules/invitation-codes/data-access"
import { InvitationCodesView } from "@/modules/invitation-codes/components/invitation-codes-view"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("invitationCodes")
return {
title: `${t("title")} - Next_Edu`,
description: t("description"),
}
}
export const dynamic = "force-dynamic"
export default async function AdminInvitationCodesPage(): Promise<JSX.Element> {
await requirePermission(Permissions.USER_MANAGE)
// 直接调用 data-access 获取初始数据SSR
// 后续生成/删除通过 Server Action + router.refresh() 触发更新
const codes = await listInvitationCodes(200, true)
// 在 Server Component 中计算「当前时间」传给 Client Component
// 避免 Client 端在 render 阶段调用 Date.now()react-hooks/purity 规则)。
// 此处使用 new Date().getTime() 与现有 student/learning/page.tsx 模式一致。
const now = new Date().getTime()
return <InvitationCodesView initialCodes={codes} now={now} />
}

View File

@@ -1,9 +1,10 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { notFound } from "next/navigation"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getLessonPlanById } from "@/modules/lesson-preparation/data-access"
import { getTextbookById, getChaptersByTextbookId } from "@/modules/textbooks/data-access"
import { getTextbookById, getChaptersByTextbookId, findChapterById } from "@/modules/textbooks/data-access"
import { LessonPlanReadonlyView } from "@/modules/lesson-preparation/components/lesson-plan-readonly-view"
import { Skeleton } from "@/shared/components/ui/skeleton"
@@ -15,7 +16,8 @@ export default async function AdminLessonPlanViewPage({
params: Promise<{ planId: string }>
}): Promise<JSX.Element> {
const { planId } = await params
const ctx = await getAuthContext()
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ)
const plan = await getLessonPlanById(planId, ctx.userId)
if (!plan) notFound()
@@ -23,21 +25,14 @@ export default async function AdminLessonPlanViewPage({
let textbookTitle: string | undefined
let chapterTitle: string | undefined
if (plan.textbookId) {
const textbook = await getTextbookById(plan.textbookId)
// V4 P2-1 修复textbook 和 chapters 查询无依赖关系,改为 Promise.all 并行查询
const [textbook, chapters] = await Promise.all([
getTextbookById(plan.textbookId),
getChaptersByTextbookId(plan.textbookId),
])
textbookTitle = textbook?.title
if (plan.chapterId) {
const chapters = await getChaptersByTextbookId(plan.textbookId)
const findChapter = (list: typeof chapters): typeof chapters[number] | undefined => {
for (const ch of list) {
if (ch.id === plan.chapterId) return ch
if (ch.children && ch.children.length > 0) {
const found = findChapter(ch.children as typeof chapters)
if (found) return found
}
}
return undefined
}
const chapter = findChapter(chapters)
const chapter = findChapterById(chapters, plan.chapterId)
chapterTitle = chapter?.title
}
}

View File

@@ -1,10 +1,13 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { getTranslations } from "next-intl/server"
import { getAuthContext } from "@/shared/lib/auth-guard"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getLessonPlans, getLessonPlanStats } from "@/modules/lesson-preparation/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { LessonPlanList } from "@/modules/lesson-preparation/components/lesson-plan-list"
import { LessonPlanProviderSetup } from "@/modules/lesson-preparation/providers/lesson-plan-provider-setup"
import { ADMIN_ROLE_CONFIG } from "@/modules/lesson-preparation/providers/lesson-plan-provider"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
@@ -12,7 +15,8 @@ export const dynamic = "force-dynamic"
export default async function AdminLessonPlansPage(): Promise<JSX.Element> {
const t = await getTranslations("lessonPreparation")
const ctx = await getAuthContext()
// V4 P0-2 修复:页面层补齐 requirePermission 权限校验
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ)
// 通过 data-access 层查询,避免 app 层直接访问数据库P0-1 修复)
const [items, subjects, stats] = await Promise.all([
@@ -72,17 +76,20 @@ export default async function AdminLessonPlansPage(): Promise<JSX.Element> {
</Card>
</div>
<Suspense
fallback={
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[180px] w-full" />
))}
</div>
}
>
<LessonPlanList initialItems={items} subjects={subjects} viewMode="admin" />
</Suspense>
{/* P0-13 修复:包裹 LessonPlanProviderSetup注入 admin 角色配置,使筛选功能生效 */}
<LessonPlanProviderSetup roleConfig={ADMIN_ROLE_CONFIG}>
<Suspense
fallback={
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-[180px] w-full" />
))}
</div>
}
>
<LessonPlanList initialItems={items} subjects={subjects} viewMode="admin" />
</Suspense>
</LessonPlanProviderSetup>
</div>
)
}

View File

@@ -0,0 +1,22 @@
"use client"
import { KeyRound } from "lucide-react"
import type { ReactElement } from "react"
import { Button } from "@/shared/components/ui/button"
export default function AdminPermissionsError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}): ReactElement {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<KeyRound className="h-12 w-12 text-destructive" aria-hidden="true" />
<h2 className="text-xl font-semibold"></h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}></Button>
</div>
)
}

View File

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

View File

@@ -0,0 +1,37 @@
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"
import { getPermissionRoleCounts } from "@/modules/rbac/data-access-permissions"
import { PermissionCatalogView } from "@/modules/rbac/components/permission-catalog-view"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("rbac")
return {
title: `${t("permissions.title")} - Next_Edu`,
description: t("permissions.description"),
}
}
export const dynamic = "force-dynamic"
export default async function AdminPermissionsPage(): Promise<JSX.Element> {
await requirePermission(Permissions.PERMISSION_READ)
const roleCountsByPermission = await getPermissionRoleCounts()
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">Permission Catalog</h1>
<p className="text-muted-foreground text-sm">
All permission points defined in the system, grouped by module.
</p>
</div>
<PermissionCatalogView roleCountsByPermission={roleCountsByPermission} />
</div>
)
}

View File

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

View File

@@ -0,0 +1,29 @@
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div className="space-y-2">
<Skeleton className="h-7 w-[200px]" />
<Skeleton className="h-4 w-[420px]" />
</div>
<Skeleton className="h-9 w-[140px]" />
</div>
<div className="space-y-4">
<Skeleton className="h-10 w-full" />
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 6 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,138 @@
import type { JSX } from "react"
import { Suspense } from "react"
import { ClipboardList } from "lucide-react"
import { getTranslations } from "next-intl/server"
import { QuestionFilters } from "@/modules/questions/components/question-filters"
import { CreateQuestionButton } from "@/modules/questions/components/create-question-button"
import { QuestionBankResultsClient } from "@/modules/questions/components/question-bank-results-client"
import { ImportExportButtons } from "@/modules/questions/components/import-export-buttons"
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"
const VALID_QUESTION_TYPES: ReadonlySet<string> = new Set([
"single_choice",
"multiple_choice",
"text",
"judgment",
"composite",
])
function parseQuestionType(v?: string): QuestionType | undefined {
return v && VALID_QUESTION_TYPES.has(v) ? (v as QuestionType) : undefined
}
async function QuestionBankResults({ searchParams }: { searchParams: Promise<SearchParams> }): Promise<JSX.Element> {
await requirePermission(Permissions.QUESTION_READ)
const params = await searchParams
const q = getParam(params, "q")
const type = getParam(params, "type")
const difficulty = getParam(params, "difficulty")
const knowledgePointId = getParam(params, "kp")
const textbookId = getParam(params, "tb")
const chapterId = getParam(params, "ch")
const questionType = parseQuestionType(type)
const difficultyNum = difficulty && difficulty !== "all" ? Number(difficulty) : undefined
const safeDifficulty = difficultyNum !== undefined && Number.isFinite(difficultyNum) ? difficultyNum : undefined
const { data: questions } = await getQuestions({
q: q || undefined,
type: questionType,
difficulty: safeDifficulty,
knowledgePointId: knowledgePointId && knowledgePointId !== "all" ? knowledgePointId : undefined,
textbookId: textbookId && textbookId !== "all" ? textbookId : undefined,
chapterId: chapterId && chapterId !== "all" ? chapterId : undefined,
pageSize: 200,
})
const hasFilters = Boolean(
q ||
(type && type !== "all") ||
(difficulty && difficulty !== "all") ||
(knowledgePointId && knowledgePointId !== "all") ||
(textbookId && textbookId !== "all") ||
(chapterId && chapterId !== "all")
)
if (questions.length === 0) {
const t = await getTranslations("questions")
return (
<EmptyState
icon={ClipboardList}
title={hasFilters ? t("empty.withFilters") : t("empty.withoutFilters")}
description={
hasFilters
? t("empty.withFiltersDesc")
: t("empty.withoutFiltersDesc")
}
action={hasFilters ? { label: t("filters.clear"), href: "/admin/questions" } : undefined}
className="h-[360px] bg-card"
/>
)
}
return (
<div className="rounded-md border bg-card">
<QuestionBankResultsClient questions={questions} />
</div>
)
}
export default async function AdminQuestionBankPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}): Promise<JSX.Element> {
const t = await getTranslations("questions")
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div className="flex flex-col justify-between space-y-4 md:flex-row md:items-center md:space-y-0">
<div>
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
<div className="flex items-center space-x-2">
<ImportExportButtons />
<CreateQuestionButton />
</div>
</div>
<div className="space-y-4">
<Suspense fallback={<div className="h-10 w-full animate-pulse rounded-md bg-muted" />}>
<QuestionFilters />
</Suspense>
<Suspense fallback={<QuestionBankResultsFallback />}>
<QuestionBankResults searchParams={searchParams} />
</Suspense>
</div>
</div>
)
}
function QuestionBankResultsFallback() {
return (
<div className="rounded-md border bg-card">
<div className="p-4">
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2 p-4 pt-0">
{Array.from({ length: 6 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 w-full" />
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,22 @@
"use client"
import { ShieldAlert } from "lucide-react"
import type { ReactElement } from "react"
import { Button } from "@/shared/components/ui/button"
export default function AdminRoleDetailError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}): ReactElement {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<ShieldAlert className="h-12 w-12 text-destructive" aria-hidden="true" />
<h2 className="text-xl font-semibold"></h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}></Button>
</div>
)
}

View File

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

View File

@@ -0,0 +1,94 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import Link from "next/link"
import { ArrowLeft, ShieldCheck, Shield } from "lucide-react"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { Button } from "@/shared/components/ui/button"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent } from "@/shared/components/ui/card"
import { getRoleById } from "@/modules/rbac/data-access"
import { RolePermissionMatrix } from "@/modules/rbac/components/role-permission-matrix"
import { RoleDetailEditButton } from "@/modules/rbac/components/role-detail-edit-button"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("rbac")
return {
title: `${t("roles.detail")} - Next_Edu`,
}
}
export const dynamic = "force-dynamic"
export default async function AdminRoleDetailPage({
params,
}: {
params: Promise<{ id: string }>
}): Promise<JSX.Element> {
await requirePermission(Permissions.ROLE_READ)
const { id } = await params
const role = await getRoleById(id)
if (!role) notFound()
const isAdmin = role.name === "admin"
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div className="flex items-center gap-4">
<Button asChild variant="ghost" size="icon">
<Link href="/admin/roles">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div className="flex-1">
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold tracking-tight">{role.name}</h1>
{role.isSystem ? (
<Badge variant="secondary">
<ShieldCheck className="mr-1 h-3 w-3" />
System
</Badge>
) : (
<Badge variant="outline">
<Shield className="mr-1 h-3 w-3" />
Custom
</Badge>
)}
{role.isEnabled ? (
<Badge variant="default">Enabled</Badge>
) : (
<Badge variant="destructive">Disabled</Badge>
)}
</div>
{role.description && (
<p className="text-muted-foreground text-sm mt-1">{role.description}</p>
)}
</div>
{!isAdmin && <RoleDetailEditButton role={role} />}
</div>
{isAdmin && (
<Card className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20">
<CardContent className="py-3 text-sm text-amber-700 dark:text-amber-400">
The <strong>admin</strong> role is fully locked: its name, status, and
permissions cannot be modified. This prevents accidentally locking
yourself out of the system.
</CardContent>
</Card>
)}
<RolePermissionMatrix
roleId={role.id}
roleName={role.name}
currentPermissions={role.permissions}
isLocked={isAdmin}
userCount={role.userCount}
/>
</div>
)
}

View File

@@ -0,0 +1,22 @@
"use client"
import { ShieldAlert } from "lucide-react"
import type { ReactElement } from "react"
import { Button } from "@/shared/components/ui/button"
export default function AdminRolesError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}): ReactElement {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<ShieldAlert className="h-12 w-12 text-destructive" aria-hidden="true" />
<h2 className="text-xl font-semibold"></h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}></Button>
</div>
)
}

View File

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

View File

@@ -0,0 +1,37 @@
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"
import { getRoles } from "@/modules/rbac/data-access"
import { RoleManagementView } from "@/modules/rbac/components/role-management-view"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("rbac")
return {
title: `${t("roles.title")} - Next_Edu`,
description: t("roles.description"),
}
}
export const dynamic = "force-dynamic"
export default async function AdminRolesPage(): Promise<JSX.Element> {
await requirePermission(Permissions.ROLE_READ)
const roles = await getRoles()
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">Role Management</h1>
<p className="text-muted-foreground text-sm">
Create custom roles and manage permission assignments.
</p>
</div>
<RoleManagementView roles={roles} />
</div>
)
}

View File

@@ -5,8 +5,9 @@ import { getTranslations } from "next-intl/server"
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 { ClassErrorBoundary } from "@/modules/classes/components/class-error-boundary"
import { AdminClassesClient } from "@/modules/classes/components/admin-classes-view"
import { getGrades, getSchools } from "@/modules/school/data-access"
export const dynamic = "force-dynamic"
@@ -34,7 +35,9 @@ export default async function AdminSchoolClassesPage(): Promise<JSX.Element> {
<h2 className="text-2xl font-bold tracking-tight">{t("classManagement.title")}</h2>
<p className="text-muted-foreground">{t("classManagement.description")}</p>
</div>
<AdminClassesClient classes={classes} teachers={teachers} schools={schools} grades={grades} />
<ClassErrorBoundary>
<AdminClassesClient classes={classes} teachers={teachers} schools={schools} grades={grades} />
</ClassErrorBoundary>
</div>
)
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { JSX } from "react"
import { Skeleton } from "@/shared/components/ui/skeleton"
export default function AdminGradesInsightsLoading() {
export default function AdminGradesInsightsLoading(): JSX.Element {
return (
<div className="h-full flex-1 flex-col space-y-8 p-8 md:flex">
<div className="space-y-2">

View File

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

View File

@@ -0,0 +1,22 @@
"use client"
import { Users } from "lucide-react"
import type { ReactElement } from "react"
import { Button } from "@/shared/components/ui/button"
export default function AdminUsersError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}): ReactElement {
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<Users className="h-12 w-12 text-destructive" aria-hidden="true" />
<h2 className="text-xl font-semibold"></h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}></Button>
</div>
)
}

View File

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

View File

@@ -7,6 +7,7 @@ import { Permissions } from "@/shared/types/permissions"
import { getSearchParam, type SearchParams } from "@/shared/lib/utils"
import { getAdminUsers, getAdminUserRoles } from "@/modules/users/data-access"
import { AdminUsersView } from "@/modules/users/components/admin-users-view"
import { getRoles } from "@/modules/rbac/data-access"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("users")
@@ -30,9 +31,10 @@ export default async function AdminUsersPage({
const search = getSearchParam(sp, "search") ?? ""
const role = getSearchParam(sp, "role") ?? ""
const [result, roleOptions] = await Promise.all([
const [result, roleOptions, roles] = await Promise.all([
getAdminUsers({ page, search: search || undefined, role: role || undefined }),
getAdminUserRoles(),
getRoles(),
])
return (
@@ -46,6 +48,13 @@ export default async function AdminUsersPage({
totalPages={result.totalPages}
search={search}
roleFilter={role}
assignableRoles={roles.map((r) => ({
id: r.id,
name: r.name,
description: r.description,
isSystem: r.isSystem,
isEnabled: r.isEnabled,
}))}
/>
</div>
)

View File

@@ -5,14 +5,15 @@ import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getAnnouncementById, isAnnouncementReadByUser } from "@/modules/announcements/data-access"
import { getAnnouncementByIdForUser } from "@/modules/announcements/data-access"
import { AnnouncementDetail } from "@/modules/announcements/components/announcement-detail"
import { AnnouncementsServiceProvider } from "@/modules/announcements/components/announcements-service-context"
export const dynamic = "force-dynamic"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("announcements")
return { title: t("title.detail") }
return { title: t("title.detail"), description: t("description.detail") }
}
export default async function AnnouncementDetailPage({
@@ -23,19 +24,20 @@ export default async function AnnouncementDetailPage({
const { id } = await params
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
const announcement = await getAnnouncementById(id)
// P0-1 修复:在 data-access 层结合受众与 status 过滤,避免越权读取草稿/归档/他人班级公告。
// 不可见时统一返回 404不暴露公告存在性。
const announcement = await getAnnouncementByIdForUser(id, ctx.userId, ctx.dataScope)
if (!announcement) notFound()
// 获取当前用户的已读状态
const isReadByCurrentUser = await isAnnouncementReadByUser(id, ctx.userId)
return (
<div className="flex h-full flex-col space-y-8 p-8">
<AnnouncementDetail
announcement={{ ...announcement, isReadByCurrentUser }}
canManage={false}
backHref="/announcements"
/>
</div>
<AnnouncementsServiceProvider>
<div className="flex h-full flex-col space-y-8 p-8">
<AnnouncementDetail
announcement={announcement}
canManage={false}
backHref="/announcements"
/>
</div>
</AnnouncementsServiceProvider>
)
}

View File

@@ -1,37 +1,5 @@
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { AnnouncementListSkeleton } from "@/modules/announcements/components/announcement-list-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>
)
return <AnnouncementListSkeleton />
}

View File

@@ -3,102 +3,68 @@ 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 { getUserAnnouncementsPageData } from "@/modules/announcements/data-access"
import { AnnouncementList } from "@/modules/announcements/components/announcement-list"
import {
getStudentActiveClassId,
getStudentActiveGradeId,
getClassGradeId,
} from "@/modules/classes/data-access"
import { AnnouncementsServiceProvider } from "@/modules/announcements/components/announcements-service-context"
export const dynamic = "force-dynamic"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("announcements")
return { title: t("title.list") }
return { title: t("title.list"), description: t("description.list") }
}
/**
* 根据当前用户身份解析受众信息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
const PAGE_SIZE = 12
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() {
export default async function AnnouncementsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; status?: string }>
}) {
const t = await getTranslations("announcements")
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
const audience = await resolveAudience(ctx)
const announcements = await getAnnouncements({
status: "published",
audience: audience ?? undefined,
})
const sp = await searchParams
const requestedPage = Number(sp.page)
const page = Number.isFinite(requestedPage) && requestedPage > 0 ? Math.floor(requestedPage) : 1
const { items, total, page: currentPage, pageSize } = await getUserAnnouncementsPageData(
ctx.userId,
ctx.dataScope,
page,
PAGE_SIZE
)
// 构建分页 URL保留 ?status= 过滤参数(与 AnnouncementList 的过滤机制一致)
const buildPageHref = (targetPage: number): string => {
const params = new URLSearchParams()
if (sp.status) params.set("status", sp.status)
if (targetPage > 1) params.set("page", String(targetPage))
const qs = params.toString()
return qs ? `/announcements?${qs}` : "/announcements"
}
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title.list")}</h2>
<p className="text-muted-foreground">
{t("description.list")}
</p>
<AnnouncementsServiceProvider>
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title.list")}</h2>
<p className="text-muted-foreground">
{t("description.list")}
</p>
</div>
<AnnouncementList
announcements={items}
detailHrefPrefix="/announcements"
initialStatus={sp.status === "published" || sp.status === "draft" || sp.status === "archived" ? sp.status : "all"}
pagination={{
page: currentPage,
pageSize,
total,
buildPageHref,
}}
/>
</div>
<AnnouncementList
announcements={announcements}
detailHrefPrefix="/announcements"
/>
</div>
</AnnouncementsServiceProvider>
)
}

View File

@@ -12,7 +12,7 @@ export default async function DashboardPage(): Promise<void> {
if (!session?.user) redirect("/login")
const roles = (session.user.roles ?? []).filter(isRole)
const permissions = resolvePermissions(roles)
const permissions = await resolvePermissions(roles)
// 按优先级匹配仪表盘权限admin > student > parent > teacher
if (permissions.includes(Permissions.DASHBOARD_ADMIN_READ)) {

View File

@@ -5,36 +5,17 @@ import {
AiClientProvider,
} from "@/modules/ai/context/ai-client-provider"
import { AiAssistantWidget } from "@/modules/ai/components/ai-assistant-widget"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
generateChildSummaryAction,
recommendStudyPathAction,
getAiUsageStatsAction,
} from "@/modules/ai/actions"
import type { AiClientService } from "@/modules/ai/types"
const aiClientService: AiClientService = {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
generateChildSummary: generateChildSummaryAction,
recommendStudyPath: recommendStudyPathAction,
getAiUsageStats: getAiUsageStatsAction,
}
import { createFullAiClientService } from "@/modules/ai/context/create-ai-client-service"
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
// 在 Server Component 函数体内创建 service避免模块级单例在未来扩展请求级状态时泄露上下文。
// service 对象本身仅是 Server Action 引用集合无共享可变状态Action 内部通过 requirePermission() 动态解析当前用户。
const aiClientService = createFullAiClientService()
return (
<AiClientProvider service={aiClientService}>
<SidebarProvider sidebar={<AppSidebar />}>

View File

@@ -15,6 +15,7 @@ import { GradeDistributionPanel } from "@/modules/school/components/grade-dashbo
import { GradeHomeworkPanel } from "@/modules/school/components/grade-dashboard/grade-homework-panel"
import { GradeExamsPanel } from "@/modules/school/components/grade-dashboard/grade-exams-panel"
import { GradeProgressPanel } from "@/modules/school/components/grade-dashboard/grade-progress-panel"
import { SchoolErrorBoundary } from "@/modules/school/components/school-error-boundary"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { ChipNav } from "@/shared/components/ui/chip-nav"
import { BarChart3 } from "lucide-react"
@@ -125,54 +126,56 @@ export default async function GradeDashboardPage({
<p className="text-muted-foreground">{t("grades.gradeDashboard.description")}</p>
</div>
<GradeInsightsFilters
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
currentGradeId={selected || "all"}
buildHref={buildHref}
/>
{!selected ? (
<EmptyState
icon={BarChart3}
title={t("grades.gradeDashboard.selectToView")}
description={t("grades.gradeDashboard.selectToViewDescription")}
className="h-[360px] bg-card"
<SchoolErrorBoundary>
<GradeInsightsFilters
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
currentGradeId={selected || "all"}
buildHref={buildHref}
/>
) : (
<div className="space-y-6">
<ChipNav
options={tabOptions}
currentId={tab}
buildHref={buildTabHref}
{!selected ? (
<EmptyState
icon={BarChart3}
title={t("grades.gradeDashboard.selectToView")}
description={t("grades.gradeDashboard.selectToViewDescription")}
className="h-[360px] bg-card"
/>
{tab === "distribution" && distributionData && (
<GradeDistributionPanel data={distributionData} />
)}
{tab === "homework" && homeworkData && (
<GradeHomeworkPanel data={homeworkData} />
)}
{tab === "exams" && examsData && (
<GradeExamsPanel data={examsData} />
)}
{tab === "progress" && progressData && (
<GradeProgressPanel data={progressData} />
)}
{/* Fallback: data was null (e.g. homework insights returned null) */}
{((tab === "distribution" && !distributionData) ||
(tab === "homework" && !homeworkData) ||
(tab === "exams" && !examsData) ||
(tab === "progress" && !progressData)) && (
<EmptyState
icon={BarChart3}
title={t("grades.gradeDashboard.noData")}
description={t("grades.gradeDashboard.noDataDescription")}
className="h-[360px] bg-card"
) : (
<div className="space-y-6">
<ChipNav
options={tabOptions}
currentId={tab}
buildHref={buildTabHref}
/>
)}
</div>
)}
{tab === "distribution" && distributionData && (
<GradeDistributionPanel data={distributionData} />
)}
{tab === "homework" && homeworkData && (
<GradeHomeworkPanel data={homeworkData} />
)}
{tab === "exams" && examsData && (
<GradeExamsPanel data={examsData} />
)}
{tab === "progress" && progressData && (
<GradeProgressPanel data={progressData} />
)}
{/* Fallback: data was null (e.g. homework insights returned null) */}
{((tab === "distribution" && !distributionData) ||
(tab === "homework" && !homeworkData) ||
(tab === "exams" && !examsData) ||
(tab === "progress" && !progressData)) && (
<EmptyState
icon={BarChart3}
title={t("grades.gradeDashboard.noData")}
description={t("grades.gradeDashboard.noDataDescription")}
className="h-[360px] bg-card"
/>
)}
</div>
)}
</SchoolErrorBoundary>
</div>
)
}

View File

@@ -2,24 +2,38 @@
import { useEffect } from "react"
import { BarChart3 } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
export default function Error() {
export default function GradePracticeError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}): React.ReactNode {
const t = useTranslations("practice")
useEffect(() => {
console.error("Grade practice analytics page error")
}, [])
console.error("Grade practice analytics page error:", error)
}, [error])
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"></p>
<h1 className="text-2xl font-bold tracking-tight">
{t("errors.pageErrorTitleGrade")}
</h1>
<p className="text-muted-foreground">{t("errors.pageErrorGrade")}</p>
</div>
<EmptyState
icon={BarChart3}
title="加载失败"
description="请刷新页面重试,或联系管理员检查数据访问权限。"
title={t("errors.loadFailed")}
description={t("errors.loadFailedDescription")}
action={{
label: t("errors.retry"),
onClick: () => reset(),
}}
className="h-[360px] bg-card"
/>
</div>

View File

@@ -1,11 +1,14 @@
import { notFound } from "next/navigation"
import type { Metadata } from "next"
import type { JSX } from "react"
import { Suspense } from "react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { getMessageDetailPageData } from "@/modules/messaging/data-access"
import { MessageDetail } from "@/modules/messaging/components/message-detail"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
@@ -14,6 +17,24 @@ export async function generateMetadata(): Promise<Metadata> {
return { title: t("title.detail") }
}
/**
* P2-9: 消息详情骨架屏。
*/
function MessageDetailSkeleton(): JSX.Element {
return (
<div className="space-y-6" role="status" aria-label="Loading message">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-32 w-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-4/5" />
</div>
</div>
)
}
export default async function MessageDetailPage({
params,
}: {
@@ -27,7 +48,12 @@ export default async function MessageDetailPage({
return (
<div className="flex h-full flex-col p-8">
<MessageDetail message={message} currentUserId={ctx.userId} />
{/* P2-8 + P2-9: 详情区块 ErrorBoundary 包裹 */}
<SectionErrorBoundary namespace="messages">
<Suspense fallback={<MessageDetailSkeleton />}>
<MessageDetail message={message} currentUserId={ctx.userId} />
</Suspense>
</SectionErrorBoundary>
</div>
)
}

View File

@@ -3,8 +3,10 @@ import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getRecipients } from "@/modules/messaging/data-access"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { getRecipients, getMessageDrafts } from "@/modules/messaging/data-access"
import { MessageCompose } from "@/modules/messaging/components/message-compose"
import { MessageDraftList } from "@/modules/messaging/components/message-draft-list"
export const dynamic = "force-dynamic"
@@ -25,7 +27,11 @@ export default async function ComposeMessagePage({
const sp = await searchParams
const t = await getTranslations("messages")
const recipients = await getRecipients(ctx.userId, ctx.dataScope)
const [recipients, drafts] = await Promise.all([
getRecipients(ctx.userId, ctx.dataScope),
// P1-1: 获取草稿列表用于展示与恢复编辑
getMessageDrafts(ctx.userId),
])
return (
<div className="flex h-full flex-col p-8">
@@ -34,12 +40,20 @@ export default async function ComposeMessagePage({
<h2 className="text-2xl font-bold tracking-tight">{t("title.compose")}</h2>
<p className="text-muted-foreground">{t("description.compose")}</p>
</div>
<MessageCompose
recipients={recipients}
parentMessageId={sp.parentId}
defaultReceiverId={sp.receiverId}
defaultSubject={sp.subject}
/>
{/* P2-8: 撰写表单区块独立 ErrorBoundary失败不影响草稿列表 */}
<SectionErrorBoundary namespace="messages">
<MessageCompose
recipients={recipients}
parentMessageId={sp.parentId}
defaultReceiverId={sp.receiverId}
defaultSubject={sp.subject}
/>
</SectionErrorBoundary>
{/* P2-8: 草稿列表区块独立 ErrorBoundary */}
<SectionErrorBoundary namespace="messages">
{/* P1-1: 草稿列表 - 允许用户查看、继续编辑、删除未发送的草稿 */}
<MessageDraftList drafts={drafts} />
</SectionErrorBoundary>
</div>
</div>
)

View File

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

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 GroupComposeLoading() {
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,52 @@
import type { Metadata } from "next"
import type { JSX } from "react"
import { notFound } from "next/navigation"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { getClassNamesByIds } from "@/modules/classes/data-access"
import { MessageGroupCompose } from "@/modules/messaging/components/message-group-compose"
export const dynamic = "force-dynamic"
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("messages")
return {
title: t("title.groupCompose"),
description: t("description.groupCompose"),
}
}
export default async function GroupComposeMessagePage(): Promise<JSX.Element> {
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
const t = await getTranslations("messages")
// P2-2: 群发仅限教师class_taught DataScope其他角色无权访问此页面
if (ctx.dataScope.type !== "class_taught") {
notFound()
}
const classIds = ctx.dataScope.classIds
const classNameMap = await getClassNamesByIds(classIds)
const classOptions = classIds.map((id) => ({
id,
name: classNameMap.get(id) ?? id,
}))
return (
<div className="flex h-full flex-col p-8">
<div className="mx-auto w-full max-w-3xl space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">{t("title.groupCompose")}</h2>
<p className="text-muted-foreground">{t("description.groupCompose")}</p>
</div>
<SectionErrorBoundary namespace="messages">
<MessageGroupCompose classOptions={classOptions} />
</SectionErrorBoundary>
</div>
</div>
)
}

View File

@@ -1,10 +1,16 @@
import { Suspense } from "react"
import type { JSX } from "react"
import { getTranslations } from "next-intl/server"
import { requirePermission } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { getMessagesPageData } from "@/modules/messaging/data-access"
import { MessageList } from "@/modules/messaging/components/message-list"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
import { getNotifications } from "@/modules/notifications/data-access"
import { NotificationList } from "@/modules/notifications/components/notification-list"
import { MessageListSection } from "@/modules/messaging/components/message-list-section"
import { MessageListSkeleton } from "@/modules/messaging/components/message-list-skeleton"
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
export const dynamic = "force-dynamic"
@@ -13,13 +19,40 @@ export async function generateMetadata() {
return { title: t("title.list") }
}
export default async function MessagesPage() {
/**
* P2-9: 通知列表区块骨架屏Suspense fallback
*/
function NotificationListSkeleton(): JSX.Element {
return (
<div className="space-y-4" role="status" aria-label="Loading notifications">
<Skeleton className="h-10 w-48" />
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} aria-hidden="true">
<CardHeader className="space-y-2 pb-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</CardHeader>
<CardContent className="pt-0">
<Skeleton className="h-3 w-full" />
</CardContent>
</Card>
))}
</div>
)
}
/**
* P2-9: 通知列表区块Server Component流式加载
*/
async function NotificationListSection({ userId }: { userId: string }): Promise<JSX.Element> {
const result = await getNotifications(userId, { page: 1, pageSize: 20 })
return <NotificationList notifications={result.items} />
}
export default async function MessagesPage(): Promise<JSX.Element> {
const t = await getTranslations("messages")
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const { messages: messagesResult, notifications: notificationsResult } =
await getMessagesPageData(ctx.userId)
return (
<div className="flex h-full flex-col space-y-8 p-8">
<div>
@@ -29,9 +62,19 @@ export default async function MessagesPage() {
</p>
</div>
<MessageList messages={messagesResult.items} currentUserId={ctx.userId} />
{/* P2-8 + P2-9: 消息列表区块独立 Suspense + ErrorBoundary局部失败不影响通知区块 */}
<Suspense fallback={<MessageListSkeleton />}>
<SectionErrorBoundary namespace="messages">
<MessageListSection userId={ctx.userId} canGroupSend={ctx.dataScope.type === "class_taught"} />
</SectionErrorBoundary>
</Suspense>
<NotificationList notifications={notificationsResult.items} />
{/* P2-8 + P2-9: 通知列表区块独立 Suspense + ErrorBoundary */}
<Suspense fallback={<NotificationListSkeleton />}>
<SectionErrorBoundary namespace="notifications">
<NotificationListSection userId={ctx.userId} />
</SectionErrorBoundary>
</Suspense>
</div>
)
}

View File

@@ -1,24 +1,7 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { RouteErrorBoundary } from "@/shared/components/route-error"
export default function ParentAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
const t = useTranslations("attendance")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title={t("errors.unexpected")}
description={t("errors.unexpected")}
action={{
label: t("actions.save"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"
/>
</div>
)
return <RouteErrorBoundary reset={reset} namespace="attendance" />
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
"use client"
import { AlertCircle } from "lucide-react"
import { useTranslations } from "next-intl"
import { EmptyState } from "@/shared/components/ui/empty-state"
@@ -10,14 +11,15 @@ export default function ParentDiagnosticError({
error: Error & { digest?: string }
reset: () => void
}) {
const t = useTranslations("diagnostic")
return (
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
<EmptyState
icon={AlertCircle}
title="子女学情诊断加载失败"
description="抱歉,加载子女诊断数据时发生了意外错误。请稍后重试。"
title={t("error.parentLoadFailed")}
description={t("error.parentLoadFailedDesc")}
action={{
label: "重试",
label: t("error.retry"),
onClick: () => reset(),
}}
className="border-none shadow-none h-auto"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More