mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-20 03:30:50 +08:00
feat(usage): add column sorting to model stats and API details tables
This commit is contained in:
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user