feat(admin): add global token usage statistics
- Add token aggregation to statistics/overview endpoint - Include total tokens, cost, and API calls for all time - Include today's token usage and cost breakdown - Display token stats in ConversationsPage with 2 rows of cards - Add formatNumber helper for K/M number formatting - Export GlobalTokenStats and TodayTokenStats types Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7acdf78e0c
commit
2d4e6285a4
|
|
@ -64,4 +64,6 @@ export type {
|
|||
ConversationStatistics,
|
||||
ConversationQueryParams,
|
||||
TokenDetails,
|
||||
GlobalTokenStats,
|
||||
TodayTokenStats,
|
||||
} from '../infrastructure/conversations.api';
|
||||
|
|
|
|||
|
|
@ -78,6 +78,24 @@ export interface PaginatedConversations {
|
|||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface GlobalTokenStats {
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalCacheCreationTokens: number;
|
||||
totalCacheReadTokens: number;
|
||||
totalTokens: number;
|
||||
totalEstimatedCost: number;
|
||||
totalApiCalls: number;
|
||||
}
|
||||
|
||||
export interface TodayTokenStats {
|
||||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
totalTokens: number;
|
||||
totalEstimatedCost: number;
|
||||
totalApiCalls: number;
|
||||
}
|
||||
|
||||
export interface ConversationStatistics {
|
||||
total: number;
|
||||
active: number;
|
||||
|
|
@ -85,6 +103,8 @@ export interface ConversationStatistics {
|
|||
converted: number;
|
||||
todayCount: number;
|
||||
conversionRate: string;
|
||||
tokenStats: GlobalTokenStats;
|
||||
todayTokenStats: TodayTokenStats;
|
||||
}
|
||||
|
||||
export interface ConversationQueryParams {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ import {
|
|||
GlobalOutlined,
|
||||
LaptopOutlined,
|
||||
BarChartOutlined,
|
||||
DollarOutlined,
|
||||
ApiOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
|
|
@ -196,7 +199,7 @@ export function ConversationsPage() {
|
|||
<div className="p-6">
|
||||
<Title level={4} className="mb-6">对话管理</Title>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
{/* Statistics Cards - Conversations */}
|
||||
<Row gutter={[16, 16]} className="mb-4">
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
|
|
@ -251,6 +254,76 @@ export function ConversationsPage() {
|
|||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Token Usage Statistics */}
|
||||
<Row gutter={[16, 16]} className="mb-4">
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="总 Tokens"
|
||||
value={stats?.tokenStats?.totalTokens ?? 0}
|
||||
prefix={<ThunderboltOutlined />}
|
||||
valueStyle={{ color: '#13c2c2', fontSize: 20 }}
|
||||
formatter={(value) => formatNumber(value as number)}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
输入: {formatNumber(stats?.tokenStats?.totalInputTokens ?? 0)} |
|
||||
输出: {formatNumber(stats?.tokenStats?.totalOutputTokens ?? 0)}
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="总成本"
|
||||
value={stats?.tokenStats?.totalEstimatedCost ?? 0}
|
||||
prefix={<DollarOutlined />}
|
||||
precision={2}
|
||||
valueStyle={{ color: '#eb2f96', fontSize: 20 }}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
API 调用: {formatNumber(stats?.tokenStats?.totalApiCalls ?? 0)} 次
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="今日 Tokens"
|
||||
value={stats?.todayTokenStats?.totalTokens ?? 0}
|
||||
prefix={<ApiOutlined />}
|
||||
valueStyle={{ color: '#1890ff', fontSize: 20 }}
|
||||
formatter={(value) => formatNumber(value as number)}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
输入: {formatNumber(stats?.todayTokenStats?.totalInputTokens ?? 0)} |
|
||||
输出: {formatNumber(stats?.todayTokenStats?.totalOutputTokens ?? 0)}
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Spin spinning={loadingStats}>
|
||||
<Statistic
|
||||
title="今日成本"
|
||||
value={stats?.todayTokenStats?.totalEstimatedCost ?? 0}
|
||||
prefix={<DollarOutlined />}
|
||||
precision={4}
|
||||
valueStyle={{ color: '#fa8c16', fontSize: 20 }}
|
||||
/>
|
||||
<div className="mt-1 text-xs text-gray-400">
|
||||
API 调用: {formatNumber(stats?.todayTokenStats?.totalApiCalls ?? 0)} 次
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-4">
|
||||
<Space wrap>
|
||||
|
|
@ -542,3 +615,14 @@ function parseUserAgent(ua: string): string {
|
|||
|
||||
return `${browser} / ${os}`;
|
||||
}
|
||||
|
||||
// Helper function to format large numbers (e.g., 1234567 -> 1.23M)
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(2) + 'M';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K';
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -323,7 +323,7 @@ export class AdminConversationController {
|
|||
}
|
||||
|
||||
/**
|
||||
* 获取对话统计
|
||||
* 获取对话统计(包含 Token 汇总)
|
||||
*/
|
||||
@Get('statistics/overview')
|
||||
async getStatistics(@Headers('authorization') auth: string) {
|
||||
|
|
@ -342,6 +342,33 @@ export class AdminConversationController {
|
|||
.where('c.created_at >= :today', { today })
|
||||
.getCount();
|
||||
|
||||
// 全局 Token 统计
|
||||
const allTokenStats = await this.tokenUsageRepo
|
||||
.createQueryBuilder('t')
|
||||
.select([
|
||||
'SUM(t.input_tokens) as "totalInputTokens"',
|
||||
'SUM(t.output_tokens) as "totalOutputTokens"',
|
||||
'SUM(t.cache_creation_tokens) as "totalCacheCreationTokens"',
|
||||
'SUM(t.cache_read_tokens) as "totalCacheReadTokens"',
|
||||
'SUM(t.total_tokens) as "totalTokens"',
|
||||
'SUM(t.estimated_cost) as "totalEstimatedCost"',
|
||||
'COUNT(*) as "totalApiCalls"',
|
||||
])
|
||||
.getRawOne();
|
||||
|
||||
// 今日 Token 统计
|
||||
const todayTokenStats = await this.tokenUsageRepo
|
||||
.createQueryBuilder('t')
|
||||
.select([
|
||||
'SUM(t.input_tokens) as "totalInputTokens"',
|
||||
'SUM(t.output_tokens) as "totalOutputTokens"',
|
||||
'SUM(t.total_tokens) as "totalTokens"',
|
||||
'SUM(t.estimated_cost) as "totalEstimatedCost"',
|
||||
'COUNT(*) as "totalApiCalls"',
|
||||
])
|
||||
.where('t.created_at >= :today', { today })
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
|
|
@ -351,6 +378,23 @@ export class AdminConversationController {
|
|||
converted,
|
||||
todayCount,
|
||||
conversionRate: total > 0 ? ((converted / total) * 100).toFixed(1) : '0',
|
||||
// Token 汇总统计
|
||||
tokenStats: {
|
||||
totalInputTokens: parseInt(allTokenStats?.totalInputTokens || '0'),
|
||||
totalOutputTokens: parseInt(allTokenStats?.totalOutputTokens || '0'),
|
||||
totalCacheCreationTokens: parseInt(allTokenStats?.totalCacheCreationTokens || '0'),
|
||||
totalCacheReadTokens: parseInt(allTokenStats?.totalCacheReadTokens || '0'),
|
||||
totalTokens: parseInt(allTokenStats?.totalTokens || '0'),
|
||||
totalEstimatedCost: parseFloat(allTokenStats?.totalEstimatedCost || '0'),
|
||||
totalApiCalls: parseInt(allTokenStats?.totalApiCalls || '0'),
|
||||
},
|
||||
todayTokenStats: {
|
||||
totalInputTokens: parseInt(todayTokenStats?.totalInputTokens || '0'),
|
||||
totalOutputTokens: parseInt(todayTokenStats?.totalOutputTokens || '0'),
|
||||
totalTokens: parseInt(todayTokenStats?.totalTokens || '0'),
|
||||
totalEstimatedCost: parseFloat(todayTokenStats?.totalEstimatedCost || '0'),
|
||||
totalApiCalls: parseInt(todayTokenStats?.totalApiCalls || '0'),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue