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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user