mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-06-16 21:03:58 +08:00
feat(ServiceHealthCard): implement tooltip positioning and rendering logic
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
collectUsageDetails,
|
||||
@@ -10,11 +11,28 @@ import type { UsagePayload } from './hooks/useUsageData';
|
||||
import styles from '@/pages/UsagePage.module.scss';
|
||||
|
||||
const COLOR_STOPS = [
|
||||
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||
{ r: 239, g: 68, b: 68 }, // #ef4444
|
||||
{ r: 250, g: 204, b: 21 }, // #facc15
|
||||
{ r: 34, g: 197, b: 94 }, // #22c55e
|
||||
] as const;
|
||||
|
||||
const TOOLTIP_OFFSET = 8;
|
||||
const TOOLTIP_SAFE_WIDTH = 180;
|
||||
const TOOLTIP_SAFE_HEIGHT = 72;
|
||||
|
||||
type TooltipHorizontalPosition = 'center' | 'left' | 'right';
|
||||
type TooltipVerticalPosition = 'above' | 'below';
|
||||
|
||||
interface ActiveTooltipState {
|
||||
idx: number;
|
||||
anchorEl: HTMLDivElement;
|
||||
horizontal: TooltipHorizontalPosition;
|
||||
vertical: TooltipVerticalPosition;
|
||||
left: number;
|
||||
top: number;
|
||||
transform: string;
|
||||
}
|
||||
|
||||
function rateToColor(rate: number): string {
|
||||
const t = Math.max(0, Math.min(1, rate));
|
||||
const segment = t < 0.5 ? 0 : 1;
|
||||
@@ -43,7 +61,7 @@ export interface ServiceHealthCardProps {
|
||||
|
||||
export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTooltip, setActiveTooltip] = useState<number | null>(null);
|
||||
const [activeTooltip, setActiveTooltip] = useState<ActiveTooltipState | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const healthData: ServiceHealthData = useMemo(() => {
|
||||
@@ -64,11 +82,74 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
return () => document.removeEventListener('pointerdown', handler);
|
||||
}, [activeTooltip]);
|
||||
|
||||
const handlePointerEnter = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
setActiveTooltip(idx);
|
||||
}
|
||||
}, []);
|
||||
const buildTooltipState = useCallback(
|
||||
(idx: number, anchorEl: HTMLDivElement): ActiveTooltipState => {
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
|
||||
let horizontal: TooltipHorizontalPosition = 'center';
|
||||
let left = centerX;
|
||||
|
||||
if (centerX <= TOOLTIP_SAFE_WIDTH / 2) {
|
||||
horizontal = 'left';
|
||||
left = rect.left;
|
||||
} else if (centerX >= window.innerWidth - TOOLTIP_SAFE_WIDTH / 2) {
|
||||
horizontal = 'right';
|
||||
left = rect.right;
|
||||
}
|
||||
|
||||
const vertical: TooltipVerticalPosition = rect.top <= TOOLTIP_SAFE_HEIGHT ? 'below' : 'above';
|
||||
const top = vertical === 'below' ? rect.bottom + TOOLTIP_OFFSET : rect.top - TOOLTIP_OFFSET;
|
||||
const translateX = horizontal === 'center' ? '-50%' : horizontal === 'right' ? '-100%' : '0';
|
||||
const translateY = vertical === 'below' ? '0' : '-100%';
|
||||
|
||||
return {
|
||||
idx,
|
||||
anchorEl,
|
||||
horizontal,
|
||||
vertical,
|
||||
left: Math.round(left),
|
||||
top: Math.round(top),
|
||||
transform: `translate(${translateX}, ${translateY})`,
|
||||
};
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTooltip) return;
|
||||
|
||||
const updateTooltipPosition = () => {
|
||||
if (!document.body.contains(activeTooltip.anchorEl)) {
|
||||
setActiveTooltip(null);
|
||||
return;
|
||||
}
|
||||
setActiveTooltip(buildTooltipState(activeTooltip.idx, activeTooltip.anchorEl));
|
||||
};
|
||||
|
||||
window.addEventListener('resize', updateTooltipPosition);
|
||||
window.addEventListener('scroll', updateTooltipPosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateTooltipPosition);
|
||||
window.removeEventListener('scroll', updateTooltipPosition, true);
|
||||
};
|
||||
}, [activeTooltip, buildTooltipState]);
|
||||
|
||||
const openTooltip = useCallback(
|
||||
(idx: number, anchorEl: HTMLDivElement) => {
|
||||
setActiveTooltip(buildTooltipState(idx, anchorEl));
|
||||
},
|
||||
[buildTooltipState]
|
||||
);
|
||||
|
||||
const handlePointerEnter = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
openTooltip(idx, e.currentTarget);
|
||||
}
|
||||
},
|
||||
[openTooltip]
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback((e: React.PointerEvent) => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
@@ -76,39 +157,49 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent, idx: number) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
e.preventDefault();
|
||||
setActiveTooltip((prev) => (prev === idx ? null : idx));
|
||||
}
|
||||
}, []);
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>, idx: number) => {
|
||||
if (e.pointerType === 'touch') {
|
||||
e.preventDefault();
|
||||
setActiveTooltip((prev) =>
|
||||
prev?.idx === idx ? null : buildTooltipState(idx, e.currentTarget)
|
||||
);
|
||||
}
|
||||
},
|
||||
[buildTooltipState]
|
||||
);
|
||||
|
||||
const getTooltipPositionClass = (idx: number): string => {
|
||||
const col = Math.floor(idx / healthData.rows);
|
||||
if (col <= 2) return styles.healthTooltipLeft;
|
||||
if (col >= healthData.cols - 3) return styles.healthTooltipRight;
|
||||
return '';
|
||||
};
|
||||
|
||||
const getTooltipVerticalClass = (idx: number): string => {
|
||||
const row = idx % healthData.rows;
|
||||
if (row <= 1) return styles.healthTooltipBelow;
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderTooltip = (detail: StatusBlockDetail, idx: number) => {
|
||||
const renderTooltip = (detail: StatusBlockDetail, tooltipState: ActiveTooltipState) => {
|
||||
const total = detail.success + detail.failure;
|
||||
const posClass = getTooltipPositionClass(idx);
|
||||
const vertClass = getTooltipVerticalClass(idx);
|
||||
const posClass =
|
||||
tooltipState.horizontal === 'left'
|
||||
? styles.healthTooltipLeft
|
||||
: tooltipState.horizontal === 'right'
|
||||
? styles.healthTooltipRight
|
||||
: '';
|
||||
const vertClass = tooltipState.vertical === 'below' ? styles.healthTooltipBelow : '';
|
||||
const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`;
|
||||
|
||||
return (
|
||||
<div className={`${styles.healthTooltip} ${posClass} ${vertClass}`}>
|
||||
const tooltip = (
|
||||
<div
|
||||
className={`${styles.healthTooltip} ${posClass} ${vertClass}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: `${tooltipState.left}px`,
|
||||
top: `${tooltipState.top}px`,
|
||||
bottom: 'auto',
|
||||
right: 'auto',
|
||||
transform: tooltipState.transform,
|
||||
}}
|
||||
>
|
||||
<span className={styles.healthTooltipTime}>{timeRange}</span>
|
||||
{total > 0 ? (
|
||||
<span className={styles.healthTooltipStats}>
|
||||
<span className={styles.healthTooltipSuccess}>{t('status_bar.success_short')} {detail.success}</span>
|
||||
<span className={styles.healthTooltipFailure}>{t('status_bar.failure_short')} {detail.failure}</span>
|
||||
<span className={styles.healthTooltipSuccess}>
|
||||
{t('status_bar.success_short')} {detail.success}
|
||||
</span>
|
||||
<span className={styles.healthTooltipFailure}>
|
||||
{t('status_bar.failure_short')} {detail.failure}
|
||||
</span>
|
||||
<span className={styles.healthTooltipRate}>({(detail.rate * 100).toFixed(1)}%)</span>
|
||||
</span>
|
||||
) : (
|
||||
@@ -116,6 +207,8 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body);
|
||||
};
|
||||
|
||||
const rateClass = !hasData
|
||||
@@ -138,32 +231,29 @@ export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.healthGridScroller}>
|
||||
<div
|
||||
className={styles.healthGrid}
|
||||
ref={gridRef}
|
||||
>
|
||||
{healthData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip === idx;
|
||||
<div className={styles.healthGrid} ref={gridRef}>
|
||||
{healthData.blockDetails.map((detail, idx) => {
|
||||
const isIdle = detail.rate === -1;
|
||||
const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) };
|
||||
const isActive = activeTooltip?.idx === idx;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && renderTooltip(detail, idx)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
key={idx}
|
||||
className={`${styles.healthBlockWrapper} ${isActive ? styles.healthBlockActive : ''}`}
|
||||
onPointerEnter={(e) => handlePointerEnter(e, idx)}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
onPointerDown={(e) => handlePointerDown(e, idx)}
|
||||
>
|
||||
<div
|
||||
className={`${styles.healthBlock} ${isIdle ? styles.healthBlockIdle : ''}`}
|
||||
style={blockStyle}
|
||||
/>
|
||||
{isActive && activeTooltip && renderTooltip(detail, activeTooltip)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.healthLegend}>
|
||||
<span className={styles.healthLegendLabel}>{t('service_health.oldest')}</span>
|
||||
|
||||
@@ -1112,17 +1112,20 @@
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 1px solid var(--border-secondary, #e5e7eb);
|
||||
background-color: var(--floating-surface, #ffffff);
|
||||
background-image: none;
|
||||
border: 1px solid var(--floating-border, #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);
|
||||
box-shadow: var(--floating-shadow, 0 10px 24px rgba(0, 0, 0, 0.16));
|
||||
z-index: $z-dropdown;
|
||||
pointer-events: none;
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
background-clip: padding-box;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
@@ -1131,7 +1134,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: var(--bg-primary, #fff);
|
||||
border-top-color: var(--floating-surface, #ffffff);
|
||||
}
|
||||
|
||||
&::before {
|
||||
@@ -1141,7 +1144,7 @@
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--border-secondary, #e5e7eb);
|
||||
border-top-color: var(--floating-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,14 +1157,14 @@
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--bg-primary, #fff);
|
||||
border-bottom-color: var(--floating-surface, #ffffff);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
border-top-color: transparent;
|
||||
border-bottom-color: var(--border-secondary, #e5e7eb);
|
||||
border-bottom-color: var(--floating-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #f6f4ee;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.1);
|
||||
--floating-surface: #fffdf9;
|
||||
--floating-border: #d8d3ca;
|
||||
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.14);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
@@ -64,6 +67,9 @@
|
||||
--bg-hover: var(--bg-tertiary);
|
||||
--bg-quinary: #ffffff;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.08);
|
||||
--floating-surface: #ffffff;
|
||||
--floating-border: #d9d9d9;
|
||||
--floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.12);
|
||||
|
||||
--text-primary: #2d2a26;
|
||||
--text-secondary: #6d6760;
|
||||
@@ -118,6 +124,9 @@
|
||||
--bg-hover: #2e2a26;
|
||||
--bg-quinary: #191714;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.18);
|
||||
--floating-surface: #2a2723;
|
||||
--floating-border: #4a443d;
|
||||
--floating-shadow: 0 14px 30px rgba(0, 0, 0, 0.4);
|
||||
|
||||
--text-primary: #f6f4f1;
|
||||
--text-secondary: #c9c3bb;
|
||||
|
||||
Reference in New Issue
Block a user