Files
video-gen/video-gen-admin/src/pages/AdminApiKeys.tsx
T
root 9538f6157d 1、增加消息推送的前台横幅显示
2、增加apikey的整体配额增加和修改记录显示
2026-08-07 11:35:41 +08:00

761 lines
33 KiB
TypeScript

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<ApiKey[]>([]);
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<EngineOption[]>([]);
const [upscaleRules, setUpscaleRules] = useState<UpscaleRule[]>([]);
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: (
<div>
<p>请妥善保存以下 API Key,此信息仅显示一次:</p>
<Typography.Paragraph copyable style={{ background: '#f5f5f5', padding: 12, borderRadius: 8, fontFamily: 'monospace' }}>
{result.apiKey}
</Typography.Paragraph>
</div>
),
});
}
}
// 保存超分配置
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<string, string> = { daily: '每日', monthly: '每月', one_time: '一次性' };
return cycle ? map[cycle] || cycle : '无限';
};
const columns: ColumnsType<ApiKey> = [
{ title: '公司', dataIndex: 'companyName', width: 120, ellipsis: true },
{
title: 'api-key',
dataIndex: 'apiKeyPrefix',
width: 180,
render: (v: string, r: ApiKey) => (
<Space size={4}>
<code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}****</code>
<Button type="link" size="small" icon={<CopyOutlined />} onClick={() => handleCopyKey(r)}></Button>
</Space>
),
},
{
title: '配额(元)',
dataIndex: 'quotaLimit',
width: 130,
render: (_v: number, r: ApiKey) => {
if (!r.quotaLimit) return <Tag>无限</Tag>;
const used = r.quotaUsed || 0;
const limit = r.quotaLimit || 1;
const pct = Math.min(100, Math.round((used / limit) * 100));
return (
<div style={{ width: 110 }}>
<Progress percent={pct} size="small" format={() => `${used.toFixed(1)}/${limit}`} />
</div>
);
},
},
{
title: '周期',
dataIndex: 'quotaCycle',
width: 70,
render: (v: string | null) => <Tag>{cycleLabel(v)}</Tag>,
},
{
title: '状态',
dataIndex: 'isActive',
width: 70,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
},
{
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) => (
<Space size={0}>
<Button type="link" size="small" icon={<DollarOutlined />} onClick={() => setQuotaModal({ open: true, key: r })}>配额</Button>
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => viewUsage(r)}>统计</Button>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
</Space>
),
},
];
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ApiOutlined style={{ color: '#fff', fontSize: 18 }} />
</div>
<Typography.Text strong style={{ fontSize: 16 }}>API Key 管理</Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>创建 Key</Button>
</div>
</Card>
<Card variant="outlined" style={{ borderRadius: 12 }}>
<Table columns={columns} dataSource={keys} rowKey="id" loading={loading} pagination={false} scroll={{ x: 1100 }} />
</Card>
{/* 创建/编辑弹窗 */}
<Modal
title={modal.key ? '编辑 API Key' : '创建 API Key'}
open={modal.open}
onOk={handleSave}
onCancel={() => { setModal({ open: false, key: null }); form.resetFields(); }}
okText="保存" cancelText="取消" width={760}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="companyName" label="公司名称" rules={[{ required: true, message: '请输入公司名称' }]}>
<Input placeholder="公司名称" />
</Form.Item>
<Form.Item name="description" label="备注">
<Input.TextArea placeholder="备注信息" rows={2} />
</Form.Item>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="quotaLimit" label="配额总额(元)" style={{ flex: 1 }}>
<InputNumber min={0} step={10} style={{ width: '100%' }} placeholder="留空=无限" />
</Form.Item>
<Form.Item name="quotaCycle" label="配额周期" style={{ flex: 1 }}>
<Select>
<Select.Option value="daily">每日</Select.Option>
<Select.Option value="monthly">每月</Select.Option>
<Select.Option value="one_time">一次性</Select.Option>
</Select>
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="validUntil" label="有效期至" style={{ flex: 1 }}>
<DatePicker style={{ width: '100%' }} placeholder="留空=永久" />
</Form.Item>
<Form.Item name="maxConcurrentVideoTasks" label="最大并发视频任务" style={{ flex: 1 }}>
<InputNumber min={1} style={{ width: '100%' }} placeholder="留空=无限" />
</Form.Item>
</div>
<Form.Item name="engineIds" label="可调用模型">
<Select
mode="multiple"
placeholder="选择该 Key 可调用的模型(留空=允许所有已定价模型)"
allowClear
showSearch
optionFilterProp="label"
style={{ width: '100%' }}
options={(engines || []).map(e => ({
label: `[${e.genType === 'video' ? '视频' : '图片'}] ${e.name || e.modelName || e.id}`,
value: e.id,
}))}
notFoundContent={engines.length === 0 ? '暂无可用引擎' : null}
/>
</Form.Item>
</Form>
{/* 超分配置(仅编辑模式显示,新建时没ID) */}
{modal.key?.id && (
<>
<Divider />
<Typography.Text strong style={{ fontSize: 14 }}>🎬 超分配置</Typography.Text>
<div style={{ marginTop: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Typography.Text>启用超分:</Typography.Text>
<Switch checked={upscaleEnabled} onChange={setUpscaleEnabled} checkedChildren="启用" unCheckedChildren="关闭" />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Typography.Text>成功后删除源文件:</Typography.Text>
<Switch checked={deleteSource} onChange={setDeleteSource} checkedChildren="是" unCheckedChildren="否" />
</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>超分规则:</Typography.Text>
<div style={{ marginTop: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(upscaleRules || []).map((rule, idx) => (
<Card key={idx} size="small" style={{ background: '#f8f9fc' }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Select
value={rule.targetResolution}
onChange={v => handleUpscaleRuleChange(idx, 'targetResolution', v)}
style={{ width: 100 }}
options={['480p', '720p', '1080p', '2K', '4K'].map(r => ({ label: r, value: r }))}
/>
<span></span>
<Select
value={rule.providerGenerationResolution}
onChange={v => handleUpscaleRuleChange(idx, 'providerGenerationResolution', v)}
style={{ width: 100 }}
options={['480p', '720p', '1080p'].map(r => ({ label: r, value: r }))}
/>
<Select
value={rule.processorKey}
onChange={v => 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' },
]}
/>
<Popconfirm title="确定删除此规则?" onConfirm={() => handleRemoveUpscaleRule(idx)}>
<Button type="link" danger size="small" icon={<DeleteOutlined />} />
</Popconfirm>
</div>
</Card>
))}
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={handleAddUpscaleRule}>
添加规则
</Button>
</div>
</div>
</div>
</>
)}
{/* 虚拟素材库配额(仅编辑模式显示,新建时没ID) */}
{modal.key?.id && (
<>
<Divider />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
<Typography.Text strong style={{ fontSize: 14 }}>🧩 V3 虚拟素材库配额</Typography.Text>
<Tag color={vpV3Quota.enabled ? 'green' : 'default'}>
{vpV3Quota.enabled ? '已启用' : '未启用(全0=不可用)'}
</Tag>
</div>
<div style={{ padding: '12px 16px', backgroundColor: '#f6ffed', borderRadius: 8, border: '1px solid #b7eb8f' }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
默认 0 = API Key 不可使用虚拟素材库功能。项目数或素材数任一上限 &gt; 0 即启用(存储空间不再设置上限)。
</Typography.Text>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<div>
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text strong>项目数上限</Typography.Text>
<Tag color="blue">已使用 {vpV3Quota.projectUsed || 0} / {vpV3Quota.projectLimit || 0}</Tag>
</div>
<InputNumber
min={0}
max={10000}
style={{ width: '100%' }}
value={vpV3Quota.projectLimit}
onChange={(v) => setVpV3Quota(q => ({ ...q, projectLimit: Number(v) || 0 }))}
addonBefore="上限" addonAfter="个"
/>
<Progress
percent={vpV3Quota.projectLimit > 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'}
/>
</div>
<div>
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text strong>素材数上限</Typography.Text>
<Tag color="blue">已使用 {vpV3Quota.assetUsed || 0} / {vpV3Quota.assetLimit || 0}</Tag>
</div>
<InputNumber
min={0}
max={1000000}
style={{ width: '100%' }}
value={vpV3Quota.assetLimit}
onChange={(v) => setVpV3Quota(q => ({ ...q, assetLimit: Number(v) || 0 }))}
addonBefore="上限" addonAfter="张"
/>
<Progress
percent={vpV3Quota.assetLimit > 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'}
/>
</div>
</div>
{vpV3Quota.storageMbUsed > 0 && (
<div style={{ marginTop: 12, padding: '8px 12px', background: '#f0f5ff', borderRadius: 6, fontSize: 12, color: '#475569' }}>
已使用存储空间:<strong style={{ color: '#1e40af' }}>{Number(vpV3Quota.storageMbUsed || 0).toFixed(2)} MB</strong>(无上限限制,仅供参考)
</div>
)}
<div style={{ marginTop: 12 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>备注(仅后台可见):</Typography.Text>
<Input.TextArea
rows={2}
maxLength={500}
placeholder="可选:配额配置说明"
value={vpV3Quota.remark ?? ''}
onChange={(e) => setVpV3Quota(q => ({ ...q, remark: e.target.value || null }))}
style={{ marginTop: 4 }}
/>
</div>
</div>
</>
)}
</Modal>
{/* 使用统计 + 配额变更弹窗 */}
<Modal
title={`API Key 详情 - ${usageModal.key?.companyName || ''}`}
open={usageModal.open}
onCancel={() => setUsageModal({ open: false, key: null, usage: null })}
footer={null} width={720}
>
<Tabs
defaultActiveKey="usage"
items={[
{
key: 'usage',
label: '使用统计',
children: usageModal.usage && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 24 }}>
<Card><Typography.Text type="secondary">总请求数</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests}</Typography.Title></Card>
<Card><Typography.Text type="secondary">总消耗()</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalCreditsCost?.toFixed(2)}</Typography.Title></Card>
<Card><Typography.Text type="secondary">成功率</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests ? ((usageModal.usage.successCount / usageModal.usage.totalRequests) * 100).toFixed(1) : 0}%</Typography.Title></Card>
</div>
<Table
columns={[
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
{
title: '类型', dataIndex: 'genType', width: 70,
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
},
{ 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) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag> },
]}
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"
/>
</div>
),
},
{
key: 'quota',
label: '配额变更',
children: (
<div>
<Table
columns={[
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => 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<string, string> = { adjust: '增加总额', reset_usage: '重置已用', set_limit: '设置限额', change_cycle: '修改周期' };
return <Tag color="blue">{label[sub] || v}</Tag>;
},
},
{
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 ? <span style={{ fontSize: 12 }}>{parts.join(' | ')}</span> : '-';
},
},
{
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 }}
/>
</div>
),
},
]}
/>
</Modal>
{/* 配额调整弹窗 */}
<QuotaAdjustModal
open={quotaModal.open}
keyId={quotaModal.key?.id || ''}
companyName={quotaModal.key?.companyName || ''}
quotaLimit={quotaModal.key?.quotaLimit ?? null}
quotaUsed={quotaModal.key?.quotaUsed || 0}
quotaCycle={quotaModal.key?.quotaCycle || null}
onCancel={() => setQuotaModal({ open: false, key: null })}
onSuccess={() => {
setQuotaModal({ open: false, key: null });
load();
}}
/>
</Space>
);
};
export default AdminApiKeys;