feat: 更新营销页面组件与交互优化
- 删除 .impeccable.md - 新增 detail-swipe-nav, loading-state, skeleton, tooltip 组件 - 新增 use-keyboard-shortcuts, use-swipe-gesture hooks - 更新 contact, news, products, services, solutions 等页面 - 优化 header, mobile-menu, input, page-transition 组件 - 添加 terms 与错误追踪测试页面
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
onSearch?: () => void;
|
||||
onNavigateHome?: () => void;
|
||||
onSkipToContent?: () => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
onSearch,
|
||||
onNavigateHome,
|
||||
onSkipToContent,
|
||||
}: KeyboardShortcutsProps = {}) {
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
event.target instanceof HTMLSelectElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isModKey = event.metaKey || event.ctrlKey;
|
||||
|
||||
switch (true) {
|
||||
case isModKey && event.key === 'k':
|
||||
event.preventDefault();
|
||||
onSearch?.();
|
||||
break;
|
||||
case event.altKey && event.key === 'h':
|
||||
event.preventDefault();
|
||||
onNavigateHome?.();
|
||||
break;
|
||||
case event.key === 'Tab' && !event.shiftKey && (document.activeElement?.getAttribute('data-skip-to-content') === 'true' || document.activeElement?.getAttribute('data-skip-to-content')):
|
||||
event.preventDefault();
|
||||
onSkipToContent?.();
|
||||
break;
|
||||
}
|
||||
}, [onSearch, onNavigateHome, onSkipToContent]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
'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-primary)] 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-primary)]">
|
||||
<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-primary)]">
|
||||
<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-primary)]'
|
||||
: '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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user