Files
NextEdu/src/modules/settings/components/settings-view.tsx
SpecialX 7e320d78c1 feat(ai): 统一 AI 配置入口到 /admin/ai-settings
## 新增
- 创建 /admin/ai-settings 统一配置页(AiProviderSettingsCard + AiUsageDashboard)
- admin 侧边栏新增"AI 配置"菜单项(权限 AI_CONFIGURE,图标 Sparkles)
- 新增 deleteAiProvider 数据访问层(事务删除 + 自动转移默认)
- 新增 deleteAiProviderAction Server Action(Zod 校验 + 权限校验)
- AiProviderSettingsCard 新增删除按钮(AlertDialog 确认 + destructive 变体)
- 新增 i18n 翻译键(delete/deleteConfirm/deleteSuccess 等,zh-CN + en)

## 移除
- 从 /settings 移除 AI 标签页(原 VALID_TABS 含 "ai",现仅 4 标签页)
- 从考试页面移除 AI 配置弹窗(Dialog + AiProviderSettingsCard 内嵌)
- 从 ai-provider-selector.tsx 移除配置弹窗(managePanel/manageOpen props)
- 移除 settings-view.tsx 中 canConfigureAi 逻辑和未使用 import

## 变更
- 考试页面"管理"按钮改为 Link 跳转到 /admin/ai-settings
- ai-provider-selector.tsx"管理"按钮改为 Link 跳转到 /admin/ai-settings
- exam-form.tsx 移除 providerDialogOpen/providerDialogKey 状态
- 修正架构文档 004 中 Action 命名(getAiProvidersAction → getAiProviderSummaries 等)

## 架构文档同步
- 004 更新 settings 模块章节(V3 标记/修正 Action 名称/新增 deleteAiProvider)
- 005 新增 deleteAiProviderAction 节点 + /admin/ai-settings 路由
2026-06-23 19:33:28 +08:00

226 lines
8.6 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 } 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 { SecurityCenterCard } from "@/modules/settings/components/security-center-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"
interface SettingsViewProps {
/** 页面副标题描述i18n 键) */
description: string
/** 返回仪表盘的链接 */
backHref: string
/** 当前用户 */
user: UserProfile
/** 通知偏好 */
notificationPreferences: NotificationPreferences
/** General 标签页中 ProfileSettingsForm 下方的内容(角色专属快捷链接等) */
generalExtra?: ReactNode
/** 当前请求的 User-Agent用于安全中心标记当前会话 */
currentUserAgent?: string
}
const VALID_TABS = ["general", "notifications", "appearance", "security"] 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,
currentUserAgent,
}: SettingsViewProps) {
const t = useTranslations("settings")
const router = useRouter()
const searchParams = useSearchParams()
const tabParam = searchParams.get("tab")
// 解析 tab 参数
function resolveTab(value: string | null): TabValue {
if (!isTabValue(value)) return "general"
return value
}
const activeTab: TabValue = resolveTab(tabParam)
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 })
}
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>
</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 />
<SecurityCenterCard currentDeviceLabel={currentUserAgent} />
<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>
</Tabs>
</div>
)
}
export function SettingsView(props: SettingsViewProps) {
return (
<Suspense fallback={null}>
<SettingsViewInner {...props} />
</Suspense>
)
}