feat: add touch swipe support for mobile

This commit is contained in:
张翔
2026-02-27 20:29:00 +08:00
parent 1bdb95b5b6
commit aa47a45a95
3 changed files with 112 additions and 106 deletions
+52 -41
View File
@@ -7,6 +7,7 @@ import Link from 'next/link';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { TouchSwipe } from '@/components/ui/touch-swipe';
import { CASES } from '@/lib/constants'; import { CASES } from '@/lib/constants';
import { ArrowRight, Building2, TrendingUp } from 'lucide-react'; import { ArrowRight, Building2, TrendingUp } from 'lucide-react';
@@ -36,48 +37,58 @@ export function CasesSection() {
</p> </p>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <TouchSwipe
{featuredCases.map((caseItem, index) => ( onSwipeLeft={() => {
<motion.div // 切换到下一个案例
key={caseItem.id} }}
initial={{ opacity: 0, y: 20 }} onSwipeRight={() => {
animate={isInView ? { opacity: 1, y: 0 } : {}} // 切换到上一个案例
transition={{ duration: 0.5, delay: 0.1 + index * 0.1 }} }}
> className="md:hidden"
<Link href={`/cases/${caseItem.id}`}> >
<Card className="h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors overflow-hidden"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="relative h-40 bg-gradient-to-br from-[#F5F5F5] to-[#E5E5E5] flex items-center justify-center"> {featuredCases.map((caseItem, index) => (
<Building2 className="w-16 h-16 text-[#C41E3A]/20 group-hover:scale-110 transition-transform duration-300" /> <motion.div
<div className="absolute top-4 right-4"> key={caseItem.id}
<Badge className="bg-white/90 text-[#1C1C1C] hover:bg-white"> initial={{ opacity: 0, y: 20 }}
{caseItem.industry} animate={isInView ? { opacity: 1, y: 0 } : {}}
</Badge> transition={{ duration: 0.5, delay: 0.1 + index * 0.1 }}
</div> >
</div> <Link href={`/cases/${caseItem.id}`}>
<CardContent className="p-6"> <Card className="h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors overflow-hidden">
<div className="flex items-center gap-2 mb-3"> <div className="relative h-40 bg-gradient-to-br from-[#F5F5F5] to-[#E5E5E5] flex items-center justify-center">
<Building2 className="w-4 h-4 text-[#C41E3A]" /> <Building2 className="w-16 h-16 text-[#C41E3A]/20 group-hover:scale-110 transition-transform duration-300" />
<span className="text-sm text-[#5C5C5C]">{caseItem.client}</span> <div className="absolute top-4 right-4">
</div> <Badge className="bg-white/90 text-[#1C1C1C] hover:bg-white">
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-3 group-hover:text-[#C41E3A] transition-colors"> {caseItem.industry}
{caseItem.title} </Badge>
</h3>
<p className="text-[#5C5C5C] text-sm line-clamp-2 mb-4">
{caseItem.description}
</p>
{caseItem.results.length > 0 && (
<div className="flex items-center gap-2 text-[#C41E3A]">
<TrendingUp className="w-4 h-4" />
<span className="text-sm font-medium">{caseItem.results[0].value}</span>
<span className="text-sm text-[#5C5C5C]">{caseItem.results[0].label}</span>
</div> </div>
)} </div>
</CardContent> <CardContent className="p-6">
</Card> <div className="flex items-center gap-2 mb-3">
</Link> <Building2 className="w-4 h-4 text-[#C41E3A]" />
</motion.div> <span className="text-sm text-[#5C5C5C]">{caseItem.client}</span>
))} </div>
</div> <h3 className="text-lg font-semibold text-[#1C1C1C] mb-3 group-hover:text-[#C41E3A] transition-colors">
{caseItem.title}
</h3>
<p className="text-[#5C5C5C] text-sm line-clamp-2 mb-4">
{caseItem.description}
</p>
{caseItem.results.length > 0 && (
<div className="flex items-center gap-2 text-[#C41E3A]">
<TrendingUp className="w-4 h-4" />
<span className="text-sm font-medium">{caseItem.results[0].value}</span>
<span className="text-sm text-[#5C5C5C]">{caseItem.results[0].label}</span>
</div>
)}
</CardContent>
</Card>
</Link>
</motion.div>
))}
</div>
</TouchSwipe>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
+48 -37
View File
@@ -5,6 +5,7 @@ import { useInView } from 'framer-motion';
import { useRef } from 'react'; import { useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { TouchSwipe } from '@/components/ui/touch-swipe';
import { ArrowRight, Calendar } from 'lucide-react'; import { ArrowRight, Calendar } from 'lucide-react';
import { NEWS } from '@/lib/constants'; import { NEWS } from '@/lib/constants';
@@ -29,43 +30,53 @@ export function NewsSection() {
</p> </p>
</motion.div> </motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-5xl mx-auto"> <TouchSwipe
{NEWS.slice(0, 4).map((news, idx) => ( onSwipeLeft={() => {
<motion.div // 切换到下一页新闻
key={news.id} }}
initial={{ opacity: 0, y: 20 }} onSwipeRight={() => {
animate={isInView ? { opacity: 1, y: 0 } : {}} // 切换到上一页新闻
transition={{ duration: 0.5, delay: 0.1 + idx * 0.1 }} }}
> className="md:hidden"
<Card className="h-full flex flex-col group cursor-pointer border-[#E5E5E5] hover:border-[#1C1C1C]"> >
<CardHeader> <div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-5xl mx-auto">
<div className="flex items-center gap-2 mb-3"> {NEWS.slice(0, 4).map((news, idx) => (
<span className="inline-block px-2 py-0.5 rounded-full bg-[#F5F5F5] text-[#1C1C1C] text-xs font-medium"> <motion.div
{news.category} key={news.id}
</span> initial={{ opacity: 0, y: 20 }}
<span className="text-sm text-[#5C5C5C] flex items-center gap-1"> animate={isInView ? { opacity: 1, y: 0 } : {}}
<Calendar className="w-3 h-3" /> transition={{ duration: 0.5, delay: 0.1 + idx * 0.1 }}
{news.date} >
</span> <Card className="h-full flex flex-col group cursor-pointer border-[#E5E5E5] hover:border-[#1C1C1C]">
</div> <CardHeader>
<CardTitle className="text-xl leading-tight">{news.title}</CardTitle> <div className="flex items-center gap-2 mb-3">
</CardHeader> <span className="inline-block px-2 py-0.5 rounded-full bg-[#F5F5F5] text-[#1C1C1C] text-xs font-medium">
<CardContent className="flex-1 flex flex-col"> {news.category}
<CardDescription className="text-base leading-relaxed mb-6 flex-1"> </span>
{news.excerpt} <span className="text-sm text-[#5C5C5C] flex items-center gap-1">
</CardDescription> <Calendar className="w-3 h-3" />
<a {news.date}
href={`/news/${news.id}`} </span>
className="inline-flex items-center text-sm font-medium text-[#1C1C1C] hover:text-[#C41E3A] transition-colors group/link" </div>
> <CardTitle className="text-xl leading-tight">{news.title}</CardTitle>
</CardHeader>
<ArrowRight className="ml-1 w-4 h-4 transition-transform group-hover/link:translate-x-1" /> <CardContent className="flex-1 flex flex-col">
</a> <CardDescription className="text-base leading-relaxed mb-6 flex-1">
</CardContent> {news.excerpt}
</Card> </CardDescription>
</motion.div> <a
))} href={`/news/${news.id}`}
</div> className="inline-flex items-center text-sm font-medium text-[#1C1C1C] hover:text-[#C41E3A] transition-colors group/link"
>
<ArrowRight className="ml-1 w-4 h-4 transition-transform group-hover/link:translate-x-1" />
</a>
</CardContent>
</Card>
</motion.div>
))}
</div>
</TouchSwipe>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
+12 -28
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef, type ReactNode } from 'react'; import { useRef, ReactNode } from 'react';
interface TouchSwipeProps { interface TouchSwipeProps {
children: ReactNode; children: ReactNode;
@@ -17,38 +17,23 @@ export function TouchSwipe({
threshold = 50, threshold = 50,
className = '', className = '',
}: TouchSwipeProps) { }: TouchSwipeProps) {
const [touchStart, setTouchStart] = useState<number | null>(null); const touchStartX = useRef(0);
const [touchEnd, setTouchEnd] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const handleTouchStart = (e: React.TouchEvent) => { const handleTouchStart = (e: React.TouchEvent) => {
setTouchEnd(null); touchStartX.current = e.touches[0].clientX;
const touch = e.targetTouches[0];
if (touch) {
setTouchStart(touch.clientX);
}
}; };
const handleTouchMove = (e: React.TouchEvent) => { const handleTouchEnd = (e: React.TouchEvent) => {
const touch = e.targetTouches[0]; const touchEndX = e.changedTouches[0].clientX;
if (touch) { const diff = touchStartX.current - touchEndX;
setTouchEnd(touch.clientX);
}
};
const handleTouchEnd = () => { if (Math.abs(diff) > threshold) {
if (!touchStart || !touchEnd) {return;} if (diff > 0 && onSwipeLeft) {
onSwipeLeft();
const distance = touchStart - touchEnd; } else if (diff < 0 && onSwipeRight) {
const isLeftSwipe = distance > threshold; onSwipeRight();
const isRightSwipe = distance < -threshold; }
if (isLeftSwipe && onSwipeLeft) {
onSwipeLeft();
}
if (isRightSwipe && onSwipeRight) {
onSwipeRight();
} }
}; };
@@ -57,7 +42,6 @@ export function TouchSwipe({
ref={containerRef} ref={containerRef}
className={className} className={className}
onTouchStart={handleTouchStart} onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd} onTouchEnd={handleTouchEnd}
> >
{children} {children}