初始提交: Qihu-AIFlow v2.0.1 完整项目
- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
This commit is contained in:
commit
78ff261b5a
199 files changed
+69126
No files matched your search
@@ -0,0 +1,864 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
|
||||
|
||||
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatNumber = (n: number) => {
|
||||
if (n >= 100000000) return (n / 100000000).toFixed(1) + '亿';
|
||||
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
|
||||
return n.toLocaleString();
|
||||
};
|
||||
|
||||
// Capability标签颜色映射
|
||||
const capabilityColors: Record<string, string> = {
|
||||
text: '#5B5FED',
|
||||
vision: '#219B5E',
|
||||
audio: '#E8922C',
|
||||
video: '#D9383A',
|
||||
image: '#D9383A',
|
||||
thinking: '#8B5CF6',
|
||||
tool: '#3B82F6',
|
||||
};
|
||||
|
||||
const capabilityLabels: Record<string, string> = {
|
||||
text: '文本',
|
||||
vision: '视觉',
|
||||
audio: '音频',
|
||||
video: '视频',
|
||||
image: '图像',
|
||||
thinking: '思考',
|
||||
tool: '工具',
|
||||
};
|
||||
|
||||
const Models: React.FC = () => {
|
||||
// 状态
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
const [models, setModels] = useState<AIModel[]>([]);
|
||||
const [protocols, setProtocols] = useState<ProtocolDef[]>([]);
|
||||
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
|
||||
const [fetchedModels, setFetchedModels] = useState<FetchableModel[]>([]);
|
||||
const [fetchSource, setFetchSource] = useState<string>(''); // 模型来源标识
|
||||
const [fetchMessage, setFetchMessage] = useState<string>(''); // API错误消息
|
||||
const [selectedModelIds, setSelectedModelIds] = useState<Set<string>>(new Set());
|
||||
const [showAddProvider, setShowAddProvider] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<ModelProvider | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 模型定价编辑状态
|
||||
const [editingModelPrice, setEditingModelPrice] = useState<string | null>(null);
|
||||
const [editPriceValue, setEditPriceValue] = useState<string>('');
|
||||
const [priceSaving, setPriceSaving] = useState(false);
|
||||
|
||||
// 供应商表单
|
||||
const [providerForm, setProviderForm] = useState({
|
||||
name: '',
|
||||
protocol: 'openai_compatible',
|
||||
api_key: '',
|
||||
api_base_url: '',
|
||||
});
|
||||
|
||||
// 加载数据
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [provRes, modelsRes, protoRes] = await Promise.all([
|
||||
adminAPI.getProviders(),
|
||||
adminAPI.getAdminModels({ page_size: 100 }),
|
||||
adminAPI.getProtocols(),
|
||||
]);
|
||||
setProviders(Array.isArray(provRes) ? provRes : (provRes.data || []));
|
||||
setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || []));
|
||||
// 后端models API返回分页格式: {total, page, page_size, items}
|
||||
// 需要兼容 {code, data: {items}} 和 {total, items} 两种格式
|
||||
const modelData = modelsRes.data || modelsRes;
|
||||
const rawModels = Array.isArray(modelData) ? modelData : (modelData?.items || []);
|
||||
// 后端返回snake_case,前端接口使用camelCase,需要映射
|
||||
const mappedModels = (Array.isArray(rawModels) ? rawModels : []).map((m: any) => ({
|
||||
...m,
|
||||
providerId: m.providerId || m.provider_id,
|
||||
providerName: m.providerName || m.provider_name,
|
||||
apiKey: m.apiKey || m.api_key,
|
||||
pricePerMillionTokens: m.pricePerMillionTokens ?? m.price_per_million_tokens ?? 0,
|
||||
isEnabled: m.isEnabled ?? m.is_enabled ?? true,
|
||||
isDefault: m.isDefault ?? m.is_default ?? false,
|
||||
totalCalls: m.totalCalls ?? m.total_calls ?? 0,
|
||||
totalTokens: m.totalTokens ?? m.total_tokens ?? 0,
|
||||
}));
|
||||
setModels(mappedModels);
|
||||
} catch (err: any) {
|
||||
const msg = typeof err.message === 'string' ? err.message : (err.detail || JSON.stringify(err));
|
||||
setError('加载数据失败:' + msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 供应商操作
|
||||
const handleAddProvider = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!providerForm.name.trim()) return;
|
||||
try {
|
||||
setError(null);
|
||||
await adminAPI.addProvider({
|
||||
name: providerForm.name,
|
||||
protocol: providerForm.protocol,
|
||||
api_key: providerForm.api_key,
|
||||
api_base_url: providerForm.api_base_url || undefined,
|
||||
});
|
||||
setShowAddProvider(false);
|
||||
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '添加供应商失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateProvider = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingProvider || !providerForm.name.trim()) return;
|
||||
try {
|
||||
setError(null);
|
||||
await adminAPI.updateProvider(editingProvider.id, {
|
||||
name: providerForm.name,
|
||||
protocol: providerForm.protocol,
|
||||
api_key: providerForm.api_key,
|
||||
api_base_url: providerForm.api_base_url || undefined,
|
||||
});
|
||||
setEditingProvider(null);
|
||||
setShowAddProvider(false);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '更新供应商失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteProvider = async (id: string) => {
|
||||
if (!window.confirm('确定要删除该供应商吗?若有模型关联则无法删除。')) return;
|
||||
try {
|
||||
setError(null);
|
||||
await adminAPI.deleteProvider(id);
|
||||
alert('供应商已删除');
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || err?.detail || '删除供应商失败';
|
||||
alert(msg);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchModels = async (id: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setFetchedModels([]);
|
||||
setFetchSource('');
|
||||
setFetchMessage('');
|
||||
setSelectedModelIds(new Set());
|
||||
const res = await adminAPI.fetchModels(id);
|
||||
// 兼容后端返回数组或 {models: [...]} 两种格式
|
||||
const raw = res.data !== undefined ? res.data : res;
|
||||
const models = Array.isArray(raw) ? raw : (raw.models || []);
|
||||
const source = raw.source || '';
|
||||
const message = raw.message || '';
|
||||
setFetchedModels(models);
|
||||
setFetchSource(source);
|
||||
setFetchMessage(message);
|
||||
setExpandedProvider(id);
|
||||
} catch (err: any) {
|
||||
setError(err.message || '获取模型列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddModels = async (providerId: string) => {
|
||||
if (selectedModelIds.size === 0) return;
|
||||
try {
|
||||
setError(null);
|
||||
await adminAPI.addModels(providerId, Array.from(selectedModelIds));
|
||||
alert('模型添加成功');
|
||||
setFetchedModels([]);
|
||||
setSelectedModelIds(new Set());
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || err?.detail || '添加模型失败';
|
||||
alert(msg);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleModel = async (model: AIModel) => {
|
||||
try {
|
||||
setError(null);
|
||||
await adminAPI.toggleModel(model.id, !model.isEnabled);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '切换模型状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = async (model: AIModel) => {
|
||||
if (!window.confirm(`确定要删除模型 "${model.name}" 吗?`)) return;
|
||||
try {
|
||||
setError(null);
|
||||
await adminAPI.deleteModel(model.id);
|
||||
alert('模型已删除');
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || err?.detail || '删除模型失败';
|
||||
alert(msg);
|
||||
setError(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEditPrice = (model: AIModel) => {
|
||||
setEditingModelPrice(model.id);
|
||||
setEditPriceValue(String(model.pricePerMillionTokens || 0));
|
||||
};
|
||||
|
||||
const handleCancelEditPrice = () => {
|
||||
setEditingModelPrice(null);
|
||||
setEditPriceValue('');
|
||||
};
|
||||
|
||||
const handleSavePrice = async (model: AIModel) => {
|
||||
const newPrice = parseFloat(editPriceValue);
|
||||
if (isNaN(newPrice) || newPrice < 0) {
|
||||
setError('请输入有效的价格数值');
|
||||
return;
|
||||
}
|
||||
setPriceSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await adminAPI.updateModel(model.id, { price_per_million_tokens: newPrice });
|
||||
setEditingModelPrice(null);
|
||||
setEditPriceValue('');
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '修改价格失败');
|
||||
} finally {
|
||||
setPriceSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (model: AIModel) => {
|
||||
const newStatus = model.status === 'free' ? 'paid' : 'free';
|
||||
setError(null);
|
||||
try {
|
||||
await adminAPI.updateModel(model.id, { status: newStatus });
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
setError(err.message || '修改计费状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 全选/取消
|
||||
const toggleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedModelIds(new Set(fetchedModels.map(m => m.id)));
|
||||
} else {
|
||||
setSelectedModelIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelectModel = (modelId: string) => {
|
||||
const next = new Set(selectedModelIds);
|
||||
if (next.has(modelId)) {
|
||||
next.delete(modelId);
|
||||
} else {
|
||||
next.add(modelId);
|
||||
}
|
||||
setSelectedModelIds(next);
|
||||
};
|
||||
|
||||
// 统计
|
||||
const stats = {
|
||||
providerCount: providers.length,
|
||||
enabledCount: models.filter(m => m.isEnabled).length,
|
||||
totalCalls: models.reduce((sum, m) => sum + (m.totalCalls || 0), 0),
|
||||
totalTokens: models.reduce((sum, m) => sum + (m.totalTokens || 0), 0),
|
||||
};
|
||||
|
||||
const statCards = [
|
||||
{ label: '供应商数', value: stats.providerCount },
|
||||
{ label: '启用模型数', value: stats.enabledCount },
|
||||
{ label: '总调用量', value: formatNumber(stats.totalCalls) },
|
||||
{ label: '总Token消耗', value: formatNumber(stats.totalTokens) },
|
||||
];
|
||||
|
||||
// 获取协议名称
|
||||
const getProtocolName = (protocolId: string) => {
|
||||
const p = protocols.find(p => p.id === protocolId);
|
||||
return p ? p.name : protocolId;
|
||||
};
|
||||
|
||||
// 获取provider下的模型
|
||||
const getProviderModels = (providerId: string) => {
|
||||
return models.filter(m => m.providerId === providerId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
|
||||
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>大模型库管理</h1>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||
<button
|
||||
className="admin-btn admin-btn-outline"
|
||||
onClick={() => {
|
||||
const dialog = document.createElement('dialog');
|
||||
dialog.style.cssText = 'border:none;border-radius:16px;padding:24px;width:420px;max-width:90vw;box-shadow:0 20px 48px rgba(0,0,0,0.15);';
|
||||
dialog.innerHTML = `
|
||||
<h3 style="margin:0 0 16px;font-size:18px;font-weight:700;">设置全局默认LLM</h3>
|
||||
<p style="margin:0 0 12px;font-size:13px;color:#545A65;">选择平台默认使用的大语言模型,新对话将自动使用此模型。</p>
|
||||
<select id="defaultLLMSelect" style="width:100%;padding:10px 12px;border:1px solid #E3E6EB;border-radius:8px;font-size:14px;margin-bottom:16px;">
|
||||
${models.filter(m => m.isEnabled).map(m => `<option value="${m.id}" ${(m as any).isDefault ? 'selected' : ''}>${m.name} - ${m.providerName || ''} ${m.status === 'free' ? '(免费)' : '(付费)'}</option>`).join('')}
|
||||
${models.filter(m => m.isEnabled).length === 0 ? '<option disabled>暂无可用的启用模型</option>' : ''}
|
||||
</select>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button id="cancelDefaultLLM" style="padding:8px 16px;border:1px solid #E3E6EB;border-radius:8px;background:#fff;cursor:pointer;font-size:13px;">取消</button>
|
||||
<button id="confirmDefaultLLM" style="padding:8px 16px;border:none;border-radius:8px;background:#5B5FED;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">确认设置</button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(dialog);
|
||||
dialog.showModal();
|
||||
dialog.querySelector('#cancelDefaultLLM')!.addEventListener('click', () => { dialog.close(); dialog.remove(); });
|
||||
dialog.querySelector('#confirmDefaultLLM')!.addEventListener('click', async () => {
|
||||
const select = dialog.querySelector('#defaultLLMSelect') as HTMLSelectElement;
|
||||
const modelId = select?.value;
|
||||
if (!modelId) {
|
||||
alert('请选择模型');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adminAPI.setDefaultModel(modelId);
|
||||
alert('已设置全局默认模型');
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
alert('设置失败:' + (err?.message || err?.detail || '未知错误'));
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SVGIcon size={14}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
|
||||
设置默认LLM
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary"
|
||||
onClick={() => { setShowAddProvider(true); setEditingProvider(null); setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' }); }}
|
||||
>
|
||||
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
||||
添加供应商
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div style={{
|
||||
padding: 'var(--space-md) var(--space-lg)', marginBottom: 'var(--space-lg)',
|
||||
background: 'var(--color-error-light)', color: 'var(--color-error)',
|
||||
borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
}}>
|
||||
<span>{error}</span>
|
||||
<span style={{ cursor: 'pointer', fontWeight: 700, marginLeft: 'var(--space-lg)' }} onClick={() => setError(null)}>x</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="admin-stats">
|
||||
{statCards.map((s, i) => (
|
||||
<div key={i} className="admin-stat-card">
|
||||
<div className="admin-stat-label">{s.label}</div>
|
||||
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>{s.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 加载状态 */}
|
||||
{loading && (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-4xl)', color: 'var(--color-text-tertiary)' }}>
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 供应商列表 */}
|
||||
{!loading && (
|
||||
<div style={{ marginBottom: 'var(--space-2xl)' }}>
|
||||
<h2 style={{ fontSize: 'var(--text-lg)', fontWeight: 600, marginBottom: 'var(--space-xl)' }}>模型供应商</h2>
|
||||
{providers.length === 0 ? (
|
||||
<div className="admin-card" style={{ textAlign: 'center', padding: 'var(--space-4xl)', color: 'var(--color-text-tertiary)' }}>
|
||||
暂无供应商,请点击"添加供应商"开始
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => (
|
||||
<div key={provider.id} className="admin-card" style={{ marginBottom: 'var(--space-lg)' }}>
|
||||
{/* 供应商卡片头部 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 'var(--radius-lg)',
|
||||
background: 'var(--color-accent-light)', color: 'var(--color-accent)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<SVGIcon size={20}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--text-base)', fontWeight: 600 }}>{provider.name}</div>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
|
||||
协议: {getProtocolName(provider.protocol)}
|
||||
{provider.apiBaseUrl ? ` | ${provider.apiBaseUrl}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||||
<button
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
onClick={() => handleFetchModels(provider.id)}
|
||||
>
|
||||
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
||||
获取模型列表
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
onClick={() => {
|
||||
setEditingProvider(provider);
|
||||
setProviderForm({
|
||||
name: provider.name,
|
||||
protocol: provider.protocol,
|
||||
api_key: provider.apiKey || (provider as any).api_key || '',
|
||||
api_base_url: provider.apiBaseUrl || (provider as any).api_base_url || '',
|
||||
});
|
||||
setShowAddProvider(true);
|
||||
}}
|
||||
>
|
||||
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||
onClick={() => handleDeleteProvider(provider.id)}
|
||||
>
|
||||
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 获取到的模型列表 (fetch-models结果) */}
|
||||
{expandedProvider === provider.id && fetchedModels.length > 0 && (
|
||||
<div style={{
|
||||
background: 'var(--color-bg-tertiary)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 'var(--space-lg)',
|
||||
marginBottom: 'var(--space-md)',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
marginBottom: 'var(--space-md)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>
|
||||
可选模型 ({fetchedModels.length})
|
||||
</span>
|
||||
{fetchSource && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: fetchSource === 'api' ? '#219B5E' : fetchSource === 'error' ? '#D9383A' : '#E8922C',
|
||||
}}>
|
||||
{fetchSource === 'api' ? '真实API' : fetchSource === 'error' ? 'API错误' : '默认列表'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
disabled={selectedModelIds.size === 0}
|
||||
style={{ opacity: selectedModelIds.size === 0 ? 0.5 : 1 }}
|
||||
onClick={() => handleAddModels(provider.id)}
|
||||
>
|
||||
添加选中模型 ({selectedModelIds.size})
|
||||
</button>
|
||||
</div>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 40 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedModelIds.size === fetchedModels.length && fetchedModels.length > 0}
|
||||
onChange={e => toggleSelectAll(e.target.checked)}
|
||||
/>
|
||||
</th>
|
||||
<th>模型名称</th>
|
||||
<th>功能标签</th>
|
||||
<th>计费类型</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fetchedModels.map(fm => (
|
||||
<tr key={fm.id}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedModelIds.has(fm.id)}
|
||||
onChange={() => toggleSelectModel(fm.id)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 600 }}>{fm.name}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
{(fm.capabilities || []).map(cap => (
|
||||
<span key={cap} style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: capabilityColors[cap] || '#888',
|
||||
}}>
|
||||
{capabilityLabels[cap] || cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`admin-badge ${fm.pricing === 'free' ? 'badge-success' : 'badge-warning'}`}>
|
||||
{fm.pricing === 'free' ? '免费' : '付费'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 获取模型列表时的错误/提示信息 */}
|
||||
{expandedProvider === provider.id && fetchedModels.length === 0 && fetchMessage && (
|
||||
<div style={{
|
||||
background: fetchSource === 'error' ? '#FEF2F2' : '#F0F9FA',
|
||||
border: fetchSource === 'error' ? '1px solid #FECACA' : '1px solid #BBD9DD',
|
||||
color: fetchSource === 'error' ? '#991B1B' : '#0F5156',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 'var(--space-xl)',
|
||||
marginBottom: 'var(--space-md)',
|
||||
fontSize: 'var(--text-sm)',
|
||||
display: 'flex', flexDirection: 'column', gap: 'var(--space-md)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 24, height: 24, borderRadius: '50%',
|
||||
background: fetchSource === 'error' ? '#D9383A' : '#E8922C',
|
||||
color: '#fff', fontSize: '13px', fontWeight: 700,
|
||||
}}>
|
||||
{fetchSource === 'error' ? '!' : 'i'}
|
||||
</span>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 8px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: fetchSource === 'api' ? '#219B5E' : fetchSource === 'error' ? '#D9383A' : '#E8922C',
|
||||
}}>
|
||||
{fetchSource === 'api' ? '真实API' : fetchSource === 'error' ? 'API错误' : '默认列表'}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600 }}>{fetchSource === 'error' ? '错误详情' : '提示'}</span>
|
||||
</div>
|
||||
<span style={{ lineHeight: 1.6 }}>{fetchMessage}</span>
|
||||
{fetchSource === 'error' && (
|
||||
<button
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
onClick={() => handleFetchModels(provider.id)}
|
||||
>
|
||||
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
|
||||
重试获取
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已上架模型 */}
|
||||
{(() => {
|
||||
const providerModels = getProviderModels(provider.id);
|
||||
if (providerModels.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)',
|
||||
padding: 'var(--space-md) 0',
|
||||
}}>
|
||||
暂无模型,点击"获取模型列表"获取可用模型
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>模型名</th>
|
||||
<th>功能标签</th>
|
||||
<th>单价(元/百万Token)</th>
|
||||
<th>状态</th>
|
||||
<th>调用量</th>
|
||||
<th>Token消耗</th>
|
||||
<th>开关</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{providerModels.map(model => (
|
||||
<tr key={model.id}>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
{model.name}
|
||||
{(model as any).isDefault && (
|
||||
<span style={{
|
||||
display: 'inline-block', marginLeft: 6,
|
||||
padding: '0 6px', borderRadius: 'var(--radius-full)',
|
||||
fontSize: '10px', fontWeight: 600,
|
||||
background: '#FEF3C7', color: '#D97706',
|
||||
}}>默认</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||
{(model.capabilities || []).map((cap: string) => (
|
||||
<span key={cap} style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: '10px',
|
||||
fontWeight: 600,
|
||||
color: '#fff',
|
||||
background: capabilityColors[cap] || '#888',
|
||||
}}>
|
||||
{capabilityLabels[cap] || cap}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }}>
|
||||
{editingModelPrice === model.id ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={editPriceValue}
|
||||
onChange={e => setEditPriceValue(e.target.value)}
|
||||
style={{
|
||||
width: 80, padding: '2px 6px', fontSize: 'var(--text-xs)',
|
||||
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)',
|
||||
}}
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleSavePrice(model);
|
||||
if (e.key === 'Escape') handleCancelEditPrice();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
style={{ padding: '2px 6px', fontSize: '10px' }}
|
||||
onClick={() => handleSavePrice(model)}
|
||||
disabled={priceSaving}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
style={{ padding: '2px 6px', fontSize: '10px' }}
|
||||
onClick={handleCancelEditPrice}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
transition: 'background var(--transition-fast)',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = '#F0F4FF')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
onClick={() => handleStartEditPrice(model)}
|
||||
title="点击编辑单价"
|
||||
>
|
||||
{model.pricePerMillionTokens !== undefined && model.pricePerMillionTokens !== null
|
||||
? '¥' + model.pricePerMillionTokens.toFixed(2)
|
||||
: '未设置'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`admin-badge ${model.status === 'free' ? 'badge-success' : 'badge-warning'}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleToggleStatus(model)}
|
||||
title="点击切换计费状态"
|
||||
>
|
||||
{model.status === 'free' ? '免费' : '付费'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{formatNumber(model.totalCalls || 0)}</td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)' }}>{formatNumber(model.totalTokens || 0)}</td>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => handleToggleModel(model)}
|
||||
style={{
|
||||
width: 44, height: 24, borderRadius: 12, border: 'none',
|
||||
background: model.isEnabled ? 'var(--color-accent)' : 'var(--color-text-disabled)',
|
||||
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
position: 'absolute', top: 2, left: model.isEnabled ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
|
||||
transition: 'left var(--transition-fast)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||||
}} />
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||
onClick={() => handleDeleteModel(model)}
|
||||
>
|
||||
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
|
||||
删除
|
||||
</button>
|
||||
{(model as any).isDefault ? (
|
||||
<span className="admin-badge badge-success" style={{ marginLeft: 4, fontSize: '10px' }}>默认</span>
|
||||
) : (
|
||||
<button
|
||||
className="admin-btn admin-btn-outline admin-btn-sm"
|
||||
style={{ marginLeft: 4, fontSize: '10px' }}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await adminAPI.setDefaultModel(model.id);
|
||||
loadData();
|
||||
} catch (err: any) {
|
||||
alert('设置失败:' + (err?.message || err?.detail || ''));
|
||||
}
|
||||
}}
|
||||
>设为默认</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑供应商弹窗 */}
|
||||
{showAddProvider && (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
animation: 'fadeIn var(--transition-normal)',
|
||||
}} onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}>
|
||||
<div style={{
|
||||
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
|
||||
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
|
||||
{editingProvider ? '编辑供应商' : '添加供应商'}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={editingProvider ? handleUpdateProvider : handleAddProvider}>
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">供应商名称</label>
|
||||
<input
|
||||
className="admin-form-input"
|
||||
value={providerForm.name}
|
||||
onChange={e => setProviderForm({ ...providerForm, name: e.target.value })}
|
||||
placeholder="例如: DeepSeek"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">协议类型</label>
|
||||
<select
|
||||
className="admin-form-input"
|
||||
value={providerForm.protocol}
|
||||
onChange={e => setProviderForm({ ...providerForm, protocol: e.target.value })}
|
||||
>
|
||||
{protocols.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.name} - {p.description}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">API Key</label>
|
||||
<input
|
||||
className="admin-form-input"
|
||||
value={providerForm.api_key}
|
||||
onChange={e => setProviderForm({ ...providerForm, api_key: e.target.value })}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-group">
|
||||
<label className="admin-form-label">API Base URL(可选)</label>
|
||||
<input
|
||||
className="admin-form-input"
|
||||
value={providerForm.api_base_url}
|
||||
onChange={e => setProviderForm({ ...providerForm, api_base_url: e.target.value })}
|
||||
placeholder="例如: https://api.deepseek.com"
|
||||
/>
|
||||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 'var(--space-xs)' }}>
|
||||
根域名,不含路径。系统将自动拼接协议对应的请求路径
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-actions">
|
||||
<button type="submit" className="admin-btn admin-btn-primary">
|
||||
{editingProvider ? '保存修改' : '创建供应商'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-outline"
|
||||
onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Models;
|
||||
Reference in new issue
Block a user