From 0e63c24ed9c3edec458cfd0fa2eed79426df9347 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:26:38 +0800 Subject: [PATCH] feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests shared: - Add class-filter, error-state, route-error, section-error-boundary, widget-boundary components - Add ui/alert component - Add constants directory - Add breached-password, export-utils, permission-bitmap, rate-limit, resolve-action-error, route-permissions, route-resolver, type-guards lib - Add i18n messages (en, zh-CN) for invitation-codes, parent, questions, rbac tests: - Add integration tests for elective - Add tests/setup/empty-stub scripts: - Add update-md.cjs, tmp_append_en.ps1, tmp_merge_en.ps1 utilities --- src/shared/components/class-filter.tsx | 139 +++++ src/shared/components/error-state.tsx | 35 ++ src/shared/components/onboarding-gate.tsx | 313 ----------- src/shared/components/route-error.tsx | 44 ++ .../components/section-error-boundary.tsx | 148 +++++ src/shared/components/ui/alert.tsx | 42 ++ src/shared/components/widget-boundary.tsx | 155 +++++ src/shared/constants/attendance-status.ts | 96 ++++ src/shared/db/schema.ts | 532 +++++++++++++++++- src/shared/i18n/messages/en/ai.json | 24 +- .../i18n/messages/en/announcements.json | 16 +- src/shared/i18n/messages/en/attendance.json | 128 ++++- src/shared/i18n/messages/en/audit.json | 127 ++++- src/shared/i18n/messages/en/auth.json | 65 ++- src/shared/i18n/messages/en/classes.json | 409 ++++++++++++++ src/shared/i18n/messages/en/common.json | 15 + src/shared/i18n/messages/en/course-plans.json | 220 +++++++- src/shared/i18n/messages/en/dashboard.json | 30 +- src/shared/i18n/messages/en/diagnostic.json | 75 ++- src/shared/i18n/messages/en/elective.json | 94 +++- src/shared/i18n/messages/en/error-book.json | 189 ++++++- .../i18n/messages/en/exam-homework.json | 326 ++++++++++- src/shared/i18n/messages/en/files.json | 88 ++- src/shared/i18n/messages/en/grades.json | 331 ++++++++++- .../i18n/messages/en/invitation-codes.json | 80 +++ src/shared/i18n/messages/en/leave.json | 73 ++- .../i18n/messages/en/lesson-preparation.json | 206 +++++++ src/shared/i18n/messages/en/messages.json | 110 +++- src/shared/i18n/messages/en/nav.json | 2 + .../i18n/messages/en/notifications.json | 3 +- src/shared/i18n/messages/en/parent.json | 22 + src/shared/i18n/messages/en/practice.json | 71 ++- src/shared/i18n/messages/en/questions.json | 156 +++++ src/shared/i18n/messages/en/rbac.json | 318 +++++++++++ src/shared/i18n/messages/en/settings.json | 92 +-- src/shared/i18n/messages/en/student.json | 6 + src/shared/i18n/messages/en/textbooks.json | 11 +- src/shared/i18n/messages/en/users.json | 39 +- src/shared/i18n/messages/zh-CN/ai.json | 24 +- .../i18n/messages/zh-CN/announcements.json | 16 +- .../i18n/messages/zh-CN/attendance.json | 128 ++++- src/shared/i18n/messages/zh-CN/audit.json | 127 ++++- src/shared/i18n/messages/zh-CN/auth.json | 65 ++- src/shared/i18n/messages/zh-CN/classes.json | 417 +++++++++++++- src/shared/i18n/messages/zh-CN/common.json | 15 + .../i18n/messages/zh-CN/course-plans.json | 220 +++++++- src/shared/i18n/messages/zh-CN/dashboard.json | 30 +- .../i18n/messages/zh-CN/diagnostic.json | 71 ++- src/shared/i18n/messages/zh-CN/elective.json | 94 +++- .../i18n/messages/zh-CN/error-book.json | 187 +++++- .../i18n/messages/zh-CN/exam-homework.json | 326 ++++++++++- src/shared/i18n/messages/zh-CN/files.json | 88 ++- src/shared/i18n/messages/zh-CN/grades.json | 331 ++++++++++- .../i18n/messages/zh-CN/invitation-codes.json | 80 +++ src/shared/i18n/messages/zh-CN/leave.json | 73 ++- .../messages/zh-CN/lesson-preparation.json | 197 +++++++ src/shared/i18n/messages/zh-CN/messages.json | 107 +++- src/shared/i18n/messages/zh-CN/nav.json | 2 + .../i18n/messages/zh-CN/notifications.json | 3 +- src/shared/i18n/messages/zh-CN/parent.json | 22 + src/shared/i18n/messages/zh-CN/practice.json | 71 ++- src/shared/i18n/messages/zh-CN/questions.json | 156 +++++ src/shared/i18n/messages/zh-CN/rbac.json | 318 +++++++++++ src/shared/i18n/messages/zh-CN/settings.json | 92 +-- src/shared/i18n/messages/zh-CN/student.json | 6 + src/shared/i18n/messages/zh-CN/textbooks.json | 11 +- src/shared/i18n/messages/zh-CN/users.json | 39 +- src/shared/lib/action-utils.ts | 18 +- src/shared/lib/ai/client.ts | 49 +- src/shared/lib/ai/provider-config.ts | 20 +- src/shared/lib/auth-guard.ts | 148 +---- src/shared/lib/breached-password.ts | 125 ++++ src/shared/lib/export-utils.ts | 80 +++ src/shared/lib/permission-bitmap.ts | 226 ++++++++ src/shared/lib/permissions.ts | 88 ++- src/shared/lib/question-content.ts | 45 ++ src/shared/lib/rate-limit.ts | 119 ---- src/shared/lib/rate-limit/index.ts | 71 +++ src/shared/lib/rate-limit/memory-limiter.ts | 78 +++ src/shared/lib/rate-limit/redis-limiter.ts | 193 +++++++ src/shared/lib/rate-limit/rules.ts | 67 +++ src/shared/lib/rate-limit/types.ts | 44 ++ .../lib/rate-limit/upstash-modules.d.ts | 69 +++ src/shared/lib/resolve-action-error.ts | 60 ++ src/shared/lib/role-utils.ts | 32 +- src/shared/lib/route-permissions.ts | 83 +++ src/shared/lib/route-resolver.ts | 35 ++ src/shared/lib/track-event.ts | 75 +++ src/shared/lib/type-guards.ts | 38 ++ src/shared/types/action-state.ts | 6 + src/shared/types/permissions.ts | 106 +++- tests/e2e/smoke-auth.spec.ts | 11 +- .../elective/elective-pure-functions.test.ts | 314 +++++++++++ tests/setup/empty-stub.ts | 2 + tmp_append_en.ps1 | 224 ++++++++ tmp_merge_en.ps1 | 0 update-md.cjs | 0 97 files changed, 9753 insertions(+), 819 deletions(-) create mode 100644 src/shared/components/class-filter.tsx create mode 100644 src/shared/components/error-state.tsx delete mode 100644 src/shared/components/onboarding-gate.tsx create mode 100644 src/shared/components/route-error.tsx create mode 100644 src/shared/components/section-error-boundary.tsx create mode 100644 src/shared/components/ui/alert.tsx create mode 100644 src/shared/components/widget-boundary.tsx create mode 100644 src/shared/constants/attendance-status.ts create mode 100644 src/shared/i18n/messages/en/invitation-codes.json create mode 100644 src/shared/i18n/messages/en/parent.json create mode 100644 src/shared/i18n/messages/en/questions.json create mode 100644 src/shared/i18n/messages/en/rbac.json create mode 100644 src/shared/i18n/messages/zh-CN/invitation-codes.json create mode 100644 src/shared/i18n/messages/zh-CN/parent.json create mode 100644 src/shared/i18n/messages/zh-CN/questions.json create mode 100644 src/shared/i18n/messages/zh-CN/rbac.json create mode 100644 src/shared/lib/breached-password.ts create mode 100644 src/shared/lib/export-utils.ts create mode 100644 src/shared/lib/permission-bitmap.ts delete mode 100644 src/shared/lib/rate-limit.ts create mode 100644 src/shared/lib/rate-limit/index.ts create mode 100644 src/shared/lib/rate-limit/memory-limiter.ts create mode 100644 src/shared/lib/rate-limit/redis-limiter.ts create mode 100644 src/shared/lib/rate-limit/rules.ts create mode 100644 src/shared/lib/rate-limit/types.ts create mode 100644 src/shared/lib/rate-limit/upstash-modules.d.ts create mode 100644 src/shared/lib/resolve-action-error.ts create mode 100644 src/shared/lib/route-permissions.ts create mode 100644 src/shared/lib/route-resolver.ts create mode 100644 src/shared/lib/type-guards.ts create mode 100644 tests/integration/elective/elective-pure-functions.test.ts create mode 100644 tests/setup/empty-stub.ts create mode 100644 tmp_append_en.ps1 create mode 100644 tmp_merge_en.ps1 create mode 100644 update-md.cjs 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 ? ( - <> -
- -