mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 19:20:49 +08:00
feat(status-bar): add gradient colors and tooltip with mobile support
This commit is contained in:
@@ -1,36 +1,143 @@
|
|||||||
import { calculateStatusBarData } from '@/utils/usage';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import styles from '@/pages/AiProvidersPage.module.scss';
|
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>;
|
* 根据成功率 (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<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 hasData = statusData.totalSuccess + statusData.totalFailure > 0;
|
||||||
const rateClass = !hasData
|
const rateClass = !hasData
|
||||||
? ''
|
? ''
|
||||||
: statusData.successRate >= 90
|
: statusData.successRate >= 90
|
||||||
? styles.statusRateHigh
|
? s.statusRateHigh
|
||||||
: statusData.successRate >= 50
|
: statusData.successRate >= 50
|
||||||
? styles.statusRateMedium
|
? s.statusRateMedium
|
||||||
: styles.statusRateLow;
|
: 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 (
|
return (
|
||||||
<div className={styles.statusBar}>
|
<div className={s.statusBar}>
|
||||||
<div className={styles.statusBlocks}>
|
<div className={s.statusBlocks} ref={blocksRef}>
|
||||||
{statusData.blocks.map((state, idx) => {
|
{statusData.blockDetails.map((detail, idx) => {
|
||||||
const blockClass =
|
const isIdle = detail.rate === -1;
|
||||||
state === 'success'
|
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||||
? styles.statusBlockSuccess
|
const isActive = activeTooltip === idx;
|
||||||
: state === 'failure'
|
|
||||||
? styles.statusBlockFailure
|
return (
|
||||||
: state === 'mixed'
|
<div
|
||||||
? styles.statusBlockMixed
|
key={idx}
|
||||||
: styles.statusBlockIdle;
|
className={`${s.statusBlockWrapper} ${isActive ? s.statusBlockActive : ''}`}
|
||||||
return <div key={idx} className={`${styles.statusBlock} ${blockClass}`} />;
|
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>
|
</div>
|
||||||
<span className={`${styles.statusRate} ${rateClass}`}>
|
<span className={`${s.statusRate} ${rateClass}`}>
|
||||||
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
{hasData ? `${statusData.successRate.toFixed(1)}%` : '--'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
|
||||||
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
|
||||||
import { IconBot, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
import { IconBot, IconCode, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons';
|
||||||
|
import { ProviderStatusBar } from '@/components/providers/ProviderStatusBar';
|
||||||
import type { AuthFileItem } from '@/types';
|
import type { AuthFileItem } from '@/types';
|
||||||
import { resolveAuthProvider } from '@/utils/quota';
|
import { resolveAuthProvider } from '@/utils/quota';
|
||||||
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
import { calculateStatusBarData, type KeyStats } from '@/utils/usage';
|
||||||
@@ -88,14 +89,6 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
|||||||
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
const authIndexKey = normalizeAuthIndexValue(rawAuthIndex);
|
||||||
const statusData =
|
const statusData =
|
||||||
(authIndexKey && statusBarCache.get(authIndexKey)) || calculateStatusBarData([]);
|
(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -135,24 +128,7 @@ export function AuthFileCard(props: AuthFileCardProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.statusBar}>
|
<ProviderStatusBar statusData={statusData} styles={styles} />
|
||||||
<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>
|
|
||||||
|
|
||||||
{showQuotaLayout && quotaType && (
|
{showQuotaLayout && quotaType && (
|
||||||
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
|
<AuthFileQuotaSection file={file} quotaType={quotaType} disableControls={disableControls} />
|
||||||
|
|||||||
@@ -832,6 +832,11 @@
|
|||||||
"success": "Success",
|
"success": "Success",
|
||||||
"failure": "Failure"
|
"failure": "Failure"
|
||||||
},
|
},
|
||||||
|
"status_bar": {
|
||||||
|
"success_short": "✓",
|
||||||
|
"failure_short": "✗",
|
||||||
|
"no_requests": "No requests"
|
||||||
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Logs Viewer",
|
"title": "Logs Viewer",
|
||||||
"refresh_button": "Refresh Logs",
|
"refresh_button": "Refresh Logs",
|
||||||
|
|||||||
@@ -835,6 +835,11 @@
|
|||||||
"success": "Успех",
|
"success": "Успех",
|
||||||
"failure": "Сбой"
|
"failure": "Сбой"
|
||||||
},
|
},
|
||||||
|
"status_bar": {
|
||||||
|
"success_short": "✓",
|
||||||
|
"failure_short": "✗",
|
||||||
|
"no_requests": "Нет запросов"
|
||||||
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Просмотр журналов",
|
"title": "Просмотр журналов",
|
||||||
"refresh_button": "Обновить журналы",
|
"refresh_button": "Обновить журналы",
|
||||||
|
|||||||
@@ -832,6 +832,11 @@
|
|||||||
"success": "成功",
|
"success": "成功",
|
||||||
"failure": "失败"
|
"failure": "失败"
|
||||||
},
|
},
|
||||||
|
"status_bar": {
|
||||||
|
"success_short": "✓",
|
||||||
|
"failure_short": "✗",
|
||||||
|
"no_requests": "无请求"
|
||||||
|
},
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "日志查看",
|
"title": "日志查看",
|
||||||
"refresh_button": "刷新日志",
|
"refresh_button": "刷新日志",
|
||||||
|
|||||||
@@ -402,37 +402,123 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockWrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 6px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBlock {
|
.statusBlock {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 2px;
|
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 {
|
.statusBlockWrapper:hover &,
|
||||||
transform: scaleY(1.5);
|
.statusBlockWrapper.statusBlockActive & {
|
||||||
opacity: 0.85;
|
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 {
|
.statusBlockIdle {
|
||||||
background-color: var(--border-secondary, #e5e7eb);
|
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 {
|
.statusRate {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -460,6 +546,17 @@
|
|||||||
background: var(--failure-badge-bg);
|
background: var(--failure-badge-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.statusTooltip {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Model Config Section - Unified Layout
|
// Model Config Section - Unified Layout
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -816,6 +913,20 @@
|
|||||||
background-color: var(--border-primary, #374151);
|
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 {
|
.statusRateHigh {
|
||||||
background: rgba(34, 197, 94, 0.2);
|
background: rgba(34, 197, 94, 0.2);
|
||||||
color: #86efac;
|
color: #86efac;
|
||||||
|
|||||||
@@ -605,39 +605,121 @@
|
|||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlockWrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 6px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusBlock {
|
.statusBlock {
|
||||||
flex: 1;
|
width: 100%;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 2px;
|
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 {
|
.statusBlockWrapper:hover &,
|
||||||
transform: scaleY(1.5);
|
.statusBlockWrapper.statusBlockActive & {
|
||||||
opacity: 0.85;
|
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 {
|
.statusBlockIdle {
|
||||||
background-color: var(--border-secondary, #e5e7eb);
|
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 {
|
.statusRate {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -665,6 +747,17 @@
|
|||||||
background: var(--failure-badge-bg);
|
background: var(--failure-badge-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
.statusTooltip {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBlocks {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.prefixProxyEditor {
|
.prefixProxyEditor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1117,11 +1117,26 @@ export function buildChartData(
|
|||||||
*/
|
*/
|
||||||
export type StatusBlockState = 'success' | 'failure' | 'mixed' | 'idle';
|
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 {
|
export interface StatusBarData {
|
||||||
blocks: StatusBlockState[];
|
blocks: StatusBlockState[];
|
||||||
|
blockDetails: StatusBlockDetail[];
|
||||||
successRate: number;
|
successRate: number;
|
||||||
totalSuccess: number;
|
totalSuccess: number;
|
||||||
totalFailure: number;
|
totalFailure: number;
|
||||||
@@ -1138,7 +1153,7 @@ export function calculateStatusBarData(
|
|||||||
): StatusBarData {
|
): StatusBarData {
|
||||||
const BLOCK_COUNT = 20;
|
const BLOCK_COUNT = 20;
|
||||||
const BLOCK_DURATION_MS = 10 * 60 * 1000; // 10 minutes
|
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 now = Date.now();
|
||||||
const windowStart = now - WINDOW_MS;
|
const windowStart = now - WINDOW_MS;
|
||||||
@@ -1182,18 +1197,30 @@ export function calculateStatusBarData(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert stats to block states
|
// Convert stats to block states and build details
|
||||||
const blocks: StatusBlockState[] = blockStats.map((stat) => {
|
const blocks: StatusBlockState[] = [];
|
||||||
if (stat.success === 0 && stat.failure === 0) {
|
const blockDetails: StatusBlockDetail[] = [];
|
||||||
return 'idle';
|
|
||||||
|
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';
|
const blockStartTime = windowStart + idx * BLOCK_DURATION_MS;
|
||||||
}
|
blockDetails.push({
|
||||||
if (stat.success === 0) {
|
success: stat.success,
|
||||||
return 'failure';
|
failure: stat.failure,
|
||||||
}
|
rate: total > 0 ? stat.success / total : -1,
|
||||||
return 'mixed';
|
startTime: blockStartTime,
|
||||||
|
endTime: blockStartTime + BLOCK_DURATION_MS,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate success rate
|
// Calculate success rate
|
||||||
@@ -1202,6 +1229,7 @@ export function calculateStatusBarData(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
blocks,
|
blocks,
|
||||||
|
blockDetails,
|
||||||
successRate,
|
successRate,
|
||||||
totalSuccess,
|
totalSuccess,
|
||||||
totalFailure
|
totalFailure
|
||||||
|
|||||||
Reference in New Issue
Block a user