feat(status-bar): add gradient colors and tooltip with mobile support

This commit is contained in:
Supra4E8C
2026-02-14 13:24:53 +08:00
parent faadc3ea3e
commit 1f8c4331c7
8 changed files with 426 additions and 96 deletions

View File

@@ -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<typeof calculateStatusBarData>;
/**
* 根据成功率 (01) 在三个色标之间做 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<string, string>;
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<number | null>(null);
const blocksRef = useRef<HTMLDivElement>(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 (
<div className={`${s.statusTooltip} ${posClass}`}>
<span className={s.tooltipTime}>{timeRange}</span>
{total > 0 ? (
<span className={s.tooltipStats}>
<span className={s.tooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
<span className={s.tooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
<span className={s.tooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
</span>
) : (
<span className={s.tooltipStats}>{t('status_bar.no_requests')}</span>
)}
</div>
);
};
return (
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
<div className={s.statusBar}>
<div className={s.statusBlocks} ref={blocksRef}>
{statusData.blockDetails.map((detail, idx) => {
const isIdle = detail.rate === -1;
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
const isActive = activeTooltip === idx;
return (
<div
key={idx}
className={`${s.statusBlockWrapper} ${isActive ? s.statusBlockActive : ''}`}
onPointerEnter={(e) => handlePointerEnter(e, idx)}
onPointerLeave={handlePointerLeave}
onPointerDown={(e) => handlePointerDown(e, idx)}
>
<div
className={`${s.statusBlock} ${isIdle ? s.statusBlockIdle : ''}`}
style={blockStyle}
/>
{isActive && renderTooltip(detail, idx)}
</div>
);
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
<span className={`${s.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>

View File

@@ -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 (
<div
@@ -135,24 +128,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
</span>
</div>
<div className={styles.statusBar}>
<div className={styles.statusBlocks}>
{statusData.blocks.map((state, idx) => {
const blockClass =
state === 'success'
? styles.statusBlockSuccess
: state === 'failure'
? styles.statusBlockFailure
: state === 'mixed'
? styles.statusBlockMixed
: styles.statusBlockIdle;
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
})}
</div>
<span className={`${styles.statusRate} ${rateClass}`}>
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
</span>
</div>
<ProviderStatusBar statusData={statusData} styles={styles} />
{showQuotaLayout && quotaType && (
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />

View File

@@ -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",

View File

@@ -835,6 +835,11 @@
"success": "Успех",
"failure": "Сбой"
},
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "Нет запросов"
},
"logs": {
"title": "Просмотр журналов",
"refresh_button": "Обновить журналы",

View File

@@ -832,6 +832,11 @@
"success": "成功",
"failure": "失败"
},
"status_bar": {
"success_short": "✓",
"failure_short": "✗",
"no_requests": "无请求"
},
"logs": {
"title": "日志查看",
"refresh_button": "刷新日志",

View File

@@ -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;

View File

@@ -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;

View File

@@ -1117,11 +1117,26 @@ export function buildChartData(
*/
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
/**
* 状态栏单个格子的详细信息
*/
export interface StatusBlockDetail {
success: number;
failure: number;
/** 该格子的成功率 (01),无请求时为 -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