Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions web/src/components/ui/PortalTooltip.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.tooltip {
position: fixed;
z-index: 2100;
display: grid;
gap: 3px;
width: max-content;
max-width: min(280px, calc(100vw - 16px));
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.22);
padding: 10px 12px;
pointer-events: none;
text-align: left;
font-size: 11px;
line-height: 1.45;

span {
display: block;
white-space: normal;
overflow-wrap: anywhere;
}
}
137 changes: 137 additions & 0 deletions web/src/components/ui/PortalTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import styles from './PortalTooltip.module.scss'

const TOOLTIP_MAX_WIDTH = 280
const TOOLTIP_ESTIMATED_HEIGHT = 72
const TOOLTIP_OFFSET = 10
const TOOLTIP_VIEWPORT_PADDING = 8

export type PortalTooltipState = {
lines: string[]
x: number
y: number
placement: 'above' | 'below'
}

type PortalTooltipTarget = {
lines: string[]
anchor: HTMLElement
}

export function usePortalTooltip() {
const [tooltip, setTooltip] = useState<PortalTooltipState | null>(null)
const hoverTargetRef = useRef<PortalTooltipTarget | null>(null)
const focusTargetRef = useRef<PortalTooltipTarget | null>(null)

const positionTooltip = useCallback((target: PortalTooltipTarget | null) => {
if (!target?.anchor.isConnected) {
setTooltip(null)
return
}

// 浮层挂到 body 后不受滚动容器裁剪,并围绕当前 hover/focus 锚点限制在视口内。
const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth
const viewportHeight = typeof window === 'undefined' ? 768 : window.innerHeight
const rect = target.anchor.getBoundingClientRect()
const tooltipWidth = Math.min(
TOOLTIP_MAX_WIDTH,
Math.max(viewportWidth - TOOLTIP_VIEWPORT_PADDING * 2, 0),
)
const halfTooltipWidth = tooltipWidth / 2
const minX = TOOLTIP_VIEWPORT_PADDING + halfTooltipWidth
const maxX = viewportWidth - TOOLTIP_VIEWPORT_PADDING - halfTooltipWidth
const anchorX = rect.left + rect.width / 2
const x = maxX >= minX ? Math.max(minX, Math.min(anchorX, maxX)) : viewportWidth / 2
const spaceBelow = viewportHeight - rect.bottom - TOOLTIP_OFFSET - TOOLTIP_VIEWPORT_PADDING
const spaceAbove = rect.top - TOOLTIP_OFFSET - TOOLTIP_VIEWPORT_PADDING
const placement = spaceBelow >= TOOLTIP_ESTIMATED_HEIGHT || spaceBelow >= spaceAbove ? 'below' : 'above'
const y = placement === 'above' ? rect.top - TOOLTIP_OFFSET : rect.bottom + TOOLTIP_OFFSET

setTooltip({ lines: target.lines, x, y, placement })
}, [])

const syncTooltip = useCallback(() => {
if (hoverTargetRef.current && !hoverTargetRef.current.anchor.isConnected) {
hoverTargetRef.current = null
}
if (focusTargetRef.current && !focusTargetRef.current.anchor.isConnected) {
focusTargetRef.current = null
}
positionTooltip(hoverTargetRef.current ?? focusTargetRef.current)
}, [positionTooltip])

const showOnMouseEnter = useCallback((lines: string[], anchor: HTMLElement) => {
hoverTargetRef.current = { lines, anchor }
syncTooltip()
}, [syncTooltip])

const hideOnMouseLeave = useCallback((anchor: HTMLElement) => {
if (hoverTargetRef.current?.anchor === anchor) {
hoverTargetRef.current = null
}
syncTooltip()
}, [syncTooltip])

const showOnFocus = useCallback((lines: string[], anchor: HTMLElement) => {
focusTargetRef.current = { lines, anchor }
syncTooltip()
}, [syncTooltip])

const hideOnBlur = useCallback((anchor: HTMLElement) => {
if (focusTargetRef.current?.anchor === anchor) {
focusTargetRef.current = null
}
syncTooltip()
}, [syncTooltip])

const dismiss = useCallback(() => {
hoverTargetRef.current = null
focusTargetRef.current = null
setTooltip(null)
}, [])

useEffect(() => {
const repositionTooltip = () => {
if (hoverTargetRef.current || focusTargetRef.current) {
syncTooltip()
}
}
window.addEventListener('resize', repositionTooltip)
window.addEventListener('scroll', repositionTooltip, true)
return () => {
window.removeEventListener('resize', repositionTooltip)
window.removeEventListener('scroll', repositionTooltip, true)
}
}, [syncTooltip])

return {
tooltip,
showOnMouseEnter,
hideOnMouseLeave,
showOnFocus,
hideOnBlur,
dismiss,
}
}

export function PortalTooltip({ tooltip }: { tooltip: PortalTooltipState | null }) {
if (!tooltip || typeof document === 'undefined') return null

return createPortal(
<div
className={styles.tooltip}
role="tooltip"
style={{
left: tooltip.x,
top: tooltip.y,
transform: tooltip.placement === 'above'
? 'translate(-50%, -100%)'
: 'translateX(-50%)',
}}
>
{tooltip.lines.map((line, index) => <span key={`${index}-${line}`}>{line}</span>)}
</div>,
document.body,
)
}
130 changes: 9 additions & 121 deletions web/src/components/usage/RequestEventsDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import React, {
useState,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { EmptyState } from '@/components/ui/EmptyState';
import { MainActionButton } from '@/components/ui/MainActionButton';
import { Modal } from '@/components/ui/Modal';
import { PortalTooltip, usePortalTooltip } from '@/components/ui/PortalTooltip';
import { Select } from '@/components/ui/Select';
import { IconCheck, IconChevronDown, IconCopy, IconDownload, IconScrollText, IconSettings } from '@/components/ui/icons';
import type { UsageEvent, UsageEventRequestLogResponse, UsageSourceFilterOption } from '@/lib/types';
Expand Down Expand Up @@ -50,10 +50,6 @@ const REQUEST_LOG_VIRTUAL_PADDING_Y = 12;
const REQUEST_LOG_VIRTUAL_CHUNK_CHARS = 2048;
const REQUEST_LOG_VIRTUAL_BREAK_LOOKBACK = 256;
const REQUEST_LOG_GRAPHEME_CONTEXT_CHARS = 64;
const REQUEST_EVENTS_TOOLTIP_MAX_WIDTH = 280;
const REQUEST_EVENTS_TOOLTIP_ESTIMATED_HEIGHT = 72;
const REQUEST_EVENTS_TOOLTIP_OFFSET = 10;
const REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING = 8;
const REQUEST_EVENT_CLIENT_IP_DISPLAY_LENGTH = 39;
const REQUEST_EVENT_X_FORWARDED_FOR_DISPLAY_LENGTH = 48;
const REQUEST_EVENT_USER_AGENT_DISPLAY_LENGTH = 48;
Expand Down Expand Up @@ -126,18 +122,6 @@ type RequestEventRow = {
costAvailable: boolean;
};

type RequestEventTooltipState = {
lines: string[];
x: number;
y: number;
placement: 'above' | 'below';
};

type RequestEventTooltipTarget = {
lines: string[];
anchor: HTMLTableCellElement;
};

type RequestEventColumnDefinition = {
id: RequestEventColumnId;
label: string;
Expand Down Expand Up @@ -661,11 +645,15 @@ export function RequestEventsDetailsCard({
requestLogDownloading = false,
}: RequestEventsDetailsCardProps) {
const { t } = useTranslation();
const [requestEventsTooltip, setRequestEventsTooltip] = useState<RequestEventTooltipState | null>(null);
const {
tooltip: requestEventsTooltip,
showOnMouseEnter: handleRequestEventsTooltipMouseEnter,
hideOnMouseLeave: handleRequestEventsTooltipMouseLeave,
showOnFocus: handleRequestEventsTooltipFocus,
hideOnBlur: handleRequestEventsTooltipBlur,
} = usePortalTooltip();
const [columnSettingsOpen, setColumnSettingsOpen] = useState(false);
const [columnSettingsSession, setColumnSettingsSession] = useState(0);
const requestEventsTooltipHoverTargetRef = useRef<RequestEventTooltipTarget | null>(null);
const requestEventsTooltipFocusTargetRef = useRef<RequestEventTooltipTarget | null>(null);
const requestEventsTableWrapperRef = useRef<HTMLDivElement | null>(null);
const resultLocale = t('usage_stats.success') === 'Success' ? 'en' : 'zh';
const latencyHint = t('usage_stats.latency_unit_hint', {
Expand All @@ -675,89 +663,6 @@ export function RequestEventsDetailsCard({
const ttftHint = t('usage_stats.ttft_hint');
const speedHint = t('usage_stats.speed_hint');

const positionRequestEventsTooltip = useCallback((target: RequestEventTooltipTarget | null) => {
if (!target) {
setRequestEventsTooltip(null);
return;
}

// 浮层挂到 body 后不受表格滚动容器裁剪,并随当前 hover/focus 锚点保持在视口内。
const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth;
const viewportHeight = typeof window === 'undefined' ? 768 : window.innerHeight;
const rect = target.anchor.getBoundingClientRect();
const tooltipWidth = Math.min(
REQUEST_EVENTS_TOOLTIP_MAX_WIDTH,
Math.max(viewportWidth - REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING * 2, 0),
);
const halfTooltipWidth = tooltipWidth / 2;
const minX = REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING + halfTooltipWidth;
const maxX = viewportWidth - REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING - halfTooltipWidth;
const anchorX = rect.left + rect.width / 2;
const x = maxX >= minX ? Math.max(minX, Math.min(anchorX, maxX)) : viewportWidth / 2;
const spaceBelow = viewportHeight
- rect.bottom
- REQUEST_EVENTS_TOOLTIP_OFFSET
- REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING;
const spaceAbove = rect.top
- REQUEST_EVENTS_TOOLTIP_OFFSET
- REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING;
const placement = spaceBelow >= REQUEST_EVENTS_TOOLTIP_ESTIMATED_HEIGHT || spaceBelow >= spaceAbove
? 'below'
: 'above';
const y = placement === 'above'
? rect.top - REQUEST_EVENTS_TOOLTIP_OFFSET
: rect.bottom + REQUEST_EVENTS_TOOLTIP_OFFSET;

setRequestEventsTooltip({ lines: target.lines, x, y, placement });
}, []);

const syncRequestEventsTooltip = useCallback(() => {
if (requestEventsTooltipHoverTargetRef.current && !requestEventsTooltipHoverTargetRef.current.anchor.isConnected) {
requestEventsTooltipHoverTargetRef.current = null;
}
if (requestEventsTooltipFocusTargetRef.current && !requestEventsTooltipFocusTargetRef.current.anchor.isConnected) {
requestEventsTooltipFocusTargetRef.current = null;
}
positionRequestEventsTooltip(
requestEventsTooltipHoverTargetRef.current ?? requestEventsTooltipFocusTargetRef.current,
);
}, [positionRequestEventsTooltip]);

const handleRequestEventsTooltipMouseEnter = useCallback((lines: string[], anchor: HTMLTableCellElement) => {
requestEventsTooltipHoverTargetRef.current = { lines, anchor };
syncRequestEventsTooltip();
}, [syncRequestEventsTooltip]);
const handleRequestEventsTooltipMouseLeave = useCallback((anchor: HTMLTableCellElement) => {
if (requestEventsTooltipHoverTargetRef.current?.anchor === anchor) {
requestEventsTooltipHoverTargetRef.current = null;
}
syncRequestEventsTooltip();
}, [syncRequestEventsTooltip]);
const handleRequestEventsTooltipFocus = useCallback((lines: string[], anchor: HTMLTableCellElement) => {
requestEventsTooltipFocusTargetRef.current = { lines, anchor };
syncRequestEventsTooltip();
}, [syncRequestEventsTooltip]);
const handleRequestEventsTooltipBlur = useCallback((anchor: HTMLTableCellElement) => {
if (requestEventsTooltipFocusTargetRef.current?.anchor === anchor) {
requestEventsTooltipFocusTargetRef.current = null;
}
syncRequestEventsTooltip();
}, [syncRequestEventsTooltip]);

useEffect(() => {
const repositionRequestEventsTooltip = () => {
if (requestEventsTooltipHoverTargetRef.current || requestEventsTooltipFocusTargetRef.current) {
syncRequestEventsTooltip();
}
};
window.addEventListener('resize', repositionRequestEventsTooltip);
window.addEventListener('scroll', repositionRequestEventsTooltip, true);
return () => {
window.removeEventListener('resize', repositionRequestEventsTooltip);
window.removeEventListener('scroll', repositionRequestEventsTooltip, true);
};
}, [syncRequestEventsTooltip]);

const rows = useMemo<RequestEventRow[]>(() => {
return events.map((event, index) => {
const timestamp = event.timestamp;
Expand Down Expand Up @@ -1389,24 +1294,7 @@ export function RequestEventsDetailsCard({
onApply={handleColumnSettingsApply}
onClose={() => setColumnSettingsOpen(false)}
/>
{requestEventsTooltip && typeof document !== 'undefined'
? createPortal(
<div
className={styles.requestEventsSpeedModeTooltip}
role="tooltip"
style={{
left: requestEventsTooltip.x,
top: requestEventsTooltip.y,
transform: requestEventsTooltip.placement === 'above'
? 'translate(-50%, -100%)'
: 'translateX(-50%)',
}}
>
{requestEventsTooltip.lines.map((line) => <span key={line}>{line}</span>)}
</div>,
document.body,
)
: null}
<PortalTooltip tooltip={requestEventsTooltip} />
<Modal
open={requestLogOpen}
title={requestLogTitle}
Expand Down
Loading