VetNet-LCPS / vetcopilot-app /src /components /LoadingSpinner.tsx
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
6.3 kB
import React from 'react'
import { useTranslation } from '../i18n'
/**
* LoadingSpinner
*
* 兽医主题加载动画组件。
* 支持三种风格:paw(爪印)、heartbeat(心跳线)、dna(DNA 双螺旋)
*
* Props:
* size - 尺寸:'sm' (24px) / 'md' (40px) / 'lg' (64px)
* variant - 风格:'paw' / 'heartbeat' / 'dna' / 'spinner'(默认 paw)
* label - 无障碍文本,默认 "加载中..."
* className - 额外的包裹层 class
* colorClass - 自定义颜色类(如 'text-blue-500')
*/
export type SpinnerSize = 'sm' | 'md' | 'lg'
export type SpinnerVariant = 'paw' | 'heartbeat' | 'dna' | 'spinner'
interface LoadingSpinnerProps {
size?: SpinnerSize
variant?: SpinnerVariant
label?: string
className?: string
colorClass?: string
}
const SIZE_MAP: Record<SpinnerSize, number> = {
sm: 24,
md: 40,
lg: 64,
}
/** 爪印动画 SVG */
function PawSpinner({ size, colorClass }: { size: number; colorClass: string }) {
const scale = size / 48
return (
<svg
className={`animate-spin-slow ${colorClass}`}
width={size}
height={size}
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ transform: `scale(${scale})`, transformOrigin: 'center' }}
>
<g style={{ animation: 'paw-bounce 1.2s ease-in-out infinite' }}>
{/* 主肉垫 */}
<ellipse cx="24" cy="30" rx="10" ry="8" fill="currentColor" opacity="0.9" />
{/* 四个趾垫 */}
<ellipse cx="14" cy="18" rx="4.5" ry="5" fill="currentColor" opacity="0.9" />
<ellipse cx="34" cy="18" rx="4.5" ry="5" fill="currentColor" opacity="0.9" />
<ellipse cx="20" cy="13" rx="4" ry="5" fill="currentColor" opacity="0.9" />
<ellipse cx="28" cy="13" rx="4" ry="5" fill="currentColor" opacity="0.9" />
</g>
</svg>
)
}
/** 心跳线动画 SVG */
function HeartbeatSpinner({ size, colorClass }: { size: number; colorClass: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* 心跳线路径 */}
<path
className={colorClass}
d="M4 24h6l4-10 6 28 4-18h6"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
strokeDasharray="60"
strokeDashoffset="60"
style={{
animation: 'heartbeat-draw 1.6s ease-in-out infinite',
}}
/>
{/* 脉冲点 */}
<circle
cx="24"
cy="24"
r="2"
fill="currentColor"
className={colorClass}
style={{
animation: 'heartbeat-pulse 1.6s ease-in-out infinite',
}}
/>
</svg>
)
}
/** DNA 双螺旋动画 SVG */
function DnaSpinner({ size, colorClass }: { size: number; colorClass: string }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* 左侧螺旋 */}
<path
className={colorClass}
d="M18 4c-2 6 5 14 6 20-1 6-8 14-6 20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
fill="none"
opacity="0.6"
style={{ animation: 'dna-wave 2s ease-in-out infinite' }}
/>
{/* 右侧螺旋(相位偏移) */}
<path
className={colorClass}
d="M30 4c2 6-5 14-6 20 1 6 8 14 6 20"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
fill="none"
opacity="0.6"
style={{ animation: 'dna-wave 2s ease-in-out 0.5s infinite' }}
/>
{/* 横杠 */}
<line x1="20" y1="12" x2="28" y2="12" stroke="currentColor" strokeWidth="1.5" className={colorClass} opacity="0.5" style={{ animation: 'dna-bar 2s ease-in-out infinite' }} />
<line x1="18" y1="22" x2="30" y2="22" stroke="currentColor" strokeWidth="1.5" className={colorClass} opacity="0.5" style={{ animation: 'dna-bar 2s ease-in-out 0.3s infinite' }} />
<line x1="18" y1="32" x2="30" y2="32" stroke="currentColor" strokeWidth="1.5" className={colorClass} opacity="0.5" style={{ animation: 'dna-bar 2s ease-in-out 0.6s infinite' }} />
</svg>
)
}
/** 默认圆形旋转器 */
function DefaultSpinner({ size, colorClass }: { size: number; colorClass: string }) {
const borderWidth = size <= 24 ? 3 : 4
return (
<div
className={`rounded-full animate-spin border-gray-200 ${colorClass}`}
style={{
width: size,
height: size,
borderWidth,
borderTopColor: 'currentColor',
}}
role="presentation"
/>
)
}
export default function LoadingSpinner({
size = 'md',
variant = 'paw',
label,
className = '',
colorClass = 'text-medical-600',
}: LoadingSpinnerProps) {
const { t } = useTranslation()
const pxSize = SIZE_MAP[size]
const renderSpinner = () => {
switch (variant) {
case 'paw':
return <PawSpinner size={pxSize} colorClass={colorClass} />
case 'heartbeat':
return <HeartbeatSpinner size={pxSize} colorClass={colorClass} />
case 'dna':
return <DnaSpinner size={pxSize} colorClass={colorClass} />
case 'spinner':
default:
return <DefaultSpinner size={pxSize} colorClass={colorClass} />
}
}
return (
<div
className={`inline-flex flex-col items-center justify-center gap-2 ${className}`}
role="status"
aria-label={label || undefined}
>
{renderSpinner()}
{label && (
<span className="text-xs text-gray-400 font-medium">{label}</span>
)}
{!label && (
<span className="sr-only">{t('common.loading')}</span>
)}
</div>
)
}
/**
* 全屏加载覆盖层
* 用于页面级加载状态
*/
export function LoadingOverlay({
message = '加载中...',
variant = 'paw' as SpinnerVariant,
}: {
message?: string
variant?: SpinnerVariant
}) {
return (
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-white/60 backdrop-blur-sm"
role="alert"
aria-busy="true"
>
<LoadingSpinner size="lg" variant={variant} label={message} />
</div>
)
}