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
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
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"
|
||||
|
||||
@@ -11,8 +12,10 @@ import { ProfileSettingsForm } from "@/modules/settings/components/profile-setti
|
||||
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,
|
||||
@@ -25,13 +28,13 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import { UserProfile } from "@/modules/users/data-access"
|
||||
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
|
||||
@@ -39,7 +42,7 @@ interface SettingsViewProps {
|
||||
user: UserProfile
|
||||
/** 通知偏好 */
|
||||
notificationPreferences: NotificationPreferences
|
||||
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接/组织信息等) */
|
||||
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接等) */
|
||||
generalExtra?: ReactNode
|
||||
}
|
||||
|
||||
@@ -50,6 +53,21 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一设置页视图
|
||||
*
|
||||
@@ -61,6 +79,7 @@ function isTabValue(value: string | null): value is TabValue {
|
||||
*
|
||||
* 角色差异通过 `description`、`backHref` 和 `generalExtra` 三个 props 注入。
|
||||
* 当前激活的标签页通过 URL `?tab=` 参数持久化。
|
||||
* 每个标签页内容用 Error Boundary + Suspense 包裹,局部失败不影响整页。
|
||||
*/
|
||||
function SettingsViewInner({
|
||||
description,
|
||||
@@ -69,6 +88,7 @@ function SettingsViewInner({
|
||||
notificationPreferences,
|
||||
generalExtra,
|
||||
}: SettingsViewProps) {
|
||||
const t = useTranslations("settings")
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { hasPermission } = usePermission()
|
||||
@@ -93,12 +113,12 @@ function SettingsViewInner({
|
||||
<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">Settings</h1>
|
||||
<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}>Back to dashboard</Link>
|
||||
<Link href={backHref}>{t("backToDashboard")}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,79 +127,99 @@ function SettingsViewInner({
|
||||
<TabsList className="w-full justify-start">
|
||||
<TabsTrigger value="general" className="gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
General
|
||||
{t("tabs.general")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="notifications" className="gap-2">
|
||||
<Bell className="h-4 w-4" />
|
||||
Notifications
|
||||
{t("tabs.notifications")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="appearance" className="gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
Appearance
|
||||
{t("tabs.appearance")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="gap-2">
|
||||
<Lock className="h-4 w-4" />
|
||||
Security
|
||||
{t("tabs.security")}
|
||||
</TabsTrigger>
|
||||
{canConfigureAi ? (
|
||||
<TabsTrigger value="ai" className="gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI
|
||||
{t("tabs.ai")}
|
||||
</TabsTrigger>
|
||||
) : null}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="mt-6 space-y-6">
|
||||
<ProfileSettingsForm user={user} />
|
||||
{generalExtra}
|
||||
<SettingsSectionErrorBoundary>
|
||||
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||
<ProfileSettingsForm user={user} />
|
||||
{generalExtra}
|
||||
</Suspense>
|
||||
</SettingsSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notifications" className="mt-6 space-y-6">
|
||||
<NotificationPreferencesForm preferences={notificationPreferences} />
|
||||
<SettingsSectionErrorBoundary>
|
||||
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||
<NotificationPreferencesForm preferences={notificationPreferences} />
|
||||
</Suspense>
|
||||
</SettingsSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="appearance" className="mt-6 space-y-6">
|
||||
<ThemePreferencesCard />
|
||||
<SettingsSectionErrorBoundary>
|
||||
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||
<ThemePreferencesCard />
|
||||
</Suspense>
|
||||
</SettingsSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="mt-6 space-y-6">
|
||||
<PasswordChangeForm />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Session</CardTitle>
|
||||
<CardDescription>Account access and session controls.</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">Sign out</div>
|
||||
<div className="text-sm text-muted-foreground">Return to the login screen.</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline">Log out</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm sign out</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to sign out? You will be returned to the login screen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => signOut({ callbackUrl: "/login" })}>
|
||||
Sign out
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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">
|
||||
<AiProviderSettingsCard />
|
||||
<SettingsSectionErrorBoundary>
|
||||
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||
<AiProviderSettingsCard />
|
||||
</Suspense>
|
||||
</SettingsSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
|
||||
Reference in New Issue
Block a user