- 重构 Header 导航、Footer 页脚、MobileMenu 移动端菜单 - 新增 MobileTabBar 移动端底部导航栏 - 更新 MegaDropdown 大型下拉导航 - 重构 SEO 结构化数据组件 - 更新数据层常量 (products, services, solutions, navigation 等) - 新增 useCountUp 数字递增 Hook - 修复 .gitignore 中 src/lib 被错误忽略的问题
358 lines
10 KiB
TypeScript
358 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useRef, useCallback, useEffect, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { ArrowLeft, ArrowRight } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface SwipeGestureOptions {
|
|
threshold?: number;
|
|
edgeSize?: number;
|
|
enabled?: boolean;
|
|
onSwipeLeft?: () => void;
|
|
onSwipeRight?: () => void;
|
|
}
|
|
|
|
interface SwipeState {
|
|
isSwiping: boolean;
|
|
startX: number;
|
|
currentX: number;
|
|
progress: number;
|
|
direction: 'left' | 'right' | null;
|
|
}
|
|
|
|
export function useSwipeGesture(options: SwipeGestureOptions = {}) {
|
|
const {
|
|
edgeSize = 30,
|
|
enabled = true,
|
|
onSwipeLeft,
|
|
onSwipeRight,
|
|
} = options;
|
|
|
|
const swipeState = useRef<SwipeState>({
|
|
isSwiping: false,
|
|
startX: 0,
|
|
currentX: 0,
|
|
progress: 0,
|
|
direction: null,
|
|
});
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleTouchStart = useCallback((e: Event) => {
|
|
if (!enabled) return;
|
|
|
|
const touchEvent = e as TouchEvent;
|
|
const touch = touchEvent.touches?.[0];
|
|
if (!touch) return;
|
|
|
|
const x = touch.clientX;
|
|
|
|
if (x > window.innerWidth - edgeSize || x < edgeSize) {
|
|
swipeState.current = {
|
|
isSwiping: true,
|
|
startX: x,
|
|
currentX: x,
|
|
progress: 0,
|
|
direction: null,
|
|
};
|
|
}
|
|
}, [enabled, edgeSize]);
|
|
|
|
const handleTouchMove = useCallback((e: Event) => {
|
|
if (!swipeState.current.isSwiping) return;
|
|
|
|
e.preventDefault();
|
|
const touchEvent = e as TouchEvent;
|
|
const touch = touchEvent.touches?.[0];
|
|
if (!touch) return;
|
|
|
|
swipeState.current.currentX = touch.clientX;
|
|
const deltaX = swipeState.current.currentX - swipeState.current.startX;
|
|
const absDeltaX = Math.abs(deltaX);
|
|
|
|
if (absDeltaX > 10) {
|
|
swipeState.current.direction = deltaX > 0 ? 'right' : 'left';
|
|
swipeState.current.progress = Math.min(absDeltaX / (window.innerWidth * 0.35), 1);
|
|
}
|
|
}, []);
|
|
|
|
const handleTouchEnd = useCallback(() => {
|
|
if (!swipeState.current.isSwiping) return;
|
|
|
|
const { progress, direction } = swipeState.current;
|
|
|
|
if (progress > 0.3 && direction) {
|
|
if (direction === 'left') {
|
|
triggerHaptic('light');
|
|
onSwipeLeft?.();
|
|
} else {
|
|
triggerHaptic('light');
|
|
onSwipeRight?.();
|
|
}
|
|
}
|
|
|
|
swipeState.current = {
|
|
isSwiping: false,
|
|
startX: 0,
|
|
currentX: 0,
|
|
progress: 0,
|
|
direction: null,
|
|
};
|
|
}, [onSwipeLeft, onSwipeRight]);
|
|
|
|
useEffect(() => {
|
|
const element = containerRef.current || document;
|
|
|
|
element.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
element.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
element.addEventListener('touchend', handleTouchEnd, { passive: true });
|
|
|
|
return () => {
|
|
element.removeEventListener('touchstart', handleTouchStart);
|
|
element.removeEventListener('touchmove', handleTouchMove);
|
|
element.removeEventListener('touchend', handleTouchEnd);
|
|
};
|
|
}, [handleTouchStart, handleTouchMove, handleTouchEnd]);
|
|
|
|
return { containerRef, swipeState };
|
|
}
|
|
|
|
function triggerHaptic(style: 'light' | 'medium' | 'heavy' = 'light') {
|
|
if ('vibrate' in navigator) {
|
|
switch (style) {
|
|
case 'light':
|
|
navigator.vibrate(10);
|
|
break;
|
|
case 'medium':
|
|
navigator.vibrate(25);
|
|
break;
|
|
case 'heavy':
|
|
navigator.vibrate(50);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
interface SwipeNavigationProps {
|
|
prevRoute?: string;
|
|
nextRoute?: string;
|
|
prevLabel?: string;
|
|
nextLabel?: string;
|
|
className?: string;
|
|
}
|
|
|
|
export function SwipeNavigation({
|
|
prevRoute,
|
|
nextRoute,
|
|
prevLabel = '上一页',
|
|
nextLabel = '下一页',
|
|
className,
|
|
}: SwipeNavigationProps) {
|
|
const router = useRouter();
|
|
const [swipeHint, setSwipeHint] = useState<'prev' | 'next' | null>(null);
|
|
const [showHint, setShowHint] = useState(false);
|
|
|
|
const { containerRef } = useSwipeGesture({
|
|
onSwipeLeft: () => {
|
|
if (nextRoute) {
|
|
setSwipeHint('next');
|
|
setTimeout(() => router.push(nextRoute), 200);
|
|
}
|
|
},
|
|
onSwipeRight: () => {
|
|
if (prevRoute) {
|
|
setSwipeHint('prev');
|
|
setTimeout(() => router.push(prevRoute), 200);
|
|
}
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
const hasVisited = sessionStorage.getItem('swipe-hint-shown');
|
|
if (!hasVisited && (prevRoute || nextRoute)) {
|
|
const timer = setTimeout(() => setShowHint(true), 1500);
|
|
sessionStorage.setItem('swipe-hint-shown', 'true');
|
|
return () => clearTimeout(timer);
|
|
}
|
|
return undefined;
|
|
}, [prevRoute, nextRoute]);
|
|
|
|
useEffect(() => {
|
|
if (showHint) {
|
|
const timer = setTimeout(() => setShowHint(false), 3000);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
return undefined;
|
|
}, [showHint]);
|
|
|
|
return (
|
|
<>
|
|
<div ref={containerRef as React.RefObject<HTMLDivElement>} className={cn(className)} />
|
|
|
|
<AnimatePresence>
|
|
{swipeHint && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: 20 }}
|
|
className={cn(
|
|
'fixed bottom-24 left-1/2 -translate-x-1/2 z-50',
|
|
'px-4 py-2 rounded-full shadow-lg backdrop-blur-md',
|
|
'bg-[var(--color-brand)] text-white text-sm font-medium',
|
|
'flex items-center gap-2 pointer-events-none'
|
|
)}
|
|
>
|
|
{swipeHint === 'prev' ? (
|
|
<>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
<span>{prevLabel}</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span>{nextLabel}</span>
|
|
<ArrowRight className="w-4 h-4" />
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<AnimatePresence>
|
|
{showHint && (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
className="fixed bottom-32 left-1/2 -translate-x-1/2 z-40"
|
|
>
|
|
<div className="bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] rounded-xl px-4 py-3 shadow-lg">
|
|
<p className="text-xs text-[var(--color-text-muted)] mb-1">提示</p>
|
|
<p className="text-sm text-[var(--color-text-primary)]">
|
|
尝试左右滑动屏幕切换页面
|
|
</p>
|
|
<div className="flex items-center justify-center gap-4 mt-2">
|
|
{prevRoute && (
|
|
<div className="flex items-center gap-1 text-[var(--color-brand)]">
|
|
<ArrowLeft className="w-3 h-3" />
|
|
<span className="text-xs">{prevLabel}</span>
|
|
</div>
|
|
)}
|
|
{nextRoute && (
|
|
<div className="flex items-center gap-1 text-[var(--color-brand)]">
|
|
<span className="text-xs">{nextLabel}</span>
|
|
<ArrowRight className="w-3 h-3" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{(prevRoute || nextRoute) && (
|
|
<div className="fixed bottom-6 right-6 md:hidden z-30">
|
|
<div className="flex flex-col items-center gap-1 opacity-50">
|
|
<div className="w-8 h-0.5 bg-[var(--color-text-subtle)] rounded-full" />
|
|
<div className="w-6 h-0.5 bg-[var(--color-text-subtle)] rounded-full" />
|
|
<div className="w-4 h-0.5 bg-[var(--color-text-subtle)] rounded-full" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface PullToRefreshProps {
|
|
onRefresh: () => Promise<void>;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}
|
|
|
|
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
|
|
const [pullDistance, setPullDistance] = useState(0);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const startY = useRef(0);
|
|
const isPulling = useRef(false);
|
|
|
|
const handleTouchStart = (e: React.TouchEvent) => {
|
|
if (window.scrollY === 0 && e.touches?.[0]) {
|
|
startY.current = e.touches[0].clientY;
|
|
isPulling.current = true;
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (e: React.TouchEvent) => {
|
|
if (!isPulling.current || isRefreshing) return;
|
|
|
|
const touch = e.touches?.[0];
|
|
if (!touch) return;
|
|
|
|
const currentY = touch.clientY;
|
|
const distance = Math.max(0, (currentY - startY.current) * 0.5);
|
|
|
|
if (distance > 0 && window.scrollY === 0) {
|
|
e.preventDefault();
|
|
setPullDistance(Math.min(distance, 100));
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = async () => {
|
|
if (!isPulling.current) return;
|
|
isPulling.current = false;
|
|
|
|
if (pullDistance > 60 && !isRefreshing) {
|
|
setIsRefreshing(true);
|
|
triggerHaptic('medium');
|
|
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
setIsRefreshing(false);
|
|
}
|
|
}
|
|
|
|
setPullDistance(0);
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn('relative', className)}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
<div
|
|
className="absolute top-0 left-0 right-0 flex items-center justify-center overflow-hidden transition-transform"
|
|
style={{
|
|
height: `${Math.max(0, pullDistance)}px`,
|
|
transform: `translateY(${-pullDistance + (isRefreshing ? 50 : 0)}px)`,
|
|
}}
|
|
>
|
|
<motion.div
|
|
animate={{ rotate: isRefreshing ? 360 : pullDistance > 60 ? 180 : 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<svg
|
|
className={cn(
|
|
'w-6 h-6 transition-colors',
|
|
pullDistance > 60
|
|
? 'text-[var(--color-brand)]'
|
|
: 'text-[var(--color-text-subtle)]'
|
|
)}
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
strokeWidth={2}
|
|
>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.036 0A8.938 8.938 0 0112 21a8.938 8.938 0 01-7.97-4.94M20 9H19.418m0 0A8.938 8.938 0 0012 3a8.938 8.938 0 00-7.97 4.94" />
|
|
</svg>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|