import React, { useEffect, useState } from 'react'; import dayjs from 'dayjs'; import { Button, Card, DatePicker, Divider, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, EyeOutlined, KeyOutlined, CopyOutlined, DollarOutlined, } from '@ant-design/icons'; import QuotaAdjustModal from '../components/QuotaAdjustModal'; import { getApiKeys, createApiKey, updateApiKey, deleteApiKey, getApiKeyUsage, getGenerationAiEngines, revealApiKey, getApiKeyUpscaleConfig, saveApiKeyUpscaleConfig, getApiKeyVpV3Quota, saveApiKeyVpV3Quota, getOperationLogs, } from '../api'; import type { GenerationAiEngineOption } from '../types'; interface EngineOption { id: string; name: string; modelName: string; genType: 'video' | 'image'; } interface ApiKey { id: string; companyName: string; apiKeyPrefix: string; description: string | null; callableModels?: Array<{ engineId: string; engineType: string; modelName: string }>; quotaLimit: number | null; quotaCycle: string | null; quotaUsed: number; validFrom: string | null; validUntil: string | null; maxConcurrentVideoTasks: number | null; isActive: boolean; lastUsedAt: string | null; createdAt: string; } interface UpscaleRule { targetResolution: string; providerGenerationResolution: string; processorKey: string; } const AdminApiKeys: React.FC = () => { const [keys, setKeys] = useState([]); const [loading, setLoading] = useState(false); const [total, setTotal] = useState(0); const [modal, setModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null }); const [usageModal, setUsageModal] = useState<{ open: boolean; key: ApiKey | null; usage: any }>({ open: false, key: null, usage: null }); const [quotaModal, setQuotaModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null }); const [quotaLogs, setQuotaLogs] = useState<{ items: any[]; total: number; page: number; loading: boolean }>({ items: [], total: 0, page: 1, loading: false }); const [form] = Form.useForm(); const [engines, setEngines] = useState([]); const [upscaleRules, setUpscaleRules] = useState([]); const [upscaleEnabled, setUpscaleEnabled] = useState(false); const [deleteSource, setDeleteSource] = useState(false); // V3 虚拟素材库配额(编辑时加载) const [vpV3Quota, setVpV3Quota] = useState<{ projectLimit: number; assetLimit: number; storageMbLimit: number; projectUsed: number; assetUsed: number; storageMbUsed: number; enabled: boolean; remark?: string | null; }>({ projectLimit: 0, assetLimit: 0, storageMbLimit: 0, projectUsed: 0, assetUsed: 0, storageMbUsed: 0, enabled: false, remark: null, }); const load = async () => { setLoading(true); try { const [keysData, enginesData] = await Promise.all([ getApiKeys({ limit: 100 }), getGenerationAiEngines(), ]); setKeys(keysData?.items || keysData || []); setTotal(keysData?.total || (keysData?.length || 0)); const allEngines: EngineOption[] = [ ...(enginesData?.engine?.image || []).map((e: any) => ({ id: e.id, name: e.name || e.modelName, modelName: e.modelName, genType: 'image' as const, })), ...(enginesData?.engine?.video || []).map((e: any) => ({ id: e.id, name: e.name || e.modelName, modelName: e.modelName, genType: 'video' as const, })), ]; setEngines(allEngines); } catch { message.error('加载失败'); } finally { setLoading(false); } }; useEffect(() => { load(); }, []); const openEdit = async (key: ApiKey | null = null) => { if (key) { // 将 callableModels 转换为引擎 ID 数组用于 Select const selectedEngineIds = (key.callableModels || []).map((m: any) => m.engineId || m.engine_id); form.setFieldsValue({ companyName: key.companyName || '', description: key.description || '', quotaLimit: key.quotaLimit || null, quotaCycle: key.quotaCycle || 'monthly', validUntil: key.validUntil ? dayjs(key.validUntil) : null, maxConcurrentVideoTasks: key.maxConcurrentVideoTasks || null, engineIds: selectedEngineIds, }); // 并行加载:超分配置 + 虚拟素材库配额 await Promise.all([ loadUpscaleConfig(key.id), (async () => { try { const quota = await getApiKeyVpV3Quota(key.id); setVpV3Quota({ projectLimit: quota?.projectLimit ?? quota?.project_limit ?? 0, assetLimit: quota?.assetLimit ?? quota?.asset_limit ?? 0, storageMbLimit: quota?.storageMbLimit ?? quota?.storage_mb_limit ?? 0, projectUsed: quota?.projectUsed ?? quota?.project_used ?? 0, assetUsed: quota?.assetUsed ?? quota?.asset_used ?? 0, storageMbUsed: quota?.storageMbUsed ?? quota?.storage_mb_used ?? 0, enabled: !!quota?.enabled, remark: quota?.remark ?? null, }); } catch { setVpV3Quota({ projectLimit: 0, assetLimit: 0, storageMbLimit: 0, projectUsed: 0, assetUsed: 0, storageMbUsed: 0, enabled: false, remark: null, }); } })(), ]); } else { form.resetFields(); form.setFieldsValue({ quotaCycle: 'monthly', quotaLimit: 100, engineIds: [] }); setUpscaleEnabled(false); setDeleteSource(false); setUpscaleRules([]); setVpV3Quota({ projectLimit: 0, assetLimit: 0, storageMbLimit: 0, projectUsed: 0, assetUsed: 0, storageMbUsed: 0, enabled: false, remark: null, }); } setModal({ open: true, key }); }; const handleSave = async () => { try { const values = await form.validateFields(); // 将选中的引擎 ID 转换为 callableModels 格式 const callableModels = (values.engineIds || []).map((id: string) => { const engine = engines.find(e => e.id === id); return { engineId: id, engineType: engine?.genType || 'video', modelName: engine?.modelName || '', }; }); const payload = { companyName: values.companyName, description: values.description || null, quotaLimit: values.quotaLimit || null, quotaCycle: values.quotaCycle || null, validUntil: values.validUntil ? (values.validUntil.toISOString ? values.validUntil.toISOString() : values.validUntil) : null, maxConcurrentVideoTasks: values.maxConcurrentVideoTasks || null, callableModels, }; console.log('API Key payload:', JSON.stringify(payload, null, 2)); if (modal.key?.id) { await updateApiKey(modal.key.id, payload); } else { const result = await createApiKey(payload); if (result?.apiKey) { Modal.success({ title: 'API Key 创建成功', content: (

请妥善保存以下 API Key,此信息仅显示一次:

{result.apiKey}
), }); } } // 保存超分配置 if (modal.key?.id) { await saveUpscaleConfig(modal.key.id); // 保存 V3 虚拟素材库配额(编辑模式才需要,因为新建时还没有 id) try { await saveApiKeyVpV3Quota(modal.key.id, { projectLimit: vpV3Quota.projectLimit || 0, assetLimit: vpV3Quota.assetLimit || 0, storageMbLimit: vpV3Quota.storageMbLimit || 0, remark: vpV3Quota.remark ?? null, }); } catch (qErr: any) { message.warning(qErr?.response?.data?.detail || '虚拟素材库配额保存失败'); } } message.success('保存成功'); setModal({ open: false, key: null }); form.resetFields(); load(); } catch (e: any) { if (e?.errorFields) return; message.error('保存失败'); } }; const handleDelete = async (id: string) => { try { await deleteApiKey(id); message.success('已删除'); load(); } catch { message.error('删除失败'); } }; const handleCopyKey = async (key: ApiKey) => { try { const result = await revealApiKey(key.id); const plainKey: string | undefined = result?.apiKey || result?.data?.apiKey; if (!plainKey) { message.error('获取 API Key 失败'); return; } // 优先用 Clipboard API,不支持时回退到 execCommand if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { try { await navigator.clipboard.writeText(plainKey); } catch { fallbackCopy(plainKey); } } else { fallbackCopy(plainKey); } message.success('API Key 已复制到剪贴板'); } catch (e: any) { const msg = e?.response?.data?.detail || '复制失败'; message.error(msg); } }; const fallbackCopy = (text: string) => { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); }; const loadUsageDetail = async (keyId: string, page = 1, pageSize = 20) => { try { const usage = await getApiKeyUsage(keyId, 30, page, pageSize); setUsageModal(prev => ({ ...prev, usage })); } catch { message.error('加载使用统计失败'); } }; const loadQuotaLogs = async (keyId: string, page = 1) => { setQuotaLogs(prev => ({ ...prev, loading: true })); try { // 从 operation_logs 中筛选 quota_adjust:* 且 path 包含该 keyId 的记录 const data = await getOperationLogs({ page, pageSize: 20, action: 'quota_adjust' }); const filtered = (data?.items || []).filter((item: any) => item.path?.includes(keyId)); setQuotaLogs({ items: filtered, total: filtered.length, page, loading: false }); } catch { message.error('加载配额变更记录失败'); setQuotaLogs(prev => ({ ...prev, loading: false })); } }; const viewUsage = async (key: ApiKey) => { try { const usage = await getApiKeyUsage(key.id, 30, 1, 20); setUsageModal({ open: true, key, usage }); loadQuotaLogs(key.id, 1); } catch { message.error('加载使用统计失败'); } }; // ── 超分配置处理 ── const handleAddUpscaleRule = () => { setUpscaleRules([...upscaleRules, { targetResolution: '1080p', providerGenerationResolution: '720p', processorKey: 'volc_large_model_v1' }]); }; const handleRemoveUpscaleRule = (idx: number) => { setUpscaleRules(upscaleRules.filter((_, i) => i !== idx)); }; const handleUpscaleRuleChange = (idx: number, field: keyof UpscaleRule, value: string) => { const newRules = [...upscaleRules]; newRules[idx] = { ...newRules[idx], [field]: value }; setUpscaleRules(newRules); }; const loadUpscaleConfig = async (keyId: string) => { try { const config = await getApiKeyUpscaleConfig(keyId); setUpscaleEnabled(config?.data?.enabled || false); setDeleteSource(config?.data?.deleteSourceAfterSuccess || false); setUpscaleRules(config?.data?.rules || []); } catch { setUpscaleEnabled(false); setDeleteSource(false); setUpscaleRules([]); } }; const saveUpscaleConfig = async (keyId: string) => { try { await saveApiKeyUpscaleConfig(keyId, { data: { enabled: upscaleEnabled, deleteSourceAfterSuccess: deleteSource, rules: upscaleRules, }, }); message.success('超分配置已保存'); } catch { message.error('保存超分配置失败'); } }; const cycleLabel = (cycle: string | null) => { const map: Record = { daily: '每日', monthly: '每月', one_time: '一次性' }; return cycle ? map[cycle] || cycle : '无限'; }; const columns: ColumnsType = [ { title: '公司', dataIndex: 'companyName', width: 120, ellipsis: true }, { title: 'api-key', dataIndex: 'apiKeyPrefix', width: 180, render: (v: string, r: ApiKey) => ( {v}**** ), }, { title: '配额(元)', dataIndex: 'quotaLimit', width: 130, render: (_v: number, r: ApiKey) => { if (!r.quotaLimit) return 无限; const used = r.quotaUsed || 0; const limit = r.quotaLimit || 1; const pct = Math.min(100, Math.round((used / limit) * 100)); return (
`${used.toFixed(1)}/${limit}`} />
); }, }, { title: '周期', dataIndex: 'quotaCycle', width: 70, render: (v: string | null) => {cycleLabel(v)}, }, { title: '状态', dataIndex: 'isActive', width: 70, render: (v: boolean) => {v ? '启用' : '停用'}, }, { title: '有效期', dataIndex: 'validUntil', width: 100, render: (v: string | null) => v ? new Date(v).toLocaleDateString() : '永久', }, { title: '最后使用', dataIndex: 'lastUsedAt', width: 150, render: (v: string | null) => v ? new Date(v).toLocaleString() : '-', }, { title: '操作', key: 'actions', fixed: 'right', width: 260, render: (_: any, r: ApiKey) => ( handleDelete(r.id)}> ), }, ]; return (
API Key 管理 {total} 个
{/* 创建/编辑弹窗 */} { setModal({ open: false, key: null }); form.resetFields(); }} okText="保存" cancelText="取消" width={760} >
handleUpscaleRuleChange(idx, 'targetResolution', v)} style={{ width: 100 }} options={['480p', '720p', '1080p', '2K', '4K'].map(r => ({ label: r, value: r }))} /> handleUpscaleRuleChange(idx, 'processorKey', v)} style={{ width: 140 }} options={[ { label: '本地FFmpeg', value: 'local_ffmpeg_crop_v1' }, { label: '火山标准版', value: 'volc_standard_v1' }, { label: '火山专业版', value: 'volc_professional_v1' }, { label: '火山大模型', value: 'volc_large_model_v1' }, ]} /> handleRemoveUpscaleRule(idx)}> )} {/* 虚拟素材库配额(仅编辑模式显示,新建时没ID) */} {modal.key?.id && ( <>
🧩 V3 虚拟素材库配额 {vpV3Quota.enabled ? '已启用' : '未启用(全0=不可用)'}
默认 0 = 该 API Key 不可使用虚拟素材库功能。项目数或素材数任一上限 > 0 即启用(存储空间不再设置上限)。
项目数上限 已使用 {vpV3Quota.projectUsed || 0} / {vpV3Quota.projectLimit || 0}
setVpV3Quota(q => ({ ...q, projectLimit: Number(v) || 0 }))} addonBefore="上限" addonAfter="个" /> 0 ? Math.min(100, Math.round((vpV3Quota.projectUsed || 0) * 100 / (vpV3Quota.projectLimit || 1))) : 0} size="small" style={{ marginTop: 6 }} strokeColor={vpV3Quota.projectLimit > 0 && (vpV3Quota.projectUsed || 0) >= vpV3Quota.projectLimit ? '#ff4d4f' : '#1677ff'} />
素材数上限 已使用 {vpV3Quota.assetUsed || 0} / {vpV3Quota.assetLimit || 0}
setVpV3Quota(q => ({ ...q, assetLimit: Number(v) || 0 }))} addonBefore="上限" addonAfter="张" /> 0 ? Math.min(100, Math.round((vpV3Quota.assetUsed || 0) * 100 / (vpV3Quota.assetLimit || 1))) : 0} size="small" style={{ marginTop: 6 }} strokeColor={vpV3Quota.assetLimit > 0 && (vpV3Quota.assetUsed || 0) >= vpV3Quota.assetLimit ? '#ff4d4f' : '#1677ff'} />
{vpV3Quota.storageMbUsed > 0 && (
已使用存储空间:{Number(vpV3Quota.storageMbUsed || 0).toFixed(2)} MB(无上限限制,仅供参考)
)}
备注(仅后台可见): setVpV3Quota(q => ({ ...q, remark: e.target.value || null }))} style={{ marginTop: 4 }} />
)}
{/* 使用统计 + 配额变更弹窗 */} setUsageModal({ open: false, key: null, usage: null })} footer={null} width={720} >
总请求数{usageModal.usage.totalRequests} 总消耗(元){usageModal.usage.totalCreditsCost?.toFixed(2)} 成功率{usageModal.usage.totalRequests ? ((usageModal.usage.successCount / usageModal.usage.totalRequests) * 100).toFixed(1) : 0}%
v ? new Date(v).toLocaleString() : '-' }, { title: '类型', dataIndex: 'genType', width: 70, render: (v: string) => {v === 'video' ? '视频' : '图片'}, }, { title: '模型', dataIndex: 'modelName', width: 140, ellipsis: true }, { title: '消耗(元)', dataIndex: 'creditsCost', width: 90, render: (v: number) => v?.toFixed(2) || '0.00' }, { title: '状态', dataIndex: 'status', width: 70, render: (v: string) => {v === 'success' ? '成功' : '失败'} }, ]} dataSource={usageModal.usage.items || []} rowKey="id" pagination={{ current: usageModal.usage.page || 1, pageSize: usageModal.usage.pageSize || 20, total: usageModal.usage.total || 0, onChange: (p, ps) => loadUsageDetail(usageModal.key?.id || '', p, ps || 20), showSizeChanger: true, showTotal: (t) => `共 ${t} 条`, size: 'small', }} size="small" /> ), }, { key: 'quota', label: '配额变更', children: (
v ? new Date(v).toLocaleString() : '-' }, { title: '管理员', dataIndex: 'username', width: 100, ellipsis: true }, { title: '操作', dataIndex: 'action', width: 110, render: (v: string) => { const sub = v?.split(':')[1] || v; const label: Record = { adjust: '增加总额', reset_usage: '重置已用', set_limit: '设置限额', change_cycle: '修改周期' }; return {label[sub] || v}; }, }, { title: '变更详情', dataIndex: 'detail', width: 220, ellipsis: true, render: (v: string, row: any) => { let detail: any = v; if (typeof v === 'string') { try { detail = JSON.parse(v); } catch { return v || '-'; } } if (!detail || typeof detail !== 'object') return '-'; const parts: string[] = []; if (detail.old_limit != null || detail.new_limit != null) { parts.push(`限额: ${detail.old_limit != null ? detail.old_limit.toFixed(2) : '-'} → ${detail.new_limit != null ? detail.new_limit.toFixed(2) : '无限'}`); } if (detail.old_used != null && detail.new_used != null && detail.old_used !== detail.new_used) { parts.push(`已用: ${detail.old_used.toFixed(2)} → ${detail.new_used.toFixed(2)}`); } if (detail.old_cycle != null || detail.new_cycle != null) { if (detail.old_cycle !== detail.new_cycle) { parts.push(`周期: ${cycleLabel(detail.old_cycle)} → ${cycleLabel(detail.new_cycle)}`); } } return parts.length > 0 ? {parts.join(' | ')} : '-'; }, }, { title: '原因', dataIndex: 'detail', width: 120, ellipsis: true, render: (v: string) => { let detail: any = v; if (typeof v === 'string') { try { detail = JSON.parse(v); } catch { /* */ } } return detail?.reason || '-'; }, }, ]} dataSource={quotaLogs.items} rowKey={(r, i) => r.id || r.createdAt || i} loading={quotaLogs.loading} pagination={false} size="small" scroll={{ x: 700 }} /> ), }, ]} /> {/* 配额调整弹窗 */} setQuotaModal({ open: false, key: null })} onSuccess={() => { setQuotaModal({ open: false, key: null }); load(); }} /> ); }; export default AdminApiKeys;