1
This commit is contained in:
Vendored
+79
-78
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-BxYDA-8S.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-D4AbZER1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -261,6 +261,8 @@ async def save_upscale_config(
|
||||
async def get_usage(
|
||||
key_id: str = Path(...),
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ApiUsageSummaryResponse:
|
||||
@@ -271,7 +273,7 @@ async def get_usage(
|
||||
raise HTTPException(status_code=404, detail="API Key 不存在")
|
||||
|
||||
summary = await usage_log_service.get_usage_summary(db, api_key_id=key_id, days=days)
|
||||
total, logs = await usage_log_service.list_usage_logs(db, api_key_id=key_id, limit=100)
|
||||
total, logs = await usage_log_service.list_usage_logs(db, api_key_id=key_id, limit=page_size, skip=(page - 1) * page_size)
|
||||
|
||||
return ApiUsageSummaryResponse(
|
||||
total_requests=summary["total_requests"],
|
||||
@@ -280,6 +282,9 @@ async def get_usage(
|
||||
success_count=summary["success_count"],
|
||||
failed_count=summary["failed_count"],
|
||||
avg_duration_ms=summary["avg_duration_ms"],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
items=[
|
||||
ApiUsageLogResponse(
|
||||
id=log.id,
|
||||
@@ -312,6 +317,7 @@ async def list_all_usage(
|
||||
status_filter: str | None = Query(None, alias="status"),
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
search: str | None = None,
|
||||
admin: User = Depends(get_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
@@ -336,6 +342,12 @@ async def list_all_usage(
|
||||
filters.append(ApiUsageLog.created_at >= start_date)
|
||||
if end_date:
|
||||
filters.append(ApiUsageLog.created_at <= end_date)
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
filters.append(
|
||||
(ApiKey.company_name.ilike(search_pattern))
|
||||
| (ApiKey.api_key_prefix.ilike(search_pattern))
|
||||
)
|
||||
|
||||
for f in filters:
|
||||
query = query.where(f)
|
||||
|
||||
@@ -33,4 +33,7 @@ class ApiUsageSummaryResponse(BaseModel):
|
||||
success_count: int
|
||||
failed_count: int
|
||||
avg_duration_ms: int
|
||||
total: int = 0
|
||||
page: int = 1
|
||||
page_size: int = 20
|
||||
items: list[ApiUsageLogResponse]
|
||||
|
||||
Reference in New Issue
Block a user