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:
@@ -9,11 +9,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Toast } from '@/components/ui/toast';
|
import { Toast } from '@/components/ui/toast';
|
||||||
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from 'lucide-react';
|
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, HelpCircle } from 'lucide-react';
|
||||||
import { COMPANY_INFO } from '@/lib/constants';
|
import { COMPANY_INFO } from '@/lib/constants';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
import { trackContactForm, trackConversion } from '@/lib/analytics';
|
import { trackContactForm, trackConversion } from '@/lib/analytics';
|
||||||
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||||
|
import { Tooltip } from '@/components/ui/tooltip';
|
||||||
|
|
||||||
const contactFormSchema = z.object({
|
const contactFormSchema = z.object({
|
||||||
name: z.string().min(2, '姓名至少需要2个字符'),
|
name: z.string().min(2, '姓名至少需要2个字符'),
|
||||||
@@ -273,10 +274,18 @@ function ContactFormContent() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
<input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" aria-hidden="true" />
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
name="name"
|
name="name"
|
||||||
data-testid="name-input"
|
data-testid="name-input"
|
||||||
label="姓名"
|
label={
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
姓名
|
||||||
|
<Tooltip content="用于正式沟通和方案报价">
|
||||||
|
<HelpCircle className="w-3.5 h-3.5 text-[var(--color-text-subtle)] hover:text-[var(--color-brand-primary)] cursor-help" />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
id="name"
|
id="name"
|
||||||
placeholder="请输入您的姓名"
|
placeholder="请输入您的姓名"
|
||||||
required
|
required
|
||||||
@@ -285,13 +294,22 @@ function ContactFormContent() {
|
|||||||
onBlur={(e) => handleBlur('name', e.target.value)}
|
onBlur={(e) => handleBlur('name', e.target.value)}
|
||||||
error={errors.name}
|
error={errors.name}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
name="phone"
|
name="phone"
|
||||||
data-testid="phone-input"
|
data-testid="phone-input"
|
||||||
label="电话"
|
label={
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
电话
|
||||||
|
<Tooltip content="接收项目进度通知和验证码,仅用于业务联系">
|
||||||
|
<HelpCircle className="w-3.5 h-3.5 text-[var(--color-text-subtle)] hover:text-[var(--color-brand-primary)] cursor-help" />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
id="phone"
|
id="phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="请输入您的电话"
|
placeholder="请输入11位手机号"
|
||||||
required
|
required
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
onChange={(e) => handleChange('phone', e.target.value)}
|
onChange={(e) => handleChange('phone', e.target.value)}
|
||||||
@@ -299,6 +317,7 @@ function ContactFormContent() {
|
|||||||
error={errors.phone}
|
error={errors.phone}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
name="email"
|
name="email"
|
||||||
data-testid="email-input"
|
data-testid="email-input"
|
||||||
@@ -341,14 +360,15 @@ function ContactFormContent() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
data-testid="submit-button"
|
data-testid="submit-button"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white min-h-13 md:min-h-0"
|
className="w-full bg-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-hover)] text-white min-h-13 md:min-h-0 relative overflow-hidden disabled:opacity-90"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<span className="flex items-center justify-center gap-2">
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
发送中...
|
<span>发送中...</span>
|
||||||
</>
|
<span className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-pulse" />
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send className="mr-2 h-4 w-4" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { ErrorBoundary } from '@/components/ui/error-boundary';
|
import { ErrorBoundary } from '@/components/ui/error-boundary';
|
||||||
import { Header } from '@/components/layout/header';
|
import { Header } from '@/components/layout/header';
|
||||||
import { Footer } from '@/components/layout/footer';
|
import { Footer } from '@/components/layout/footer';
|
||||||
|
import { PageTransition } from '@/components/ui/page-transition';
|
||||||
|
|
||||||
export default function MarketingLayout({
|
export default function MarketingLayout({
|
||||||
children,
|
children,
|
||||||
@@ -11,10 +12,19 @@ export default function MarketingLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<a
|
||||||
|
href="#main-content"
|
||||||
|
data-skip-to-content
|
||||||
|
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[100] focus:px-6 focus:py-3 focus:bg-[var(--color-brand-primary)] focus:text-white focus:rounded-lg focus:shadow-lg focus:outline-none"
|
||||||
|
>
|
||||||
|
跳转到主要内容
|
||||||
|
</a>
|
||||||
<Header />
|
<Header />
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<main id="main-content" className="flex-1 pt-16">
|
<main id="main-content" tabIndex={-1} className="flex-1 pt-16" role="main" aria-label="页面主体内容">
|
||||||
|
<PageTransition>
|
||||||
{children}
|
{children}
|
||||||
|
</PageTransition>
|
||||||
</main>
|
</main>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="border-l-4 border-[var(--color-brand-primary)] pl-6 mb-8">
|
<div className="bg-[var(--color-brand-primary-bg)] rounded-xl p-6 mb-8">
|
||||||
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
<p className="text-lg text-[var(--color-text-muted)] leading-relaxed">
|
||||||
{news.excerpt}
|
{news.excerpt}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, ChangeEvent } from 'react';
|
import { useState, useMemo, ChangeEvent, useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { NEWS, COMPANY_INFO } from '@/lib/constants';
|
import { NEWS, COMPANY_INFO } from '@/lib/constants';
|
||||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
|
import { LoadingState } from '@/components/ui/loading-state';
|
||||||
import { Search, Calendar, ChevronLeft, ChevronRight, ArrowRight, Newspaper } from 'lucide-react';
|
import { Search, Calendar, ChevronLeft, ChevronRight, ArrowRight, Newspaper } from 'lucide-react';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
|
|
||||||
@@ -20,6 +21,12 @@ export default function NewsListPage() {
|
|||||||
const [selectedCategory, setSelectedCategory] = useState('全部');
|
const [selectedCategory, setSelectedCategory] = useState('全部');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setIsLoading(false), 600);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filteredNews = useMemo(() => {
|
const filteredNews = useMemo(() => {
|
||||||
return NEWS.filter((newsItem) => {
|
return NEWS.filter((newsItem) => {
|
||||||
@@ -119,6 +126,7 @@ export default function NewsListPage() {
|
|||||||
<p className="text-lg text-[var(--color-text-muted)]">没有找到相关新闻</p>
|
<p className="text-lg text-[var(--color-text-muted)]">没有找到相关新闻</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<LoadingState isLoading={isLoading} variant="list">
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{paginatedNews.map((newsItem, index) => (
|
{paginatedNews.map((newsItem, index) => (
|
||||||
@@ -205,6 +213,7 @@ export default function NewsListPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
</LoadingState>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { StaticLink } from '@/components/ui/static-link';
|
|||||||
import { PRODUCTS, COMPANY_INFO } from '@/lib/constants';
|
import { PRODUCTS, COMPANY_INFO } from '@/lib/constants';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
|
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
|
||||||
import { CheckCircle2, Zap, Target, Layers, ArrowRight, FlaskConical } from 'lucide-react';
|
import { CheckCircle2, Zap, Target, Layers, ArrowRight, FlaskConical } from 'lucide-react';
|
||||||
|
|
||||||
export async function generateStaticParams() {
|
export async function generateStaticParams() {
|
||||||
@@ -35,6 +36,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
|
<DetailSwipeNav type="product" currentId={product.id} />
|
||||||
<div className="pt-32 pb-16">
|
<div className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '产品', href: '/products' }, { label: product.title }]} />
|
<PageNav items={[{ label: '产品', href: '/products' }, { label: product.title }]} />
|
||||||
@@ -98,8 +100,9 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
|||||||
{product.benefits.map((benefit, index) => (
|
{product.benefits.map((benefit, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[var(--color-brand-primary)] bg-[var(--color-bg-section)]"
|
className="flex items-start gap-3 p-4 rounded-lg bg-[var(--color-brand-primary-bg)]"
|
||||||
>
|
>
|
||||||
|
<div className="w-1.5 h-1.5 bg-[var(--color-brand-primary)] rounded-full mt-2 shrink-0" />
|
||||||
<span className="text-[var(--color-text-primary)] font-medium text-sm">{benefit}</span>
|
<span className="text-[var(--color-text-primary)] font-medium text-sm">{benefit}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { PRODUCTS } from '@/lib/constants';
|
import { PRODUCTS } from '@/lib/constants';
|
||||||
import { ProductCard } from '@/components/ui/product-card';
|
import { ProductCard } from '@/components/ui/product-card';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@@ -7,10 +8,17 @@ import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
|||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
import { CTASection } from '@/components/sections/cta-section';
|
import { CTASection } from '@/components/sections/cta-section';
|
||||||
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||||
|
import { LoadingState } from '@/components/ui/loading-state';
|
||||||
|
|
||||||
export default function ProductsPage() {
|
export default function ProductsPage() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setIsLoading(false), 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '产品', href: '/products' }]} />
|
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '产品', href: '/products' }]} />
|
||||||
@@ -36,6 +44,7 @@ export default function ProductsPage() {
|
|||||||
|
|
||||||
<section className="pb-20">
|
<section className="pb-20">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
|
<LoadingState isLoading={isLoading} variant="card">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{PRODUCTS.map((product, index) => (
|
{PRODUCTS.map((product, index) => (
|
||||||
<ProductCard
|
<ProductCard
|
||||||
@@ -48,6 +57,7 @@ export default function ProductsPage() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</LoadingState>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
|
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -73,6 +74,7 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
|
<DetailSwipeNav type="service" currentId={service.id} />
|
||||||
<div className="pt-32 pb-16">
|
<div className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '服务', href: '/services' }, { label: service.title }]} />
|
<PageNav items={[{ label: '服务', href: '/services' }, { label: service.title }]} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { SERVICES } from '@/lib/constants';
|
import { SERVICES } from '@/lib/constants';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -7,10 +8,17 @@ import { ArrowRight, ArrowUpRight } from 'lucide-react';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
|
import { LoadingState } from '@/components/ui/loading-state';
|
||||||
|
|
||||||
export default function ServicesPage() {
|
export default function ServicesPage() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setIsLoading(false), 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<section className="pt-32 pb-16">
|
<section className="pt-32 pb-16">
|
||||||
@@ -35,6 +43,7 @@ export default function ServicesPage() {
|
|||||||
|
|
||||||
<section className="pb-20">
|
<section className="pb-20">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
|
<LoadingState isLoading={isLoading} variant="card">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{SERVICES.map((service, index) => (
|
{SERVICES.map((service, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -74,6 +83,7 @@ export default function ServicesPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</LoadingState>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
|
|||||||
{solution.challenges.map((challenge, index) => (
|
{solution.challenges.map((challenge, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-start gap-3 p-4 rounded-lg border-l-4 border-[var(--color-brand-primary)] bg-[var(--color-bg-section)]"
|
className="flex items-start gap-3 p-4 rounded-lg bg-[var(--color-brand-primary-bg)]"
|
||||||
>
|
>
|
||||||
|
<AlertTriangle className="w-4 h-4 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
|
||||||
<span className="text-[var(--color-text-primary)] text-sm">{challenge}</span>
|
<span className="text-[var(--color-text-primary)] text-sm">{challenge}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { motion } from 'framer-motion';
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
import { StaticLink } from '@/components/ui/static-link';
|
import { StaticLink } from '@/components/ui/static-link';
|
||||||
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2 } from 'lucide-react';
|
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2, ChevronDown } from 'lucide-react';
|
||||||
import { MethodologySection } from '@/components/sections/methodology-section';
|
import { MethodologySection } from '@/components/sections/methodology-section';
|
||||||
import { CTASection } from '@/components/sections/cta-section';
|
import { CTASection } from '@/components/sections/cta-section';
|
||||||
import { PageNav } from '@/components/layout/page-nav';
|
import { PageNav } from '@/components/layout/page-nav';
|
||||||
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||||
|
import { SwipeNavigation } from '@/hooks/use-swipe-gesture';
|
||||||
|
|
||||||
const modules = [
|
const modules = [
|
||||||
{
|
{
|
||||||
icon: Lightbulb,
|
icon: Lightbulb,
|
||||||
title: '数字化转型咨询 · 参谋伙伴',
|
title: '数字化转型咨询',
|
||||||
subtitle: '帮您看清前路,迈对第一步',
|
subtitle: '参谋伙伴 — 帮您看清前路,迈对第一步',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
'数字化转型最大的成本,是走错方向的成本。',
|
'数字化转型最大的成本,是走错方向的成本。',
|
||||||
'我们用行业智慧帮您洞察趋势,用理性分析帮您避开陷阱。',
|
'我们用行业智慧帮您洞察趋势,用理性分析帮您避开陷阱。',
|
||||||
@@ -28,8 +30,8 @@ const modules = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Cpu,
|
icon: Cpu,
|
||||||
title: '信息技术解决方案 · 技术伙伴',
|
title: '信息技术解决方案',
|
||||||
subtitle: '让技术真正为业务服务',
|
subtitle: '技术伙伴 — 让技术真正为业务服务',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
'我们不追逐"最火"的技术,只选择"最对"的技术。',
|
'我们不追逐"最火"的技术,只选择"最对"的技术。',
|
||||||
'将前沿技术深度融入您的业务场景,让每一行代码都产生业务价值。',
|
'将前沿技术深度融入您的业务场景,让每一行代码都产生业务价值。',
|
||||||
@@ -44,8 +46,8 @@ const modules = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Users,
|
icon: Users,
|
||||||
title: '长期陪跑服务 · 同行伙伴',
|
title: '长期陪跑服务',
|
||||||
subtitle: '从需求理解到产品落地,陪伴才是常态',
|
subtitle: '同行伙伴 — 从需求到落地,持续陪伴',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
'当产品真正为您所用那天,才是我们成为伙伴的开始。',
|
'当产品真正为您所用那天,才是我们成为伙伴的开始。',
|
||||||
'我们建立长效服务机制,定期回访、持续优化、随时响应。',
|
'我们建立长效服务机制,定期回访、持续优化、随时响应。',
|
||||||
@@ -63,9 +65,28 @@ const modules = [
|
|||||||
export default function SolutionsPage() {
|
export default function SolutionsPage() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||||
|
const [expandedModules, setExpandedModules] = useState<Set<number>>(new Set([0]));
|
||||||
|
|
||||||
|
const toggleModule = (index: number) => {
|
||||||
|
setExpandedModules(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(index)) {
|
||||||
|
next.delete(index);
|
||||||
|
} else {
|
||||||
|
next.add(index);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||||
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '解决方案', href: '/solutions' }]} />
|
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '解决方案', href: '/solutions' }]} />
|
||||||
|
<SwipeNavigation
|
||||||
|
prevRoute="/products"
|
||||||
|
nextRoute="/services"
|
||||||
|
prevLabel="产品方案"
|
||||||
|
nextLabel="服务支持"
|
||||||
|
/>
|
||||||
<section className="pt-32 pb-16">
|
<section className="pt-32 pb-16">
|
||||||
<div className="container-wide">
|
<div className="container-wide">
|
||||||
<PageNav items={[{ label: '解决方案' }]} />
|
<PageNav items={[{ label: '解决方案' }]} />
|
||||||
@@ -99,7 +120,7 @@ export default function SolutionsPage() {
|
|||||||
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
className={`p-8 md:p-12 rounded-xl border border-[var(--color-border-primary)] border-l-4 ${module.accentBorder} bg-[var(--color-bg-primary)] hover:border-[var(--color-border-primary)] transition-colors`}
|
className={`p-8 md:p-12 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)] hover:border-[var(--color-border-primary)] transition-colors`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4 mb-6">
|
<div className="flex items-start gap-4 mb-6">
|
||||||
<div className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: module.accentBg }}>
|
<div className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0" style={{ backgroundColor: module.accentBg }}>
|
||||||
@@ -114,9 +135,29 @@ export default function SolutionsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 mb-8">
|
<div className="space-y-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleModule(index)}
|
||||||
|
className="flex items-center gap-2 text-sm font-medium text-[var(--color-brand-primary)] hover:text-[var(--color-brand-primary-hover)] transition-colors group"
|
||||||
|
aria-expanded={expandedModules.has(index)}
|
||||||
|
>
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${expandedModules.has(index) ? 'rotate-180' : ''}`} />
|
||||||
|
{expandedModules.has(index) ? '收起详情' : '展开了解详情'}
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{expandedModules.has(index) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
{module.paragraphs.map((p, i) => (
|
{module.paragraphs.map((p, i) => (
|
||||||
<p key={i} className="text-[var(--color-text-primary)] leading-relaxed">{p}</p>
|
<p key={i} className="text-[var(--color-text-primary)] leading-relaxed mb-3 last:mb-0">{p}</p>
|
||||||
))}
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -169,9 +169,12 @@ export default function TermsOfServicePage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="bg-[var(--color-warning-bg)] p-6 rounded-lg border-l-4 border-[var(--color-brand-primary)]">
|
<section className="bg-[var(--color-warning-bg)] p-6 rounded-lg">
|
||||||
<p className="text-[var(--color-text-primary)] font-medium mb-2">最后更新日期</p>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<p className="text-[var(--color-text-muted)]">2026年2月26日</p>
|
<div className="w-2 h-2 bg-[var(--color-brand-primary)] rounded-full" />
|
||||||
|
<p className="text-[var(--color-text-primary)] font-medium">最后更新日期</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-[var(--color-text-muted)] pl-4">2026年2月26日</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export default function TestErrorTrackingPage() {
|
|||||||
addResult('🧪 测试 1: 触发 JavaScript 运行时错误...');
|
addResult('🧪 测试 1: 触发 JavaScript 运行时错误...');
|
||||||
throw new Error('测试错误:这是一个故意的 JavaScript 错误');
|
throw new Error('测试错误:这是一个故意的 JavaScript 错误');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackError('javascript_error', error.message, false, {
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
trackError('javascript_error', errorMessage, false, {
|
||||||
test_id: 'test_js_error',
|
test_id: 'test_js_error',
|
||||||
filename: 'test-error-page.tsx',
|
filename: 'test-error-page.tsx',
|
||||||
lineno: 20,
|
lineno: 20,
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ function HeaderContent() {
|
|||||||
className="fixed inset-0 z-40 md:hidden"
|
className="fixed inset-0 z-40 md:hidden"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
|
className="absolute inset-0 bg-[var(--color-primary)]/30 backdrop-blur-sm"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -226,7 +226,7 @@ function HeaderContent() {
|
|||||||
block px-4 py-4 text-base font-medium rounded-lg
|
block px-4 py-4 text-base font-medium rounded-lg
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
${isActive(item)
|
${isActive(item)
|
||||||
? 'text-[var(--color-text-primary)] bg-[var(--color-primary-lighter)] border-l-4 border-[var(--color-brand-primary)]'
|
? 'text-[var(--color-text-primary)] bg-[var(--color-brand-primary-bg)]'
|
||||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)]'
|
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)]'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40"
|
className="fixed inset-0 bg-[var(--color-primary)]/20 backdrop-blur-sm z-40"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { PRODUCTS, SERVICES } from '@/lib/constants';
|
||||||
|
import { SwipeNavigation } from '@/hooks/use-swipe-gesture';
|
||||||
|
|
||||||
|
interface DetailSwipeNavProps {
|
||||||
|
type: 'product' | 'service' | 'solution';
|
||||||
|
currentId: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailSwipeNav({ type, currentId, className }: DetailSwipeNavProps) {
|
||||||
|
let items: { id: string; title: string }[] = [];
|
||||||
|
|
||||||
|
if (type === 'product') {
|
||||||
|
items = PRODUCTS.map(p => ({ id: p.id, title: p.title }));
|
||||||
|
} else if (type === 'service') {
|
||||||
|
items = SERVICES.map(s => ({ id: s.id, title: s.title }));
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = items.findIndex(item => item.id === currentId);
|
||||||
|
|
||||||
|
if (currentIndex === -1) return null;
|
||||||
|
|
||||||
|
const prevItem = currentIndex > 0 ? items[currentIndex - 1] : null;
|
||||||
|
const nextItem = currentIndex < items.length - 1 ? items[currentIndex + 1] : null;
|
||||||
|
|
||||||
|
const basePath = type === 'product' ? '/products' : type === 'service' ? '/services' : `/solutions`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SwipeNavigation
|
||||||
|
prevRoute={prevItem ? `${basePath}/${prevItem.id}` : undefined}
|
||||||
|
nextRoute={nextItem ? `${basePath}/${nextItem.id}` : undefined}
|
||||||
|
prevLabel={prevItem?.title || ''}
|
||||||
|
nextLabel={nextItem?.title || ''}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
label?: string
|
label?: string | React.ReactNode
|
||||||
error?: string
|
error?: string
|
||||||
'data-testid'?: string
|
'data-testid'?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
Skeleton,
|
||||||
|
SkeletonHero,
|
||||||
|
SkeletonList,
|
||||||
|
SkeletonCard,
|
||||||
|
SkeletonForm,
|
||||||
|
} from './skeleton';
|
||||||
|
|
||||||
|
type LoadingVariant = 'hero' | 'list' | 'card' | 'form' | 'custom' | 'spinner';
|
||||||
|
|
||||||
|
interface LoadingStateProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: LoadingVariant;
|
||||||
|
listItems?: number;
|
||||||
|
formFields?: number;
|
||||||
|
className?: string;
|
||||||
|
delayMs?: number;
|
||||||
|
customSkeleton?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingState({
|
||||||
|
isLoading,
|
||||||
|
children,
|
||||||
|
variant = 'custom',
|
||||||
|
listItems = 3,
|
||||||
|
formFields = 4,
|
||||||
|
className,
|
||||||
|
delayMs = 150,
|
||||||
|
customSkeleton,
|
||||||
|
}: LoadingStateProps) {
|
||||||
|
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||||
|
const prevIsLoading = useRef(isLoading);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoading !== prevIsLoading.current) {
|
||||||
|
prevIsLoading.current = isLoading;
|
||||||
|
|
||||||
|
if (!isLoading) {
|
||||||
|
setShowSkeleton(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setShowSkeleton(true), delayMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [isLoading, delayMs]);
|
||||||
|
|
||||||
|
if (isLoading && showSkeleton) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('animate-in fade-in duration-300', className)}
|
||||||
|
role="status"
|
||||||
|
aria-label="内容加载中"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
{variant === 'hero' && <SkeletonHero />}
|
||||||
|
{variant === 'list' && <SkeletonList items={listItems} />}
|
||||||
|
{variant === 'card' && (
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<SkeletonCard key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{variant === 'form' && <SkeletonForm fields={formFields} />}
|
||||||
|
{variant === 'custom' && (customSkeleton || <Skeleton className="h-40 w-full rounded-lg" />)}
|
||||||
|
{variant === 'spinner' && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({ size = 'md', className }: SpinnerProps) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-8 h-8',
|
||||||
|
lg: 'w-12 h-12',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={cn(
|
||||||
|
'animate-spin text-[var(--color-brand-primary)]',
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
role="status"
|
||||||
|
aria-label="加载中"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageLoaderProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLoader({ isLoading, message = '正在加载...' }: PageLoaderProps) {
|
||||||
|
if (!isLoading) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-[var(--color-bg-primary)]/80 backdrop-blur-sm"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={message}
|
||||||
|
>
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<Spinner size="lg" className="mx-auto" />
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] animate-pulse">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLoadingState(initialState = false, delayMs = 150) {
|
||||||
|
const [isLoading, setIsLoading] = useState(initialState);
|
||||||
|
const [shouldShow, setShouldShow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading) {
|
||||||
|
setShouldShow(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(() => setShouldShow(true), delayMs);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [isLoading, delayMs]);
|
||||||
|
|
||||||
|
const startLoading = useCallback(() => setIsLoading(true), []);
|
||||||
|
const stopLoading = useCallback(() => setIsLoading(false), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
shouldShow,
|
||||||
|
startLoading,
|
||||||
|
stopLoading,
|
||||||
|
setLoading: setIsLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,43 +2,54 @@
|
|||||||
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { ReactNode, useSyncExternalStore } from 'react';
|
|
||||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||||
|
|
||||||
interface PageTransitionProps {
|
const pageVariants = {
|
||||||
children: ReactNode;
|
initial: {
|
||||||
}
|
opacity: 0,
|
||||||
|
y: 8,
|
||||||
function useIsMounted() {
|
scale: 0.98,
|
||||||
return useSyncExternalStore(
|
|
||||||
(callback) => {
|
|
||||||
window.addEventListener('resize', callback);
|
|
||||||
return () => window.removeEventListener('resize', callback);
|
|
||||||
},
|
},
|
||||||
() => true,
|
animate: {
|
||||||
() => false
|
opacity: 1,
|
||||||
);
|
y: 0,
|
||||||
}
|
scale: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 1, 0.5, 1] as const,
|
||||||
|
staggerChildren: 0.05,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
y: -8,
|
||||||
|
scale: 0.98,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: [0.22, 1, 0.36, 1] as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function PageTransition({ children }: PageTransitionProps) {
|
export function PageTransition({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
const mounted = useIsMounted();
|
|
||||||
|
|
||||||
if (shouldReduceMotion || !mounted) {
|
if (shouldReduceMotion) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
<motion.div
|
<motion.div
|
||||||
key={pathname}
|
key={pathname}
|
||||||
initial={{ opacity: 0, y: 8 }}
|
variants={pageVariants}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
initial="initial"
|
||||||
exit={{ opacity: 0, y: -8 }}
|
animate="animate"
|
||||||
transition={{
|
exit="exit"
|
||||||
duration: 0.3,
|
style={{
|
||||||
ease: [0.16, 1, 0.3, 1],
|
willChange: 'transform, opacity',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@@ -46,3 +57,57 @@ export function PageTransition({ children }: PageTransitionProps) {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sectionVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: (i: number) => ({
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
delay: i * 0.08,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.25, 1, 0.5, 1] as const,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StaggerChildren({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
staggerCount = 4
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
staggerCount?: number;
|
||||||
|
}) {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
if (shouldReduceMotion) {
|
||||||
|
return <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={className}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
variants={{
|
||||||
|
visible: {
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.06,
|
||||||
|
delayChildren: 0.1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.isArray(children)
|
||||||
|
? children.map((child, i) => (
|
||||||
|
<motion.div key={i} custom={Math.min(i, staggerCount)} variants={sectionVariants}>
|
||||||
|
{child}
|
||||||
|
</motion.div>
|
||||||
|
))
|
||||||
|
: children
|
||||||
|
}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
variant?: 'default' | 'circular' | 'rounded';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className, variant = 'default' }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-pulse bg-[var(--color-bg-section)]',
|
||||||
|
variant === 'circular' && 'rounded-full',
|
||||||
|
variant === 'rounded' && 'rounded-lg',
|
||||||
|
variant === 'default' && 'rounded',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonTextProps {
|
||||||
|
lines?: number;
|
||||||
|
className?: string;
|
||||||
|
lastLineWidth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonText({
|
||||||
|
lines = 3,
|
||||||
|
className,
|
||||||
|
lastLineWidth = '75%'
|
||||||
|
}: SkeletonTextProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)} aria-hidden="true" role="presentation">
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
'h-4',
|
||||||
|
i === lines - 1 ? `w-[${lastLineWidth}]` : 'w-full'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonCardProps {
|
||||||
|
className?: string;
|
||||||
|
showImage?: boolean;
|
||||||
|
showTitle?: boolean;
|
||||||
|
showDescription?: boolean;
|
||||||
|
showFooter?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCard({
|
||||||
|
className,
|
||||||
|
showImage = true,
|
||||||
|
showTitle = true,
|
||||||
|
showDescription = true,
|
||||||
|
showFooter = false,
|
||||||
|
}: SkeletonCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
|
aria-label="加载中"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
{showImage && (
|
||||||
|
<Skeleton className="h-40 w-full rounded-lg mb-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showTitle && (
|
||||||
|
<Skeleton className="h-6 w-3/4 mb-3" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDescription && (
|
||||||
|
<SkeletonText lines={2} lastLineWidth="90%" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFooter && (
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<Skeleton className="h-10 w-24 rounded-lg" />
|
||||||
|
<Skeleton className="h-10 w-20 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonListProps {
|
||||||
|
items?: number;
|
||||||
|
className?: string;
|
||||||
|
variant?: 'card' | 'list' | 'compact';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonList({
|
||||||
|
items = 3,
|
||||||
|
className,
|
||||||
|
variant = 'card',
|
||||||
|
}: SkeletonListProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)} role="status" aria-label="内容列表加载中">
|
||||||
|
{Array.from({ length: items }).map((_, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
{variant === 'card' && <SkeletonCard />}
|
||||||
|
{variant === 'list' && (
|
||||||
|
<div className="flex items-center gap-4 p-4">
|
||||||
|
<Skeleton className="w-12 h-12 rounded-lg shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-5 w-1/2" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="w-16 h-8 rounded-lg shrink-0" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{variant === 'compact' && (
|
||||||
|
<div className="flex items-center justify-between p-3">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<Skeleton className="w-8 h-8 rounded shrink-0" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-2 w-12 rounded-full" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonHeroProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonHero({ className }: SkeletonHeroProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('pt-32 pb-16', className)}
|
||||||
|
role="status"
|
||||||
|
aria-label="页面标题加载中"
|
||||||
|
aria-busy="true"
|
||||||
|
>
|
||||||
|
<div className="container-wide max-w-3xl space-y-6">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-12 md:h-14 w-full max-w-2xl" />
|
||||||
|
<Skeleton className="h-6 w-full max-w-xl" />
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Skeleton className="h-12 w-36 rounded-lg" />
|
||||||
|
<Skeleton className="h-12 w-28 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonFormProps {
|
||||||
|
fields?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonForm({ fields = 4, className }: SkeletonFormProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-6 max-w-2xl', className)} role="status" aria-label="表单加载中">
|
||||||
|
{Array.from({ length: fields }).map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-14 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Skeleton className="h-14 w-full rounded-lg mt-8" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
content: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({ content, children, side = 'top', className }: TooltipProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const showTooltip = () => {
|
||||||
|
if (!triggerRef.current || !tooltipRef.current) return;
|
||||||
|
|
||||||
|
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||||
|
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
let top = 0;
|
||||||
|
let left = 0;
|
||||||
|
|
||||||
|
switch (side) {
|
||||||
|
case 'top':
|
||||||
|
top = triggerRect.top - tooltipRect.height - 8;
|
||||||
|
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
|
||||||
|
break;
|
||||||
|
case 'bottom':
|
||||||
|
top = triggerRect.bottom + 8;
|
||||||
|
left = triggerRect.left + (triggerRect.width - tooltipRect.width) / 2;
|
||||||
|
break;
|
||||||
|
case 'left':
|
||||||
|
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
|
||||||
|
left = triggerRect.left - tooltipRect.width - 8;
|
||||||
|
break;
|
||||||
|
case 'right':
|
||||||
|
top = triggerRect.top + (triggerRect.height - tooltipRect.height) / 2;
|
||||||
|
left = triggerRect.right + 8;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition({ top, left });
|
||||||
|
setIsVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideTooltip = () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVisible) return;
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') hideTooltip();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => document.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [isVisible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
className="relative inline-flex"
|
||||||
|
onMouseEnter={showTooltip}
|
||||||
|
onMouseLeave={hideTooltip}
|
||||||
|
onFocus={showTooltip}
|
||||||
|
onBlur={hideTooltip}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{isVisible && (
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
role="tooltip"
|
||||||
|
className={cn(
|
||||||
|
'fixed z-50 px-3 py-2 text-sm rounded-lg bg-[var(--color-primary)] text-white shadow-lg',
|
||||||
|
'pointer-events-none animate-in fade-in-0 zoom-in-95 duration-200',
|
||||||
|
'max-w-xs',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
top: `${position.top}px`,
|
||||||
|
left: `${position.left}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute w-2 h-2 bg-[var(--color-primary)] rotate-45',
|
||||||
|
side === 'top' && 'bottom-[-4px] left-1/2 -translate-x-1/2',
|
||||||
|
side === 'bottom' && 'top-[-4px] left-1/2 -translate-x-1/2',
|
||||||
|
side === 'left' && 'right-[-4px] top-1/2 -translate-y-1/2',
|
||||||
|
side === 'right' && 'left-[-4px] top-1/2 -translate-y-1/2'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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