Files
NextEdu/src/modules/settings/components/settings-view.tsx
SpecialX 5d42495480 feat(settings): 设置与个人信息模块审计重构 — i18n + 服务注入解耦 + Error Boundary + 流式渲染
- 新增 SettingsService 接口 + Context 注入,组件层不再直接 import users/messaging actions

- 新增 resolveRoleSettingsConfig 配置驱动角色路由,删除 parent/student/teacher-settings-view 冗余文件

- 新增 SettingsSectionErrorBoundary,每个 TabsContent + profile 角色概览区块均包裹

- 新增 ProfileStudentOverview/ProfileTeacherOverview 异步 Server Component + 骨架屏,支持流式渲染

- 抽取 buildStudentOverviewData 等纯函数到 lib/student-overview-data.ts,便于单元测试

- 新增 settings.json 翻译文件(zh-CN + en),所有组件改用 useTranslations/getTranslations

- 重构 profile/page.tsx:i18n 适配 + Suspense 分区加载 + 业务逻辑抽离

- 同步更新架构图 004/005
2026-06-22 16:15:36 +08:00

237 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, type ReactNode } from "react"
import { useTranslations } from "next-intl"
import { User, Palette, Lock, Bell, Sparkles } from "lucide-react"
import { signOut } from "next-auth/react"
import { ThemePreferencesCard } from "@/modules/settings/components/theme-preferences-card"
import { ProfileSettingsForm } from "@/modules/settings/components/profile-settings-form"
import { PasswordChangeForm } from "@/modules/settings/components/password-change-form"
import { NotificationPreferencesForm } from "@/modules/settings/components/notification-preferences-form"
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
import { SettingsSectionErrorBoundary } from "@/modules/settings/components/settings-section-error-boundary"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Skeleton } from "@/shared/components/ui/skeleton"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
import type { UserProfile } from "@/modules/users/data-access"
import type { NotificationPreferences } from "@/modules/notifications/types"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
interface SettingsViewProps {
/** 页面副标题描述i18n 键) */
description: string
/** 返回仪表盘的链接 */
backHref: string
/** 当前用户 */
user: UserProfile
/** 通知偏好 */
notificationPreferences: NotificationPreferences
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接等) */
generalExtra?: ReactNode
}
const VALID_TABS = ["general", "notifications", "appearance", "security", "ai"] as const
type TabValue = (typeof VALID_TABS)[number]
function isTabValue(value: string | null): value is TabValue {
return value !== null && (VALID_TABS as readonly string[]).includes(value)
}
function SettingsSectionSkeleton(): ReactNode {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
)
}
/**
* 统一设置页视图
*
* 消除 admin / teacher / student / parent 四个设置视图的重复布局:
* - 相同的页面头部(标题 + 描述 + 返回按钮)
* - 相同的标签页General / Notifications / Appearance / Security / AI
* - 相同的 Notifications / Appearance / Security 标签页内容
* - 相同的 Session 卡片(登出)
*
* 角色差异通过 `description`、`backHref` 和 `generalExtra` 三个 props 注入。
* 当前激活的标签页通过 URL `?tab=` 参数持久化。
* 每个标签页内容用 Error Boundary + Suspense 包裹,局部失败不影响整页。
*/
function SettingsViewInner({
description,
backHref,
user,
notificationPreferences,
generalExtra,
}: SettingsViewProps) {
const t = useTranslations("settings")
const router = useRouter()
const searchParams = useSearchParams()
const { hasPermission } = usePermission()
const tabParam = searchParams.get("tab")
const activeTab: TabValue = isTabValue(tabParam) ? tabParam : "general"
const handleTabChange = (value: string) => {
const params = new URLSearchParams(searchParams.toString())
if (value === "general") {
params.delete("tab")
} else {
params.set("tab", value)
}
const query = params.toString()
router.push(query ? `?${query}` : "?", { scroll: false })
}
const canConfigureAi = hasPermission(Permissions.AI_CONFIGURE)
return (
<div className="flex h-full flex-col gap-8 p-8">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<div className="text-sm text-muted-foreground">{description}</div>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={backHref}>{t("backToDashboard")}</Link>
</Button>
</div>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="general" className="gap-2">
<User className="h-4 w-4" />
{t("tabs.general")}
</TabsTrigger>
<TabsTrigger value="notifications" className="gap-2">
<Bell className="h-4 w-4" />
{t("tabs.notifications")}
</TabsTrigger>
<TabsTrigger value="appearance" className="gap-2">
<Palette className="h-4 w-4" />
{t("tabs.appearance")}
</TabsTrigger>
<TabsTrigger value="security" className="gap-2">
<Lock className="h-4 w-4" />
{t("tabs.security")}
</TabsTrigger>
{canConfigureAi ? (
<TabsTrigger value="ai" className="gap-2">
<Sparkles className="h-4 w-4" />
{t("tabs.ai")}
</TabsTrigger>
) : null}
</TabsList>
<TabsContent value="general" className="mt-6 space-y-6">
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<ProfileSettingsForm user={user} />
{generalExtra}
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
<TabsContent value="notifications" className="mt-6 space-y-6">
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<NotificationPreferencesForm preferences={notificationPreferences} />
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
<TabsContent value="appearance" className="mt-6 space-y-6">
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<ThemePreferencesCard />
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
<TabsContent value="security" className="mt-6 space-y-6">
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<PasswordChangeForm />
<Card>
<CardHeader>
<CardTitle>{t("security.session.title")}</CardTitle>
<CardDescription>{t("security.session.description")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">{t("security.session.signOut")}</div>
<div className="text-sm text-muted-foreground">{t("security.session.signOutDesc")}</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline">{t("security.session.signOut")}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("security.session.confirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("security.session.confirmDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("security.session.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
{t("security.session.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
{canConfigureAi ? (
<TabsContent value="ai" className="mt-6 space-y-6">
<SettingsSectionErrorBoundary>
<Suspense fallback={<SettingsSectionSkeleton />}>
<AiProviderSettingsCard />
</Suspense>
</SettingsSectionErrorBoundary>
</TabsContent>
) : null}
</Tabs>
</div>
)
}
export function SettingsView(props: SettingsViewProps) {
return (
<Suspense fallback={null}>
<SettingsViewInner {...props} />
</Suspense>
)
}