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:
SpecialX
2026-06-23 19:33:28 +08:00
parent d884c6d513
commit 7e320d78c1
13 changed files with 618 additions and 213 deletions

View File

@@ -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" }
}
}

View File

@@ -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>

View File

@@ -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>
)

View File

@@ -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(