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 { Textarea } from '@/components/ui/textarea';
|
||||
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 { PageNav } from '@/components/layout/page-nav';
|
||||
import { trackContactForm, trackConversion } from '@/lib/analytics';
|
||||
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||
import { Tooltip } from '@/components/ui/tooltip';
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(2, '姓名至少需要2个字符'),
|
||||
@@ -273,10 +274,18 @@ function ContactFormContent() {
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<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="relative">
|
||||
<Input
|
||||
name="name"
|
||||
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"
|
||||
placeholder="请输入您的姓名"
|
||||
required
|
||||
@@ -285,13 +294,22 @@ function ContactFormContent() {
|
||||
onBlur={(e) => handleBlur('name', e.target.value)}
|
||||
error={errors.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
name="phone"
|
||||
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"
|
||||
type="tel"
|
||||
placeholder="请输入您的电话"
|
||||
placeholder="请输入11位手机号"
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
@@ -299,6 +317,7 @@ function ContactFormContent() {
|
||||
error={errors.phone}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
name="email"
|
||||
data-testid="email-input"
|
||||
@@ -341,14 +360,15 @@ function ContactFormContent() {
|
||||
type="submit"
|
||||
data-testid="submit-button"
|
||||
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}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
发送中...
|
||||
</>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<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" />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ErrorBoundary } from '@/components/ui/error-boundary';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Footer } from '@/components/layout/footer';
|
||||
import { PageTransition } from '@/components/ui/page-transition';
|
||||
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
@@ -11,10 +12,19 @@ export default function MarketingLayout({
|
||||
}) {
|
||||
return (
|
||||
<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 />
|
||||
<ErrorBoundary>
|
||||
<main id="main-content" className="flex-1 pt-16">
|
||||
{children}
|
||||
<main id="main-content" tabIndex={-1} className="flex-1 pt-16" role="main" aria-label="页面主体内容">
|
||||
<PageTransition>
|
||||
{children}
|
||||
</PageTransition>
|
||||
</main>
|
||||
</ErrorBoundary>
|
||||
<Footer />
|
||||
|
||||
@@ -70,7 +70,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
|
||||
</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">
|
||||
{news.excerpt}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, ChangeEvent } from 'react';
|
||||
import { useState, useMemo, ChangeEvent, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
import { NEWS, COMPANY_INFO } from '@/lib/constants';
|
||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 { PageNav } from '@/components/layout/page-nav';
|
||||
|
||||
@@ -20,6 +21,12 @@ export default function NewsListPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState('全部');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsLoading(false), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const filteredNews = useMemo(() => {
|
||||
return NEWS.filter((newsItem) => {
|
||||
@@ -119,8 +126,9 @@ export default function NewsListPage() {
|
||||
<p className="text-lg text-[var(--color-text-muted)]">没有找到相关新闻</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<LoadingState isLoading={isLoading} variant="list">
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{paginatedNews.map((newsItem, index) => (
|
||||
<motion.div
|
||||
key={newsItem.id}
|
||||
@@ -205,6 +213,7 @@ export default function NewsListPage() {
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</LoadingState>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StaticLink } from '@/components/ui/static-link';
|
||||
import { PRODUCTS, COMPANY_INFO } from '@/lib/constants';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
@@ -35,6 +36,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||
<DetailSwipeNav type="product" currentId={product.id} />
|
||||
<div className="pt-32 pb-16">
|
||||
<div className="container-wide">
|
||||
<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) => (
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { PRODUCTS } from '@/lib/constants';
|
||||
import { ProductCard } from '@/components/ui/product-card';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -7,10 +8,17 @@ import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
import { PageNav } from '@/components/layout/page-nav';
|
||||
import { CTASection } from '@/components/sections/cta-section';
|
||||
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
|
||||
export default function ProductsPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsLoading(false), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '产品', href: '/products' }]} />
|
||||
@@ -36,18 +44,20 @@ export default function ProductsPage() {
|
||||
|
||||
<section className="pb-20">
|
||||
<div className="container-wide">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{PRODUCTS.map((product, index) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
title={product.title}
|
||||
description={product.description}
|
||||
href={`/products/${product.id}`}
|
||||
index={index}
|
||||
status={product.status}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<LoadingState isLoading={isLoading} variant="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{PRODUCTS.map((product, index) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
title={product.title}
|
||||
description={product.description}
|
||||
href={`/products/${product.id}`}
|
||||
index={index}
|
||||
status={product.status}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</LoadingState>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageNav } from '@/components/layout/page-nav';
|
||||
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
|
||||
import {
|
||||
CheckCircle2,
|
||||
TrendingUp,
|
||||
@@ -73,6 +74,7 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||
<DetailSwipeNav type="service" currentId={service.id} />
|
||||
<div className="pt-32 pb-16">
|
||||
<div className="container-wide">
|
||||
<PageNav items={[{ label: '服务', href: '/services' }, { label: service.title }]} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SERVICES } from '@/lib/constants';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -7,10 +8,17 @@ import { ArrowRight, ArrowUpRight } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
import { PageNav } from '@/components/layout/page-nav';
|
||||
import { LoadingState } from '@/components/ui/loading-state';
|
||||
|
||||
export default function ServicesPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const fadeUp = shouldReduceMotion ? {} : { initial: { opacity: 0, y: 20 } };
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsLoading(false), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||
<section className="pt-32 pb-16">
|
||||
@@ -35,45 +43,47 @@ export default function ServicesPage() {
|
||||
|
||||
<section className="pb-20">
|
||||
<div className="container-wide">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{SERVICES.map((service, index) => (
|
||||
<motion.div
|
||||
key={service.id}
|
||||
{...fadeUp}
|
||||
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: index * 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<StaticLink
|
||||
href={`/services/${service.id}`}
|
||||
className="group block p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)] hover:border-[var(--color-brand-primary)]/40 hover:shadow-lg transition-all duration-300 h-full"
|
||||
<LoadingState isLoading={isLoading} variant="card">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{SERVICES.map((service, index) => (
|
||||
<motion.div
|
||||
key={service.id}
|
||||
{...fadeUp}
|
||||
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: index * 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<span className="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] text-sm font-bold">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<ArrowUpRight className="w-5 h-5 text-[var(--color-text-muted)] group-hover:text-[var(--color-brand-primary)] transition-colors" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-2 group-hover:text-[var(--color-brand-primary)] transition-colors">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||
{service.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{service.features.slice(0, 3).map((feature, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center text-xs px-2 py-1 bg-[var(--color-bg-tertiary)] text-[var(--color-text-muted)] rounded"
|
||||
>
|
||||
{feature.split(':')[0]}
|
||||
<StaticLink
|
||||
href={`/services/${service.id}`}
|
||||
className="group block p-6 rounded-xl border border-[var(--color-border-primary)] bg-[var(--color-bg-primary)] hover:border-[var(--color-brand-primary)]/40 hover:shadow-lg transition-all duration-300 h-full"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<span className="inline-flex items-center justify-center w-10 h-10 rounded-lg bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)] text-sm font-bold">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<ArrowUpRight className="w-5 h-5 text-[var(--color-text-muted)] group-hover:text-[var(--color-brand-primary)] transition-colors" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-text-primary)] mb-2 group-hover:text-[var(--color-brand-primary)] transition-colors">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--color-text-muted)] leading-relaxed mb-4">
|
||||
{service.description}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{service.features.slice(0, 3).map((feature, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center text-xs px-2 py-1 bg-[var(--color-bg-tertiary)] text-[var(--color-text-muted)] rounded"
|
||||
>
|
||||
{feature.split(':')[0]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</LoadingState>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -58,8 +58,9 @@ export function SolutionDetailClient({ solution, relatedProducts }: SolutionDeta
|
||||
{solution.challenges.map((challenge, index) => (
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
'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 { 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 { CTASection } from '@/components/sections/cta-section';
|
||||
import { PageNav } from '@/components/layout/page-nav';
|
||||
import { BreadcrumbSchema } from '@/components/seo/structured-data';
|
||||
import { SwipeNavigation } from '@/hooks/use-swipe-gesture';
|
||||
|
||||
const modules = [
|
||||
{
|
||||
icon: Lightbulb,
|
||||
title: '数字化转型咨询 · 参谋伙伴',
|
||||
subtitle: '帮您看清前路,迈对第一步',
|
||||
title: '数字化转型咨询',
|
||||
subtitle: '参谋伙伴 — 帮您看清前路,迈对第一步',
|
||||
paragraphs: [
|
||||
'数字化转型最大的成本,是走错方向的成本。',
|
||||
'我们用行业智慧帮您洞察趋势,用理性分析帮您避开陷阱。',
|
||||
@@ -28,8 +30,8 @@ const modules = [
|
||||
},
|
||||
{
|
||||
icon: Cpu,
|
||||
title: '信息技术解决方案 · 技术伙伴',
|
||||
subtitle: '让技术真正为业务服务',
|
||||
title: '信息技术解决方案',
|
||||
subtitle: '技术伙伴 — 让技术真正为业务服务',
|
||||
paragraphs: [
|
||||
'我们不追逐"最火"的技术,只选择"最对"的技术。',
|
||||
'将前沿技术深度融入您的业务场景,让每一行代码都产生业务价值。',
|
||||
@@ -44,8 +46,8 @@ const modules = [
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: '长期陪跑服务 · 同行伙伴',
|
||||
subtitle: '从需求理解到产品落地,陪伴才是常态',
|
||||
title: '长期陪跑服务',
|
||||
subtitle: '同行伙伴 — 从需求到落地,持续陪伴',
|
||||
paragraphs: [
|
||||
'当产品真正为您所用那天,才是我们成为伙伴的开始。',
|
||||
'我们建立长效服务机制,定期回访、持续优化、随时响应。',
|
||||
@@ -63,9 +65,28 @@ const modules = [
|
||||
export default function SolutionsPage() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
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 (
|
||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||
<BreadcrumbSchema items={[{ name: '首页', href: '/' }, { name: '解决方案', href: '/solutions' }]} />
|
||||
<SwipeNavigation
|
||||
prevRoute="/products"
|
||||
nextRoute="/services"
|
||||
prevLabel="产品方案"
|
||||
nextLabel="服务支持"
|
||||
/>
|
||||
<section className="pt-32 pb-16">
|
||||
<div className="container-wide">
|
||||
<PageNav items={[{ label: '解决方案' }]} />
|
||||
@@ -99,7 +120,7 @@ export default function SolutionsPage() {
|
||||
whileInView={shouldReduceMotion ? {} : { opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
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="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 className="space-y-4 mb-8">
|
||||
{module.paragraphs.map((p, i) => (
|
||||
<p key={i} className="text-[var(--color-text-primary)] leading-relaxed">{p}</p>
|
||||
))}
|
||||
<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) => (
|
||||
<p key={i} className="text-[var(--color-text-primary)] leading-relaxed mb-3 last:mb-0">{p}</p>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
|
||||
@@ -169,9 +169,12 @@ export default function TermsOfServicePage() {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="bg-[var(--color-warning-bg)] p-6 rounded-lg border-l-4 border-[var(--color-brand-primary)]">
|
||||
<p className="text-[var(--color-text-primary)] font-medium mb-2">最后更新日期</p>
|
||||
<p className="text-[var(--color-text-muted)]">2026年2月26日</p>
|
||||
<section className="bg-[var(--color-warning-bg)] p-6 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,8 @@ export default function TestErrorTrackingPage() {
|
||||
addResult('🧪 测试 1: 触发 JavaScript 运行时错误...');
|
||||
throw new Error('测试错误:这是一个故意的 JavaScript 错误');
|
||||
} 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',
|
||||
filename: 'test-error-page.tsx',
|
||||
lineno: 20,
|
||||
|
||||
@@ -196,7 +196,7 @@ function HeaderContent() {
|
||||
className="fixed inset-0 z-40 md:hidden"
|
||||
>
|
||||
<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)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -226,7 +226,7 @@ function HeaderContent() {
|
||||
block px-4 py-4 text-base font-medium rounded-lg
|
||||
transition-all duration-200
|
||||
${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)]'
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -61,7 +61,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
{isOpen && (
|
||||
<>
|
||||
<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)}
|
||||
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"
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
label?: string | React.ReactNode
|
||||
error?: 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,47 +2,112 @@
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ReactNode, useSyncExternalStore } from 'react';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function useIsMounted() {
|
||||
return useSyncExternalStore(
|
||||
(callback) => {
|
||||
window.addEventListener('resize', callback);
|
||||
return () => window.removeEventListener('resize', callback);
|
||||
const pageVariants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: 8,
|
||||
scale: 0.98,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.25, 1, 0.5, 1] as const,
|
||||
staggerChildren: 0.05,
|
||||
},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
}
|
||||
},
|
||||
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 shouldReduceMotion = useReducedMotion();
|
||||
const mounted = useIsMounted();
|
||||
|
||||
if (shouldReduceMotion || !mounted) {
|
||||
if (shouldReduceMotion) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
variants={pageVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
style={{
|
||||
willChange: 'transform, opacity',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</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