diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index 930fa209..f2cc2006 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -17,7 +17,7 @@ import { LiveWire } from './components/LiveWire'; import { Meter } from './components/Meter'; import { Sparkline } from './components/Sparkline'; import { ThroughputChart } from './components/ThroughputChart'; -import { useCountUp, useRevealOnScroll } from './components/motion'; +import { useCountUp, useRevealGroup, useRevealOnScroll } from './components/motion'; import { providerLabel, splitWindowMinutes, toneForSuccessRate, type MeterTone } from './utils'; import styles from './dashboard.module.scss'; @@ -45,12 +45,13 @@ export function DashboardPage() { useHeaderRefresh(refresh, connected); - const heroRef = useRevealOnScroll(); - const statsRef = useRevealOnScroll(0.05); + /* Hero 与静态网格走分组级联;异步内容区(图表/供应商)保持整块 reveal */ + const heroRef = useRevealGroup(); + const statsRef = useRevealGroup(0.12); const trafficRef = useRevealOnScroll(); const fleetRef = useRevealOnScroll(); - const detailRef = useRevealOnScroll(); - const ctaRef = useRevealOnScroll(); + const detailRef = useRevealGroup(); + const ctaRef = useRevealGroup(); const animatedTotal = useCountUp(traffic.total, connected); @@ -97,6 +98,9 @@ export function DashboardPage() { return { key: keyByTone[successRateTone], accent: TILE_ACCENTS[successRateTone] }; }, [connected, connectionStatus, traffic.total, traffic.successRate, successRateTone]); + /* 句号状态灯只在「有活着的流量」时呼吸;离线/静默时保持安静 */ + const heroAlive = connected && traffic.total > 0; + const connectionLabel = t( connectionStatus === 'connected' ? 'common.connected' @@ -224,24 +228,32 @@ export function DashboardPage() { {/* ---------- Hero ---------- */}
-

+

{t(`dashboard.${verdict.key}`)} - + {t('dashboard.hero_period')}

-

{heroMetaLine}

-
+

+ {heroMetaLine} +

+
{t('dashboard.cta_manage_providers')} - {t('dashboard.cta_inspect_logs')} + {t('dashboard.cta_inspect_logs')}{' '} +
-
+
{t('dashboard.hero_requests_label')} {connected && ( @@ -301,6 +313,7 @@ export function DashboardPage() {
-
+
{t('dashboard.health_eyebrow')}

{t('dashboard.health_title')}

@@ -455,13 +468,16 @@ export function DashboardPage() {
- {t('dashboard.health_link')} + {t('dashboard.health_link')}{' '} + )}
-
+
{t('dashboard.runtime_eyebrow')}

{t('dashboard.runtime_title')}

@@ -490,20 +506,23 @@ export function DashboardPage() { )} - {t('dashboard.runtime_link')} + {t('dashboard.runtime_link')}{' '} +
{/* ---------- CTA ---------- */}
-
+
{t('dashboard.cta_eyebrow')}

{t('dashboard.cta_title')}

{ctaCards.map((card) => ( - + {card.icon} {card.title} {card.description} diff --git a/src/features/dashboard/components/LiveWire.module.scss b/src/features/dashboard/components/LiveWire.module.scss index 59800ca9..72297080 100644 --- a/src/features/dashboard/components/LiveWire.module.scss +++ b/src/features/dashboard/components/LiveWire.module.scss @@ -17,6 +17,7 @@ overflow: visible; } +/* 描画排在 hero 文案/面板级联之后:脉搏最后抵达,收束整场入场 */ .line { stroke: var(--wire-stroke); stroke-width: 2px; @@ -24,11 +25,11 @@ stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 0; - animation: wireDraw 1.3s cubic-bezier(0.25, 0.6, 0.35, 1) 0.2s backwards; + animation: wireDraw 1.3s cubic-bezier(0.25, 0.6, 0.35, 1) 0.55s backwards; } .area { - animation: wireFade 0.9s ease-out 0.9s backwards; + animation: wireFade 0.9s ease-out 1.25s backwards; } .idleLine { @@ -47,7 +48,9 @@ transform: translate(50%, -50%); background: var(--wire-stroke); box-shadow: 0 0 0 0 color-mix(in srgb, var(--wire-color) 45%, transparent); - animation: wirePulse 2.4s ease-out 1.5s infinite; + animation: wirePulse 2.4s ease-out 1.9s infinite; + /* 数据刷新时光点滑到新位置,而不是瞬移 */ + transition: top 400ms var(--ease-out-strong, ease-out); } @keyframes wireDraw { @@ -86,4 +89,8 @@ .pulse { animation: none; } + + .pulse { + transition: none; + } } diff --git a/src/features/dashboard/components/Meter.module.scss b/src/features/dashboard/components/Meter.module.scss index 095c83de..1748874d 100644 --- a/src/features/dashboard/components/Meter.module.scss +++ b/src/features/dashboard/components/Meter.module.scss @@ -12,7 +12,8 @@ height: 100%; border-radius: $radius-full; background: var(--meter-fill); - transition: width $transition-normal; + // 数据刷新时填充平滑走位;曲线继承页面级 token,独立使用时退回 ease + transition: width 360ms var(--ease-out-strong, ease); @media (prefers-reduced-motion: reduce) { transition: none; diff --git a/src/features/dashboard/components/ThroughputChart.module.scss b/src/features/dashboard/components/ThroughputChart.module.scss index 4cb29ff1..b01c8f98 100644 --- a/src/features/dashboard/components/ThroughputChart.module.scss +++ b/src/features/dashboard/components/ThroughputChart.module.scss @@ -116,6 +116,7 @@ } .column { + position: relative; flex: 1 1 0; min-width: 0; height: 100%; @@ -135,11 +136,14 @@ flex-direction: column; justify-content: flex-end; animation: barGrow 0.42s cubic-bezier(0.4, 0, 0.2, 1) both; - animation-delay: calc(var(--bar-index, 0) * 22ms); + /* 级差由 TSX 按桶数归一化写入,整波 ≤360ms */ + animation-delay: var(--bar-delay, 0ms); transform-origin: bottom center; + transition: filter 120ms ease-out; @media (prefers-reduced-motion: reduce) { animation: none; + transition: none; } } @@ -180,9 +184,9 @@ background: var(--border-primary); } +/* bottom 由 TSX 按柱高写入;柱子全部落定后再淡入 */ .peakLabel { position: absolute; - bottom: 100%; left: 50%; transform: translateX(-50%); margin-bottom: 6px; @@ -192,6 +196,11 @@ font-variant-numeric: tabular-nums; white-space: nowrap; pointer-events: none; + animation: peakFade 240ms ease-out 620ms backwards; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } } .noRequests { @@ -227,6 +236,19 @@ font-size: 12px; color: var(--text-secondary); pointer-events: none; + /* 悬停换柱时滑到新位置(可中断,CSS transition 自然 retarget),入场只淡入 */ + transition: + left 200ms var(--ease-out-strong, ease-out), + transform 200ms var(--ease-out-strong, ease-out), + opacity 150ms ease-out; + + @starting-style { + opacity: 0; + } + + @media (prefers-reduced-motion: reduce) { + transition: opacity 150ms ease-out; + } } .tooltipTime { @@ -345,3 +367,12 @@ opacity: 1; } } + +@keyframes peakFade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/src/features/dashboard/components/ThroughputChart.tsx b/src/features/dashboard/components/ThroughputChart.tsx index d761b6e4..b26e1cef 100644 --- a/src/features/dashboard/components/ThroughputChart.tsx +++ b/src/features/dashboard/components/ThroughputChart.tsx @@ -124,6 +124,9 @@ export function ThroughputChart({ traffic }: ThroughputChartProps) { const successHeight = (bucket.success / scaleMax) * 100; const failureHeight = (bucket.failed / scaleMax) * 100; const hasBoth = bucket.success > 0 && bucket.failed > 0; + /* 级差按桶数归一化:不管窗口多长,整波入场都收在 360ms 内 */ + const barDelayMs = + buckets.length > 1 ? Math.round((index / (buckets.length - 1)) * 360) : 0; return (
setActiveIndex(index)} onClick={() => setActiveIndex((current) => (current === index ? null : index))} > + {/* 峰值直标放在 scaleY 容器之外,避免入场时被一起挤压 */} + {index === peakIndex && peakTotal > 0 && ( + + {peakTotal.toLocaleString()} + + )}
- {index === peakIndex && peakTotal > 0 && ( - {peakTotal.toLocaleString()} - )} {bucket.failed > 0 && ( 1 - (1 - progress) ** 4; @@ -76,6 +79,92 @@ export function useRevealOnScroll(delaySeconds = 0) { return ref; } +/** + * 容器进入视口时,其内 `[data-reveal]` 子元素按 DOM 顺序级联上浮淡入。 + * + * 级差 70ms、整组封顶 360ms(子项多时自动压缩级差),符合 micro-cascade 预算; + * `data-reveal="scale"` 的子项额外从 0.97 缩放入场(用于玻璃面板的 materialize)。 + * 初始态同样在绘制前写入行内样式,脚本缺席时内容始终可见。 + */ +export function useRevealGroup(baseDelaySeconds = 0) { + const ref = useRef(null); + + useLayoutEffect(() => { + const container = ref.current; + if (!container) return; + + const children = Array.from(container.querySelectorAll('[data-reveal]')); + if (children.length === 0) return; + + const hiddenTransform = (element: HTMLElement) => + element.dataset.reveal === 'scale' + ? `translate3d(0, ${REVEAL_DISTANCE}px, 0) scale(0.97)` + : `translate3d(0, ${REVEAL_DISTANCE}px, 0)`; + const settledTransform = (element: HTMLElement) => + element.dataset.reveal === 'scale' ? 'translate3d(0, 0, 0) scale(1)' : 'translate3d(0, 0, 0)'; + + const clearInlineState = (element: HTMLElement) => { + element.style.removeProperty('opacity'); + element.style.removeProperty('transform'); + element.style.removeProperty('will-change'); + }; + const clearAll = () => children.forEach(clearInlineState); + + if (prefersReducedMotion() || typeof IntersectionObserver === 'undefined') { + clearAll(); + return; + } + + children.forEach((element) => { + element.style.opacity = '0'; + element.style.transform = hiddenTransform(element); + element.style.willChange = 'opacity, transform'; + }); + + const step = + children.length > 1 + ? Math.min(GROUP_STAGGER, GROUP_MAX_TOTAL / (children.length - 1)) + : GROUP_STAGGER; + + const animations: Array> = []; + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting) return; + observer.unobserve(entry.target); + children.forEach((element, index) => { + const animation = animate( + element, + { + opacity: [0, 1], + transform: [hiddenTransform(element), settledTransform(element)], + }, + { + duration: REVEAL_DURATION, + delay: baseDelaySeconds + index * step, + ease: easeOutQuart, + } + ); + animations.push(animation); + void animation.finished.then(() => clearInlineState(element)).catch(() => undefined); + }); + }); + }, + { rootMargin: '0px 0px -12% 0px', threshold: 0.05 } + ); + + observer.observe(container); + + return () => { + observer.disconnect(); + animations.forEach((animation) => animation.stop()); + clearAll(); + }; + }, [baseDelaySeconds]); + + return ref; +} + /** * 数字滚动到目标值。减少动效偏好下直接落到终值。 */ diff --git a/src/features/dashboard/dashboard.module.scss b/src/features/dashboard/dashboard.module.scss index 86b227c6..e1b1585d 100644 --- a/src/features/dashboard/dashboard.module.scss +++ b/src/features/dashboard/dashboard.module.scss @@ -10,6 +10,14 @@ --viz-success: #10b981; --viz-failure: #c65746; + /* + * 动效词汇表:内置 ease 太弱,交互反馈统一用强减速曲线。 + * 按压 160ms / 悬停 200ms,UI 动效不超过 300ms。 + */ + --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1); + --dur-press: 160ms; + --dur-hover: 200ms; + position: relative; max-width: 1120px; width: 100%; @@ -119,6 +127,15 @@ display: inline; } +/* 有活流量时状态灯极缓呼吸(仅 opacity,inline 元素安全) */ +.heroPeriodLive { + animation: periodBreath 3.2s ease-in-out 1.8s infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } +} + .heroMeta { margin: 0; font-family: $font-mono; @@ -146,19 +163,21 @@ font-weight: 600; text-decoration: none; transition: - transform $transition-fast, + transform var(--dur-press) var(--ease-out-strong), background-color $transition-fast, border-color $transition-fast, - box-shadow $transition-fast; + box-shadow var(--dur-hover) var(--ease-out-strong), + color $transition-fast; - &:hover { - transform: translateY(-1px); + /* 按下即回应:不等 click,指针落下就缩 */ + &:active { + transform: scale(0.97); } @media (prefers-reduced-motion: reduce) { transition: none; - &:hover { + &:active { transform: none; } } @@ -170,13 +189,29 @@ color: var(--bg-secondary); border: 1px solid transparent; - &:hover { - background: color-mix(in srgb, var(--text-primary) 86%, var(--bg-secondary)); - box-shadow: 0 12px 26px color-mix(in srgb, var(--text-primary) 22%, transparent); + @media (hover: hover) and (pointer: fine) { + &:hover { + transform: translateY(-1px); + background: color-mix(in srgb, var(--text-primary) 86%, var(--bg-secondary)); + box-shadow: 0 12px 26px color-mix(in srgb, var(--text-primary) 22%, transparent); + } + } + + /* 悬停态下按压:抬升让位于回缩 */ + &:active { + transform: translateY(0) scale(0.97); + } + + @media (prefers-reduced-motion: reduce) { + @media (hover: hover) and (pointer: fine) { + &:hover { + transform: none; + } + } } } -/* 次要动作降为安静的文字链接,避免双药丸并排 */ +/* 次要动作降为安静的文字链接:不抬升,箭头代它点头 */ .ghostAction { padding-left: $spacing-xs; padding-right: $spacing-xs; @@ -184,9 +219,26 @@ color: var(--text-secondary); background: transparent; - &:hover { - color: var(--text-primary); - background: transparent; + @media (hover: hover) and (pointer: fine) { + &:hover { + color: var(--text-primary); + background: transparent; + + .linkArrow { + transform: translateX(3px); + } + } + } +} + +/* 文字链接末尾的箭头:hover 时前移 3px,暗示去向 */ +.linkArrow { + display: inline-block; + transition: transform var(--dur-hover, 200ms) var(--ease-out-strong, ease-out); + + @media (prefers-reduced-motion: reduce) { + transition: none; + transform: none !important; } } @@ -350,10 +402,7 @@ border-radius: 14px; border: 1px solid var(--border-color); background: color-mix(in srgb, var(--bg-primary) 82%, transparent); - transition: - transform $transition-fast, - border-color $transition-fast, - box-shadow $transition-fast; + transition: border-color $transition-fast; /* 左上角色签:语义色调由 --tile-accent 注入 */ &::before { @@ -365,18 +414,15 @@ background: var(--tile-accent, var(--border-hover)); } - &:hover { - transform: translateY(-2px); - border-color: var(--border-hover); - box-shadow: var(--shadow-lg); + /* 卡片不可点击,因此不抬升不投影——悬停只轻描边框辅助聚焦 */ + @media (hover: hover) and (pointer: fine) { + &:hover { + border-color: var(--border-hover); + } } @media (prefers-reduced-motion: reduce) { transition: none; - - &:hover { - transform: none; - } } } @@ -492,8 +538,14 @@ color: var(--primary-active); text-decoration: none; - &:hover { - text-decoration: underline; + @media (hover: hover) and (pointer: fine) { + &:hover { + text-decoration: underline; + + .linkArrow { + transform: translateX(3px); + } + } } } @@ -837,29 +889,42 @@ text-decoration: none; overflow: hidden; transition: - transform $transition-fast, + transform var(--dur-press) var(--ease-out-strong), border-color $transition-fast, - box-shadow $transition-fast; + box-shadow var(--dur-hover) var(--ease-out-strong); - &:hover { - transform: translateY(-2px); - border-color: var(--border-hover); - box-shadow: 0 14px 30px rgb(0 0 0 / 0.12); + @media (hover: hover) and (pointer: fine) { + &:hover { + transform: translateY(-2px); + border-color: var(--border-hover); + box-shadow: 0 14px 30px rgb(0 0 0 / 0.12); - .ctaArrow { - transform: translateX(3px); - opacity: 1; + .ctaArrow { + transform: translateX(3px); + opacity: 1; + } } } + /* 整卡可点:按下回缩,面积大所以比按钮更含蓄 */ + &:active { + transform: translateY(0) scale(0.98); + } + @media (prefers-reduced-motion: reduce) { transition: none; - &:hover { + &:active { transform: none; + } - .ctaArrow { + @media (hover: hover) and (pointer: fine) { + &:hover { transform: none; + + .ctaArrow { + transform: none; + } } } } @@ -898,8 +963,18 @@ color: var(--text-tertiary); opacity: 0.55; transition: - transform $transition-fast, - opacity $transition-fast; + transform var(--dur-hover) var(--ease-out-strong), + opacity var(--dur-hover) var(--ease-out-strong); +} + +@keyframes periodBreath { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } } @keyframes livePing {