feat(dashboard): enhance animations and transitions for improved UI responsiveness

This commit is contained in:
Supra4E8C
2026-07-29 04:44:22 +08:00
parent d85afcc2e1
commit 0f87214e26
7 changed files with 297 additions and 66 deletions
+36 -17
View File
@@ -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<HTMLElement>();
const statsRef = useRevealOnScroll<HTMLElement>(0.05);
/* Hero 与静态网格走分组级联;异步内容区(图表/供应商)保持整块 reveal */
const heroRef = useRevealGroup<HTMLElement>();
const statsRef = useRevealGroup<HTMLElement>(0.12);
const trafficRef = useRevealOnScroll<HTMLElement>();
const fleetRef = useRevealOnScroll<HTMLElement>();
const detailRef = useRevealOnScroll<HTMLElement>();
const ctaRef = useRevealOnScroll<HTMLElement>();
const detailRef = useRevealGroup<HTMLElement>();
const ctaRef = useRevealGroup<HTMLElement>();
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 ---------- */}
<section className={styles.hero} ref={heroRef}>
<div className={styles.heroCopy}>
<h1 className={styles.heroTitle}>
<h1 className={styles.heroTitle} data-reveal>
{t(`dashboard.${verdict.key}`)}
<span className={styles.heroPeriod} style={{ color: verdict.accent }}>
<span
className={`${styles.heroPeriod} ${heroAlive ? styles.heroPeriodLive : ''}`}
style={{ color: verdict.accent }}
>
{t('dashboard.hero_period')}
</span>
</h1>
<p className={styles.heroMeta}>{heroMetaLine}</p>
<div className={styles.heroActions}>
<p className={styles.heroMeta} data-reveal>
{heroMetaLine}
</p>
<div className={styles.heroActions} data-reveal>
<Link to="/ai-providers" className={styles.primaryAction}>
{t('dashboard.cta_manage_providers')}
</Link>
<Link to="/logs" className={styles.ghostAction}>
{t('dashboard.cta_inspect_logs')} <span aria-hidden="true"></span>
{t('dashboard.cta_inspect_logs')}{' '}
<span className={styles.linkArrow} aria-hidden="true">
</span>
</Link>
</div>
</div>
<div className={styles.heroPanel}>
<div className={styles.heroPanel} data-reveal="scale">
<div className={styles.heroPanelTop}>
<span className={styles.heroPanelLabel}>{t('dashboard.hero_requests_label')}</span>
{connected && (
@@ -301,6 +313,7 @@ export function DashboardPage() {
<article
key={tile.key}
className={styles.statTile}
data-reveal
style={
{
'--tile-accent': tile.tone ? TILE_ACCENTS[tile.tone] : 'var(--border-hover)',
@@ -391,7 +404,7 @@ export function DashboardPage() {
{/* ---------- Credential health + runtime ---------- */}
<section className={styles.detailGrid} ref={detailRef}>
<div className={styles.panel}>
<div className={styles.panel} data-reveal>
<header className={styles.panelHead}>
<span className={styles.eyebrow}>{t('dashboard.health_eyebrow')}</span>
<h2 className={styles.panelTitle}>{t('dashboard.health_title')}</h2>
@@ -455,13 +468,16 @@ export function DashboardPage() {
</ul>
</div>
<Link to="/auth-files" className={styles.panelLink}>
{t('dashboard.health_link')} <span aria-hidden="true"></span>
{t('dashboard.health_link')}{' '}
<span className={styles.linkArrow} aria-hidden="true">
</span>
</Link>
</>
)}
</div>
<div className={styles.panel}>
<div className={styles.panel} data-reveal>
<header className={styles.panelHead}>
<span className={styles.eyebrow}>{t('dashboard.runtime_eyebrow')}</span>
<h2 className={styles.panelTitle}>{t('dashboard.runtime_title')}</h2>
@@ -490,20 +506,23 @@ export function DashboardPage() {
</ul>
)}
<Link to="/config" className={styles.panelLink}>
{t('dashboard.runtime_link')} <span aria-hidden="true"></span>
{t('dashboard.runtime_link')}{' '}
<span className={styles.linkArrow} aria-hidden="true">
</span>
</Link>
</div>
</section>
{/* ---------- CTA ---------- */}
<section className={styles.section} ref={ctaRef}>
<header className={styles.sectionHead}>
<header className={styles.sectionHead} data-reveal>
<span className={styles.eyebrow}>{t('dashboard.cta_eyebrow')}</span>
<h2 className={styles.sectionTitle}>{t('dashboard.cta_title')}</h2>
</header>
<div className={styles.ctaGrid}>
{ctaCards.map((card) => (
<Link key={card.to} to={card.to} className={styles.ctaCard}>
<Link key={card.to} to={card.to} className={styles.ctaCard} data-reveal>
<span className={styles.ctaIcon}>{card.icon}</span>
<span className={styles.ctaTitle}>{card.title}</span>
<span className={styles.ctaDescription}>{card.description}</span>
@@ -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;
}
}
@@ -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;
@@ -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;
}
}
@@ -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 (
<div
@@ -132,13 +135,19 @@ export function ThroughputChart({ traffic }: ThroughputChartProps) {
onMouseEnter={() => setActiveIndex(index)}
onClick={() => setActiveIndex((current) => (current === index ? null : index))}
>
{/* 峰值直标放在 scaleY 容器之外,避免入场时被一起挤压 */}
{index === peakIndex && peakTotal > 0 && (
<span
className={styles.peakLabel}
style={{ bottom: `${Math.min(100, (bucketTotal / scaleMax) * 100)}%` }}
>
{peakTotal.toLocaleString()}
</span>
)}
<div
className={styles.stack}
style={{ '--bar-index': index } as React.CSSProperties}
style={{ '--bar-delay': `${barDelayMs}ms` } as React.CSSProperties}
>
{index === peakIndex && peakTotal > 0 && (
<span className={styles.peakLabel}>{peakTotal.toLocaleString()}</span>
)}
{bucket.failed > 0 && (
<span
className={`${styles.segment} ${styles.segmentFailure} ${
@@ -8,6 +8,9 @@ import { animate } from 'motion/mini';
const REVEAL_DISTANCE = 24;
const REVEAL_DURATION = 0.45;
const COUNT_UP_DURATION = 900;
/** 分组入场的级差与总预算:级差再大也不许整组拖过 360ms */
const GROUP_STAGGER = 0.07;
const GROUP_MAX_TOTAL = 0.36;
const easeOutQuart = (progress: number) => 1 - (1 - progress) ** 4;
@@ -76,6 +79,92 @@ export function useRevealOnScroll<T extends HTMLElement>(delaySeconds = 0) {
return ref;
}
/**
* 容器进入视口时,其内 `[data-reveal]` 子元素按 DOM 顺序级联上浮淡入。
*
* 级差 70ms、整组封顶 360ms(子项多时自动压缩级差),符合 micro-cascade 预算;
* `data-reveal="scale"` 的子项额外从 0.97 缩放入场(用于玻璃面板的 materialize)。
* 初始态同样在绘制前写入行内样式,脚本缺席时内容始终可见。
*/
export function useRevealGroup<T extends HTMLElement>(baseDelaySeconds = 0) {
const ref = useRef<T | null>(null);
useLayoutEffect(() => {
const container = ref.current;
if (!container) return;
const children = Array.from(container.querySelectorAll<HTMLElement>('[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<ReturnType<typeof animate>> = [];
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;
}
/**
* 数字滚动到目标值。减少动效偏好下直接落到终值。
*/
+114 -39
View File
@@ -10,6 +10,14 @@
--viz-success: #10b981;
--viz-failure: #c65746;
/*
* 动效词汇表:内置 ease 太弱,交互反馈统一用强减速曲线。
* 按压 160ms / 悬停 200msUI 动效不超过 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 {