mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-01 15:34:05 +08:00
feat(dashboard): enhance animations and transitions for improved UI responsiveness
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字滚动到目标值。减少动效偏好下直接落到终值。
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user