初始提交: Qihu-AIFlow v2.0.1 完整项目

- 前端 React+TypeScript (源码+构建产物)
- 后端 Python FastAPI
- 数据库迁移 001-015
- Docker部署配置
- 微信ClawBot API频道绑定
- 项目文档 README.md
This commit is contained in:
qyyfw committed 2026-07-02 14:40:46 +08:00
commit 78ff261b5a
199 files changed
+69126

No files matched your search

+864
View File
@@ -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;