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:
张翔
2026-05-14 18:21:54 +08:00
parent b8ab1fd0e3
commit 7f6128a6ff
23 changed files with 1190 additions and 111 deletions
+45
View File
@@ -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]);
}
+357
View File
@@ -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>
);
}