'use client'; import { useEffect, useState } from 'react'; import { Sparkles, Plus, CheckCircle2, AlertTriangle, Loader2, Trash2, Pencil, X } from 'lucide-react'; import { useAuthStore } from '@/stores/useAuthStore'; import { useMemberStore } from '@/stores/useMemberStore'; import { api } from '@/lib/api'; import type { AiConfigPublic, AiProviderPublic, AiProviderUpsertInput, AiProviderFormat, } from '@ftb/shared'; const FORMAT_LABEL: Record = { anthropic: 'Anthropic 兼容', openai: 'OpenAI 兼容', }; const FORMAT_HINT: Record = { anthropic: 'Anthropic 官方 + 兼容 Anthropic 格式的中转站', openai: 'OpenAI 官方 + 兼容 OpenAI Chat Completions 格式的中转站', }; const PRESETS: Array<{ key: string; label: string; data: Partial & { format: AiProviderFormat; model: string } }> = [ { key: 'anthropic-official', label: 'Anthropic 官方', data: { id: 'anthropic-official', name: 'Anthropic 官方', format: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-4-6' }, }, { key: 'openai-official', label: 'OpenAI 官方', data: { id: 'openai-official', name: 'OpenAI 官方', format: 'openai', baseURL: 'https://api.openai.com/v1', model: 'gpt-4o' }, }, { key: 'custom-anthropic', label: '+ 自定义(Anthropic 兼容)', data: { id: '', name: '', format: 'anthropic', baseURL: '', model: 'claude-sonnet-4-6' }, }, { key: 'custom-openai', label: '+ 自定义(OpenAI 兼容)', data: { id: '', name: '', format: 'openai', baseURL: '', model: 'gpt-4o' }, }, ]; export default function AiConfigPage() { return ( ); } function SuperAdminGuard({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); const role = useMemberStore((s) => s.roles.find((r) => r.id === user?.roleId)); const isSuperAdmin = !!role && role.permissions.includes('*'); if (!isSuperAdmin) { return (

仅超级管理员可访问 AI 配置

); } return <>{children}; } function AiConfigContent() { const user = useAuthStore((s) => s.user); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); const [showModal, setShowModal] = useState(false); const [editingProvider, setEditingProvider] = useState(null); const [testing, setTesting] = useState(null); const [testResults, setTestResults] = useState>({}); const [actionMessage, setActionMessage] = useState(null); const fetchConfig = async () => { setLoading(true); try { const c = await api.get('/config/ai'); setConfig(c); } catch (e: any) { setActionMessage(`读取配置失败:${e.message}`); } finally { setLoading(false); } }; useEffect(() => { fetchConfig(); }, []); const handleActivate = async (id: string) => { try { const next = await api.post('/config/ai/activate', { id, operator: user?.name }); setConfig(next); setActionMessage(`已激活:${next.providers.find((p) => p.id === id)?.name}`); } catch (e: any) { setActionMessage(`激活失败:${e.message}`); } }; const handleDelete = async (id: string, name: string) => { if (!confirm(`确认删除提供商 "${name}"?`)) return; try { const next = await api.delete(`/config/ai/providers/${id}?operator=${encodeURIComponent(user?.name || '')}`); setConfig(next); setActionMessage(`已删除:${name}`); } catch (e: any) { setActionMessage(`删除失败:${e.message}`); } }; const handleTest = async (id: string) => { setTesting(id); setTestResults((prev) => ({ ...prev, [id]: undefined as any })); try { const r = await api.post<{ ok: boolean; message?: string; error?: string }>('/config/ai/test', { id }); setTestResults((prev) => ({ ...prev, [id]: { ok: r.ok, message: r.ok ? r.message || '连接成功' : r.error || '连接失败' }, })); } catch (e: any) { setTestResults((prev) => ({ ...prev, [id]: { ok: false, message: e.message } })); } finally { setTesting(null); } }; const activeProvider = config?.providers.find((p) => p.isActive); return (

AI 配置

{loading ? (
加载中…
) : ( <> {/* 当前激活 */}

当前激活

{activeProvider ? (
{activeProvider.name}
{FORMAT_LABEL[activeProvider.format]} · {activeProvider.baseURL}
{activeProvider.keyMask} · {activeProvider.model}
) : (
尚未配置任何提供商,AI 拆解功能不可用
)}
{/* 操作消息 */} {actionMessage && (
{actionMessage}
)} {/* 提供商列表 */}

已配置的提供商

{config?.providers.length === 0 ? (

还没有任何提供商,点上方"新增"添加

) : (
{config?.providers.map((p) => ( handleActivate(p.id)} onEdit={() => { setEditingProvider(p); setShowModal(true); }} onDelete={() => handleDelete(p.id, p.name)} onTest={() => handleTest(p.id)} /> ))}
)}
)}
{showModal && ( setShowModal(false)} onSaved={(next) => { setConfig(next); setShowModal(false); setActionMessage('保存成功'); }} operator={user?.name} /> )}
); } function ProviderRow({ provider, testing, testResult, onActivate, onEdit, onDelete, onTest, }: { provider: AiProviderPublic; testing: boolean; testResult?: { ok: boolean; message: string }; onActivate: () => void; onEdit: () => void; onDelete: () => void; onTest: () => void; }) { return (
{provider.name} {provider.isActive && ( 已激活 )} {FORMAT_LABEL[provider.format]}
{provider.baseURL}
{provider.keyMask} · {provider.model}
{provider.remark &&
{provider.remark}
} {testResult && (
{testResult.ok ? : } {testResult.message}
)}
{!provider.isActive && ( )}
); } function ProviderModal({ provider, onClose, onSaved, operator, }: { provider: AiProviderPublic | null; onClose: () => void; onSaved: (next: AiConfigPublic) => void; operator?: string; }) { const isEdit = !!provider; const [id, setId] = useState(provider?.id || ''); const [name, setName] = useState(provider?.name || ''); const [format, setFormat] = useState(provider?.format || 'anthropic'); const [baseURL, setBaseURL] = useState(provider?.baseURL || ''); const [apiKey, setApiKey] = useState(''); const [model, setModel] = useState(provider?.model || 'claude-sonnet-4-6'); const [remark, setRemark] = useState(provider?.remark || ''); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const applyPreset = (presetKey: string) => { const p = PRESETS.find((x) => x.key === presetKey); if (!p) return; setId(p.data.id || ''); setName(p.data.name || ''); setFormat(p.data.format); setBaseURL(p.data.baseURL || ''); setModel(p.data.model); }; const canSubmit = id.trim() && name.trim() && baseURL.trim() && model.trim() && (isEdit || apiKey.trim()); const handleSave = async () => { if (!canSubmit) return; setSaving(true); setError(null); try { const payload: AiProviderUpsertInput & { operator?: string } = { id: id.trim(), name: name.trim(), format, baseURL: baseURL.trim(), model: model.trim(), remark: remark.trim() || undefined, operator, }; if (apiKey.trim()) payload.apiKey = apiKey.trim(); const next = await api.patch('/config/ai/providers', payload); onSaved(next); } catch (e: any) { setError(e?.message || '保存失败'); } finally { setSaving(false); } }; return (
e.stopPropagation()}>

{isEdit ? '编辑提供商' : '新增提供商'}

{!isEdit && (
{PRESETS.map((p) => ( ))}
)}
setId(e.target.value)} disabled={isEdit} placeholder="ikuncode" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:opacity-60" />
setName(e.target.value)} placeholder="ikuncode" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
{(['anthropic', 'openai'] as AiProviderFormat[]).map((f) => ( ))}
setBaseURL(e.target.value)} placeholder="https://api.ikuncode.com" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none" />
setApiKey(e.target.value)} placeholder={isEdit ? provider!.keyMask : 'sk-...'} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none" />

出于安全考虑,Key 输入后无法再被读出,仅可重新填写覆盖

setModel(e.target.value)} placeholder={format === 'anthropic' ? 'claude-sonnet-4-6' : 'gpt-4o'} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-mono focus:border-[var(--accent)] focus:outline-none" />

中转站不会替你选模型 — 必须填写一个该 baseURL 支持的模型 ID(如 claude-sonnet-4-6、claude-opus-4-7、gpt-4o、deepseek-v3 等)。请查阅中转站文档。

setRemark(e.target.value)} placeholder="公司账号 / 个人测试 / ..." className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
{error && (
{error}
)}
); }