feat(app): add error/loading boundaries across all dashboard routes and new routes
- Add error.tsx and loading.tsx boundaries for admin, parent, student, teacher routes - Add admin announcements edit, audit-logs overview, curriculum-map, invitation-codes, permissions, questions, roles routes - Add admin elective detail and components, files, course-plans, users, scheduling boundaries - Add messages group-compose route - Add parent course-plans, elective, grades report-card, practice routes - Add student course-plans, elective detail, error-book dialogs, grades report-card, learning study-path, leave, schedule boundaries - Add teacher attendance report, classes boundaries, course-plans boundaries, elective, exams analytics/edit-rich/all/create/new, grades report-card, homework boundaries, leave, lesson-plans calendar - Add auth loading, onboarding loading, api cron
This commit is contained in:
@@ -1,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>
|
||||
}
|
||||
|
||||
19
src/app/(auth)/loading.tsx
Normal file
19
src/app/(auth)/loading.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
/**
|
||||
* 认证路由组 loading.tsx(audit-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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
50
src/app/(dashboard)/admin/announcements/[id]/edit/page.tsx
Normal file
50
src/app/(dashboard)/admin/announcements/[id]/edit/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<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"
|
||||
<AnnouncementDetail
|
||||
announcement={announcement}
|
||||
grades={grades}
|
||||
canManage
|
||||
editHref={`/admin/announcements/${id}/edit`}
|
||||
backHref="/admin/announcements"
|
||||
/>
|
||||
</div>
|
||||
</AnnouncementsServiceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
// 并行获取:记录、统计、年级列表、对比汇总
|
||||
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,
|
||||
})
|
||||
|
||||
const stats = await getAttendanceStats({
|
||||
}),
|
||||
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 gradeOptions = grades.map((g) => ({ id: g.id, name: g.name }))
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between space-y-2">
|
||||
@@ -77,6 +103,7 @@ export default async function AdminAttendancePage({
|
||||
stats={<AttendanceStatsCards stats={stats} />}
|
||||
filters={<AttendanceFilters classes={classOptions} />}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{result.items.length === 0 && !classId && !status && !date ? (
|
||||
<EmptyState
|
||||
title={t("list.empty")}
|
||||
@@ -86,6 +113,19 @@ export default async function AdminAttendancePage({
|
||||
) : (
|
||||
<AttendanceRecordList records={result.items} />
|
||||
)}
|
||||
|
||||
<ClassComparisonCard
|
||||
summary={comparison}
|
||||
grades={gradeOptions}
|
||||
currentGradeId={gradeId ?? undefined}
|
||||
/>
|
||||
|
||||
{/* L-9:考勤与成绩关联分析(仅当指定班级时显示) */}
|
||||
{classId && classId !== "all" && (
|
||||
<AttendanceGradeCorrelationCard summary={correlation} />
|
||||
)}
|
||||
</div>
|
||||
</AttendancePageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
26
src/app/(dashboard)/admin/audit-logs/data-changes/error.tsx
Normal file
26
src/app/(dashboard)/admin/audit-logs/data-changes/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,7 +67,8 @@ export default async function DataChangeLogsPage({
|
||||
</div>
|
||||
<AuditLogExportButton exportType="dataChange" params={exportParams} />
|
||||
</div>
|
||||
<DataChangeLogTable
|
||||
<AuditErrorBoundary>
|
||||
<DataChangeLogView
|
||||
items={result.items}
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
@@ -73,6 +77,7 @@ export default async function DataChangeLogsPage({
|
||||
tableOptions={tableOptions}
|
||||
stats={stats}
|
||||
/>
|
||||
</AuditErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
26
src/app/(dashboard)/admin/audit-logs/error.tsx
Normal file
26
src/app/(dashboard)/admin/audit-logs/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
src/app/(dashboard)/admin/audit-logs/loading.tsx
Normal file
16
src/app/(dashboard)/admin/audit-logs/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/app/(dashboard)/admin/audit-logs/login-logs/error.tsx
Normal file
26
src/app/(dashboard)/admin/audit-logs/login-logs/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
16
src/app/(dashboard)/admin/audit-logs/login-logs/loading.tsx
Normal file
16
src/app/(dashboard)/admin/audit-logs/login-logs/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,6 +65,7 @@ export default async function LoginLogsPage({
|
||||
</div>
|
||||
<AuditLogExportButton exportType="login" params={exportParams} />
|
||||
</div>
|
||||
<AuditErrorBoundary>
|
||||
<LoginLogView
|
||||
items={result.items}
|
||||
page={result.page}
|
||||
@@ -69,6 +73,7 @@ export default async function LoginLogsPage({
|
||||
total={result.total}
|
||||
totalPages={result.totalPages}
|
||||
/>
|
||||
</AuditErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
26
src/app/(dashboard)/admin/audit-logs/overview/error.tsx
Normal file
26
src/app/(dashboard)/admin/audit-logs/overview/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/app/(dashboard)/admin/audit-logs/overview/loading.tsx
Normal file
40
src/app/(dashboard)/admin/audit-logs/overview/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/app/(dashboard)/admin/audit-logs/overview/page.tsx
Normal file
40
src/app/(dashboard)/admin/audit-logs/overview/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,6 +64,7 @@ export default async function AuditLogsPage({
|
||||
</div>
|
||||
<AuditLogExportButton exportType="audit" params={exportParams} />
|
||||
</div>
|
||||
<AuditErrorBoundary>
|
||||
<AuditLogView
|
||||
items={result.items}
|
||||
page={result.page}
|
||||
@@ -69,6 +73,7 @@ export default async function AuditLogsPage({
|
||||
totalPages={result.totalPages}
|
||||
moduleOptions={moduleOptions}
|
||||
/>
|
||||
</AuditErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
20
src/app/(dashboard)/admin/course-plans/[id]/edit/error.tsx
Normal file
20
src/app/(dashboard)/admin/course-plans/[id]/edit/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
28
src/app/(dashboard)/admin/course-plans/[id]/edit/loading.tsx
Normal file
28
src/app/(dashboard)/admin/course-plans/[id]/edit/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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-2:admin 页面补充 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>
|
||||
)
|
||||
|
||||
20
src/app/(dashboard)/admin/course-plans/[id]/error.tsx
Normal file
20
src/app/(dashboard)/admin/course-plans/[id]/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
27
src/app/(dashboard)/admin/course-plans/[id]/loading.tsx
Normal file
27
src/app/(dashboard)/admin/course-plans/[id]/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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-2:admin 页面补充 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>
|
||||
)
|
||||
|
||||
20
src/app/(dashboard)/admin/course-plans/create/error.tsx
Normal file
20
src/app/(dashboard)/admin/course-plans/create/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
28
src/app/(dashboard)/admin/course-plans/create/loading.tsx
Normal file
28
src/app/(dashboard)/admin/course-plans/create/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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-2:admin 页面补充 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>
|
||||
)
|
||||
|
||||
20
src/app/(dashboard)/admin/course-plans/error.tsx
Normal file
20
src/app/(dashboard)/admin/course-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
21
src/app/(dashboard)/admin/course-plans/loading.tsx
Normal file
21
src/app/(dashboard)/admin/course-plans/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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-2:admin 页面补充 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">
|
||||
|
||||
20
src/app/(dashboard)/admin/curriculum-map/error.tsx
Normal file
20
src/app/(dashboard)/admin/curriculum-map/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/app/(dashboard)/admin/curriculum-map/loading.tsx
Normal file
20
src/app/(dashboard)/admin/curriculum-map/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/app/(dashboard)/admin/curriculum-map/page.tsx
Normal file
59
src/app/(dashboard)/admin/curriculum-map/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/app/(dashboard)/admin/elective/[id]/loading.tsx
Normal file
38
src/app/(dashboard)/admin/elective/[id]/loading.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
47
src/app/(dashboard)/admin/elective/[id]/page.tsx
Normal file
47
src/app/(dashboard)/admin/elective/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,7 +11,51 @@ 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">
|
||||
<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>
|
||||
@@ -22,6 +69,5 @@ export default function Loading() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,6 +138,7 @@ async function AdminErrorBookContent({
|
||||
) : null}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<WidgetBoundary title={t("admin.title")}>
|
||||
<AnalyticsStatsCards
|
||||
totalStudents={limitedStudentIds.length}
|
||||
studentsWithErrorBook={studentsWithErrorBook.length}
|
||||
@@ -145,16 +147,21 @@ async function AdminErrorBookContent({
|
||||
dueReviewCount={totalDueReview}
|
||||
knowledgePointCount={knowledgePointCount}
|
||||
/>
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 学科错题分布图(仅在"全部学科"视图下显示) */}
|
||||
{!subjectParam && subjectDist.length > 0 ? (
|
||||
<WidgetBoundary title={t("admin.title")}>
|
||||
<SubjectDistributionChart data={subjectDist} />
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
|
||||
{/* 章节错题分布 + 知识点薄弱度(并排) */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{chapterWeakness.length > 0 ? (
|
||||
<WidgetBoundary title={t("admin.noChapterDataTitle")}>
|
||||
<ChapterWeaknessChart data={chapterWeakness} />
|
||||
</WidgetBoundary>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
@@ -164,7 +171,9 @@ async function AdminErrorBookContent({
|
||||
/>
|
||||
)}
|
||||
{weakKps.length > 0 ? (
|
||||
<WidgetBoundary title={t("admin.noKnowledgePointDataTitle")}>
|
||||
<KnowledgePointWeaknessChart data={weakKps} />
|
||||
</WidgetBoundary>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
@@ -184,11 +193,13 @@ async function AdminErrorBookContent({
|
||||
</span>
|
||||
</div>
|
||||
{sortedSummaries.length > 0 ? (
|
||||
<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 ? (
|
||||
<WidgetBoundary title={t("admin.title")}>
|
||||
<TopWrongQuestions questions={topWrongQuestions} />
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
33
src/app/(dashboard)/admin/files/error.tsx
Normal file
33
src/app/(dashboard)/admin/files/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
src/app/(dashboard)/admin/files/loading.tsx
Normal file
54
src/app/(dashboard)/admin/files/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
src/app/(dashboard)/admin/invitation-codes/error.tsx
Normal file
25
src/app/(dashboard)/admin/invitation-codes/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/app/(dashboard)/admin/invitation-codes/loading.tsx
Normal file
42
src/app/(dashboard)/admin/invitation-codes/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/app/(dashboard)/admin/invitation-codes/page.tsx
Normal file
32
src/app/(dashboard)/admin/invitation-codes/page.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +76,8 @@ export default async function AdminLessonPlansPage(): Promise<JSX.Element> {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
@@ -83,6 +89,7 @@ export default async function AdminLessonPlansPage(): Promise<JSX.Element> {
|
||||
>
|
||||
<LessonPlanList initialItems={items} subjects={subjects} viewMode="admin" />
|
||||
</Suspense>
|
||||
</LessonPlanProviderSetup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
22
src/app/(dashboard)/admin/permissions/error.tsx
Normal file
22
src/app/(dashboard)/admin/permissions/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
src/app/(dashboard)/admin/permissions/loading.tsx
Normal file
30
src/app/(dashboard)/admin/permissions/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/app/(dashboard)/admin/permissions/page.tsx
Normal file
37
src/app/(dashboard)/admin/permissions/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/app/(dashboard)/admin/questions/error.tsx
Normal file
29
src/app/(dashboard)/admin/questions/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
src/app/(dashboard)/admin/questions/loading.tsx
Normal file
29
src/app/(dashboard)/admin/questions/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
src/app/(dashboard)/admin/questions/page.tsx
Normal file
138
src/app/(dashboard)/admin/questions/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/app/(dashboard)/admin/roles/[id]/error.tsx
Normal file
22
src/app/(dashboard)/admin/roles/[id]/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
src/app/(dashboard)/admin/roles/[id]/loading.tsx
Normal file
34
src/app/(dashboard)/admin/roles/[id]/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
94
src/app/(dashboard)/admin/roles/[id]/page.tsx
Normal file
94
src/app/(dashboard)/admin/roles/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
src/app/(dashboard)/admin/roles/error.tsx
Normal file
22
src/app/(dashboard)/admin/roles/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/app/(dashboard)/admin/roles/loading.tsx
Normal file
35
src/app/(dashboard)/admin/roles/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/app/(dashboard)/admin/roles/page.tsx
Normal file
37
src/app/(dashboard)/admin/roles/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
<ClassErrorBoundary>
|
||||
<AdminClassesClient classes={classes} teachers={teachers} schools={schools} grades={grades} />
|
||||
</ClassErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
22
src/app/(dashboard)/admin/users/error.tsx
Normal file
22
src/app/(dashboard)/admin/users/error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
src/app/(dashboard)/admin/users/loading.tsx
Normal file
41
src/app/(dashboard)/admin/users/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<AnnouncementsServiceProvider>
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<AnnouncementDetail
|
||||
announcement={{ ...announcement, isReadByCurrentUser }}
|
||||
announcement={announcement}
|
||||
canManage={false}
|
||||
backHref="/announcements"
|
||||
/>
|
||||
</div>
|
||||
</AnnouncementsServiceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
}
|
||||
|
||||
@@ -3,91 +3,49 @@ 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 (
|
||||
<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>
|
||||
@@ -96,9 +54,17 @@ export default async function AnnouncementsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<AnnouncementList
|
||||
announcements={announcements}
|
||||
announcements={items}
|
||||
detailHrefPrefix="/announcements"
|
||||
initialStatus={sp.status === "published" || sp.status === "draft" || sp.status === "archived" ? sp.status : "all"}
|
||||
pagination={{
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
total,
|
||||
buildPageHref,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AnnouncementsServiceProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 />}>
|
||||
|
||||
@@ -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,6 +126,7 @@ export default async function GradeDashboardPage({
|
||||
<p className="text-muted-foreground">{t("grades.gradeDashboard.description")}</p>
|
||||
</div>
|
||||
|
||||
<SchoolErrorBoundary>
|
||||
<GradeInsightsFilters
|
||||
grades={grades.map((g) => ({ id: g.id, name: g.name, schoolName: g.school.name }))}
|
||||
currentGradeId={selected || "all"}
|
||||
@@ -173,6 +175,7 @@ export default async function GradeDashboardPage({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SchoolErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
{/* P2-8 + P2-9: 详情区块 ErrorBoundary 包裹 */}
|
||||
<SectionErrorBoundary namespace="messages">
|
||||
<Suspense fallback={<MessageDetailSkeleton />}>
|
||||
<MessageDetail message={message} currentUserId={ctx.userId} />
|
||||
</Suspense>
|
||||
</SectionErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
{/* 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>
|
||||
)
|
||||
|
||||
24
src/app/(dashboard)/messages/group-compose/error.tsx
Normal file
24
src/app/(dashboard)/messages/group-compose/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function 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>
|
||||
)
|
||||
}
|
||||
40
src/app/(dashboard)/messages/group-compose/loading.tsx
Normal file
40
src/app/(dashboard)/messages/group-compose/loading.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/app/(dashboard)/messages/group-compose/page.tsx
Normal file
52
src/app/(dashboard)/messages/group-compose/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { RouteErrorBoundary } from "@/shared/components/route-error"
|
||||
|
||||
export default function ParentAttendanceError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("attendance")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.unexpected")}
|
||||
description={t("errors.unexpected")}
|
||||
action={{
|
||||
label: t("actions.save"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
return <RouteErrorBoundary reset={reset} namespace="attendance" />
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getTranslations } from "next-intl/server"
|
||||
import { getAuthContext } from "@/shared/lib/auth-guard"
|
||||
import { getStudentAttendanceSummary } from "@/modules/attendance/data-access-stats"
|
||||
import { StudentAttendanceView } from "@/modules/attendance/components/student-attendance-view"
|
||||
import { CalendarCheck } from "lucide-react"
|
||||
|
||||
import { createAttendanceReadService } from "@/modules/attendance/services/attendance-data-service"
|
||||
import type { AttendanceReadService } from "@/modules/attendance/services/types"
|
||||
import {
|
||||
ParentChildrenDataPage,
|
||||
ParentNoChildrenPage,
|
||||
@@ -9,10 +11,39 @@ import {
|
||||
import { ParentAttendanceWarning } from "@/modules/parent/components/parent-attendance-warning"
|
||||
import { ParentAttendanceRateCard } from "@/modules/parent/components/parent-attendance-rate-card"
|
||||
import { ParentAttendanceCalendar } from "@/modules/parent/components/parent-attendance-calendar"
|
||||
import { CalendarCheck } from "lucide-react"
|
||||
import { ParentStudentAttendanceDetail } from "@/modules/parent/components/parent-student-attendance-detail"
|
||||
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* 将 attendance 模块的 StudentAttendanceSummary 映射为 parent 模块的视图模型。
|
||||
* parent 模块仅消费自身类型,不依赖 attendance 内部类型变更(P1-2 解耦)。
|
||||
*/
|
||||
function toParentSummary(
|
||||
summary: Awaited<ReturnType<AttendanceReadService["getStudentSummary"]>>,
|
||||
): ParentStudentAttendanceSummary | null {
|
||||
if (!summary) return null
|
||||
return {
|
||||
studentId: summary.studentId,
|
||||
studentName: summary.studentName,
|
||||
stats: {
|
||||
total: summary.stats.total,
|
||||
present: summary.stats.present,
|
||||
absent: summary.stats.absent,
|
||||
late: summary.stats.late,
|
||||
presentRate: summary.stats.presentRate,
|
||||
},
|
||||
recentRecords: summary.recentRecords.map((r) => ({
|
||||
id: r.id,
|
||||
date: r.date,
|
||||
status: r.status,
|
||||
reason: r.reason,
|
||||
remark: r.remark,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ParentAttendancePage() {
|
||||
const t = await getTranslations("attendance")
|
||||
const ctx = await getAuthContext()
|
||||
@@ -29,16 +60,18 @@ export default async function ParentAttendancePage() {
|
||||
)
|
||||
}
|
||||
|
||||
// 通过接口抽象消费 attendance 数据(P1-2 修复:不再直接 import data-access)
|
||||
const attendanceService = createAttendanceReadService(ctx.dataScope)
|
||||
|
||||
// 使用 allSettled 容错:单个子女查询失败不影响其他子女展示
|
||||
const results = await Promise.allSettled(
|
||||
ctx.dataScope.childrenIds.map((id) => getStudentAttendanceSummary(id)),
|
||||
ctx.dataScope.childrenIds.map((id) => attendanceService.getStudentSummary(id)),
|
||||
)
|
||||
const validSummaries = results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<NonNullable<Awaited<ReturnType<typeof getStudentAttendanceSummary>>>> =>
|
||||
r.status === "fulfilled" && r.value !== null,
|
||||
.map((r) =>
|
||||
r.status === "fulfilled" ? toParentSummary(r.value) : null,
|
||||
)
|
||||
.map((r) => r.value)
|
||||
.filter((s): s is ParentStudentAttendanceSummary => s !== null)
|
||||
|
||||
return (
|
||||
<ParentChildrenDataPage
|
||||
@@ -52,7 +85,7 @@ export default async function ParentAttendancePage() {
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold border-b pb-2">{summary.studentName}</h3>
|
||||
<ParentAttendanceCalendar summary={summary} />
|
||||
<StudentAttendanceView summary={summary} />
|
||||
<ParentStudentAttendanceDetail summary={summary} />
|
||||
</div>
|
||||
)}
|
||||
headerExtra={
|
||||
|
||||
20
src/app/(dashboard)/parent/course-plans/[id]/error.tsx
Normal file
20
src/app/(dashboard)/parent/course-plans/[id]/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ParentCoursePlanDetailError() {
|
||||
const t = useTranslations("coursePlans")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={t("errors.loadFailed")}
|
||||
description={t("errors.loadFailedDesc")}
|
||||
action={{ label: t("errors.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
src/app/(dashboard)/parent/course-plans/[id]/loading.tsx
Normal file
23
src/app/(dashboard)/parent/course-plans/[id]/loading.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function ParentCoursePlanDetailLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-[240px]" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[100px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/app/(dashboard)/parent/course-plans/[id]/page.tsx
Normal file
45
src/app/(dashboard)/parent/course-plans/[id]/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { JSX } from "react"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getCoursePlanById } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanDetail } from "@/modules/course-plans/components/course-plan-detail"
|
||||
import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentCoursePlanDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const { id } = await params
|
||||
|
||||
// 家长视角:解析所有孩子的班级 ID,仅允许查看孩子所在班级的计划
|
||||
let classIds: string[] = []
|
||||
if (ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0) {
|
||||
const results = await Promise.all(
|
||||
ctx.dataScope.childrenIds.map((sid) => getStudentActiveClassId(sid))
|
||||
)
|
||||
classIds = results.filter((cid): cid is string => cid !== null)
|
||||
}
|
||||
|
||||
const plan =
|
||||
classIds.length > 0
|
||||
? await getCoursePlanById(id, { userId: ctx.userId, isAdmin: false, classIds })
|
||||
: null
|
||||
|
||||
if (!plan) notFound()
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<CoursePlanDetail
|
||||
plan={plan}
|
||||
backHref="/parent/course-plans"
|
||||
successHref="/parent/course-plans"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/(dashboard)/parent/course-plans/error.tsx
Normal file
20
src/app/(dashboard)/parent/course-plans/error.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ClipboardList } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ParentCoursePlansError() {
|
||||
const t = useTranslations("coursePlans")
|
||||
return (
|
||||
<div className="p-8">
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={t("errors.loadFailed")}
|
||||
description={t("errors.loadFailedDesc")}
|
||||
action={{ label: t("errors.retry"), onClick: () => window.location.reload() }}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
17
src/app/(dashboard)/parent/course-plans/loading.tsx
Normal file
17
src/app/(dashboard)/parent/course-plans/loading.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function ParentCoursePlansLoading() {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-[180px]" />
|
||||
<Skeleton className="h-4 w-[300px]" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-[160px] w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/app/(dashboard)/parent/course-plans/page.tsx
Normal file
45
src/app/(dashboard)/parent/course-plans/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { JSX } from "react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getCoursePlans } from "@/modules/course-plans/data-access"
|
||||
import { CoursePlanList } from "@/modules/course-plans/components/course-plan-list"
|
||||
import { getStudentActiveClassId } from "@/modules/classes/data-access"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function ParentCoursePlansPage(): Promise<JSX.Element> {
|
||||
const ctx = await requirePermission(Permissions.COURSE_PLAN_READ)
|
||||
const t = await getTranslations("coursePlans")
|
||||
|
||||
// 家长视角:解析所有孩子的班级 ID,用于过滤课程计划
|
||||
let classIds: string[] = []
|
||||
if (ctx.dataScope.type === "children" && ctx.dataScope.childrenIds.length > 0) {
|
||||
const results = await Promise.all(
|
||||
ctx.dataScope.childrenIds.map((sid) => getStudentActiveClassId(sid))
|
||||
)
|
||||
classIds = results.filter((id): id is string => id !== null)
|
||||
}
|
||||
|
||||
const plans =
|
||||
classIds.length > 0
|
||||
? await getCoursePlans(
|
||||
{ status: "active" },
|
||||
{ userId: ctx.userId, isAdmin: false, classIds }
|
||||
)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t("parent.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("parent.description")}</p>
|
||||
</div>
|
||||
<CoursePlanList
|
||||
plans={plans}
|
||||
detailBaseHref="/parent/course-plans"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import { use } from "react"
|
||||
import { Users } from "lucide-react"
|
||||
|
||||
import { getParentDashboardAction } from "@/modules/dashboard/actions"
|
||||
import { ParentDashboard } from "@/modules/parent/components/parent-dashboard"
|
||||
import { ParentDashboard } from "@/modules/dashboard/components/parent-dashboard/parent-dashboard"
|
||||
import { AiChildSummary } from "@/modules/ai/components/ai-child-summary"
|
||||
import { ChildCard } from "@/modules/parent/components/child-card"
|
||||
import { ParentAttentionBanner } from "@/modules/parent/components/parent-attention-banner"
|
||||
import { ParentNoChildrenPage } from "@/modules/parent/components/parent-children-data-page"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import type { ParentDashboardData } from "@/modules/parent/types"
|
||||
@@ -62,9 +65,67 @@ async function ParentDashboardBody({
|
||||
)
|
||||
}
|
||||
|
||||
const { parentName, children } = data
|
||||
|
||||
// 组合注入:子女卡片列表(移动端水平滑动 + 桌面端网格)
|
||||
const childrenSlot = (
|
||||
<>
|
||||
<div
|
||||
className="flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory sm:hidden"
|
||||
aria-label={t("title.parent")}
|
||||
>
|
||||
{children.map((child) => (
|
||||
<div key={child.basicInfo.id} className="snap-start shrink-0 w-[85%]">
|
||||
<ChildCard child={child} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden sm:grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{children.map((child) => (
|
||||
<ChildCard key={child.basicInfo.id} child={child} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
// AI 学情摘要区域:每个子女一张卡片
|
||||
// AiChildSummary 是客户端组件,由 AiClientProvider(dashboard layout 注入)提供数据服务
|
||||
// 仅传入已知安全字段(studentId / studentName / grade / homeworkCompletionRate),
|
||||
// recentGrades 字段因当前数据源不含 subject 信息暂不传,AI 仍可基于基本信息生成摘要
|
||||
const aiSummarySlot = (
|
||||
<div className="space-y-4 pt-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{t("title.parent")}</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{children.map((child) => {
|
||||
const { pendingCount, submittedCount, gradedCount, overdueCount } = child.homeworkSummary
|
||||
const totalHomework = pendingCount + submittedCount + gradedCount + overdueCount
|
||||
const homeworkCompletionRate = totalHomework > 0
|
||||
? (submittedCount + gradedCount) / totalHomework
|
||||
: undefined
|
||||
return (
|
||||
<AiChildSummary
|
||||
key={child.basicInfo.id}
|
||||
studentId={child.basicInfo.id}
|
||||
studentName={child.basicInfo.name ?? undefined}
|
||||
grade={child.basicInfo.gradeName ?? undefined}
|
||||
homeworkCompletionRate={homeworkCompletionRate}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8">
|
||||
<ParentDashboard data={data} />
|
||||
<ParentDashboard
|
||||
parentName={parentName ?? ""}
|
||||
childrenCount={children.length}
|
||||
childrenSlot={childrenSlot}
|
||||
attentionBannerSlot={<ParentAttentionBanner data={data} />}
|
||||
aiSummarySlot={aiSummarySlot}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -10,14 +11,15 @@ export default function ParentDiagnosticError({
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
const t = useTranslations("diagnostic")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="子女学情诊断加载失败"
|
||||
description="抱歉,加载子女诊断数据时发生了意外错误。请稍后重试。"
|
||||
title={t("error.parentLoadFailed")}
|
||||
description={t("error.parentLoadFailedDesc")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -93,8 +93,8 @@ export default async function ParentDiagnosticPage() {
|
||||
title={t("parent.title")}
|
||||
description={t("parent.description")}
|
||||
icon={Stethoscope}
|
||||
noRecordsTitle={t("parent.noReports")}
|
||||
noRecordsDescription={t("parent.noReports")}
|
||||
noRecordsTitle={t("parent.noReportsTitle")}
|
||||
noRecordsDescription={t("parent.noRecordsDescription")}
|
||||
items={items}
|
||||
renderItem={(item) => (
|
||||
<>
|
||||
@@ -105,7 +105,7 @@ export default async function ParentDiagnosticPage() {
|
||||
<StudentDiagnosticView
|
||||
summary={item.summary}
|
||||
reports={item.reports}
|
||||
practiceHrefBase={null}
|
||||
role="parent"
|
||||
/>
|
||||
) : (
|
||||
// v4-P1-9: 错误卡片,提示家长该子女数据加载失败
|
||||
@@ -113,10 +113,10 @@ export default async function ParentDiagnosticPage() {
|
||||
<CardContent className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{t("error.loadFailed")} for {item.studentName}.
|
||||
{t("error.childLoadFailed", { studentName: item.studentName })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("error.loadFailed")}. Please refresh the page or contact the school administrator if the problem persists.
|
||||
{t("error.childLoadFailedDesc")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
24
src/app/(dashboard)/parent/elective/error.tsx
Normal file
24
src/app/(dashboard)/parent/elective/error.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
export default function ParentElectiveError({ reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const t = useTranslations("elective")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title={t("errors.title")}
|
||||
description={t("errors.description")}
|
||||
action={{
|
||||
label: t("actions.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
src/app/(dashboard)/parent/elective/loading.tsx
Normal file
31
src/app/(dashboard)/parent/elective/loading.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="space-y-8 p-6 md:p-8">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
src/app/(dashboard)/parent/elective/page.tsx
Normal file
81
src/app/(dashboard)/parent/elective/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import {
|
||||
getChildren,
|
||||
getChildBasicInfo,
|
||||
} from "@/modules/parent/data-access"
|
||||
import { getStudentSelections } from "@/modules/elective/data-access-selections"
|
||||
import {
|
||||
ParentChildrenDataPage,
|
||||
ParentNoChildrenPage,
|
||||
} from "@/modules/parent/components/parent-children-data-page"
|
||||
import { ParentSelectionView } from "@/modules/elective/components/parent-selection-view"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
interface ChildSelectionData {
|
||||
studentId: string
|
||||
studentName: string
|
||||
selections: Awaited<ReturnType<typeof getStudentSelections>>
|
||||
}
|
||||
|
||||
export default async function ParentElectivePage() {
|
||||
const t = await getTranslations("elective")
|
||||
const ctx = await requirePermission(Permissions.ELECTIVE_READ)
|
||||
const parentId = ctx.userId
|
||||
|
||||
// dataScope 校验:家长必须有关联子女才能查看选课信息
|
||||
if (ctx.dataScope.type !== "children" || ctx.dataScope.childrenIds.length === 0) {
|
||||
return (
|
||||
<ParentNoChildrenPage
|
||||
title={t("parent.title")}
|
||||
description={t("parent.description")}
|
||||
icon={BookOpen}
|
||||
emptyTitle={t("parent.noChildrenTitle")}
|
||||
emptyDescription={t("parent.noChildrenDescription")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 并行拉取每个子女的选课记录,使用 allSettled 容错
|
||||
const relations = await getChildren(parentId)
|
||||
const results = await Promise.allSettled(
|
||||
relations.map(async (r): Promise<ChildSelectionData> => {
|
||||
// 双重校验:parentId + studentId 必须存在关联关系,防止跨家庭信息泄露
|
||||
const basicInfo = await getChildBasicInfo(r.studentId, r.relation)
|
||||
const selections = await getStudentSelections(r.studentId)
|
||||
return {
|
||||
studentId: r.studentId,
|
||||
studentName: basicInfo?.name ?? r.studentId,
|
||||
selections,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const validItems = results
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<ChildSelectionData> =>
|
||||
r.status === "fulfilled"
|
||||
)
|
||||
.map((r) => r.value)
|
||||
|
||||
return (
|
||||
<ParentChildrenDataPage
|
||||
title={t("parent.title")}
|
||||
description={t("parent.description")}
|
||||
icon={BookOpen}
|
||||
noRecordsTitle={t("parent.noRecordsTitle")}
|
||||
noRecordsDescription={t("parent.noRecordsDescription")}
|
||||
items={validItems}
|
||||
renderItem={(item) => (
|
||||
<ParentSelectionView
|
||||
selections={item.selections}
|
||||
studentName={item.studentName}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -7,14 +7,15 @@ import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import { formatNumber } from "@/shared/lib/utils"
|
||||
|
||||
import { getErrorBookStats } from "@/modules/error-book/data-access"
|
||||
import {
|
||||
getErrorBookStats,
|
||||
getStudentNameMap,
|
||||
getTopWrongQuestionsByStudentIds,
|
||||
getKnowledgePointWeakness,
|
||||
} from "@/modules/error-book/data-access"
|
||||
} from "@/modules/error-book/data-access-analytics"
|
||||
import { ErrorBookStatsCards } from "@/modules/error-book/components/error-book-stats-cards"
|
||||
import { TopWrongQuestions } from "@/modules/error-book/components/top-wrong-questions"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
@@ -64,9 +65,12 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
|
||||
|
||||
{childrenIds.length === 1 ? (
|
||||
// 单子女:直接展示统计卡片
|
||||
<WidgetBoundary title={t("parent.title")} skeletonHeight={300}>
|
||||
<ErrorBookStatsCards stats={childStatsList[0]} />
|
||||
</WidgetBoundary>
|
||||
) : (
|
||||
// 多子女:每个子女一张卡片
|
||||
<WidgetBoundary title={t("parent.title")} skeletonHeight={300}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{childrenIds.map((childId, idx) => {
|
||||
const stats = childStatsList[idx]
|
||||
@@ -108,10 +112,12 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</WidgetBoundary>
|
||||
)}
|
||||
|
||||
{/* 薄弱知识点 */}
|
||||
{weakKps.length > 0 ? (
|
||||
<WidgetBoundary title={t("parent.weakPoints")} skeletonHeight={300}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("parent.weakPoints")}</CardTitle>
|
||||
@@ -132,9 +138,12 @@ export default async function ParentErrorBookPage(): Promise<JSX.Element> {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</WidgetBoundary>
|
||||
) : null}
|
||||
|
||||
<WidgetBoundary title={t("parent.weakPoints")} skeletonHeight={300}>
|
||||
<TopWrongQuestions questions={topWrongQuestions} />
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import type { JSX } from "react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
@@ -9,15 +11,16 @@ export default function ParentGradesError({
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
}): JSX.Element {
|
||||
const t = useTranslations("grades")
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-4 p-8">
|
||||
<EmptyState
|
||||
icon={AlertCircle}
|
||||
title="子女成绩页面加载失败"
|
||||
description="抱歉,页面加载时发生了意外错误。请稍后重试。"
|
||||
title={t("page.error.title")}
|
||||
description={t("page.error.description")}
|
||||
action={{
|
||||
label: "重试",
|
||||
label: t("page.error.retry"),
|
||||
onClick: () => reset(),
|
||||
}}
|
||||
className="border-none shadow-none h-auto"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { JSX } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||
|
||||
export default function Loading() {
|
||||
export default function Loading(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-8 p-6 md:p-8">
|
||||
<div className="space-y-2">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user