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>