feat(usage): add column sorting to model stats and API details tables

This commit is contained in:
Supra4E8C
2026-02-13 13:25:03 +08:00
parent 5d0232e5de
commit 7ec5329576
3 changed files with 184 additions and 54 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage'; import { formatTokensInMillions, formatUsd, type ApiStats } from '@/utils/usage';
@@ -10,9 +10,14 @@ export interface ApiDetailsCardProps {
hasPrices: boolean; hasPrices: boolean;
} }
type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost';
type SortDir = 'asc' | 'desc';
export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) { export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set()); const [expandedApis, setExpandedApis] = useState<Set<string>>(new Set());
const [sortKey, setSortKey] = useState<ApiSortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const toggleExpand = (endpoint: string) => { const toggleExpand = (endpoint: string) => {
setExpandedApis((prev) => { setExpandedApis((prev) => {
@@ -26,13 +31,58 @@ 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 ( return (
<Card title={t('usage_stats.api_details')}> <Card title={t('usage_stats.api_details')}>
{loading ? ( {loading ? (
<div className={styles.hint}>{t('common.loading')}</div> <div className={styles.hint}>{t('common.loading')}</div>
) : apiStats.length > 0 ? ( ) : sorted.length > 0 ? (
<>
<div className={styles.apiSortBar}>
{([
['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]) => (
<button
key={key}
type="button"
className={`${styles.apiSortBtn} ${sortKey === key ? styles.apiSortBtnActive : ''}`}
onClick={() => handleSort(key)}
>
{t(labelKey)}{arrow(key)}
</button>
))}
</div>
<div className={styles.apiList}> <div className={styles.apiList}>
{apiStats.map((api) => ( {sorted.map((api) => (
<div key={api.endpoint} className={styles.apiItem}> <div key={api.endpoint} className={styles.apiItem}>
<div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}> <div className={styles.apiHeader} onClick={() => toggleExpand(api.endpoint)}>
<div className={styles.apiInfo}> <div className={styles.apiInfo}>
@@ -85,6 +135,7 @@ export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardP
</div> </div>
))} ))}
</div> </div>
</>
) : ( ) : (
<div className={styles.hint}>{t('usage_stats.no_data')}</div> <div className={styles.hint}>{t('usage_stats.no_data')}</div>
)} )}

View File

@@ -1,3 +1,4 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { formatTokensInMillions, formatUsd } from '@/utils/usage'; import { formatTokensInMillions, formatUsd } from '@/utils/usage';
@@ -18,26 +19,63 @@ export interface ModelStatsCardProps {
hasPrices: boolean; hasPrices: boolean;
} }
type SortKey = 'model' | 'requests' | 'tokens' | 'cost';
type SortDir = 'asc' | 'desc';
export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) { export function ModelStatsCard({ modelStats, loading, hasPrices }: ModelStatsCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('requests');
const [sortDir, setSortDir] = useState<SortDir>('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 ( return (
<Card title={t('usage_stats.models')}> <Card title={t('usage_stats.models')}>
{loading ? ( {loading ? (
<div className={styles.hint}>{t('common.loading')}</div> <div className={styles.hint}>{t('common.loading')}</div>
) : modelStats.length > 0 ? ( ) : sorted.length > 0 ? (
<div className={styles.tableWrapper}> <div className={styles.tableWrapper}>
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
<th>{t('usage_stats.model_name')}</th> <th className={styles.sortableHeader} onClick={() => handleSort('model')}>
<th>{t('usage_stats.requests_count')}</th> {t('usage_stats.model_name')}{arrow('model')}
<th>{t('usage_stats.tokens_count')}</th> </th>
{hasPrices && <th>{t('usage_stats.total_cost')}</th>} <th className={styles.sortableHeader} onClick={() => handleSort('requests')}>
{t('usage_stats.requests_count')}{arrow('requests')}
</th>
<th className={styles.sortableHeader} onClick={() => handleSort('tokens')}>
{t('usage_stats.tokens_count')}{arrow('tokens')}
</th>
{hasPrices && (
<th className={styles.sortableHeader} onClick={() => handleSort('cost')}>
{t('usage_stats.total_cost')}{arrow('cost')}
</th>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{modelStats.map((stat) => ( {sorted.map((stat) => (
<tr key={stat.model}> <tr key={stat.model}>
<td className={styles.modelCell}>{stat.model}</td> <td className={styles.modelCell}>{stat.model}</td>
<td> <td>

View File

@@ -423,6 +423,37 @@
} }
// API List (80%比例) // 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 { .apiList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -550,6 +581,16 @@
white-space: nowrap; white-space: nowrap;
} }
th.sortableHeader {
cursor: pointer;
user-select: none;
transition: color 0.15s ease;
&:hover {
color: var(--text-primary);
}
}
td { td {
color: var(--text-primary); color: var(--text-primary);
} }