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 路由
This commit is contained in:
43
src/app/(dashboard)/admin/ai-settings/page.tsx
Normal file
43
src/app/(dashboard)/admin/ai-settings/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { Metadata } from "next"
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
||||
import { AiUsageDashboard } from "@/modules/ai/components/ai-usage-dashboard"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI 配置 - Next_Edu",
|
||||
description: "统一管理 AI 服务商、API 密钥与使用统计",
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* AI 统一配置页
|
||||
*
|
||||
* 作为 AI 模块的独立配置入口,取代:
|
||||
* - /settings?tab=ai(已移除 AI 标签页)
|
||||
* - 考试页面内嵌的 AI 配置弹窗(已改为跳转链接)
|
||||
*
|
||||
* 权限:AI_CONFIGURE(当前仅 admin 角色拥有)
|
||||
*/
|
||||
export default async function AiSettingsPage(): Promise<JSX.Element> {
|
||||
await requirePermission(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">AI 配置</h1>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
统一管理 AI 服务商、API 密钥与使用统计
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<AiProviderSettingsCard />
|
||||
<AiUsageDashboard />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Settings } from "lucide-react"
|
||||
import {
|
||||
@@ -17,14 +18,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import type { Control } from "react-hook-form"
|
||||
|
||||
@@ -47,11 +40,6 @@ type AiProviderSelectorProps = {
|
||||
loading?: boolean
|
||||
/** Provider 标签映射 */
|
||||
providerLabels?: Record<string, string>
|
||||
/** 管理面板触发器 */
|
||||
managePanel?: React.ReactNode
|
||||
/** 管理面板打开状态 */
|
||||
manageOpen?: boolean
|
||||
onManageOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +47,8 @@ type AiProviderSelectorProps = {
|
||||
*
|
||||
* 可复用的表单字段组件,用于选择 AI Provider。
|
||||
* 从 exam-ai-generator.tsx 抽取,支持在任何需要 AI Provider 选择的表单中复用。
|
||||
*
|
||||
* V3:移除内嵌配置弹窗,"管理"按钮改为跳转到 /admin/ai-settings 统一配置页。
|
||||
*/
|
||||
export function AiProviderSelector({
|
||||
control,
|
||||
@@ -66,9 +56,6 @@ export function AiProviderSelector({
|
||||
providers,
|
||||
loading = false,
|
||||
providerLabels,
|
||||
managePanel,
|
||||
manageOpen,
|
||||
onManageOpenChange,
|
||||
}: AiProviderSelectorProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
|
||||
@@ -80,28 +67,12 @@ export function AiProviderSelector({
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>{t("provider.label")}</FormLabel>
|
||||
{managePanel ? (
|
||||
<Dialog open={manageOpen} onOpenChange={onManageOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||
{t("provider.manage")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[960px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("provider.manageTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("provider.manageDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{managePanel}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
<Button asChild type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
|
||||
<Link href="/admin/ai-settings">
|
||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||
{t("provider.manage")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
|
||||
<FormControl>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Control, UseFormReturn } from "react-hook-form"
|
||||
import { useTranslations } from "next-intl"
|
||||
import Link from "next/link"
|
||||
import { Settings } from "lucide-react"
|
||||
import {
|
||||
FormField,
|
||||
@@ -27,15 +28,6 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/shared/components/ui/dialog"
|
||||
import { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
||||
import type { AiProviderSummary } from "@/modules/settings/actions"
|
||||
import { formatDateTime } from "@/shared/lib/utils"
|
||||
import type { ExamFormValues, PreviewBackgroundTask } from "./exam-form-types"
|
||||
@@ -47,10 +39,6 @@ type ExamAiGeneratorProps = {
|
||||
aiProviders: AiProviderSummary[]
|
||||
setAiProviders: (providers: AiProviderSummary[]) => void
|
||||
loadingAiProviders: boolean
|
||||
providerDialogOpen: boolean
|
||||
setProviderDialogOpen: (open: boolean) => void
|
||||
providerDialogKey: number
|
||||
setProviderDialogKey: (key: number | ((prev: number) => number)) => void
|
||||
handlePreview: () => void
|
||||
handleBackgroundPreview: () => void
|
||||
previewLoading: boolean
|
||||
@@ -62,15 +50,11 @@ type ExamAiGeneratorProps = {
|
||||
}
|
||||
|
||||
export function ExamAiGenerator({
|
||||
form,
|
||||
form: _form,
|
||||
control,
|
||||
aiProviders,
|
||||
setAiProviders,
|
||||
setAiProviders: _setAiProviders,
|
||||
loadingAiProviders,
|
||||
providerDialogOpen,
|
||||
setProviderDialogOpen,
|
||||
providerDialogKey,
|
||||
setProviderDialogKey,
|
||||
handlePreview,
|
||||
handleBackgroundPreview,
|
||||
previewLoading,
|
||||
@@ -104,41 +88,12 @@ export function ExamAiGenerator({
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel>{t("provider.label")}</FormLabel>
|
||||
<Dialog
|
||||
open={providerDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setProviderDialogOpen(open)
|
||||
if (open) {
|
||||
setProviderDialogKey((value) => value + 1)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
|
||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||
{t("provider.manage")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[960px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("provider.manageTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("provider.manageDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AiProviderSettingsCard
|
||||
key={providerDialogKey}
|
||||
initialMode="new"
|
||||
onProvidersChanged={(rows) => {
|
||||
setAiProviders(rows)
|
||||
const preferred = rows.find((item) => item.isDefault) ?? rows[0]
|
||||
if (preferred) {
|
||||
form.setValue("aiProviderId", preferred.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button asChild type="button" variant="ghost" size="sm" className="h-7 px-2 text-muted-foreground hover:text-foreground">
|
||||
<Link href="/admin/ai-settings">
|
||||
<Settings className="mr-1 h-3.5 w-3.5" />
|
||||
{t("provider.manage")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled={loadingAiProviders}>
|
||||
<FormControl>
|
||||
|
||||
@@ -25,8 +25,6 @@ export type { ExamFormValues } from "./exam-form-types"
|
||||
export function ExamForm() {
|
||||
const router = useRouter()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [providerDialogOpen, setProviderDialogOpen] = useState(false)
|
||||
const [providerDialogKey, setProviderDialogKey] = useState(0)
|
||||
const [subjects, setSubjects] = useState<{ id: string; name: string }[]>([])
|
||||
const [loadingSubjects, setLoadingSubjects] = useState(true)
|
||||
const [grades, setGrades] = useState<{ id: string; name: string }[]>([])
|
||||
@@ -219,10 +217,6 @@ export function ExamForm() {
|
||||
aiProviders={aiProviders}
|
||||
setAiProviders={setAiProviders}
|
||||
loadingAiProviders={loadingAiProviders}
|
||||
providerDialogOpen={providerDialogOpen}
|
||||
setProviderDialogOpen={setProviderDialogOpen}
|
||||
providerDialogKey={providerDialogKey}
|
||||
setProviderDialogKey={setProviderDialogKey}
|
||||
handlePreview={preview.handlePreview}
|
||||
handleBackgroundPreview={preview.handleBackgroundPreview}
|
||||
previewLoading={preview.previewLoading}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
BookCopy,
|
||||
Files,
|
||||
BookX,
|
||||
Sparkles,
|
||||
} from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
@@ -134,6 +135,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
||||
href: "/admin/error-book",
|
||||
permission: Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||
},
|
||||
{
|
||||
title: "课案管理",
|
||||
icon: PenTool,
|
||||
href: "/admin/lesson-plans",
|
||||
permission: Permissions.LESSON_PLAN_READ,
|
||||
},
|
||||
{
|
||||
title: "Audit Logs",
|
||||
icon: ScrollText,
|
||||
@@ -146,6 +153,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
||||
]
|
||||
},
|
||||
...COMMON_NAV_ITEMS,
|
||||
{
|
||||
title: "AI 配置",
|
||||
icon: Sparkles,
|
||||
href: "/admin/ai-settings",
|
||||
permission: Permissions.AI_CONFIGURE,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
icon: Settings,
|
||||
@@ -268,6 +281,7 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
||||
permission: Permissions.GRADE_MANAGE,
|
||||
items: [
|
||||
{ title: "年级班级", href: "/management/grade/classes" },
|
||||
{ title: "年级仪表盘", href: "/management/grade/dashboard" },
|
||||
{ title: "年级洞察", href: "/management/grade/insights" },
|
||||
]
|
||||
},
|
||||
@@ -287,6 +301,7 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
||||
permission: Permissions.GRADE_MANAGE,
|
||||
items: [
|
||||
{ title: "年级班级", href: "/management/grade/classes" },
|
||||
{ title: "年级仪表盘", href: "/management/grade/dashboard" },
|
||||
{ title: "年级洞察", href: "/management/grade/insights" },
|
||||
]
|
||||
},
|
||||
@@ -332,6 +347,7 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
||||
permission: Permissions.GRADE_MANAGE,
|
||||
items: [
|
||||
{ title: "年级班级", href: "/management/grade/classes" },
|
||||
{ title: "年级仪表盘", href: "/management/grade/dashboard" },
|
||||
{ title: "年级洞察", href: "/management/grade/insights" },
|
||||
]
|
||||
},
|
||||
@@ -392,6 +408,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
||||
href: "/student/grades",
|
||||
permission: Permissions.GRADE_RECORD_READ,
|
||||
},
|
||||
{
|
||||
title: "我的课案",
|
||||
icon: PenTool,
|
||||
href: "/student/lesson-plans",
|
||||
permission: Permissions.LESSON_PLAN_READ,
|
||||
},
|
||||
{
|
||||
title: "Attendance",
|
||||
icon: CalendarCheck,
|
||||
@@ -430,6 +452,12 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
||||
href: "/parent/grades",
|
||||
permission: Permissions.GRADE_RECORD_READ,
|
||||
},
|
||||
{
|
||||
title: "孩子课案",
|
||||
icon: PenTool,
|
||||
href: "/parent/lesson-plans",
|
||||
permission: Permissions.LESSON_PLAN_READ,
|
||||
},
|
||||
{
|
||||
title: "Attendance",
|
||||
icon: CalendarCheck,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderC
|
||||
import {
|
||||
countDefaultAiProviders,
|
||||
createAiProvider,
|
||||
deleteAiProvider as deleteAiProviderRecord,
|
||||
getAiProviderForUpdate,
|
||||
getAiProviderSummaries as fetchAiProviderSummaries,
|
||||
updateAiProvider,
|
||||
@@ -181,3 +182,31 @@ export async function testAiProviderAction(
|
||||
return { success: false, message: getAiErrorMessage(error) }
|
||||
}
|
||||
}
|
||||
|
||||
const DeleteAiProviderSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* 删除 AI Provider
|
||||
*
|
||||
* 如果删除的是默认 Provider,自动将最新的一条记录设为默认(若存在)。
|
||||
*/
|
||||
export async function deleteAiProviderAction(
|
||||
input: z.infer<typeof DeleteAiProviderSchema>
|
||||
): Promise<ActionState<null>> {
|
||||
try {
|
||||
await ensureUser()
|
||||
const parsed = DeleteAiProviderSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid provider id" }
|
||||
}
|
||||
await deleteAiProviderRecord(parsed.data.id)
|
||||
revalidatePath("/admin/ai-settings")
|
||||
revalidatePath("/settings")
|
||||
return { success: true, message: "AI provider deleted", data: null }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
|
||||
return { success: false, message: "Failed to delete AI provider" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,23 @@ import { z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { Loader2, Save, Sparkles } from "lucide-react"
|
||||
import { Loader2, Save, Sparkles, Trash2 } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -28,7 +39,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions"
|
||||
import { deleteAiProviderAction, getAiProviderSummaries, testAiProviderAction, upsertAiProviderAction, type AiProviderSummary } from "@/modules/settings/actions"
|
||||
|
||||
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom"])
|
||||
|
||||
@@ -242,6 +253,44 @@ export function AiProviderSettingsCard({
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
const id = form.getValues("id")
|
||||
if (!id?.trim()) {
|
||||
toast.error(t("deleteNeedSelect"))
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await deleteAiProviderAction({ id: id.trim() })
|
||||
if (result.success) {
|
||||
toast.success(result.message ?? t("deleteSuccess"))
|
||||
const summariesResult = await getAiProviderSummaries()
|
||||
if (summariesResult.success && summariesResult.data) {
|
||||
const rows = summariesResult.data
|
||||
setProviders(rows)
|
||||
onProvidersChanged?.(rows)
|
||||
if (rows.length > 0) {
|
||||
const next = rows[0]
|
||||
setSelectedId(next.id)
|
||||
form.reset({
|
||||
id: next.id,
|
||||
provider: next.provider,
|
||||
baseUrl: next.baseUrl ?? "",
|
||||
model: next.model,
|
||||
apiKey: "",
|
||||
isDefault: next.isDefault,
|
||||
})
|
||||
} else {
|
||||
resetToNew()
|
||||
}
|
||||
} else {
|
||||
resetToNew()
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message ?? t("deleteFailure"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -339,32 +388,56 @@ export function AiProviderSettingsCard({
|
||||
/>
|
||||
|
||||
<CardFooter className="flex justify-between border-t px-0 pt-4">
|
||||
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
|
||||
{testStatus === "testing" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("testing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t("test")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" onClick={form.handleSubmit(onSubmit)} disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("saving")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isPending || !form.getValues("id")?.trim()}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteConfirmDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("deleteCancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>{t("deleteConfirm")}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleTest} disabled={isPending || testStatus === "testing"}>
|
||||
{testStatus === "testing" ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("testing")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
{t("test")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button type="button" onClick={form.handleSubmit(onSubmit)} disabled={isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("saving")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("save")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -4,14 +4,13 @@ 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 { 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 { AiProviderSettingsCard } from "@/modules/settings/components/ai-provider-settings-card"
|
||||
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"
|
||||
@@ -31,8 +30,6 @@ import {
|
||||
} 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 键) */
|
||||
@@ -49,7 +46,7 @@ interface SettingsViewProps {
|
||||
currentUserAgent?: string
|
||||
}
|
||||
|
||||
const VALID_TABS = ["general", "notifications", "appearance", "security", "ai"] as const
|
||||
const VALID_TABS = ["general", "notifications", "appearance", "security"] as const
|
||||
type TabValue = (typeof VALID_TABS)[number]
|
||||
|
||||
function isTabValue(value: string | null): value is TabValue {
|
||||
@@ -95,15 +92,12 @@ function SettingsViewInner({
|
||||
const t = useTranslations("settings")
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { hasPermission } = usePermission()
|
||||
|
||||
const tabParam = searchParams.get("tab")
|
||||
const canConfigureAi = hasPermission(Permissions.AI_CONFIGURE)
|
||||
|
||||
// 解析 tab 参数,对无权限的 tab(如非管理员的 ai)回退到 general
|
||||
// 解析 tab 参数
|
||||
function resolveTab(value: string | null): TabValue {
|
||||
if (!isTabValue(value)) return "general"
|
||||
if (value === "ai" && !canConfigureAi) return "general"
|
||||
return value
|
||||
}
|
||||
const activeTab: TabValue = resolveTab(tabParam)
|
||||
@@ -151,12 +145,6 @@ function SettingsViewInner({
|
||||
<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">
|
||||
@@ -223,16 +211,6 @@ function SettingsViewInner({
|
||||
</Suspense>
|
||||
</SettingsSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
{canConfigureAi ? (
|
||||
<TabsContent value="ai" className="mt-6 space-y-6">
|
||||
<SettingsSectionErrorBoundary>
|
||||
<Suspense fallback={<SettingsSectionSkeleton />}>
|
||||
<AiProviderSettingsCard />
|
||||
</Suspense>
|
||||
</SettingsSectionErrorBoundary>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -111,6 +111,42 @@ export async function createAiProvider(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 AI Provider
|
||||
*
|
||||
* 如果删除的是默认 Provider,自动将最新的一条记录设为默认(若存在)。
|
||||
*/
|
||||
export async function deleteAiProvider(id: string): Promise<{ wasDefault: boolean }> {
|
||||
return await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ isDefault: aiProviders.isDefault })
|
||||
.from(aiProviders)
|
||||
.where(eq(aiProviders.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!existing) {
|
||||
return { wasDefault: false }
|
||||
}
|
||||
|
||||
await tx.delete(aiProviders).where(eq(aiProviders.id, id))
|
||||
|
||||
// 如果删除的是默认 Provider,自动选一条最新的设为默认
|
||||
if (existing.isDefault) {
|
||||
const [next] = await tx
|
||||
.select({ id: aiProviders.id })
|
||||
.from(aiProviders)
|
||||
.orderBy(desc(aiProviders.updatedAt))
|
||||
.limit(1)
|
||||
|
||||
if (next) {
|
||||
await tx.update(aiProviders).set({ isDefault: true }).where(eq(aiProviders.id, next.id))
|
||||
}
|
||||
}
|
||||
|
||||
return { wasDefault: existing.isDefault }
|
||||
})
|
||||
}
|
||||
|
||||
// --- Password change operations ---
|
||||
|
||||
export async function getUserPasswordHash(
|
||||
|
||||
@@ -227,7 +227,15 @@
|
||||
"saveFailure": "Failed to save",
|
||||
"loadFailure": "Failed to load AI providers",
|
||||
"needKey": "Please enter API key to test",
|
||||
"needTest": "Please test the configuration before saving"
|
||||
"needTest": "Please test the configuration before saving",
|
||||
"delete": "Delete",
|
||||
"deleteNeedSelect": "Please select a provider to delete",
|
||||
"deleteSuccess": "Provider deleted",
|
||||
"deleteFailure": "Failed to delete provider",
|
||||
"deleteConfirmTitle": "Confirm deletion",
|
||||
"deleteConfirmDescription": "This action cannot be undone. If the deleted provider was the default, the most recent provider will be set as default automatically.",
|
||||
"deleteConfirm": "Delete",
|
||||
"deleteCancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"quickLinks": {
|
||||
|
||||
@@ -227,7 +227,15 @@
|
||||
"saveFailure": "保存失败",
|
||||
"loadFailure": "加载 AI 服务商失败",
|
||||
"needKey": "请输入 API 密钥进行测试",
|
||||
"needTest": "保存前请先测试配置"
|
||||
"needTest": "保存前请先测试配置",
|
||||
"delete": "删除",
|
||||
"deleteNeedSelect": "请先选择要删除的服务商",
|
||||
"deleteSuccess": "已删除服务商",
|
||||
"deleteFailure": "删除失败",
|
||||
"deleteConfirmTitle": "确认删除",
|
||||
"deleteConfirmDescription": "此操作不可撤销。删除后若该服务商为默认,将自动选择最新的服务商作为默认。",
|
||||
"deleteConfirm": "确认删除",
|
||||
"deleteCancel": "取消"
|
||||
}
|
||||
},
|
||||
"quickLinks": {
|
||||
|
||||
Reference in New Issue
Block a user