feat: 更新营销页面组件与交互优化

- 删除 .impeccable.md
- 新增 detail-swipe-nav, loading-state, skeleton, tooltip 组件
- 新增 use-keyboard-shortcuts, use-swipe-gesture hooks
- 更新 contact, news, products, services, solutions 等页面
- 优化 header, mobile-menu, input, page-transition 组件
- 添加 terms 与错误追踪测试页面
This commit is contained in:
张翔
2026-05-14 18:21:54 +08:00
parent b8ab1fd0e3
commit 7f6128a6ff
23 changed files with 1190 additions and 111 deletions
+29 -9
View File
@@ -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" />
+12 -2
View File
@@ -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>
+12 -3
View File
@@ -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>
+4 -1
View File
@@ -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>
))}
+22 -12
View File
@@ -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 }]} />
+47 -37
View File
@@ -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>
))}
+53 -12
View File
@@ -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">
+6 -3
View File
@@ -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)]">2026226</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">2026226</p>
</section>
</div>
</div>
+2 -1
View File
@@ -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,