From 1f8c4331c79cb4adcaaaa87aea1d9c120965f376 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 14 Feb 2026 13:24:53 +0800 Subject: [PATCH] feat(status-bar): add gradient colors and tooltip with mobile support --- .../providers/ProviderStatusBar.tsx | 149 +++++++++++++++--- .../authFiles/components/AuthFileCard.tsx | 28 +--- src/i18n/locales/en.json | 5 + src/i18n/locales/ru.json | 5 + src/i18n/locales/zh-CN.json | 5 + src/pages/AiProvidersPage.module.scss | 145 +++++++++++++++-- src/pages/AuthFilesPage.module.scss | 133 +++++++++++++--- src/utils/usage.ts | 52 ++++-- 8 files changed, 426 insertions(+), 96 deletions(-) diff --git a/src/components/providers/ProviderStatusBar.tsx b/src/components/providers/ProviderStatusBar.tsx index 6bda64c..4b7c1ee 100644 --- a/src/components/providers/ProviderStatusBar.tsx +++ b/src/components/providers/ProviderStatusBar.tsx @@ -1,36 +1,143 @@ -import { calculateStatusBarData } from '@/utils/usage'; -import styles from '@/pages/AiProvidersPage.module.scss'; +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { StatusBarData, StatusBlockDetail } from '@/utils/usage'; +import defaultStyles from '@/pages/AiProvidersPage.module.scss'; -interface ProviderStatusBarProps { - statusData: ReturnType; +/** + * 根据成功率 (0–1) 在三个色标之间做 RGB 线性插值 + * 0 → 红 (#ef4444) → 0.5 → 金黄 (#facc15) → 1 → 绿 (#22c55e) + */ +const COLOR_STOPS = [ + { r: 239, g: 68, b: 68 }, // #ef4444 + { r: 250, g: 204, b: 21 }, // #facc15 + { r: 34, g: 197, b: 94 }, // #22c55e +] as const; + +function rateToColor(rate: number): string { + const t = Math.max(0, Math.min(1, rate)); + const segment = t < 0.5 ? 0 : 1; + const localT = segment === 0 ? t * 2 : (t - 0.5) * 2; + const from = COLOR_STOPS[segment]; + const to = COLOR_STOPS[segment + 1]; + const r = Math.round(from.r + (to.r - from.r) * localT); + const g = Math.round(from.g + (to.g - from.g) * localT); + const b = Math.round(from.b + (to.b - from.b) * localT); + return `rgb(${r}, ${g}, ${b})`; } -export function ProviderStatusBar({ statusData }: ProviderStatusBarProps) { +function formatTime(timestamp: number): string { + const date = new Date(timestamp); + const h = date.getHours().toString().padStart(2, '0'); + const m = date.getMinutes().toString().padStart(2, '0'); + return `${h}:${m}`; +} + +type StylesModule = Record; + +interface ProviderStatusBarProps { + statusData: StatusBarData; + styles?: StylesModule; +} + +export function ProviderStatusBar({ statusData, styles: stylesProp }: ProviderStatusBarProps) { + const { t } = useTranslation(); + const s = (stylesProp || defaultStyles) as StylesModule; + const [activeTooltip, setActiveTooltip] = useState(null); + const blocksRef = useRef(null); + const hasData = statusData.totalSuccess + statusData.totalFailure > 0; const rateClass = !hasData ? '' : statusData.successRate >= 90 - ? styles.statusRateHigh + ? s.statusRateHigh : statusData.successRate >= 50 - ? styles.statusRateMedium - : styles.statusRateLow; + ? s.statusRateMedium + : s.statusRateLow; + + // 点击外部关闭 tooltip(移动端) + useEffect(() => { + if (activeTooltip === null) return; + const handler = (e: PointerEvent) => { + if (blocksRef.current && !blocksRef.current.contains(e.target as Node)) { + setActiveTooltip(null); + } + }; + document.addEventListener('pointerdown', handler); + return () => document.removeEventListener('pointerdown', handler); + }, [activeTooltip]); + + const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => { + if (e.pointerType === 'mouse') { + setActiveTooltip(idx); + } + }, []); + + const handlePointerLeave = useCallback((e: React.PointerEvent) => { + if (e.pointerType === 'mouse') { + setActiveTooltip(null); + } + }, []); + + const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => { + if (e.pointerType === 'touch') { + e.preventDefault(); + setActiveTooltip((prev) => (prev === idx ? null : idx)); + } + }, []); + + const getTooltipPositionClass = (idx: number, total: number): string => { + if (idx <= 2) return s.statusTooltipLeft; + if (idx >= total - 3) return s.statusTooltipRight; + return ''; + }; + + const renderTooltip = (detail: StatusBlockDetail, idx: number) => { + const total = detail.success + detail.failure; + const posClass = getTooltipPositionClass(idx, statusData.blockDetails.length); + const timeRange = `${formatTime(detail.startTime)} – ${formatTime(detail.endTime)}`; + + return ( +
+ {timeRange} + {total > 0 ? ( + + {t('status_bar.success_short')} {detail.success} + {t('status_bar.failure_short')} {detail.failure} + ({(detail.rate * 100).toFixed(1)}%) + + ) : ( + {t('status_bar.no_requests')} + )} +
+ ); + }; return ( -
-
- {statusData.blocks.map((state, idx) => { - const blockClass = - state === 'success' - ? styles.statusBlockSuccess - : state === 'failure' - ? styles.statusBlockFailure - : state === 'mixed' - ? styles.statusBlockMixed - : styles.statusBlockIdle; - return
; +
+
+ {statusData.blockDetails.map((detail, idx) => { + const isIdle = detail.rate === -1; + const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) }; + const isActive = activeTooltip === idx; + + return ( +
handlePointerEnter(e, idx)} + onPointerLeave={handlePointerLeave} + onPointerDown={(e) => handlePointerDown(e, idx)} + > +
+ {isActive && renderTooltip(detail, idx)} +
+ ); })}
- + {hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
diff --git a/src/features/authFiles/components/AuthFileCard.tsx b/src/features/authFiles/components/AuthFileCard.tsx index c269c65..f92acc7 100644 --- a/src/features/authFiles/components/AuthFileCard.tsx +++ b/src/features/authFiles/components/AuthFileCard.tsx @@ -3,6 +3,7 @@ import { Button } from '@/components/ui/Button'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconBot, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons'; +import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar'; import type { AuthFileItem } from '@/types'; import { resolveAuthProvider } from '@/utils/quota'; import { calculateStatusBarData, type KeyStats } from '@/utils/usage'; @@ -88,14 +89,6 @@ export function AuthFileCard(props: AuthFileCardProps) { const authIndexKey = normalizeAuthIndexValue(rawAuthIndex); const statusData = (authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]); - const hasData = statusData.totalSuccess + statusData.totalFailure > 0; - const rateClass = !hasData - ? '' - : statusData.successRate >= 90 - ? styles.statusRateHigh - : statusData.successRate >= 50 - ? styles.statusRateMedium - : styles.statusRateLow; return (
-
-
- {statusData.blocks.map((state, idx) => { - const blockClass = - state === 'success' - ? styles.statusBlockSuccess - : state === 'failure' - ? styles.statusBlockFailure - : state === 'mixed' - ? styles.statusBlockMixed - : styles.statusBlockIdle; - return
; - })} -
- - {hasData ? `${statusData.successRate.toFixed(1)}%` : '--'} - -
+ {showQuotaLayout && quotaType && ( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9407a9b..8efa501 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -832,6 +832,11 @@ "success": "Success", "failure": "Failure" }, + "status_bar": { + "success_short": "✓", + "failure_short": "✗", + "no_requests": "No requests" + }, "logs": { "title": "Logs Viewer", "refresh_button": "Refresh Logs", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6ccd1de..2afb0cf 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -835,6 +835,11 @@ "success": "Успех", "failure": "Сбой" }, + "status_bar": { + "success_short": "✓", + "failure_short": "✗", + "no_requests": "Нет запросов" + }, "logs": { "title": "Просмотр журналов", "refresh_button": "Обновить журналы", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9a2b58a..3a7bb65 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -832,6 +832,11 @@ "success": "成功", "failure": "失败" }, + "status_bar": { + "success_short": "✓", + "failure_short": "✗", + "no_requests": "无请求" + }, "logs": { "title": "日志查看", "refresh_button": "刷新日志", diff --git a/src/pages/AiProvidersPage.module.scss b/src/pages/AiProvidersPage.module.scss index c252dca..efc59aa 100644 --- a/src/pages/AiProvidersPage.module.scss +++ b/src/pages/AiProvidersPage.module.scss @@ -402,37 +402,123 @@ gap: 2px; flex: 1; min-width: 180px; + position: relative; +} + +.statusBlockWrapper { + flex: 1; + min-width: 6px; + position: relative; + cursor: pointer; + -webkit-tap-highlight-color: transparent; } .statusBlock { - flex: 1; + width: 100%; height: 8px; border-radius: 2px; - min-width: 6px; transition: transform 0.15s ease, opacity 0.15s ease; - &:hover { - transform: scaleY(1.5); - opacity: 0.85; + .statusBlockWrapper:hover &, + .statusBlockWrapper.statusBlockActive & { + transform: scaleY(1.8); + opacity: 0.9; } } -.statusBlockSuccess { - background-color: var(--success-color, #22c55e); -} - -.statusBlockFailure { - background-color: var(--danger-color); -} - -.statusBlockMixed { - background-color: var(--warning-color); -} - .statusBlockIdle { background-color: var(--border-secondary, #e5e7eb); } +.statusTooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-primary, #fff); + border: 1px solid var(--border-secondary, #e5e7eb); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + line-height: 1.5; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: $z-dropdown; + pointer-events: none; + color: var(--text-primary); + + // 小箭头 + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--bg-primary, #fff); + } + + &::before { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: var(--border-secondary, #e5e7eb); + } +} + +// 防止左右溢出 +.statusTooltipLeft { + left: 0; + transform: translateX(0); + + &::after, + &::before { + left: 8px; + transform: none; + } +} + +.statusTooltipRight { + left: auto; + right: 0; + transform: translateX(0); + + &::after, + &::before { + left: auto; + right: 8px; + transform: none; + } +} + +.tooltipTime { + color: var(--text-secondary); + display: block; + margin-bottom: 2px; +} + +.tooltipStats { + display: flex; + align-items: center; + gap: 8px; +} + +.tooltipSuccess { + color: var(--success-color, #22c55e); +} + +.tooltipFailure { + color: var(--danger-color, #ef4444); +} + +.tooltipRate { + color: var(--text-secondary); + margin-left: 2px; +} + .statusRate { display: flex; align-items: center; @@ -460,6 +546,17 @@ background: var(--failure-badge-bg); } +@include mobile { + .statusTooltip { + font-size: 12px; + padding: 8px 12px; + } + + .statusBlocks { + min-width: 140px; + } +} + // ============================================ // Model Config Section - Unified Layout // ============================================ @@ -816,6 +913,20 @@ background-color: var(--border-primary, #374151); } + .statusTooltip { + background: var(--bg-secondary, #1f2937); + border-color: var(--border-primary, #374151); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + + &::after { + border-top-color: var(--bg-secondary, #1f2937); + } + + &::before { + border-top-color: var(--border-primary, #374151); + } + } + .statusRateHigh { background: rgba(34, 197, 94, 0.2); color: #86efac; diff --git a/src/pages/AuthFilesPage.module.scss b/src/pages/AuthFilesPage.module.scss index c32af34..626ffb8 100644 --- a/src/pages/AuthFilesPage.module.scss +++ b/src/pages/AuthFilesPage.module.scss @@ -605,39 +605,121 @@ gap: 2px; flex: 1; min-width: 180px; + position: relative; +} + +.statusBlockWrapper { + flex: 1; + min-width: 6px; + position: relative; + cursor: pointer; + -webkit-tap-highlight-color: transparent; } .statusBlock { - flex: 1; + width: 100%; height: 8px; border-radius: 2px; - min-width: 6px; - transition: - transform 0.15s ease, - opacity 0.15s ease; + transition: transform 0.15s ease, opacity 0.15s ease; - &:hover { - transform: scaleY(1.5); - opacity: 0.85; + .statusBlockWrapper:hover &, + .statusBlockWrapper.statusBlockActive & { + transform: scaleY(1.8); + opacity: 0.9; } } -.statusBlockSuccess { - background-color: var(--success-color, #22c55e); -} - -.statusBlockFailure { - background-color: var(--danger-color); -} - -.statusBlockMixed { - background-color: var(--warning-color); -} - .statusBlockIdle { background-color: var(--border-secondary, #e5e7eb); } +.statusTooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: var(--bg-primary, #fff); + border: 1px solid var(--border-secondary, #e5e7eb); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + line-height: 1.5; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: $z-dropdown; + pointer-events: none; + color: var(--text-primary); + + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--bg-primary, #fff); + } + + &::before { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: var(--border-secondary, #e5e7eb); + } +} + +.statusTooltipLeft { + left: 0; + transform: translateX(0); + + &::after, + &::before { + left: 8px; + transform: none; + } +} + +.statusTooltipRight { + left: auto; + right: 0; + transform: translateX(0); + + &::after, + &::before { + left: auto; + right: 8px; + transform: none; + } +} + +.tooltipTime { + color: var(--text-secondary); + display: block; + margin-bottom: 2px; +} + +.tooltipStats { + display: flex; + align-items: center; + gap: 8px; +} + +.tooltipSuccess { + color: var(--success-color, #22c55e); +} + +.tooltipFailure { + color: var(--danger-color, #ef4444); +} + +.tooltipRate { + color: var(--text-secondary); + margin-left: 2px; +} + .statusRate { display: flex; align-items: center; @@ -665,6 +747,17 @@ background: var(--failure-badge-bg); } +@include mobile { + .statusTooltip { + font-size: 12px; + padding: 8px 12px; + } + + .statusBlocks { + min-width: 140px; + } +} + .prefixProxyEditor { display: flex; flex-direction: column; diff --git a/src/utils/usage.ts b/src/utils/usage.ts index ddd2119..e6d03d8 100644 --- a/src/utils/usage.ts +++ b/src/utils/usage.ts @@ -1117,11 +1117,26 @@ export function buildChartData( */ export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle'; +/** + * 状态栏单个格子的详细信息 + */ +export interface StatusBlockDetail { + success: number; + failure: number; + /** 该格子的成功率 (0–1),无请求时为 -1 */ + rate: number; + /** 格子起始时间戳 (ms) */ + startTime: number; + /** 格子结束时间戳 (ms) */ + endTime: number; +} + /** * 状态栏数据 */ export interface StatusBarData { blocks: StatusBlockState[]; + blockDetails: StatusBlockDetail[]; successRate: number; totalSuccess: number; totalFailure: number; @@ -1138,7 +1153,7 @@ export function calculateStatusBarData( ): StatusBarData { const BLOCK_COUNT = 20; const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes - const WINDOW_MS = 200 * 60 * 1000; // 200 minutes + const WINDOW_MS = BLOCK_COUNT * BLOCK_DURATION_MS; // 200 minutes const now = Date.now(); const windowStart = now - WINDOW_MS; @@ -1182,18 +1197,30 @@ export function calculateStatusBarData( } }); - // Convert stats to block states - const blocks: StatusBlockState[] = blockStats.map((stat) => { - if (stat.success === 0 && stat.failure === 0) { - return 'idle'; + // Convert stats to block states and build details + const blocks: StatusBlockState[] = []; + const blockDetails: StatusBlockDetail[] = []; + + blockStats.forEach((stat, idx) => { + const total = stat.success + stat.failure; + if (total === 0) { + blocks.push('idle'); + } else if (stat.failure === 0) { + blocks.push('success'); + } else if (stat.success === 0) { + blocks.push('failure'); + } else { + blocks.push('mixed'); } - if (stat.failure === 0) { - return 'success'; - } - if (stat.success === 0) { - return 'failure'; - } - return 'mixed'; + + const blockStartTime = windowStart + idx * BLOCK_DURATION_MS; + blockDetails.push({ + success: stat.success, + failure: stat.failure, + rate: total > 0 ? stat.success / total : -1, + startTime: blockStartTime, + endTime: blockStartTime + BLOCK_DURATION_MS, + }); }); // Calculate success rate @@ -1202,6 +1229,7 @@ export function calculateStatusBarData( return { blocks, + blockDetails, successRate, totalSuccess, totalFailure