From 8577280ab22f9b1aa258231b869adef04f43b771 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Mon, 12 Jan 2026 10:49:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A6=96=E6=AC=A1=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=BC=95=E5=AF=BC=E4=B8=8E=E6=B3=A8=E5=86=8C=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 28 ++ .../002_teacher_dashboard_implementation.md | 15 + drizzle/0008_add_user_profile_fields.sql | 17 + drizzle/meta/_journal.json | 7 + src/app/(auth)/register/page.tsx | 111 +++++-- src/app/api/onboarding/complete/route.ts | 109 +++++++ src/app/api/onboarding/status/route.ts | 25 ++ src/app/layout.tsx | 2 + src/auth.ts | 24 ++ src/modules/layout/components/site-header.tsx | 23 +- src/shared/components/onboarding-gate.tsx | 307 ++++++++++++++++++ src/shared/db/schema.ts | 10 + 12 files changed, 653 insertions(+), 25 deletions(-) create mode 100644 drizzle/0008_add_user_profile_fields.sql create mode 100644 src/app/api/onboarding/complete/route.ts create mode 100644 src/app/api/onboarding/status/route.ts create mode 100644 src/shared/components/onboarding-gate.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b9d25f1..fa245a0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -330,3 +330,31 @@ jobs: # 构建时跳过 ESLint/TS 检查 (因为已经在 quality-check job 做过了,加速构建) NEXT_TELEMETRY_DISABLED: 1 ``` + +## 工作记录(2026-01-12) + +### 注册与首次登录引导 +- 注册流程调整为“仅创建账户并跳转登录”,首次登录后通过全局弹窗分步骤完成资料配置 +- 全局引导弹窗包含:选择角色 → 通用信息(姓名/电话/住址)→ 角色信息(可跳过,后续在设置中补全)→ 完成 +- 新增/补齐用户扩展字段与迁移:phone、address、gender、age、gradeId、departmentId、onboardedAt +- 新增引导状态与提交接口:`/api/onboarding/status`、`/api/onboarding/complete` + +相关文件: +- src/shared/components/onboarding-gate.tsx +- src/app/api/onboarding/status/route.ts +- src/app/api/onboarding/complete/route.ts +- src/shared/db/schema.ts +- drizzle/0008_add_user_profile_fields.sql + +### 注册失败排查与错误提示 +- 注册 server action 增强错误信息(可识别重复邮箱、未迁移、权限错误、连接失败等),开发环境可返回更具体的底层错误消息 +- 本地排查曾出现 `ECONNREFUSED`,属于数据库连接不可达问题(需检查 MySQL 服务状态与 DATABASE_URL 配置) + +相关文件: +- src/app/(auth)/register/page.tsx + +### 顶部头像信息修复 +- 修复右上角头像/下拉信息写死为 admin 的问题,改为从 NextAuth session 动态读取当前用户 name/email 并生成头像 fallback + +相关文件: +- src/modules/layout/components/site-header.tsx diff --git a/docs/design/002_teacher_dashboard_implementation.md b/docs/design/002_teacher_dashboard_implementation.md index 2d83bb5..cd6b88f 100644 --- a/docs/design/002_teacher_dashboard_implementation.md +++ b/docs/design/002_teacher_dashboard_implementation.md @@ -214,3 +214,18 @@ Seed 脚本已覆盖班级相关数据,以便在开发环境快速验证页面 #### 6.7.4 Seed 支持 - `scripts/seed.ts` 为示例班级补充 `invitationCode`,便于在开发环境直接验证加入流程。 + +### 6.8 更新记录(2026-01-09) + +#### 6.8.1 班级创建权限收紧 +- 目标:仅允许年级组长与 admin 创建班级。 +- 后端:`createTeacherClassAction` 增加权限校验,非 admin 必须是对应年级的 `gradeHead`;`createAdminClassAction` 强制仅 admin 可调用(`src/modules/classes/actions.ts`)。 +- 前端:教师端「My Classes」页基于当前用户是否为任一年级 `gradeHead` 计算 `canCreateClass`,并禁用创建入口(`src/app/(dashboard)/teacher/classes/my/page.tsx`、`src/modules/classes/components/my-classes-grid.tsx`)。 + +#### 6.8.2 注册页面从演示提交改为真实注册 +- `/register` 增加服务端注册动作:校验输入、邮箱查重、插入 `users` 表,默认 `role=student`(`src/app/(auth)/register/page.tsx`)。 +- 注册表单接入注册动作并展示成功/失败提示,成功后跳转至 `/login`(`src/modules/auth/components/register-form.tsx`)。 + +#### 6.8.3 生产环境登录 UntrustedHost 修复 +- 问题:服务器上访问 `/api/auth/session` 报 `[auth][error] UntrustedHost`。 +- 修复:Auth.js 配置开启 `trustHost: true` 并显式设置 `secret`(`src/auth.ts`)。 diff --git a/drizzle/0008_add_user_profile_fields.sql b/drizzle/0008_add_user_profile_fields.sql new file mode 100644 index 0000000..56effd0 --- /dev/null +++ b/drizzle/0008_add_user_profile_fields.sql @@ -0,0 +1,17 @@ +ALTER TABLE `users` ADD `phone` varchar(30); +--> statement-breakpoint +ALTER TABLE `users` ADD `address` varchar(255); +--> statement-breakpoint +ALTER TABLE `users` ADD `gender` varchar(20); +--> statement-breakpoint +ALTER TABLE `users` ADD `age` int; +--> statement-breakpoint +ALTER TABLE `users` ADD `grade_id` varchar(128); +--> statement-breakpoint +ALTER TABLE `users` ADD `department_id` varchar(128); +--> statement-breakpoint +ALTER TABLE `users` ADD `onboarded_at` timestamp; +--> statement-breakpoint +CREATE INDEX `users_grade_id_idx` ON `users` (`grade_id`); +--> statement-breakpoint +CREATE INDEX `users_department_id_idx` ON `users` (`department_id`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 507dbe5..c0cad15 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1767782500000, "tag": "0007_add_class_invitation_code", "breakpoints": true + }, + { + "idx": 8, + "version": "5", + "when": 1767941300000, + "tag": "0008_add_user_profile_fields", + "breakpoints": true } ] } diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 4b60b33..49cc1a9 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -2,8 +2,6 @@ import { Metadata } from "next" import { createId } from "@paralleldrive/cuid2" import { eq } from "drizzle-orm" -import { db } from "@/shared/db" -import { users } from "@/shared/db/schema" import type { ActionState } from "@/shared/types/action-state" import { RegisterForm } from "@/modules/auth/components/register-form" @@ -16,29 +14,100 @@ export default function RegisterPage() { async function registerAction(formData: FormData): Promise { "use server" - const name = String(formData.get("name") ?? "").trim() - const email = String(formData.get("email") ?? "").trim().toLowerCase() - const password = String(formData.get("password") ?? "") + const databaseUrl = process.env.DATABASE_URL + if (!databaseUrl) return { success: false, message: "DATABASE_URL 未配置" } - if (!email) return { success: false, message: "Email is required" } - if (!password) return { success: false, message: "Password is required" } - if (password.length < 6) return { success: false, message: "Password must be at least 6 characters" } + try { + const [{ db }, { users }] = await Promise.all([ + import("@/shared/db"), + import("@/shared/db/schema"), + ]) - const existing = await db.query.users.findFirst({ - where: eq(users.email, email), - columns: { id: true }, - }) - if (existing) return { success: false, message: "Email already registered" } + const name = String(formData.get("name") ?? "").trim() + const email = String(formData.get("email") ?? "").trim().toLowerCase() + const password = String(formData.get("password") ?? "") - await db.insert(users).values({ - id: createId(), - name: name.length ? name : null, - email, - password, - role: "student", - }) + if (!email) return { success: false, message: "请输入邮箱" } + if (!password) return { success: false, message: "请输入密码" } + if (password.length < 6) return { success: false, message: "密码至少 6 位" } - return { success: true, message: "Account created" } + const existing = await db.query.users.findFirst({ + where: eq(users.email, email), + columns: { id: true }, + }) + if (existing) return { success: false, message: "该邮箱已注册" } + + await db.insert(users).values({ + id: createId(), + name: name.length ? name : null, + email, + password, + role: "student", + }) + + 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 diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts new file mode 100644 index 0000000..e5256c4 --- /dev/null +++ b/src/app/api/onboarding/complete/route.ts @@ -0,0 +1,109 @@ +import { NextResponse } from "next/server" +import { eq, inArray } from "drizzle-orm" + +import { auth } from "@/auth" +import { db } from "@/shared/db" +import { classes, classSubjectTeachers, users } from "@/shared/db/schema" +import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types" +import { enrollStudentByInvitationCode } from "@/modules/classes/data-access" + +export const dynamic = "force-dynamic" + +function parseCodes(input: string) { + const raw = input + .split(/[\s,,;;]+/g) + .map((s) => s.trim()) + .filter(Boolean) + return Array.from(new Set(raw)) +} + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null +} + +export async function POST(req: Request) { + const session = await auth() + const userId = String(session?.user?.id ?? "").trim() + if (!userId) return NextResponse.json({ success: false, message: "Unauthorized" }, { status: 401 }) + + const body = await req.json().catch(() => null) + if (!isRecord(body)) return NextResponse.json({ success: false, message: "Invalid payload" }, { status: 400 }) + + const roleRaw = String(body.role ?? "").trim() + const allowedRoles = ["student", "teacher", "parent", "admin"] as const + const role = (allowedRoles as readonly string[]).includes(roleRaw) ? roleRaw : null + if (!role) return NextResponse.json({ success: false, message: "Invalid role" }, { status: 400 }) + + const current = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { role: true }, + }) + const currentRole = String(current?.role ?? "student") + + if (role === "admin" && currentRole !== "admin") { + return NextResponse.json({ success: false, message: "Forbidden" }, { status: 403 }) + } + + const name = String(body.name ?? "").trim() + if (!name) return NextResponse.json({ success: false, message: "Name is required" }, { status: 400 }) + + const phone = String(body.phone ?? "").trim() + const address = String(body.address ?? "").trim() + + const classCodesText = String(body.classCodes ?? "").trim() + const codes = classCodesText.length ? parseCodes(classCodesText) : [] + + const teacherSubjectsRaw = Array.isArray(body.teacherSubjects) ? body.teacherSubjects : [] + const teacherSubjects = teacherSubjectsRaw + .map((s) => String(s).trim()) + .filter((s): s is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(s as ClassSubject)) + + await db + .update(users) + .set({ + role, + name, + phone: phone.length ? phone : null, + address: address.length ? address : null, + }) + .where(eq(users.id, userId)) + + if (role === "student" && codes.length) { + for (const code of codes) { + await enrollStudentByInvitationCode(userId, code) + } + } + + if (role === "teacher" && codes.length && teacherSubjects.length) { + const classRows = await db + .select({ id: classes.id, invitationCode: classes.invitationCode }) + .from(classes) + .where(inArray(classes.invitationCode, codes)) + + const byCode = new Map() + for (const r of classRows) { + if (typeof r.invitationCode === "string") { + byCode.set(r.invitationCode, r.id) + } + } + + for (const code of codes) { + const classId = byCode.get(code) + if (!classId) continue + for (const subject of teacherSubjects) { + await db + .insert(classSubjectTeachers) + .values({ classId, subject, teacherId: userId }) + .onDuplicateKeyUpdate({ set: { teacherId: userId, updatedAt: new Date() } }) + } + } + } + + await db + .update(users) + .set({ onboardedAt: new Date() }) + .where(eq(users.id, userId)) + + return NextResponse.json({ success: true }) +} + diff --git a/src/app/api/onboarding/status/route.ts b/src/app/api/onboarding/status/route.ts new file mode 100644 index 0000000..fe223cf --- /dev/null +++ b/src/app/api/onboarding/status/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server" +import { eq } from "drizzle-orm" + +import { auth } from "@/auth" +import { db } from "@/shared/db" +import { users } from "@/shared/db/schema" + +export const dynamic = "force-dynamic" + +export async function GET() { + const session = await auth() + const userId = String(session?.user?.id ?? "").trim() + if (!userId) { + return NextResponse.json({ required: false }) + } + + const row = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { onboardedAt: true, role: true }, + }) + + const required = !row?.onboardedAt + return NextResponse.json({ required, role: row?.role ?? "student" }) +} + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0cb0779..c0f1520 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from "@/shared/components/theme-provider"; import { Toaster } from "@/shared/components/ui/sonner"; import { NuqsAdapter } from 'nuqs/adapters/next/app' import { AuthSessionProvider } from "@/shared/components/auth-session-provider" +import { OnboardingGate } from "@/shared/components/onboarding-gate" import "./globals.css"; export const metadata: Metadata = { @@ -29,6 +30,7 @@ export default function RootLayout({ {children} + diff --git a/src/auth.ts b/src/auth.ts index bcce846..8f3d0f6 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -49,13 +49,37 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ if (user) { token.id = (user as { id: string }).id token.role = (user as { role?: string }).role ?? "student" + token.name = (user as { name?: string }).name } + + const userId = String(token.id ?? "").trim() + if (userId) { + const [{ eq }, { db }, { users }] = await Promise.all([ + import("drizzle-orm"), + import("@/shared/db"), + import("@/shared/db/schema"), + ]) + + const fresh = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { role: true, name: true }, + }) + + if (fresh) { + token.role = fresh.role ?? token.role ?? "student" + token.name = fresh.name ?? token.name + } + } + return token }, session: async ({ session, token }) => { if (session.user) { session.user.id = String(token.id ?? "") session.user.role = String(token.role ?? "student") + if (typeof token.name === "string") { + session.user.name = token.name + } } return session }, diff --git a/src/modules/layout/components/site-header.tsx b/src/modules/layout/components/site-header.tsx index 17568ab..6e0f1aa 100644 --- a/src/modules/layout/components/site-header.tsx +++ b/src/modules/layout/components/site-header.tsx @@ -3,7 +3,7 @@ import * as React from "react" import Link from "next/link" import { Bell, Menu, Search } from "lucide-react" -import { signOut } from "next-auth/react" +import { signOut, useSession } from "next-auth/react" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" @@ -30,6 +30,19 @@ import { useSidebar } from "./sidebar-provider" export function SiteHeader() { const { toggleSidebar, isMobile } = useSidebar() + const { data: session, status } = useSession() + + const name = session?.user?.name ?? "" + const email = session?.user?.email ?? "" + const displayName = name || email || (status === "loading" ? "加载中..." : "未登录") + + const fallbackBase = name || email || "?" + const avatarFallback = fallbackBase + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((p) => p[0]?.toUpperCase()) + .join("") return (
@@ -80,15 +93,17 @@ export function SiteHeader() {
-

Admin User

-

admin@nextedu.com

+

{displayName}

+ {email ? ( +

{email}

+ ) : null}
diff --git a/src/shared/components/onboarding-gate.tsx b/src/shared/components/onboarding-gate.tsx new file mode 100644 index 0000000..43bd4e3 --- /dev/null +++ b/src/shared/components/onboarding-gate.tsx @@ -0,0 +1,307 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import { toast } from "sonner" + +import { Button } from "@/shared/components/ui/button" +import { Checkbox } from "@/shared/components/ui/checkbox" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/components/ui/dialog" +import { Input } from "@/shared/components/ui/input" +import { Label } from "@/shared/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select" +import { Textarea } from "@/shared/components/ui/textarea" +import { cn } from "@/shared/lib/utils" + +type Role = "student" | "teacher" | "parent" | "admin" + +const TEACHER_SUBJECTS = ["语文", "数学", "英语", "美术", "体育", "科学", "社会", "音乐"] as const +type TeacherSubject = (typeof TEACHER_SUBJECTS)[number] + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null +} + +export function OnboardingGate() { + const router = useRouter() + const { status, data: session } = useSession() + const [required, setRequired] = useState(false) + const [currentRole, setCurrentRole] = useState("student") + const [open, setOpen] = useState(false) + const [step, setStep] = useState(0) + const [isSubmitting, setIsSubmitting] = useState(false) + + const [role, setRole] = useState("student") + const [name, setName] = useState("") + const [phone, setPhone] = useState("") + const [address, setAddress] = useState("") + + const [classCodes, setClassCodes] = useState("") + const [teacherSubjects, setTeacherSubjects] = useState([]) + + const canClose = useMemo(() => !required, [required]) + + useEffect(() => { + if (status !== "authenticated") return + let cancelled = false + ;(async () => { + const res = await fetch("/api/onboarding/status", { cache: "no-store" }).catch(() => null) + const json = res ? await res.json().catch(() => null) : null + if (cancelled) return + if (isRecord(json)) { + const required = Boolean(json.required) + const role = String(json.role ?? "student") as Role + setRequired(required) + setCurrentRole(role) + setRole(role === "admin" ? "admin" : "student") + setName(String(session?.user?.name ?? "").trim()) + if (required) { + setOpen(true) + setStep(0) + } + } + })() + + return () => { + cancelled = true + } + }, [status, session?.user?.name]) + + useEffect(() => { + if (!open) return + if (!required) return + setOpen(true) + }, [open, required]) + + const title = + step === 0 ? "角色选择" : step === 1 ? "通用信息" : step === 2 ? "角色信息(可跳过)" : "完成" + const description = + step === 0 + ? "请选择你在系统中的角色" + : step === 1 + ? "填写姓名、电话、住址等信息" + : step === 2 + ? "不同角色可配置班级代码、教学科目等" + : "配置完成,可以进入系统" + + const canNextFromStep0 = role.length > 0 + const canNextFromStep1 = name.trim().length > 0 && phone.trim().length > 0 + + const onNext = async () => { + if (step === 0) { + if (!canNextFromStep0) return + setStep(1) + return + } + if (step === 1) { + if (!canNextFromStep1) { + toast.error("请填写姓名与电话") + return + } + if (role === "admin") { + setStep(3) + } else { + setStep(2) + } + return + } + if (step === 2) { + setStep(3) + return + } + } + + const onBack = () => { + if (step === 0) return + setStep((s) => Math.max(0, s - 1)) + } + + const toggleSubject = (subject: TeacherSubject) => { + setTeacherSubjects((prev) => (prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject])) + } + + const onFinish = async () => { + setIsSubmitting(true) + try { + const res = await fetch("/api/onboarding/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + role, + name, + phone, + address, + classCodes, + teacherSubjects, + }), + }) + const json = await res.json().catch(() => null) + if (!res.ok || !isRecord(json) || json.success !== true) { + const msg = isRecord(json) ? String(json.message ?? "") : "" + throw new Error(msg || "提交失败") + } + + toast.success("配置完成") + setRequired(false) + setOpen(false) + router.push("/dashboard") + router.refresh() + } catch (e) { + const msg = e instanceof Error ? e.message : "提交失败" + toast.error(msg) + } finally { + setIsSubmitting(false) + } + } + + return ( + { + if (canClose) setOpen(v) + else setOpen(true) + }} + > + + + {title} + {description} + + +
+
+
= 0 ? "bg-primary" : "bg-muted")} /> +
= 1 ? "bg-primary" : "bg-muted")} /> +
= 2 ? "bg-primary" : "bg-muted")} /> +
= 3 ? "bg-primary" : "bg-muted")} /> +
+ + {step === 0 ? ( +
+ + {currentRole === "admin" ? ( +
admin
+ ) : ( + + )} +
+ ) : null} + + {step === 1 ? ( +
+
+ + setName(e.target.value)} /> +
+
+ + setPhone(e.target.value)} /> +
+
+ + setAddress(e.target.value)} /> +
+
+ ) : null} + + {step === 2 ? ( +
+ {role === "teacher" ? ( + <> +
+ +