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
+2 -2
View File
@@ -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)]'
}
`}
+1 -1
View File
@@ -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"
/>
+41
View File
@@ -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}
/>
);
}
+1 -1
View File
@@ -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
}
+176
View File
@@ -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,
};
}
+90 -25
View File
@@ -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>
);
}
+180
View File
@@ -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>
);
}
+105
View File
@@ -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>
);
}