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

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

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

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

- Add messages group-compose route

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

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

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

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

View File

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

View File

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

View File

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