flnxi-Wu/lib/ui-animations.ts
WuYongJie 8705b9846a
Some checks failed
CI Check / check (push) Has been cancelled
feat: 统一弹窗样式系统与分类管理优化
- 创建统一的Modal组件,采用Apple风格设计
- 实现毛玻璃效果、圆角、动画和细边框描边
- 修复弹窗打开时背景滚动问题
- 修复分类管理中重复卡片和数组字符串显示问题
- 优化选项解析逻辑,添加去重和过滤机制
2025-11-25 09:37:00 +08:00

371 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 统一的动画工具类
* 苹果风格 + 炫彩元素
*/
// 基础动画类
export const animations = {
// 淡入淡出
fadeIn: 'animate-in fade-in duration-500',
fadeOut: 'animate-out fade-out duration-300',
fadeInUp: 'animate-in fade-in slide-in-from-bottom-4 duration-500',
fadeInDown: 'animate-in fade-in slide-in-from-top-4 duration-500',
fadeInLeft: 'animate-in fade-in slide-in-from-left-4 duration-500',
fadeInRight: 'animate-in fade-in slide-in-from-right-4 duration-500',
// 缩放
scaleIn: 'animate-in zoom-in-95 duration-300',
scaleOut: 'animate-out zoom-out-95 duration-200',
scaleUp: 'hover:scale-105 active:scale-95 transition-transform duration-200',
// 旋转
spin: 'animate-spin',
spinSlow: 'animate-spin-slow',
// 脉冲
pulse: 'animate-pulse',
pulseSlow: 'animate-pulse-slow',
// 弹跳
bounce: 'animate-bounce',
bounceIn: 'animate-in bounce-in duration-500',
// 滑动
slideInFromLeft: 'animate-in slide-in-from-left duration-500',
slideInFromRight: 'animate-in slide-in-from-right duration-500',
slideInFromTop: 'animate-in slide-in-from-top duration-500',
slideInFromBottom: 'animate-in slide-in-from-bottom duration-500',
// 交错动画延迟
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 = {
// 苹果风格渐变(统一 Hero 卡片背景颜色)
appleLight: 'bg-gradient-to-br from-slate-50 via-blue-50 to-indigo-50 dark:from-slate-900 dark:via-slate-800 dark:to-indigo-900',
appleDark: 'bg-gradient-to-br from-slate-900 via-indigo-900 to-purple-900 dark:from-slate-950 dark:via-indigo-950 dark:to-purple-950',
// Hero 卡片背景渐变(CSS linear-gradient 格式,用于内联样式)
heroBase: {
light: 'linear-gradient(135deg, #f8fafc 0%, #eff6ff 50%, #eef2ff 100%)',
dark: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #1e1b4b 100%)',
},
// 炫彩渐变
rainbow: 'bg-gradient-to-r from-pink-500 via-red-500 via-yellow-500 via-green-500 via-blue-500 via-indigo-500 to-purple-500',
rainbowSubtle: 'bg-gradient-to-br from-pink-100 via-purple-100 via-indigo-100 to-blue-100 dark:from-pink-900/20 dark:via-purple-900/20 dark:via-indigo-900/20 dark:to-blue-900/20',
rainbowVibrant: 'bg-gradient-to-br from-pink-500/20 via-purple-500/20 via-indigo-500/20 to-blue-500/20',
// 霓虹效果
neon: 'bg-gradient-to-r from-cyan-400 via-blue-500 to-purple-500',
neonSubtle: 'bg-gradient-to-br from-cyan-100 via-blue-100 to-purple-100 dark:from-cyan-900/30 dark:via-blue-900/30 dark:to-purple-900/30',
// 金属质感
metallic: 'bg-gradient-to-br from-gray-200 via-gray-300 to-gray-400 dark:from-gray-700 dark:via-gray-600 dark:to-gray-500',
// 玻璃态
glass: 'bg-white/10 dark:bg-white/5 backdrop-blur-md border border-white/20',
glassStrong: 'bg-white/20 dark:bg-white/10 backdrop-blur-lg border border-white/30',
// 毛玻璃半透明(用于顶部栏)
glassNav: 'bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl border-b border-white/20 dark:border-slate-700/30',
glassNavLight: 'bg-white/70 backdrop-blur-xl border-b border-slate-200/50',
glassNavDark: 'bg-slate-900/70 backdrop-blur-xl border-b border-slate-700/50',
} as const
// 炫彩光晕效果
export const glowEffects = {
// 基础光晕
glow: 'shadow-lg shadow-indigo-500/50',
glowStrong: 'shadow-xl shadow-indigo-500/70',
glowRainbow: 'shadow-xl shadow-[0_0_30px_rgba(139,92,246,0.5)]',
// 彩色光晕
glowPink: 'shadow-lg shadow-pink-500/50',
glowPurple: 'shadow-lg shadow-purple-500/50',
glowBlue: 'shadow-lg shadow-blue-500/50',
glowCyan: 'shadow-lg shadow-cyan-500/50',
// 悬停光晕
glowHover: 'hover:shadow-xl hover:shadow-indigo-500/60 transition-shadow duration-300',
glowHoverRainbow: 'hover:shadow-2xl hover:shadow-[0_0_40px_rgba(139,92,246,0.6)] transition-shadow duration-300',
} as const
// 边框动画
export const borderAnimations = {
// 渐变边框
gradientBorder: 'border-2 border-transparent bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 bg-clip-border',
// 闪烁边框
shimmer: 'relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent',
// 霓虹边框
neon: 'border-2 border-cyan-400 shadow-[0_0_10px_rgba(34,211,238,0.5)]',
neonHover: 'hover:border-cyan-300 hover:shadow-[0_0_20px_rgba(34,211,238,0.7)] transition-all duration-300',
} as const
// 文本动画
export const textAnimations = {
// 渐变文本
gradientText: 'bg-gradient-to-r from-indigo-600 via-purple-600 to-pink-600 bg-clip-text text-transparent',
gradientTextRainbow: 'bg-gradient-to-r from-pink-500 via-red-500 via-yellow-500 via-green-500 via-blue-500 via-indigo-500 to-purple-500 bg-clip-text text-transparent',
// 闪烁文本
shimmerText: 'animate-[shimmer_2s_infinite] bg-gradient-to-r from-gray-400 via-white to-gray-400 bg-clip-text text-transparent',
// 发光文本
glowText: 'text-indigo-400 drop-shadow-[0_0_8px_rgba(99,102,241,0.8)]',
glowTextRainbow: 'drop-shadow-[0_0_10px_rgba(139,92,246,0.6)]',
} as const
// 卡片动画
export const cardAnimations = {
// 悬停提升
lift: 'transition-all duration-300 hover:-translate-y-1 hover:shadow-xl',
liftStrong: 'transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl',
// 3D 倾斜效果
tilt: 'transition-all duration-300 hover:rotate-1 hover:scale-105',
// 闪烁效果
shimmer: 'relative overflow-hidden before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent',
// 光晕跟随鼠标
glowFollow: 'transition-all duration-500',
} as const
// 按钮动画
export const buttonAnimations = {
// 基础按钮动画
base: 'transition-all duration-200 active:scale-95',
// 炫彩按钮
rainbow: 'bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 hover:from-pink-600 hover:via-purple-600 hover:to-indigo-600 transition-all duration-300',
// 霓虹按钮
neon: 'bg-cyan-500 hover:bg-cyan-400 shadow-lg shadow-cyan-500/50 hover:shadow-xl hover:shadow-cyan-500/70 transition-all duration-300',
// 玻璃态按钮
glass: 'bg-white/10 dark:bg-white/5 backdrop-blur-md border border-white/20 hover:bg-white/20 dark:hover:bg-white/10 transition-all duration-300',
// 脉冲按钮
pulse: 'animate-pulse hover:animate-none',
} 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-all duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
springSmooth: 'transition-all duration-400 ease-[cubic-bezier(0.25,0.46,0.45,0.94)]',
// 滚动时隐藏/显示
hideOnScroll: 'transition-all duration-300 ease-out opacity-0 -translate-y-2',
showOnScroll: 'transition-all duration-300 ease-out opacity-100 translate-y-0',
// 苹果风格的弹性滚动(用于 scrollIntoView)
// 使用 CSS scroll-behavior: smooth 配合 JavaScript 实现弹性效果
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
}
// 苹果风格的缓入缓出函数(ease-in-out-cubic)
function easeInOutCubic(t: number): number {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2
}
// 苹果风格的弹性滚动函数(用于 JavaScript 调用)
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)
})
})
}