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,
|
ConversationStatistics,
|
||||||
ConversationQueryParams,
|
ConversationQueryParams,
|
||||||
TokenDetails,
|
TokenDetails,
|
||||||
|
GlobalTokenStats,
|
||||||
|
TodayTokenStats,
|
||||||
} from '../infrastructure/conversations.api';
|
} from '../infrastructure/conversations.api';
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,24 @@ export interface PaginatedConversations {
|
||||||
totalPages: number;
|
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 {
|
export interface ConversationStatistics {
|
||||||
total: number;
|
total: number;
|
||||||
active: number;
|
active: number;
|
||||||
|
|
@ -85,6 +103,8 @@ export interface ConversationStatistics {
|
||||||
converted: number;
|
converted: number;
|
||||||
todayCount: number;
|
todayCount: number;
|
||||||
conversionRate: string;
|
conversionRate: string;
|
||||||
|
tokenStats: GlobalTokenStats;
|
||||||
|
todayTokenStats: TodayTokenStats;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConversationQueryParams {
|
export interface ConversationQueryParams {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ import {
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
LaptopOutlined,
|
LaptopOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
|
DollarOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
|
@ -196,7 +199,7 @@ export function ConversationsPage() {
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<Title level={4} className="mb-6">对话管理</Title>
|
<Title level={4} className="mb-6">对话管理</Title>
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards - Conversations */}
|
||||||
<Row gutter={[16, 16]} className="mb-4">
|
<Row gutter={[16, 16]} className="mb-4">
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -251,6 +254,76 @@ export function ConversationsPage() {
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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 */}
|
{/* Filters */}
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
|
@ -542,3 +615,14 @@ function parseUserAgent(ua: string): string {
|
||||||
|
|
||||||
return `${browser} / ${os}`;
|
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')
|
@Get('statistics/overview')
|
||||||
async getStatistics(@Headers('authorization') auth: string) {
|
async getStatistics(@Headers('authorization') auth: string) {
|
||||||
|
|
@ -342,6 +342,33 @@ export class AdminConversationController {
|
||||||
.where('c.created_at >= :today', { today })
|
.where('c.created_at >= :today', { today })
|
||||||
.getCount();
|
.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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -351,6 +378,23 @@ export class AdminConversationController {
|
||||||
converted,
|
converted,
|
||||||
todayCount,
|
todayCount,
|
||||||
conversionRate: total > 0 ? ((converted / total) * 100).toFixed(1) : '0',
|
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