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} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user