Some checks failed
CI Check / check (push) Has been cancelled
- 创建统一的Modal组件,采用Apple风格设计 - 实现毛玻璃效果、圆角、动画和细边框描边 - 修复弹窗打开时背景滚动问题 - 修复分类管理中重复卡片和数组字符串显示问题 - 优化选项解析逻辑,添加去重和过滤机制
371 lines
14 KiB
TypeScript
371 lines
14 KiB
TypeScript
/**
|
||
* 统一的动画工具类
|
||
* 苹果风格 + 炫彩元素
|
||
*/
|
||
|
||
// 基础动画类
|
||
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)
|
||
})
|
||
})
|
||
}
|
||
|