File size: 6,297 Bytes
793e953 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | 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>
)
}
|