diff --git a/src/shared/components/class-filter.tsx b/src/shared/components/class-filter.tsx new file mode 100644 index 0000000..2b1f5eb --- /dev/null +++ b/src/shared/components/class-filter.tsx @@ -0,0 +1,139 @@ +"use client" + +import { useRouter, useSearchParams } from "next/navigation" +import { cn } from "@/shared/lib/utils" +import { Badge } from "@/shared/components/ui/badge" + +export interface ClassFilterItem { + classId: string + className: string + /** 可选徽章文本(已翻译,如 "5 次练习" 或 "3 错题") */ + badgeText?: string + /** 可选次要信息文本(如 "2 待复习") */ + secondaryText?: string + /** 次要信息是否使用警告样式(红色) */ + secondaryWarning?: boolean +} + +interface ClassFilterProps { + classes: ClassFilterItem[] + /** 当前选中的班级 ID("all" 表示全部) */ + currentClassId: string + /** URL 参数名(默认 "classId") */ + paramName?: string + /** "全部班级" 按钮文本(已翻译) */ + allLabel: string + /** 无障碍标签(已翻译,如 "选择班级") */ + ariaLabel?: string +} + +/** + * 通用班级筛选器(共享组件)。 + * + * 通过 URL 查询参数(默认 `classId`)管理选中状态,点击切换班级。 + * + * 解耦设计: + * - 不依赖任何业务模块的类型或翻译 + * - 所有展示文本(allLabel、badgeText、secondaryText)由调用方翻译后传入 + * - 适用于教师/年级主任在 error-book、practice、homework 等模块的班级切换 + * + * @example + * // Server Component 页面调用 + * ({ + * classId: c.classId, + * className: c.className, + * badgeText: t("classFilter.sessionCount", { count: c.totalSessions }), + * }))} + * currentClassId={effectiveClassId} + * allLabel={t("classFilter.all")} + * ariaLabel={t("classFilter.selectClass")} + * /> + */ +export function ClassFilter({ + classes, + currentClassId, + paramName = "classId", + allLabel, + ariaLabel, +}: ClassFilterProps): React.ReactNode { + const router = useRouter() + const searchParams = useSearchParams() + + function handleSelect(classId: string): void { + const params = new URLSearchParams(searchParams.toString()) + if (classId === "all") { + params.delete(paramName) + } else { + params.set(paramName, classId) + } + router.push(`?${params.toString()}`, { scroll: false }) + } + + if (classes.length === 0) return null + + return ( +
+ + {classes.map((cls) => { + const isActive = currentClassId === cls.classId + return ( + + ) + })} +
+ ) +} diff --git a/src/shared/components/error-state.tsx b/src/shared/components/error-state.tsx new file mode 100644 index 0000000..66ef49f --- /dev/null +++ b/src/shared/components/error-state.tsx @@ -0,0 +1,35 @@ +"use client" + +import { AlertTriangle } from "lucide-react" +import { useTranslations } from "next-intl" + +import { EmptyState } from "@/shared/components/ui/empty-state" + +/** + * 通用错误边界展示组件(P1-4 审计修复)。 + * + * 统一 error.tsx 的渲染逻辑,避免 30+ error.tsx 重复同样的代码。 + * 各模块 error.tsx 只需调用 ``。 + * + * 翻译键查找规则:在传入 namespace 下查找 error.title / error.description / error.retry。 + */ +export function ErrorState({ + error, + reset, + namespace, +}: { + error: Error & { digest?: string } + reset: () => void + namespace: string +}) { + const t = useTranslations(namespace) + + return ( + + ) +} diff --git a/src/shared/components/onboarding-gate.tsx b/src/shared/components/onboarding-gate.tsx deleted file mode 100644 index 985cce9..0000000 --- a/src/shared/components/onboarding-gate.tsx +++ /dev/null @@ -1,313 +0,0 @@ -"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" -import { Permissions } from "@/shared/types/permissions" - -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, update } = useSession() - const [required, setRequired] = useState(false) - 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) - setRole(role === "admin" ? "admin" : role) - 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 permissions = (session?.user?.permissions ?? []) as string[] - const isAdmin = permissions.includes(Permissions.SETTINGS_ADMIN) - const isTeacher = permissions.includes(Permissions.EXAM_CREATE) - const isStudent = permissions.includes(Permissions.HOMEWORK_SUBMIT) && !permissions.includes(Permissions.EXAM_CREATE) - const isParent = !permissions.includes(Permissions.EXAM_CREATE) && !permissions.includes(Permissions.HOMEWORK_SUBMIT) && permissions.includes(Permissions.EXAM_READ) - - const onNext = async () => { - if (step === 0) { - if (!canNextFromStep0) return - setStep(1) - return - } - if (step === 1) { - if (!canNextFromStep1) { - toast.error("请填写姓名与电话") - return - } - if (isAdmin) { - 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 || "提交失败") - } - - await update?.() - 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 ? ( -
- - {isAdmin ? ( -
admin
- ) : ( - - )} -
- ) : null} - - {step === 1 ? ( -
-
- - setName(e.target.value)} /> -
-
- - setPhone(e.target.value)} /> -
-
- - setAddress(e.target.value)} /> -
-
- ) : null} - - {step === 2 ? ( -
- {isTeacher ? ( - <> -
- -