346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
/**
|
||
* 统一的动画工具类
|
||
* 面向后台工作台的低干扰动画与状态类
|
||
*/
|
||
|
||
// 基础动画类
|
||
export const animations = {
|
||
// 淡入淡出
|
||
fadeIn: 'animate-in fade-in duration-500',
|
||
fadeOut: 'animate-out fade-out duration-300',
|
||
fadeInUp: 'animate-in fade-in duration-300',
|
||
fadeInDown: 'animate-in fade-in duration-300',
|
||
fadeInLeft: 'animate-in fade-in duration-300',
|
||
fadeInRight: 'animate-in fade-in duration-300',
|
||
|
||
// 缩放
|
||
scaleIn: 'animate-in zoom-in-95 duration-300',
|
||
scaleOut: 'animate-out zoom-out-95 duration-200',
|
||
scaleUp: 'active:scale-[0.99] transition-transform duration-150',
|
||
|
||
// 旋转
|
||
spin: 'animate-spin',
|
||
spinSlow: 'animate-spin-slow',
|
||
|
||
// 脉冲
|
||
pulse: 'animate-pulse',
|
||
pulseSlow: 'animate-pulse-slow',
|
||
|
||
// 弹跳
|
||
bounce: 'animate-bounce',
|
||
bounceIn: 'animate-in fade-in duration-300',
|
||
|
||
// 滑动
|
||
slideInFromLeft: 'animate-in fade-in duration-300',
|
||
slideInFromRight: 'animate-in fade-in duration-300',
|
||
slideInFromTop: 'animate-in fade-in duration-300',
|
||
slideInFromBottom: 'animate-in fade-in duration-300',
|
||
|
||
// 交错动画延迟
|
||
stagger: {
|
||
'0': 'delay-0',
|
||
'75': 'delay-75',
|
||
'100': 'delay-100',
|
||
'150': 'delay-150',
|
||
'200': 'delay-200',
|
||
'300': 'delay-300',
|
||
'500': 'delay-500',
|
||
},
|
||
} as const
|
||
|
||
// 背景与边框状态
|
||
export const gradientBackgrounds = {
|
||
appleLight: 'bg-slate-50 dark:bg-slate-900',
|
||
appleDark: 'bg-slate-900 dark:bg-slate-950',
|
||
|
||
// 兼容旧调用的普通后台背景(CSS linear-gradient 格式,用于内联样式)
|
||
heroBase: {
|
||
light: '#f8fafc',
|
||
dark: '#0f172a',
|
||
},
|
||
|
||
rainbow: 'bg-slate-100 dark:bg-slate-800',
|
||
rainbowSubtle: 'bg-slate-50 dark:bg-slate-900',
|
||
rainbowVibrant: 'bg-slate-100 dark:bg-slate-800',
|
||
|
||
neon: 'bg-slate-100 dark:bg-slate-800',
|
||
neonSubtle: 'bg-slate-50 dark:bg-slate-900',
|
||
|
||
metallic: 'bg-slate-100 dark:bg-slate-800',
|
||
|
||
glass: 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700',
|
||
glassStrong: 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700',
|
||
glassNav: 'bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700',
|
||
glassNavLight: 'bg-white border-b border-slate-200',
|
||
glassNavDark: 'bg-slate-900 border-b border-slate-700',
|
||
} as const
|
||
|
||
// 阴影效果
|
||
export const glowEffects = {
|
||
glow: 'shadow-sm',
|
||
glowStrong: 'shadow-md',
|
||
glowRainbow: 'shadow-sm',
|
||
|
||
glowPink: 'shadow-sm',
|
||
glowPurple: 'shadow-sm',
|
||
glowBlue: 'shadow-sm',
|
||
glowCyan: 'shadow-sm',
|
||
|
||
glowHover: 'hover:shadow-sm transition-shadow duration-150',
|
||
glowHoverRainbow: 'hover:shadow-sm transition-shadow duration-150',
|
||
} as const
|
||
|
||
// 边框动画
|
||
export const borderAnimations = {
|
||
gradientBorder: 'border border-slate-200 dark:border-slate-700',
|
||
|
||
shimmer: 'relative overflow-hidden',
|
||
|
||
neon: 'border border-slate-300 dark:border-slate-600',
|
||
neonHover: 'hover:border-slate-400 dark:hover:border-slate-500 transition-colors duration-150',
|
||
} as const
|
||
|
||
// 文本动画
|
||
export const textAnimations = {
|
||
gradientText: 'text-slate-900 dark:text-slate-100',
|
||
gradientTextRainbow: 'text-slate-900 dark:text-slate-100',
|
||
|
||
shimmerText: 'text-slate-500 dark:text-slate-400',
|
||
|
||
glowText: 'text-slate-700 dark:text-slate-200',
|
||
glowTextRainbow: 'text-slate-700 dark:text-slate-200',
|
||
} as const
|
||
|
||
// 卡片动画
|
||
export const cardAnimations = {
|
||
// 悬停提升
|
||
lift: 'transition-colors duration-150',
|
||
liftStrong: 'transition-colors duration-150',
|
||
|
||
tilt: 'transition-colors duration-150',
|
||
|
||
// 闪烁效果
|
||
shimmer: 'relative overflow-hidden',
|
||
|
||
// 光晕跟随鼠标
|
||
glowFollow: 'transition-colors duration-150',
|
||
} as const
|
||
|
||
// 按钮动画
|
||
export const buttonAnimations = {
|
||
// 基础按钮动画
|
||
base: 'transition-colors duration-150',
|
||
|
||
rainbow: 'bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white transition-colors duration-150',
|
||
|
||
neon: 'bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white transition-colors duration-150',
|
||
|
||
glass: 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150',
|
||
|
||
pulse: 'transition-colors duration-150',
|
||
} as const
|
||
|
||
// 组合动画工具函数
|
||
export function combineAnimations(...animations: string[]): string {
|
||
return animations.filter(Boolean).join(' ')
|
||
}
|
||
|
||
// 条件动画
|
||
export function conditionalAnimation(condition: boolean, animation: string, fallback = ''): string {
|
||
return condition ? animation : fallback
|
||
}
|
||
|
||
// 滚动动画
|
||
export const scrollAnimations = {
|
||
// 平滑滚动
|
||
smoothScroll: 'scroll-smooth',
|
||
|
||
// 滚动时淡入淡出
|
||
fadeOnScroll: 'transition-opacity duration-300 ease-out',
|
||
|
||
// 滚动时缩放
|
||
scaleOnScroll: 'transition-transform duration-300 ease-out',
|
||
|
||
// 滚动时位移
|
||
translateOnScroll: 'transition-transform duration-300 ease-out',
|
||
|
||
spring: 'transition-colors duration-150 ease-out',
|
||
springSmooth: 'transition-colors duration-150 ease-out',
|
||
|
||
// 滚动时隐藏/显示
|
||
hideOnScroll: 'transition-all duration-300 ease-out opacity-0 -translate-y-2',
|
||
showOnScroll: 'transition-all duration-300 ease-out opacity-100 translate-y-0',
|
||
|
||
elasticScroll: 'scroll-smooth',
|
||
} as const
|
||
|
||
// 平滑的弹性缓动函数(减少弹性效果,更柔和)
|
||
function easeOutElastic(t: number): number {
|
||
if (t === 0) return 0
|
||
if (t === 1) return 1
|
||
|
||
// 进一步减少弹性效果:降低振幅和频率
|
||
const c4 = (2 * Math.PI) / 3
|
||
// 使用更小的衰减系数和更低的频率,让动画更平滑
|
||
return Math.pow(2, -6 * t) * Math.sin((t * 4 - 0.3) * c4) + 1
|
||
}
|
||
|
||
function easeInOutCubic(t: number): number {
|
||
return t < 0.5
|
||
? 4 * t * t * t
|
||
: 1 - Math.pow(-2 * t + 2, 3) / 2
|
||
}
|
||
|
||
export function scrollToElementWithSpring(
|
||
element: HTMLElement | null,
|
||
container: HTMLElement | null = null,
|
||
options: {
|
||
offset?: number
|
||
duration?: number
|
||
retryCount?: number
|
||
easing?: 'elastic' | 'ease-in-out'
|
||
} = {}
|
||
): void {
|
||
if (!element) return
|
||
|
||
const {
|
||
offset = 24,
|
||
duration = 600,
|
||
retryCount = 0,
|
||
easing = 'elastic',
|
||
} = options
|
||
|
||
// 如果没有提供容器,尝试查找滚动容器
|
||
let targetContainer = container || element.closest('[data-scroll-container]') as HTMLElement
|
||
|
||
// 如果仍然没有找到容器,使用页面级别的滚动(window)
|
||
if (!targetContainer) {
|
||
// 使用 window 作为滚动容器,需要特殊处理
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
const elementRect = element.getBoundingClientRect()
|
||
const startScrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||
const targetScrollTop = startScrollTop + elementRect.top - offset
|
||
|
||
const maxScrollTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
|
||
const clampedTargetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
|
||
|
||
const scrollDifference = Math.abs(clampedTargetScrollTop - startScrollTop)
|
||
if (scrollDifference < 1) {
|
||
return
|
||
}
|
||
|
||
const startTime = performance.now()
|
||
const easingFunc = easing === 'ease-in-out' ? easeInOutCubic : easeOutElastic
|
||
|
||
const animate = (currentTime: number) => {
|
||
const elapsed = currentTime - startTime
|
||
const progress = Math.min(elapsed / duration, 1)
|
||
|
||
const eased = easingFunc(progress)
|
||
const currentScrollTop = startScrollTop + (clampedTargetScrollTop - startScrollTop) * eased
|
||
|
||
window.scrollTo(0, currentScrollTop)
|
||
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animate)
|
||
}
|
||
}
|
||
|
||
requestAnimationFrame(animate)
|
||
})
|
||
})
|
||
return
|
||
}
|
||
|
||
// 使用双重 requestAnimationFrame 确保 DOM 完全更新
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
const startScrollTop = targetContainer.scrollTop
|
||
|
||
// 使用 getBoundingClientRect 计算元素相对于容器的位置
|
||
const containerRect = targetContainer.getBoundingClientRect()
|
||
const elementRect = element.getBoundingClientRect()
|
||
|
||
// 元素相对于容器可视区域顶部的位置
|
||
const elementTopRelativeToViewport = elementRect.top
|
||
const containerTopRelativeToViewport = containerRect.top
|
||
const elementTopRelativeToContainer = elementTopRelativeToViewport - containerTopRelativeToViewport
|
||
|
||
// 计算目标滚动位置:当前滚动位置 + 元素在可视区域的位置 - offset
|
||
const targetScrollTop = startScrollTop + elementTopRelativeToContainer - offset
|
||
|
||
// 确保目标位置在有效范围内
|
||
const maxScrollTop = Math.max(0, targetContainer.scrollHeight - targetContainer.clientHeight)
|
||
|
||
console.log('[scrollToElementWithSpring] Scroll calculation:', {
|
||
startScrollTop,
|
||
elementTopRelativeToContainer,
|
||
targetScrollTop,
|
||
maxScrollTop,
|
||
scrollHeight: targetContainer.scrollHeight,
|
||
clientHeight: targetContainer.clientHeight,
|
||
offset,
|
||
retryCount
|
||
})
|
||
|
||
// 如果容器不可滚动(maxScrollTop === 0),但目标位置需要滚动(targetScrollTop > 0)
|
||
// 说明容器还没有完全渲染,需要延迟重试
|
||
if (maxScrollTop === 0 && targetScrollTop > 0 && retryCount < 5) {
|
||
console.log('[scrollToElementWithSpring] Container not ready, retrying...', { retryCount })
|
||
setTimeout(() => {
|
||
scrollToElementWithSpring(element, container, { ...options, retryCount: retryCount + 1 })
|
||
}, 150)
|
||
return
|
||
}
|
||
|
||
// 如果重试多次后仍然不可滚动,使用 scrollIntoView 作为后备
|
||
if (maxScrollTop === 0 && targetScrollTop > 0) {
|
||
console.log('[scrollToElementWithSpring] Container not scrollable after retries, using scrollIntoView fallback')
|
||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
return
|
||
}
|
||
|
||
const clampedTargetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
|
||
|
||
// 如果已经在目标位置,跳过动画
|
||
const scrollDifference = Math.abs(clampedTargetScrollTop - startScrollTop)
|
||
if (scrollDifference < 1) {
|
||
console.log('[scrollToElementWithSpring] Already at target position, skipping animation')
|
||
return
|
||
}
|
||
|
||
console.log('[scrollToElementWithSpring] Starting animation', {
|
||
from: startScrollTop,
|
||
to: clampedTargetScrollTop,
|
||
difference: scrollDifference
|
||
})
|
||
|
||
// 开始弹性滚动动画
|
||
const startTime = performance.now()
|
||
|
||
const animate = (currentTime: number) => {
|
||
const elapsed = currentTime - startTime
|
||
const progress = Math.min(elapsed / duration, 1)
|
||
|
||
// 根据选项选择缓动函数
|
||
const eased = easing === 'ease-in-out'
|
||
? easeInOutCubic(progress)
|
||
: easeOutElastic(progress)
|
||
const currentScrollTop = startScrollTop + (clampedTargetScrollTop - startScrollTop) * eased
|
||
|
||
targetContainer.scrollTop = currentScrollTop
|
||
|
||
if (progress < 1) {
|
||
requestAnimationFrame(animate)
|
||
} else {
|
||
console.log('[scrollToElementWithSpring] Animation completed')
|
||
}
|
||
}
|
||
|
||
requestAnimationFrame(animate)
|
||
})
|
||
})
|
||
}
|
||
|