504 lines
20 KiB
TypeScript
504 lines
20 KiB
TypeScript
'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-6、claude-opus-4-7、gpt-4o、deepseek-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>
|
||
);
|
||
}
|