Files
ftb-project-management/apps/web/app/admin/ai-config/page.tsx
2026-06-25 15:21:32 +08:00

504 lines
20 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 { 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<AiProviderFormat, string> = {
anthropic: 'Anthropic 兼容',
openai: 'OpenAI 兼容',
};
const FORMAT_HINT: Record<AiProviderFormat, string> = {
anthropic: 'Anthropic 官方 + 兼容 Anthropic 格式的中转站',
openai: 'OpenAI 官方 + 兼容 OpenAI Chat Completions 格式的中转站',
};
const PRESETS: Array<{ key: string; label: string; data: Partial<AiProviderUpsertInput> & { 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 (
<SuperAdminGuard>
<AiConfigContent />
</SuperAdminGuard>
);
}
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 (
<div className="flex h-full items-center justify-center">
<p className="text-[14px] text-[var(--ink-soft)]">访 AI </p>
</div>
);
}
return <>{children}</>;
}
function AiConfigContent() {
const user = useAuthStore((s) => s.user);
const [config, setConfig] = useState<AiConfigPublic | null>(null);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<AiProviderPublic | null>(null);
const [testing, setTesting] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, { ok: boolean; message: string }>>({});
const [actionMessage, setActionMessage] = useState<string | null>(null);
const fetchConfig = async () => {
setLoading(true);
try {
const c = await api.get<AiConfigPublic>('/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<AiConfigPublic>('/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<AiConfigPublic>(`/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 (
<div className="flex h-full flex-col bg-[var(--bg)]">
<header className="flex h-14 shrink-0 items-center gap-2 border-b border-[var(--line)] bg-[var(--bg-card)] px-5">
<Sparkles className="h-4 w-4 text-purple-600" />
<h1 className="text-[15px] font-semibold tracking-tight text-[var(--ink)]">AI </h1>
</header>
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-3xl mx-auto space-y-4">
{loading ? (
<div className="text-[13px] text-[var(--ink-muted)]"></div>
) : (
<>
{/* 当前激活 */}
<section className="rounded-2xl border border-purple-200 bg-purple-50/50 p-5">
<h2 className="text-[12px] font-semibold text-purple-700 uppercase tracking-wide mb-2"></h2>
{activeProvider ? (
<div>
<div className="text-[15px] font-semibold text-[var(--ink)]">{activeProvider.name}</div>
<div className="mt-1 text-[12px] text-[var(--ink-soft)]">
{FORMAT_LABEL[activeProvider.format]} · {activeProvider.baseURL}
</div>
<div className="mt-1 text-[12px] text-[var(--ink-soft)] font-mono">
{activeProvider.keyMask} · {activeProvider.model}
</div>
</div>
) : (
<div className="text-[13px] text-amber-700">AI </div>
)}
</section>
{/* 操作消息 */}
{actionMessage && (
<div className="rounded-lg bg-blue-50 border border-blue-200 px-3 py-2 text-[12px] text-blue-700 flex items-center justify-between">
<span>{actionMessage}</span>
<button onClick={() => setActionMessage(null)} className="text-blue-500 hover:text-blue-700">
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* 提供商列表 */}
<section className="rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-[14px] font-semibold text-[var(--ink)]"></h2>
<button
onClick={() => { setEditingProvider(null); setShowModal(true); }}
className="flex items-center gap-1 h-8 px-3 rounded-lg text-[12px] font-medium bg-purple-600 text-white hover:bg-purple-700"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{config?.providers.length === 0 ? (
<div className="rounded-lg border border-dashed border-[var(--line)] py-12 text-center">
<p className="text-[13px] text-[var(--ink-muted)]">"新增"</p>
</div>
) : (
<div className="space-y-2">
{config?.providers.map((p) => (
<ProviderRow
key={p.id}
provider={p}
testing={testing === p.id}
testResult={testResults[p.id]}
onActivate={() => handleActivate(p.id)}
onEdit={() => { setEditingProvider(p); setShowModal(true); }}
onDelete={() => handleDelete(p.id, p.name)}
onTest={() => handleTest(p.id)}
/>
))}
</div>
)}
</section>
</>
)}
</div>
</div>
{showModal && (
<ProviderModal
provider={editingProvider}
onClose={() => setShowModal(false)}
onSaved={(next) => {
setConfig(next);
setShowModal(false);
setActionMessage('保存成功');
}}
operator={user?.name}
/>
)}
</div>
);
}
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 (
<div className={`rounded-lg border p-3 ${provider.isActive ? 'border-purple-300 bg-purple-50/30' : 'border-[var(--line)]'}`}>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-semibold text-[var(--ink)]">{provider.name}</span>
{provider.isActive && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-600 text-white font-medium"></span>
)}
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-subtle)] text-[var(--ink-soft)]">
{FORMAT_LABEL[provider.format]}
</span>
</div>
<div className="mt-1 text-[11px] text-[var(--ink-soft)] truncate">{provider.baseURL}</div>
<div className="mt-0.5 text-[11px] text-[var(--ink-soft)] font-mono">
{provider.keyMask} · {provider.model}
</div>
{provider.remark && <div className="mt-1 text-[11px] text-[var(--ink-muted)]">{provider.remark}</div>}
{testResult && (
<div className={`mt-2 inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded border ${
testResult.ok
? 'bg-emerald-50 border-emerald-200 text-emerald-700'
: 'bg-rose-50 border-rose-200 text-rose-700'
}`}>
{testResult.ok ? <CheckCircle2 className="h-3 w-3" /> : <AlertTriangle className="h-3 w-3" />}
{testResult.message}
</div>
)}
</div>
<div className="flex flex-col gap-1.5 shrink-0">
{!provider.isActive && (
<button
onClick={onActivate}
className="h-7 px-3 rounded text-[11px] font-medium bg-purple-600 text-white hover:bg-purple-700"
>
</button>
)}
<button
onClick={onTest}
disabled={testing}
className="h-7 px-3 rounded text-[11px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] inline-flex items-center justify-center gap-1 disabled:opacity-50"
>
{testing ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
</button>
<button
onClick={onEdit}
className="h-7 px-3 rounded text-[11px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)] inline-flex items-center justify-center gap-1"
>
<Pencil className="h-3 w-3" />
</button>
<button
onClick={onDelete}
className="h-7 px-3 rounded text-[11px] font-medium border border-rose-200 text-rose-600 hover:bg-rose-50 inline-flex items-center justify-center gap-1"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
</div>
);
}
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<AiProviderFormat>(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<string | null>(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<AiConfigPublic>('/config/ai/providers', payload);
onSaved(next);
} catch (e: any) {
setError(e?.message || '保存失败');
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
<div className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl bg-[var(--bg-card)] border border-[var(--line)] p-5 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-[14px] font-semibold text-[var(--ink)]">{isEdit ? '编辑提供商' : '新增提供商'}</h3>
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)]"><X className="h-4 w-4 text-[var(--ink-muted)]" /></button>
</div>
{!isEdit && (
<div className="mb-4">
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1.5"></label>
<div className="flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.key}
onClick={() => applyPreset(p.key)}
className="h-7 px-2.5 rounded-md border border-[var(--line)] text-[11px] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"
>
{p.label}
</button>
))}
</div>
</div>
)}
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">ID *</label>
<input
value={id}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"> *</label>
<input
value={name}
onChange={(e) => 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"
/>
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">API *</label>
<div className="grid grid-cols-2 gap-2">
{(['anthropic', 'openai'] as AiProviderFormat[]).map((f) => (
<label
key={f}
className={`p-3 rounded-lg border cursor-pointer ${
format === f ? 'border-purple-400 bg-purple-50' : 'border-[var(--line)] hover:bg-[var(--bg-subtle)]'
}`}
>
<input type="radio" name="format" checked={format === f} onChange={() => setFormat(f)} className="mr-2" />
<span className="text-[13px] font-medium text-[var(--ink)]">{FORMAT_LABEL[f]}</span>
<p className="mt-0.5 text-[10px] text-[var(--ink-muted)]">{FORMAT_HINT[f]}</p>
</label>
))}
</div>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">Base URL *</label>
<input
value={baseURL}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1">
API Key {isEdit ? '(留空则保留原 Key' : '*'}
</label>
<input
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => 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"
/>
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">Key </p>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"> *</label>
<input
value={model}
onChange={(e) => 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"
/>
<p className="mt-1 text-[10px] text-[var(--ink-muted)]">
baseURL ID claude-sonnet-4-6claude-opus-4-7gpt-4odeepseek-v3
</p>
</div>
<div>
<label className="block text-[11px] font-medium text-[var(--ink-soft)] mb-1"></label>
<input
value={remark}
onChange={(e) => 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"
/>
</div>
{error && (
<div className="rounded-lg bg-rose-50 border border-rose-200 px-3 py-2 text-[12px] text-rose-700">
{error}
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-[var(--line)] mt-4">
<button onClick={onClose} className="h-9 px-3 rounded-lg text-[13px] font-medium border border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
</button>
<button
onClick={handleSave}
disabled={!canSubmit || saving}
className="h-9 px-4 rounded-lg text-[13px] font-medium bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
</div>
);
}