fix(frontend): smooth health timeline popover tracking

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-10 18:13:25 +08:00
co-authored by Codex
parent 6c56a23508
commit 9bf86f7a7c
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { DashboardHealthTimeline, DashboardHealthTimelinePoint } from '@/api'
import { ZCard, ZPopover } from '@codex-proxy/ui'
import { ZButton, ZCard, ZPopover } from '@codex-proxy/ui'
import { usePreferredReducedMotion } from '@vueuse/core'
import { useEventListener, usePreferredReducedMotion, useResizeObserver } from '@vueuse/core'
import { gsap } from 'gsap'
import { computed, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, shallowRef, useTemplateRef, watch } from 'vue'
import HealthTimelinePointPopover from '@/components/usage/HealthTimelinePointPopover.vue'
import { formatHealthCount, healthLegend, healthReliabilityValueClass, healthStatusMeta } from '@/components/usage/shared/health'
@@ -18,6 +18,8 @@ const healthPopoverArrowSurfaceClasses = {
}
const timelineGrid = useTemplateRef<HTMLElement>('timelineGrid')
const popoverAnchor = useTemplateRef<HTMLElement>('popoverAnchor')
const pointPopover = useTemplateRef<InstanceType<typeof ZPopover>>('pointPopover')
const preferredMotion = usePreferredReducedMotion()
const points = computed(() => props.timeline.points)
@@ -26,6 +28,8 @@ const activeAnchor = shallowRef<HTMLElement | null>(null)
const popoverOpen = shallowRef(false)
const highlightedPointBucket = shallowRef<string>()
let wavedCellIndexes = new Set<number>()
let anchorXTo: ReturnType<typeof gsap.quickTo> | undefined
let anchorYTo: ReturnType<typeof gsap.quickTo> | undefined
function observedRequests(point: DashboardHealthTimelinePoint) {
return point.successRequests + point.failedRequests + point.cancelledRequests + point.callerErrorRequests
@@ -39,31 +43,86 @@ function isActivePoint(point: DashboardHealthTimelinePoint) {
return popoverOpen.value && highlightedPointBucket.value === point.bucketStart
}
function activatePoint(point: DashboardHealthTimelinePoint, pointIndex: number, event: Event) {
async function activatePoint(point: DashboardHealthTimelinePoint, pointIndex: number, event: Event) {
if (!(event.currentTarget instanceof HTMLElement))
return
// 浮层卸载会恢复原焦点,不能把关闭期间的焦点恢复当作再次打开
if (event instanceof FocusEvent && !activePoint.value && activeAnchor.value && !event.relatedTarget)
return
if (!isInteractivePoint(point)) {
closePointPopover()
return
}
const anchor = event.currentTarget
const opening = !activePoint.value || !popoverOpen.value
activePoint.value = point
activeAnchor.value = event.currentTarget
popoverOpen.value = true
highlightedPointBucket.value = point.bucketStart
animatePointWave(pointIndex)
// 首次挂载后再打开,让浮层先建立定位监听,后续逐格切换直接更新
if (opening) {
await nextTick()
if (activePoint.value !== point)
return
}
positionPointAnchor(anchor, opening)
activeAnchor.value = popoverAnchor.value
popoverOpen.value = true
}
function positionPointAnchor(button: HTMLElement, immediate: boolean) {
const anchor = popoverAnchor.value
const grid = timelineGrid.value
if (!anchor || !grid)
return
const buttonRect = button.getBoundingClientRect()
const gridRect = grid.getBoundingClientRect()
const x = buttonRect.left + buttonRect.width / 2 - gridRect.left - 0.5
const y = buttonRect.top - gridRect.top
if (immediate || preferredMotion.value === 'reduce') {
stopPointAnchor()
gsap.set(anchor, { x, y })
}
if (!anchorXTo || !anchorYTo) {
anchorXTo = gsap.quickTo(anchor, 'x', { duration: 0.16, ease: 'power3.out' })
anchorYTo = gsap.quickTo(anchor, 'y', {
duration: 0.16,
ease: 'power3.out',
// 原生锚点直接跟随变换,事件定位路径也在同一帧更新面板与箭头
onUpdate: () => pointPopover.value?.updatePosition(),
})
}
if (!immediate && preferredMotion.value !== 'reduce') {
anchorXTo(x)
anchorYTo(y)
}
}
function stopPointAnchor() {
if (popoverAnchor.value)
gsap.killTweensOf(popoverAnchor.value)
anchorXTo = undefined
anchorYTo = undefined
}
function closePointPopover() {
popoverOpen.value = false
resetPointInteraction()
void resetPointInteraction()
}
function resetPointInteraction() {
async function resetPointInteraction() {
highlightedPointBucket.value = undefined
activePoint.value = undefined
activeAnchor.value = null
releasePointWave()
stopPointAnchor()
// 锚点保留到浮层卸载完成,避免焦点恢复重新打开气泡或关闭时位置跳变
await nextTick()
if (!activePoint.value) {
popoverOpen.value = false
activeAnchor.value = null
}
}
function timelineButtons() {
@@ -168,10 +227,19 @@ function pointAccessibilityLabel(point: DashboardHealthTimelinePoint) {
}
watch(popoverOpen, (open) => {
if (open)
return
if (!open && activePoint.value)
closePointPopover()
})
resetPointInteraction()
useEventListener(timelineGrid, 'mouseleave', closePointPopover)
useResizeObserver(timelineGrid, () => {
if (!popoverOpen.value || !activePoint.value)
return
const index = points.value.findIndex(point => point.bucketStart === activePoint.value?.bucketStart)
const button = timelineButtons()[index]
if (button)
positionPointAnchor(button, true)
})
onBeforeUnmount(() => {
@@ -179,6 +247,7 @@ onBeforeUnmount(() => {
.map(button => timelineCell(button))
.filter((cell): cell is HTMLElement => Boolean(cell))
gsap.killTweensOf(cells)
stopPointAnchor()
})
</script>
@@ -210,58 +279,57 @@ onBeforeUnmount(() => {
</template>
<template #body>
<div>
<div class="relative flex flex-col">
<!-- 同一锚点连续追向各时间格,避免切换原生锚点时的位置跳变 -->
<span ref="popoverAnchor" aria-hidden="true" class="pointer-events-none absolute left-0 top-0 h-5 w-px opacity-0" />
<div
ref="timelineGrid" class="grid w-full grid-cols-[repeat(var(--timeline-columns),minmax(0,1fr))] items-end gap-x-0.5 gap-y-1 sm:grid-cols-[repeat(var(--timeline-columns-wide),minmax(0,1fr))]"
:style="{ '--timeline-columns': Math.max(1, Math.ceil(points.length / 2)), '--timeline-columns-wide': Math.max(1, points.length) }"
>
<ZButton
v-for="(point, pointIndex) in points"
:key="point.bucketStart"
data-health-timeline-point
variant="text"
:aria-disabled="!isInteractivePoint(point)"
:tabindex="isInteractivePoint(point) ? 0 : -1"
:aria-expanded="isActivePoint(point)"
aria-haspopup="dialog"
:aria-label="pointAccessibilityLabel(point)"
class="group relative h-5! w-full min-w-0.5 bg-transparent! p-0! [&>span]:w-full"
:class="isInteractivePoint(point) ? 'cursor-pointer' : 'cursor-default'"
@mouseenter="activatePoint(point, pointIndex, $event)"
@focus="activatePoint(point, pointIndex, $event)"
@blur="closePointPopover"
@click="activatePoint(point, pointIndex, $event)"
>
<span
data-health-timeline-cell
class="block h-3.5 w-full origin-bottom rounded-xs transition-[filter,box-shadow] duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none"
:class="[
healthStatusMeta[point.status].cellClass,
isActivePoint(point)
? 'brightness-105 shadow-[0_5px_10px_-5px_var(--cp-color-shadow)]'
: isInteractivePoint(point)
? 'group-hover:brightness-95'
: undefined,
]"
/>
</ZButton>
</div>
<!-- 时间线负责逐格悬停,关闭时直接卸载浮层,避免退场期间锚点失效后重新定位 -->
<ZPopover
v-if="activePoint"
ref="pointPopover"
v-model="popoverOpen"
trigger="hover"
placement="top"
:offset="12"
:reference-element="activeAnchor"
:disabled="!activePoint"
animate-position
:arrow-surface-class="healthPopoverArrowSurfaceClasses"
class="min-w-0 w-full"
>
<template #reference>
<div
ref="timelineGrid" class="grid w-full grid-cols-[repeat(var(--timeline-columns),minmax(0,1fr))] items-end gap-x-0.5 gap-y-1 sm:grid-cols-[repeat(var(--timeline-columns-wide),minmax(0,1fr))]"
:style="{ '--timeline-columns': Math.max(1, Math.ceil(points.length / 2)), '--timeline-columns-wide': Math.max(1, points.length) }"
>
<button
v-for="(point, pointIndex) in points"
:key="point.bucketStart"
data-health-timeline-point
type="button"
:aria-disabled="!isInteractivePoint(point)"
:tabindex="isInteractivePoint(point) ? 0 : -1"
:aria-expanded="isActivePoint(point)"
aria-haspopup="dialog"
:aria-label="pointAccessibilityLabel(point)"
class="group relative flex h-5 w-full min-w-0.5 items-center border-0 bg-transparent p-0 outline-none"
:class="isInteractivePoint(point) ? 'cursor-pointer' : 'cursor-default'"
@mouseenter="activatePoint(point, pointIndex, $event)"
@focus="activatePoint(point, pointIndex, $event)"
@blur="closePointPopover"
@click="activatePoint(point, pointIndex, $event)"
>
<span
data-health-timeline-cell
class="block h-3.5 w-full origin-bottom rounded-xs transition-[filter,box-shadow] duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none group-focus-visible:shadow-[0_0_0_2px_var(--cp-color-bg-container),0_0_0_4px_var(--cp-control-outline)]"
:class="[
healthStatusMeta[point.status].cellClass,
isActivePoint(point)
? 'brightness-105 shadow-[0_5px_10px_-5px_var(--cp-color-shadow)]'
: isInteractivePoint(point)
? 'group-hover:brightness-95'
: undefined,
]"
/>
</button>
</div>
</template>
<div class="w-72 overflow-hidden rounded-cp-lg">
<HealthTimelinePointPopover v-if="activePoint" :point="activePoint" />
<div data-health-timeline-popover class="w-72 overflow-hidden rounded-cp-lg">
<HealthTimelinePointPopover :point="activePoint" />
</div>
</ZPopover>
</div>