feat: enhance MainLayout with header height management using useLayoutEffect, improve AiProvidersPage by removing priority field, and update UsagePage with dynamic stats cards and sparkline charts for better data visualization

This commit is contained in:
Supra4E8C
2025-12-10 01:42:21 +08:00
parent d8f540cdb1
commit c71af9a8a5
9 changed files with 371 additions and 102 deletions

View File

@@ -28,7 +28,6 @@ interface OpenAIFormState {
name: string;
baseUrl: string;
headers: HeaderEntry[];
priority?: number;
testModel?: string;
modelsText: string;
apiKeyEntries: ApiKeyEntry[];
@@ -167,7 +166,6 @@ export function AiProvidersPage() {
headers: [],
apiKeyEntries: [buildApiKeyEntry()],
modelsText: '',
priority: undefined,
testModel: undefined
});
};
@@ -202,7 +200,6 @@ export function AiProvidersPage() {
name: entry.name,
baseUrl: entry.baseUrl,
headers: headersToEntries(entry.headers),
priority: entry.priority,
testModel: entry.testModel,
modelsText: modelsToText(entry.models),
apiKeyEntries: entry.apiKeyEntries?.length ? entry.apiKeyEntries : [buildApiKeyEntry()]
@@ -255,11 +252,17 @@ export function AiProvidersPage() {
};
const saveProvider = async (type: 'codex' | 'claude') => {
const baseUrl = (providerForm.baseUrl ?? '').trim();
if (!baseUrl) {
showNotification(t('codex_base_url_required'), 'error');
return;
}
setSaving(true);
try {
const payload: ProviderKeyConfig = {
apiKey: providerForm.apiKey.trim(),
baseUrl: providerForm.baseUrl?.trim() || undefined,
baseUrl,
proxyUrl: providerForm.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(providerForm.headers as any)),
models: parseModelsText(providerForm.modelsText)
@@ -333,7 +336,6 @@ export function AiProvidersPage() {
headers: entry.headers
}))
};
if (openaiForm.priority !== undefined) payload.priority = openaiForm.priority;
if (openaiForm.testModel) payload.testModel = openaiForm.testModel.trim();
const models = parseModelsText(openaiForm.modelsText);
if (models.length) payload.models = models;
@@ -578,7 +580,6 @@ export function AiProvidersPage() {
<div className="pill">
{t('ai_providers.openai_models_count')}: {item.models?.length || 0}
</div>
{item.priority !== undefined && <div className="pill">Priority: {item.priority}</div>}
{item.testModel && <div className="pill">{item.testModel}</div>}
</Fragment>
),
@@ -738,14 +739,6 @@ export function AiProvidersPage() {
value={openaiForm.baseUrl}
onChange={(e) => setOpenaiForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label="Priority"
type="number"
value={openaiForm.priority ?? ''}
onChange={(e) =>
setOpenaiForm((prev) => ({ ...prev, priority: e.target.value ? Number(e.target.value) : undefined }))
}
/>
<Input
label={t('ai_providers.openai_test_model_placeholder')}
value={openaiForm.testModel ?? ''}

View File

@@ -40,6 +40,17 @@ export function AuthFilesPage() {
const disableControls = connectionStatus !== 'connected';
const formatModified = (item: AuthFileItem): string => {
const raw = (item as any).modtime ?? item.modified;
if (!raw) return t('auth_files.file_modified');
const asNumber = Number(raw);
const date =
Number.isFinite(asNumber) && !Number.isNaN(asNumber)
? new Date(asNumber < 1e12 ? asNumber * 1000 : asNumber)
: new Date(String(raw));
return Number.isNaN(date.getTime()) ? t('auth_files.file_modified') : date.toLocaleString();
};
const loadFiles = async () => {
setLoading(true);
setError('');
@@ -134,7 +145,7 @@ export function AuthFilesPage() {
const handleDownload = async (name: string) => {
try {
const response = await apiClient.getRaw(`/auth-files/${encodeURIComponent(name)}`, {
const response = await apiClient.getRaw(`/auth-files/download?name=${encodeURIComponent(name)}`, {
responseType: 'blob'
});
const blob = new Blob([response.data]);
@@ -298,7 +309,7 @@ export function AuthFilesPage() {
</div>
<div className="cell">{item.size ? formatFileSize(item.size) : '-'}</div>
<div className="cell">
{item.modified ? new Date(item.modified).toLocaleString() : t('auth_files.file_modified')}
{formatModified(item)}
</div>
<div className="cell">
<div className="item-actions">

View File

@@ -43,10 +43,10 @@
.statsGrid {
display: grid;
gap: $spacing-md;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@include tablet {
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(3, 1fr);
}
@include mobile {
@@ -62,6 +62,40 @@
display: flex;
flex-direction: column;
gap: $spacing-sm;
min-height: 200px;
box-shadow: $shadow-sm;
transition: transform $transition-fast, box-shadow $transition-fast, border-color $transition-fast;
overflow: hidden;
&:hover {
transform: translateY(-2px);
box-shadow: $shadow-md;
border-color: rgba(37, 99, 235, 0.2);
}
}
.statCardHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: $spacing-sm;
}
.statLabelGroup {
display: flex;
flex-direction: column;
gap: 2px;
}
.statIconBadge {
width: 40px;
height: 40px;
border-radius: $radius-md;
display: grid;
place-items: center;
color: #fff;
font-size: 18px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
}
.statHeader {
@@ -75,13 +109,15 @@
}
.statLabel {
font-size: 14px;
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
font-weight: 600;
letter-spacing: 0.01em;
text-transform: uppercase;
}
.statValue {
font-size: 28px;
font-size: 30px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
@@ -128,6 +164,52 @@
color: var(--text-secondary);
}
.statMetaRow {
display: flex;
flex-wrap: wrap;
gap: $spacing-sm;
font-size: 12px;
color: var(--text-secondary);
}
.statMetaItem {
display: inline-flex;
align-items: center;
gap: 6px;
}
.statMetaDot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--text-secondary);
}
.statSubtle {
color: var(--text-tertiary);
}
.statTrend {
margin-top: auto;
background: var(--bg-secondary, #f6f8fb);
border-radius: $radius-md;
padding: $spacing-xs $spacing-sm;
height: 72px;
border: 1px solid var(--border-color);
}
.statTrendPlaceholder {
width: 100%;
height: 100%;
background: var(--bg-tertiary, #eef1f6);
border-radius: $radius-sm;
}
.sparkline {
width: 100%;
height: 100% !important;
}
.statHint {
color: var(--text-tertiary);
font-style: italic;

View File

@@ -29,6 +29,8 @@ import {
loadModelPrices,
saveModelPrices,
buildChartData,
collectUsageDetails,
extractTotalTokens,
type ModelPrice
} from '@/utils/usage';
import styles from './UsagePage.module.scss';
@@ -97,7 +99,9 @@ export function UsagePage() {
// Calculate derived data
const tokenBreakdown = usage ? calculateTokenBreakdown(usage) : { cachedTokens: 0, reasoningTokens: 0 };
const rateStats = usage ? calculateRecentPerMinuteRates(30, usage) : { rpm: 0, tpm: 0 };
const rateStats = usage
? calculateRecentPerMinuteRates(30, usage)
: { rpm: 0, tpm: 0, windowMinutes: 30, requestCount: 0, tokenCount: 0 };
const totalCost = usage ? calculateTotalCost(usage, modelPrices) : 0;
const modelNames = usage ? getModelNamesFromUsage(usage) : [];
const apiStats = usage ? getApiStats(usage, modelPrices) : [];
@@ -115,6 +119,102 @@ export function UsagePage() {
return buildChartData(usage, tokensPeriod, 'tokens', chartLines);
}, [usage, tokensPeriod, chartLines]);
const sparklineOptions = useMemo(
() => ({
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { enabled: false } },
scales: { x: { display: false }, y: { display: false } },
elements: { line: { tension: 0.45 }, point: { radius: 0 } }
}),
[]
);
const buildLastHourSeries = useCallback(
(metric: 'requests' | 'tokens'): { labels: string[]; data: number[] } => {
if (!usage) return { labels: [], data: [] };
const details = collectUsageDetails(usage);
if (!details.length) return { labels: [], data: [] };
const windowMinutes = 60;
const now = Date.now();
const windowStart = now - windowMinutes * 60 * 1000;
const buckets = new Array(windowMinutes).fill(0);
details.forEach(detail => {
const timestamp = Date.parse(detail.timestamp);
if (Number.isNaN(timestamp) || timestamp < windowStart) {
return;
}
const minuteIndex = Math.min(
windowMinutes - 1,
Math.floor((timestamp - windowStart) / 60000)
);
const increment = metric === 'tokens' ? extractTotalTokens(detail) : 1;
buckets[minuteIndex] += increment;
});
const labels = buckets.map((_, idx) => {
const date = new Date(windowStart + (idx + 1) * 60000);
const h = date.getHours().toString().padStart(2, '0');
const m = date.getMinutes().toString().padStart(2, '0');
return `${h}:${m}`;
});
return { labels, data: buckets };
},
[usage]
);
const buildSparkline = useCallback(
(series: { labels: string[]; data: number[] }, color: string, backgroundColor: string) => {
if (loading || !series?.data?.length) {
return null;
}
const sliceStart = Math.max(series.data.length - 60, 0);
const labels = series.labels.slice(sliceStart);
const points = series.data.slice(sliceStart);
return {
data: {
labels,
datasets: [
{
data: points,
borderColor: color,
backgroundColor,
fill: true,
tension: 0.45,
pointRadius: 0,
borderWidth: 2
}
]
}
};
},
[loading]
);
const requestsSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#2563eb', 'rgba(37, 99, 235, 0.12)'),
[buildLastHourSeries, buildSparkline]
);
const tokensSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#8b5cf6', 'rgba(139, 92, 246, 0.12)'),
[buildLastHourSeries, buildSparkline]
);
const rpmSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('requests'), '#22c55e', 'rgba(34, 197, 94, 0.12)'),
[buildLastHourSeries, buildSparkline]
);
const tpmSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#f97316', 'rgba(249, 115, 22, 0.12)'),
[buildLastHourSeries, buildSparkline]
);
const costSparkline = useMemo(
() => buildSparkline(buildLastHourSeries('tokens'), '#f59e0b', 'rgba(245, 158, 11, 0.12)'),
[buildLastHourSeries, buildSparkline]
);
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
@@ -215,6 +315,93 @@ export function UsagePage() {
});
};
const statsCards = [
{
key: 'requests',
label: t('usage_stats.total_requests'),
icon: '🛰️',
accent: '#2563eb',
value: loading ? '-' : (usage?.total_requests ?? 0).toLocaleString(),
meta: (
<>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#10b981' }} />
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
</span>
<span className={styles.statMetaItem}>
<span className={styles.statMetaDot} style={{ backgroundColor: '#ef4444' }} />
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span>
</>
),
trend: requestsSparkline
},
{
key: 'tokens',
label: t('usage_stats.total_tokens'),
icon: '💠',
accent: '#8b5cf6',
value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0),
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
</span>
<span className={styles.statMetaItem}>
{t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
</span>
</>
),
trend: tokensSparkline
},
{
key: 'rpm',
label: t('usage_stats.rpm_30m'),
icon: '⏱️',
accent: '#22c55e',
value: loading ? '-' : formatPerMinuteValue(rateStats.rpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()}
</span>
),
trend: rpmSparkline
},
{
key: 'tpm',
label: t('usage_stats.tpm_30m'),
icon: '📈',
accent: '#f97316',
value: loading ? '-' : formatPerMinuteValue(rateStats.tpm),
meta: (
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)}
</span>
),
trend: tpmSparkline
},
{
key: 'cost',
label: t('usage_stats.total_cost'),
icon: '💰',
accent: '#f59e0b',
value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--',
meta: (
<>
<span className={styles.statMetaItem}>
{t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
</span>
{!hasPrices && (
<span className={`${styles.statMetaItem} ${styles.statSubtle}`}>
{t('usage_stats.cost_need_price')}
</span>
)}
</>
),
trend: hasPrices ? costSparkline : null
}
];
return (
<div className={styles.container}>
<div className={styles.header}>
@@ -233,77 +420,30 @@ export function UsagePage() {
{/* Stats Overview Cards */}
<div className={styles.statsGrid}>
{/* Total Requests Card */}
<div className={styles.statCard}>
<div className={styles.statHeader}>
<span className={styles.statIcon}>📊</span>
<span className={styles.statLabel}>{t('usage_stats.total_requests')}</span>
</div>
<div className={styles.statValue}>
{loading ? '-' : (usage?.total_requests ?? 0).toLocaleString()}
</div>
<div className={styles.statMeta}>
<span className={styles.statSuccess}>
{t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)}
</span>
<span className={styles.statFailure}>
{t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)}
</span>
</div>
</div>
{/* Total Tokens Card */}
<div className={styles.statCard}>
<div className={styles.statHeader}>
<span className={styles.statIcon}>🔤</span>
<span className={styles.statLabel}>{t('usage_stats.total_tokens')}</span>
</div>
<div className={styles.statValue}>
{loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)}
</div>
<div className={styles.statMeta}>
<span className={styles.statNeutral}>
💾 {t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)}
</span>
<span className={styles.statNeutral}>
🧠 {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)}
</span>
</div>
</div>
{/* RPM/TPM Card */}
<div className={styles.statCard}>
<div className={styles.statHeader}>
<span className={styles.statIcon}></span>
<span className={styles.statLabel}>{t('usage_stats.rate_30m')}</span>
</div>
<div className={styles.statValueRow}>
<div className={styles.statValueSmall}>
<span className={styles.statValueLabel}>{t('usage_stats.rpm_30m')}</span>
<span className={styles.statValueNum}>{loading ? '-' : formatPerMinuteValue(rateStats.rpm)}</span>
{statsCards.map(card => (
<div key={card.key} className={styles.statCard}>
<div className={styles.statCardHeader}>
<div className={styles.statLabelGroup}>
<span className={styles.statLabel}>{card.label}</span>
</div>
<span
className={styles.statIconBadge}
style={{ backgroundColor: card.accent }}
>
{card.icon}
</span>
</div>
<div className={styles.statValueSmall}>
<span className={styles.statValueLabel}>{t('usage_stats.tpm_30m')}</span>
<span className={styles.statValueNum}>{loading ? '-' : formatPerMinuteValue(rateStats.tpm)}</span>
<div className={styles.statValue}>{card.value}</div>
{card.meta && <div className={styles.statMetaRow}>{card.meta}</div>}
<div className={styles.statTrend}>
{card.trend ? (
<Line className={styles.sparkline} data={card.trend.data} options={sparklineOptions} />
) : (
<div className={styles.statTrendPlaceholder}></div>
)}
</div>
</div>
</div>
{/* Total Cost Card */}
<div className={styles.statCard}>
<div className={styles.statHeader}>
<span className={styles.statIcon}>💰</span>
<span className={styles.statLabel}>{t('usage_stats.total_cost')}</span>
</div>
<div className={styles.statValue}>
{loading ? '-' : hasPrices ? formatUsd(totalCost) : '--'}
</div>
{!hasPrices && (
<div className={styles.statMeta}>
<span className={styles.statHint}>{t('usage_stats.cost_need_price')}</span>
</div>
)}
</div>
))}
</div>
{/* Chart Line Selection */}