1
This commit is contained in:
@@ -438,8 +438,12 @@ export async function deleteApiKey(id: string): Promise<void> {
|
||||
await api.delete(`/admin/api-keys/${id}`);
|
||||
}
|
||||
|
||||
export async function getApiKeyUsage(id: string, days?: number): Promise<any> {
|
||||
const qs = days ? `?days=${days}` : '';
|
||||
export async function getApiKeyUsage(id: string, days?: number, page?: number, pageSize?: number): Promise<any> {
|
||||
const params = new URLSearchParams();
|
||||
if (days) params.set('days', String(days));
|
||||
if (page) params.set('page', String(page));
|
||||
if (pageSize) params.set('page_size', String(pageSize));
|
||||
const qs = params.toString() ? `?${params.toString()}` : '';
|
||||
return api.get(`/admin/api-keys/${id}/usage${qs}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -264,9 +264,18 @@ const AdminApiKeys: React.FC = () => {
|
||||
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 viewUsage = async (key: ApiKey) => {
|
||||
try {
|
||||
const usage = await getApiKeyUsage(key.id, 30);
|
||||
const usage = await getApiKeyUsage(key.id, 30, 1, 20);
|
||||
setUsageModal({ open: true, key, usage });
|
||||
} catch {
|
||||
message.error('加载使用统计失败');
|
||||
@@ -614,15 +623,26 @@ const AdminApiKeys: React.FC = () => {
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ title: '时间', dataIndex: 'createdAt', render: (v: string) => new Date(v).toLocaleString() },
|
||||
{ title: '类型', dataIndex: 'requestType' },
|
||||
{ title: '模型', dataIndex: 'modelName' },
|
||||
{ title: '消耗(元)', dataIndex: 'creditsCost' },
|
||||
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v}</Tag> },
|
||||
{ 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={false}
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
TableOutlined, ReloadOutlined,
|
||||
TableOutlined, ReloadOutlined, SearchOutlined, ExportOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getApiUsageAll } from '../api';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -32,12 +32,12 @@ const AdminApiUsage: React.FC = () => {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [searchCompany, setSearchCompany] = useState('');
|
||||
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 = async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
@@ -48,6 +48,7 @@ const AdminApiUsage: React.FC = () => {
|
||||
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 || []);
|
||||
@@ -57,15 +58,48 @@ const AdminApiUsage: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [page, pageSize, filterGenType, filterStatus, dateRange, searchText]);
|
||||
|
||||
useEffect(() => { load(); }, [page, pageSize, filterGenType, filterStatus, dateRange]);
|
||||
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.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: '时间',
|
||||
@@ -80,7 +114,7 @@ const AdminApiUsage: React.FC = () => {
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: 'Key 前缀',
|
||||
title: 'api-key',
|
||||
dataIndex: 'apiKeyPrefix',
|
||||
width: 110,
|
||||
render: (v: string) => v ? <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}</code> : '-',
|
||||
@@ -150,6 +184,7 @@ const AdminApiUsage: React.FC = () => {
|
||||
<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>
|
||||
@@ -158,6 +193,15 @@ const AdminApiUsage: React.FC = () => {
|
||||
{/* 筛选栏 */}
|
||||
<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}
|
||||
|
||||
Reference in New Issue
Block a user