feat(平台): 补齐服务端持久化和AI拆解契约

This commit is contained in:
Script Generator
2026-06-25 15:21:32 +08:00
parent 5723356d08
commit 5adc7759ad
73 changed files with 5599 additions and 368 deletions

View File

@@ -0,0 +1,503 @@
'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>
);
}