From 7ec53295761d634d5f2552c5d305a5acda3f0b89 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 13 Feb 2026 13:25:03 +0800 Subject: [PATCH] feat(usage): add column sorting to model stats and API details tables --- src/components/usage/ApiDetailsCard.tsx | 147 ++++++++++++++++-------- src/components/usage/ModelStatsCard.tsx | 50 +++++++- src/pages/UsagePage.module.scss | 41 +++++++ 3 files changed, 184 insertions(+), 54 deletions(-) diff --git a/src/components/usage/ApiDetailsCard.tsx b/src/components/usage/ApiDetailsCard.tsx index 246a853..c5a3747 100644 --- a/src/components/usage/ApiDetailsCard.tsx +++ b/src/components/usage/ApiDetailsCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage'; @@ -10,9 +10,14 @@ export interface ApiDetailsCardProps { hasPrices: boolean; } +type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost'; +type SortDir = 'asc' | 'desc'; + export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) { const { t } = useTranslation(); const [expandedApis, setExpandedApis] = useState>(new Set()); + const [sortKey, setSortKey] = useState('requests'); + const [sortDir, setSortDir] = useState('desc'); const toggleExpand = (endpoint: string) => { setExpandedApis((prev) => { @@ -26,65 +31,111 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP }); }; + const handleSort = (key: ApiSortKey) => { + if (sortKey === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir(key === 'endpoint' ? 'asc' : 'desc'); + } + }; + + const sorted = useMemo(() => { + const list = [...apiStats]; + const dir = sortDir === 'asc' ? 1 : -1; + list.sort((a, b) => { + switch (sortKey) { + case 'endpoint': return dir * a.endpoint.localeCompare(b.endpoint); + case 'requests': return dir * (a.totalRequests - b.totalRequests); + case 'tokens': return dir * (a.totalTokens - b.totalTokens); + case 'cost': return dir * (a.totalCost - b.totalCost); + default: return 0; + } + }); + return list; + }, [apiStats, sortKey, sortDir]); + + const arrow = (key: ApiSortKey) => + sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''; + return ( {loading ? (
{t('common.loading')}
- ) : apiStats.length > 0 ? ( -
- {apiStats.map((api) => ( -
-
toggleExpand(api.endpoint)}> -
- {api.endpoint} -
- - - - {t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()} - - - ({api.successCount.toLocaleString()}{' '} - {api.failureCount.toLocaleString()}) - - - - - {t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)} - - {hasPrices && api.totalCost > 0 && ( + ) : sorted.length > 0 ? ( + <> +
+ {([ + ['endpoint', 'usage_stats.api_endpoint'], + ['requests', 'usage_stats.requests_count'], + ['tokens', 'usage_stats.tokens_count'], + ...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []), + ] as [ApiSortKey, string][]).map(([key, labelKey]) => ( + + ))} +
+
+ {sorted.map((api) => ( +
+
toggleExpand(api.endpoint)}> +
+ {api.endpoint} +
- {t('usage_stats.total_cost')}: {formatUsd(api.totalCost)} - - )} -
-
- - {expandedApis.has(api.endpoint) ? '▼' : '▶'} - -
- {expandedApis.has(api.endpoint) && ( -
- {Object.entries(api.models).map(([model, stats]) => ( -
- {model} - - {stats.requests.toLocaleString()} + + {t('usage_stats.requests_count')}: {api.totalRequests.toLocaleString()} + - ({stats.successCount.toLocaleString()}{' '} - {stats.failureCount.toLocaleString()}) + ({api.successCount.toLocaleString()}{' '} + {api.failureCount.toLocaleString()}) - {formatTokensInMillions(stats.tokens)} + + {t('usage_stats.tokens_count')}: {formatTokensInMillions(api.totalTokens)} + + {hasPrices && api.totalCost > 0 && ( + + {t('usage_stats.total_cost')}: {formatUsd(api.totalCost)} + + )}
- ))} +
+ + {expandedApis.has(api.endpoint) ? '▼' : '▶'} +
- )} -
- ))} -
+ {expandedApis.has(api.endpoint) && ( +
+ {Object.entries(api.models).map(([model, stats]) => ( +
+ {model} + + + {stats.requests.toLocaleString()} + + ({stats.successCount.toLocaleString()}{' '} + {stats.failureCount.toLocaleString()}) + + + + {formatTokensInMillions(stats.tokens)} +
+ ))} +
+ )} +
+ ))} +
+ ) : (
{t('usage_stats.no_data')}
)} diff --git a/src/components/usage/ModelStatsCard.tsx b/src/components/usage/ModelStatsCard.tsx index 763cf10..57285e7 100644 --- a/src/components/usage/ModelStatsCard.tsx +++ b/src/components/usage/ModelStatsCard.tsx @@ -1,3 +1,4 @@ +import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { formatTokensInMillions, formatUsd } from '@/utils/usage'; @@ -18,26 +19,63 @@ export interface ModelStatsCardProps { hasPrices: boolean; } +type SortKey = 'model' | 'requests' | 'tokens' | 'cost'; +type SortDir = 'asc' | 'desc'; + export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) { const { t } = useTranslation(); + const [sortKey, setSortKey] = useState('requests'); + const [sortDir, setSortDir] = useState('desc'); + + const handleSort = (key: SortKey) => { + if (sortKey === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir(key === 'model' ? 'asc' : 'desc'); + } + }; + + const sorted = useMemo(() => { + const list = [...modelStats]; + const dir = sortDir === 'asc' ? 1 : -1; + list.sort((a, b) => { + if (sortKey === 'model') return dir * a.model.localeCompare(b.model); + return dir * ((a[sortKey] as number) - (b[sortKey] as number)); + }); + return list; + }, [modelStats, sortKey, sortDir]); + + const arrow = (key: SortKey) => + sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''; return ( {loading ? (
{t('common.loading')}
- ) : modelStats.length > 0 ? ( + ) : sorted.length > 0 ? (
- - - - {hasPrices && } + + + + {hasPrices && ( + + )} - {modelStats.map((stat) => ( + {sorted.map((stat) => (
{t('usage_stats.model_name')}{t('usage_stats.requests_count')}{t('usage_stats.tokens_count')}{t('usage_stats.total_cost')} handleSort('model')}> + {t('usage_stats.model_name')}{arrow('model')} + handleSort('requests')}> + {t('usage_stats.requests_count')}{arrow('requests')} + handleSort('tokens')}> + {t('usage_stats.tokens_count')}{arrow('tokens')} + handleSort('cost')}> + {t('usage_stats.total_cost')}{arrow('cost')} +
{stat.model} diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index 1b56d82..462ad8e 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -423,6 +423,37 @@ } // API List (80%比例) +.apiSortBar { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.apiSortBtn { + padding: 4px 10px; + border-radius: $radius-full; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } +} + +.apiSortBtnActive { + border-color: rgba(59, 130, 246, 0.5); + background: rgba(59, 130, 246, 0.10); + color: var(--text-primary); + font-weight: 600; +} + .apiList { display: flex; flex-direction: column; @@ -550,6 +581,16 @@ white-space: nowrap; } + th.sortableHeader { + cursor: pointer; + user-select: none; + transition: color 0.15s ease; + + &:hover { + color: var(--text-primary); + } + } + td { color: var(--text-primary); }