diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index dd8dcd1..eef3169 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -1,4 +1,4 @@ -import { ReactNode, SVGProps, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ReactNode, SVGProps, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; @@ -144,12 +144,40 @@ export function MainLayout() { const [checkingVersion, setCheckingVersion] = useState(false); const [brandExpanded, setBrandExpanded] = useState(true); const brandCollapseTimer = useRef | null>(null); + const headerRef = useRef(null); const isLocal = useMemo(() => isLocalhost(window.location.hostname), []); const fullBrandName = 'CLI Proxy API Management Center'; const abbrBrandName = t('title.abbr'); + // 将顶栏高度写入 CSS 变量,确保侧栏/内容区计算一致,防止滚动时抖动 + useLayoutEffect(() => { + const updateHeaderHeight = () => { + const height = headerRef.current?.offsetHeight; + if (height) { + document.documentElement.style.setProperty('--header-height', `${height}px`); + } + }; + + updateHeaderHeight(); + + const resizeObserver = + typeof ResizeObserver !== 'undefined' && headerRef.current ? new ResizeObserver(updateHeaderHeight) : null; + if (resizeObserver && headerRef.current) { + resizeObserver.observe(headerRef.current); + } + + window.addEventListener('resize', updateHeaderHeight); + + return () => { + if (resizeObserver) { + resizeObserver.disconnect(); + } + window.removeEventListener('resize', updateHeaderHeight); + }; + }, []); + // 5秒后自动收起品牌名称 useEffect(() => { brandCollapseTimer.current = setTimeout(() => { @@ -244,7 +272,7 @@ export function MainLayout() { return (
-
+
{item.size ? formatFileSize(item.size) : '-'}
- {item.modified ? new Date(item.modified).toLocaleString() : t('auth_files.file_modified')} + {formatModified(item)}
diff --git a/src/pages/UsagePage.module.scss b/src/pages/UsagePage.module.scss index 072dc9a..834d8fe 100644 --- a/src/pages/UsagePage.module.scss +++ b/src/pages/UsagePage.module.scss @@ -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; diff --git a/src/pages/UsagePage.tsx b/src/pages/UsagePage.tsx index db47176..caca78a 100644 --- a/src/pages/UsagePage.tsx +++ b/src/pages/UsagePage.tsx @@ -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: ( + <> + + + {t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)} + + + + {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)} + + + ), + trend: requestsSparkline + }, + { + key: 'tokens', + label: t('usage_stats.total_tokens'), + icon: '💠', + accent: '#8b5cf6', + value: loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0), + meta: ( + <> + + {t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)} + + + {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)} + + + ), + trend: tokensSparkline + }, + { + key: 'rpm', + label: t('usage_stats.rpm_30m'), + icon: '⏱️', + accent: '#22c55e', + value: loading ? '-' : formatPerMinuteValue(rateStats.rpm), + meta: ( + + {t('usage_stats.total_requests')}: {loading ? '-' : rateStats.requestCount.toLocaleString()} + + ), + trend: rpmSparkline + }, + { + key: 'tpm', + label: t('usage_stats.tpm_30m'), + icon: '📈', + accent: '#f97316', + value: loading ? '-' : formatPerMinuteValue(rateStats.tpm), + meta: ( + + {t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(rateStats.tokenCount)} + + ), + trend: tpmSparkline + }, + { + key: 'cost', + label: t('usage_stats.total_cost'), + icon: '💰', + accent: '#f59e0b', + value: loading ? '-' : hasPrices ? formatUsd(totalCost) : '--', + meta: ( + <> + + {t('usage_stats.total_tokens')}: {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)} + + {!hasPrices && ( + + {t('usage_stats.cost_need_price')} + + )} + + ), + trend: hasPrices ? costSparkline : null + } + ]; + return (
@@ -233,77 +420,30 @@ export function UsagePage() { {/* Stats Overview Cards */}
- {/* Total Requests Card */} -
-
- 📊 - {t('usage_stats.total_requests')} -
-
- {loading ? '-' : (usage?.total_requests ?? 0).toLocaleString()} -
-
- - ✓ {t('usage_stats.success_requests')}: {loading ? '-' : (usage?.success_count ?? 0)} - - - ✗ {t('usage_stats.failed_requests')}: {loading ? '-' : (usage?.failure_count ?? 0)} - -
-
- - {/* Total Tokens Card */} -
-
- 🔤 - {t('usage_stats.total_tokens')} -
-
- {loading ? '-' : formatTokensInMillions(usage?.total_tokens ?? 0)} -
-
- - 💾 {t('usage_stats.cached_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.cachedTokens)} - - - 🧠 {t('usage_stats.reasoning_tokens')}: {loading ? '-' : formatTokensInMillions(tokenBreakdown.reasoningTokens)} - -
-
- - {/* RPM/TPM Card */} -
-
- - {t('usage_stats.rate_30m')} -
-
-
- {t('usage_stats.rpm_30m')} - {loading ? '-' : formatPerMinuteValue(rateStats.rpm)} + {statsCards.map(card => ( +
+
+
+ {card.label} +
+ + {card.icon} +
-
- {t('usage_stats.tpm_30m')} - {loading ? '-' : formatPerMinuteValue(rateStats.tpm)} +
{card.value}
+ {card.meta &&
{card.meta}
} +
+ {card.trend ? ( + + ) : ( +
+ )}
-
- - {/* Total Cost Card */} -
-
- 💰 - {t('usage_stats.total_cost')} -
-
- {loading ? '-' : hasPrices ? formatUsd(totalCost) : '--'} -
- {!hasPrices && ( -
- {t('usage_stats.cost_need_price')} -
- )} -
+ ))}
{/* Chart Line Selection */} diff --git a/src/services/api/authFiles.ts b/src/services/api/authFiles.ts index 0844d41..81caffc 100644 --- a/src/services/api/authFiles.ts +++ b/src/services/api/authFiles.ts @@ -19,7 +19,11 @@ export const authFilesApi = { deleteAll: () => apiClient.delete('/auth-files', { params: { all: true } }), // OAuth 排除模型 - getOauthExcludedModels: () => apiClient.get('/oauth-excluded-models'), + async getOauthExcludedModels(): Promise> { + const data = await apiClient.get('/oauth-excluded-models'); + const payload = (data && (data['oauth-excluded-models'] ?? data.items ?? data)) as any; + return payload && typeof payload === 'object' ? payload : {}; + }, saveOauthExcludedModels: (provider: string, models: string[]) => apiClient.patch('/oauth-excluded-models', { provider, models }), diff --git a/src/services/api/config.ts b/src/services/api/config.ts index acc947f..03f4c25 100644 --- a/src/services/api/config.ts +++ b/src/services/api/config.ts @@ -72,9 +72,4 @@ export const configApi = { * WebSocket 鉴权开关 */ updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }), - - /** - * 重载配置 - */ - reloadConfig: () => apiClient.post('/config/reload') }; diff --git a/src/services/api/oauth.ts b/src/services/api/oauth.ts index 4f58156..cba7c85 100644 --- a/src/services/api/oauth.ts +++ b/src/services/api/oauth.ts @@ -17,8 +17,13 @@ export interface OAuthStartResponse { state?: string; } +const WEBUI_SUPPORTED: OAuthProvider[] = ['codex', 'anthropic', 'antigravity', 'gemini-cli', 'iflow']; + export const oauthApi = { - startAuth: (provider: OAuthProvider) => apiClient.get(`/${provider}-auth-url`, { params: { is_webui: 1 } }), + startAuth: (provider: OAuthProvider) => + apiClient.get(`/${provider}-auth-url`, { + params: WEBUI_SUPPORTED.includes(provider) ? { is_webui: true } : undefined + }), getAuthStatus: (state: string) => apiClient.get<{ status: 'ok' | 'wait' | 'error'; error?: string }>(`/get-auth-status`, { diff --git a/src/styles/layout.scss b/src/styles/layout.scss index 5c1f02d..7505a33 100644 --- a/src/styles/layout.scss +++ b/src/styles/layout.scss @@ -1,9 +1,15 @@ @use './variables.scss' as *; +:root { + --header-height: 64px; +} + .app-shell { display: flex; flex-direction: column; min-height: 100vh; + height: 100vh; + overflow: hidden; background: var(--bg-secondary); color: var(--text-primary); } @@ -183,8 +189,13 @@ display: flex; flex: 1; min-height: 0; + height: calc(100vh - var(--header-height)); overflow: hidden; position: relative; + + @supports (height: 100dvh) { + height: calc(100dvh - var(--header-height)); + } } .sidebar { @@ -198,7 +209,7 @@ transition: width $transition-normal, transform $transition-normal; overflow-y: auto; flex-shrink: 0; - height: calc(100vh - 57px); // 减去顶栏高度 + height: 100%; &.collapsed { width: 60px; @@ -265,7 +276,7 @@ position: fixed; z-index: $z-dropdown; left: 0; - top: 56px; + top: var(--header-height); bottom: 0; transform: translateX(-100%); box-shadow: $shadow-lg; @@ -282,7 +293,7 @@ flex-direction: column; min-width: 0; overflow-y: auto; - height: calc(100vh - 57px); // 减去顶栏高度 + height: 100%; } .main-content {