761 lines
33 KiB
TypeScript
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 不可使用虚拟素材库功能。项目数或素材数任一上限 > 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;
|