Files
video-gen/video-gen-admin/src/pages/AdminApiUsage.tsx
T
2026-08-06 17:53:06 +08:00

259 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useCallback } from 'react';
import {
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
} from 'antd';
import {
TableOutlined, ReloadOutlined, SearchOutlined, ExportOutlined,
} from '@ant-design/icons';
import { getApiUsageAll } from '../api';
import dayjs from 'dayjs';
interface UsageItem {
id: string;
apiKeyId: string;
companyName: string;
apiKeyPrefix: string | null;
taskId: string | null;
requestType: string;
modelName: string;
genType: string;
creditsCost: number;
tokensUsed: number;
requestDurationMs: number;
duration: number | null;
resolution: string | null;
status: string;
errorMessage: string | null;
errorCode: string | null;
createdAt: string | null;
}
const AdminApiUsage: React.FC = () => {
const [items, setItems] = useState<UsageItem[]>([]);
const [loading, setLoading] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [searchText, setSearchText] = useState('');
const [filterGenType, setFilterGenType] = useState<string | undefined>(undefined);
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
const load = useCallback(async () => {
setLoading(true);
try {
const params: any = {
skip: (page - 1) * pageSize,
limit: pageSize,
};
if (filterGenType) params.genType = filterGenType;
if (filterStatus) params.status = filterStatus;
if (dateRange[0]) params.startDate = dateRange[0].startOf('day').toISOString();
if (dateRange[1]) params.endDate = dateRange[1].endOf('day').toISOString();
if (searchText.trim()) params.search = searchText.trim();
const data = await getApiUsageAll(params);
setItems(data?.items || []);
setTotal(data?.total || 0);
} catch {
message.error('加载失败');
} finally {
setLoading(false);
}
}, [page, pageSize, filterGenType, filterStatus, dateRange, searchText]);
useEffect(() => { load(); }, [load]);
const handleSearch = () => {
setPage(1);
load();
};
// 导出 CSV
const handleExport = () => {
const headers = ['时间', '公司', 'api-key', '类型', '模型', '时长(秒)', '分辨率', '消耗(元)', 'Token', '耗时(ms)', '状态', '错误信息'];
const rows = items.map(item => [
item.createdAt ? new Date(item.createdAt).toLocaleString() : '',
item.companyName || '',
item.apiKeyPrefix || '',
item.genType === 'video' ? '视频' : '图片',
item.modelName || '',
item.duration || '',
item.resolution || '',
(item.creditsCost || 0).toFixed(2),
item.tokensUsed || '',
item.requestDurationMs || '',
item.status === 'success' ? '成功' : '失败',
item.errorMessage || '',
]);
const csvContent = [headers, ...rows]
.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const BOM = '';
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `api_usage_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('导出成功');
};
const columns = [
{
title: '时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => v ? new Date(v).toLocaleString() : '-',
},
{
title: '公司',
dataIndex: 'companyName',
width: 120,
render: (v: string) => v || '-',
},
{
title: 'api-key',
dataIndex: 'apiKeyPrefix',
width: 110,
render: (v: string) => v ? <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}</code> : '-',
},
{
title: '类型',
dataIndex: 'genType',
width: 70,
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
},
{
title: '模型',
dataIndex: 'modelName',
width: 160,
ellipsis: true,
},
{
title: '消耗(元)',
dataIndex: 'creditsCost',
width: 90,
render: (v: number) => <span style={{ color: v > 0 ? '#f5222d' : '#52c41a', fontWeight: 500 }}>{v?.toFixed(2) || '0.00'}</span>,
},
{
title: 'Token',
dataIndex: 'tokensUsed',
width: 80,
render: (v: number) => v || '-',
},
{
title: '耗时(ms)',
dataIndex: 'requestDurationMs',
width: 90,
render: (v: number) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag>,
},
{
title: '错误信息',
dataIndex: 'errorMessage',
width: 200,
ellipsis: true,
render: (v: string) => v ? <span style={{ color: '#f5222d' }}>{v}</span> : '-',
},
];
// 统计
const totalCost = items.reduce((sum, item) => sum + (item.creditsCost || 0), 0);
const successCount = items.filter(i => i.status === 'success').length;
const failedCount = items.filter(i => i.status === 'failed').length;
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' }}>
<TableOutlined style={{ color: '#fff', fontSize: 18 }} />
</div>
<Typography.Text strong style={{ fontSize: 16 }}>API 消耗列表</Typography.Text>
<Tag color="purple">{total} </Tag>
</Space>
<Space>
<Tag color="blue">本页消耗: {totalCost.toFixed(2)} </Tag>
<Tag color="green">成功: {successCount}</Tag>
<Tag color="red">失败: {failedCount}</Tag>
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button>
<Button icon={<ReloadOutlined />} onClick={handleSearch}>刷新</Button>
</Space>
</div>
</Card>
{/* 筛选栏 */}
<Card variant="outlined" style={{ borderRadius: 12 }}>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<Input
placeholder="搜索公司名或 Key 前缀"
prefix={<SearchOutlined />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
onPressEnter={handleSearch}
allowClear
style={{ width: 220 }}
/>
<Select
placeholder="类型"
value={filterGenType}
onChange={v => { setFilterGenType(v); setPage(1); }}
allowClear
style={{ width: 100 }}
>
<Select.Option value="video">视频</Select.Option>
<Select.Option value="image">图片</Select.Option>
</Select>
<Select
placeholder="状态"
value={filterStatus}
onChange={v => { setFilterStatus(v); setPage(1); }}
allowClear
style={{ width: 100 }}
>
<Select.Option value="success">成功</Select.Option>
<Select.Option value="failed">失败</Select.Option>
</Select>
<DatePicker.RangePicker
value={dateRange}
onChange={(dates) => { setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]); setPage(1); }}
/>
<Button type="primary" onClick={handleSearch}>筛选</Button>
</div>
</Card>
<Card variant="outlined" style={{ borderRadius: 12 }}>
<Table
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
pagination={{
current: page,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps || 50); },
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`,
}}
scroll={{ x: 1300 }}
/>
</Card>
</Space>
);
};
export default AdminApiUsage;