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
+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,
};
}