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:
hailin 2026-01-25 17:29:59 -08:00
parent 7acdf78e0c
commit 2d4e6285a4
4 changed files with 152 additions and 2 deletions

View File

@ -64,4 +64,6 @@ export type {
ConversationStatistics,
ConversationQueryParams,
TokenDetails,
GlobalTokenStats,
TodayTokenStats,
} from '../infrastructure/conversations.api';

View File

@ -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 {

View File

@ -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();
}

View File

@ -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'),
},
},
};
}