- 重构 Button、Card、Badge、Input、Textarea 等基础组件 - 新增 Accordion、Alert、Dialog、Dropdown、Form 等 shadcn/ui 组件 - 新增 AnimatedCounter、StatsShowcase、MetricCard 等数据展示组件 - 新增 ScrollReveal 滚动动画组件 - 重构 Toast 通知系统与 Tooltip 提示组件 - 更新设计令牌系统,对齐新品牌视觉
177 lines
4.2 KiB
TypeScript
177 lines
4.2 KiB
TypeScript
'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)]',
|
|
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,
|
|
};
|
|
}
|