You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
160 lines
5.3 KiB
160 lines
5.3 KiB
|
1 month ago
|
import { useEffect, useRef, useState } from 'react';
|
||
|
|
import { TrendingUp, TrendingDown, Activity, DollarSign, BarChart3, Zap } from 'lucide-react';
|
||
|
|
import type { MarketOverview } from '@/types';
|
||
|
|
|
||
|
|
interface MarketOverviewProps {
|
||
|
|
data: MarketOverview;
|
||
|
|
}
|
||
|
|
|
||
|
|
function AnimatedNumber({ value, decimals = 1, suffix = '' }: { value: number; decimals?: number; suffix?: string }) {
|
||
|
|
const [displayValue, setDisplayValue] = useState(0);
|
||
|
|
const startTime = useRef<number | null>(null);
|
||
|
|
const duration = 1000;
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const animate = (timestamp: number) => {
|
||
|
|
if (!startTime.current) startTime.current = timestamp;
|
||
|
|
const progress = Math.min((timestamp - startTime.current) / duration, 1);
|
||
|
|
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||
|
|
setDisplayValue(value * easeOut);
|
||
|
|
|
||
|
|
if (progress < 1) {
|
||
|
|
requestAnimationFrame(animate);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
requestAnimationFrame(animate);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
startTime.current = null;
|
||
|
|
};
|
||
|
|
}, [value]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<span>
|
||
|
|
{displayValue.toFixed(decimals)}{suffix}
|
||
|
|
</span>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function MiniChart({ isUp }: { isUp: boolean }) {
|
||
|
|
const points = isUp
|
||
|
|
? '0,30 10,25 20,28 30,20 40,22 50,15 60,18 70,10 80,12 90,5 100,8'
|
||
|
|
: '0,10 10,15 20,12 30,20 40,18 50,25 60,22 70,30 80,28 90,35 100,32';
|
||
|
|
|
||
|
|
return (
|
||
|
|
<svg viewBox="0 0 100 40" className="w-full h-10">
|
||
|
|
<polyline
|
||
|
|
fill="none"
|
||
|
|
stroke={isUp ? '#0ECB81' : '#F6465D'}
|
||
|
|
strokeWidth="2"
|
||
|
|
points={points}
|
||
|
|
/>
|
||
|
|
<defs>
|
||
|
|
<linearGradient id={`gradient-${isUp}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
||
|
|
<stop offset="0%" stopColor={isUp ? '#0ECB81' : '#F6465D'} stopOpacity="0.3" />
|
||
|
|
<stop offset="100%" stopColor={isUp ? '#0ECB81' : '#F6465D'} stopOpacity="0" />
|
||
|
|
</linearGradient>
|
||
|
|
</defs>
|
||
|
|
<polygon
|
||
|
|
fill={`url(#gradient-${isUp})`}
|
||
|
|
points={`${points} 100,40 0,40`}
|
||
|
|
/>
|
||
|
|
</svg>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function MarketOverviewPanel({ data }: MarketOverviewProps) {
|
||
|
|
const cards = [
|
||
|
|
{
|
||
|
|
title: '市场热度指数',
|
||
|
|
value: data.heatIndex,
|
||
|
|
change: data.heatChange,
|
||
|
|
suffix: '',
|
||
|
|
decimals: 1,
|
||
|
|
icon: Activity,
|
||
|
|
isUp: data.heatChange > 0,
|
||
|
|
description: data.heatChange > 0 ? '多头情绪高涨' : '市场情绪偏冷',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '涨跌分布',
|
||
|
|
value: data.upCount,
|
||
|
|
change: data.downCount,
|
||
|
|
suffix: '',
|
||
|
|
decimals: 0,
|
||
|
|
icon: BarChart3,
|
||
|
|
isUp: true,
|
||
|
|
description: `涨: ${data.upCount} | 跌: ${data.downCount}`,
|
||
|
|
isDistribution: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '资金流向',
|
||
|
|
value: data.capitalFlow,
|
||
|
|
change: 0,
|
||
|
|
suffix: '亿',
|
||
|
|
decimals: 1,
|
||
|
|
icon: DollarSign,
|
||
|
|
isUp: data.capitalFlow > 0,
|
||
|
|
description: data.capitalFlow > 0 ? '资金净流入' : '资金净流出',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '波动率指数',
|
||
|
|
value: data.volatilityIndex,
|
||
|
|
change: data.volatilityChange,
|
||
|
|
suffix: '',
|
||
|
|
decimals: 1,
|
||
|
|
icon: Zap,
|
||
|
|
isUp: data.volatilityChange > 0,
|
||
|
|
description: data.volatilityChange > 0 ? '波动扩大' : '波动收窄',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-space-6">
|
||
|
|
{cards.map((card, index) => (
|
||
|
|
<div
|
||
|
|
key={card.title}
|
||
|
|
className="bg-white border border-binanceBorder-light rounded-card p-space-5 hover:shadow-card-hover transition-all duration-300 group"
|
||
|
|
style={{
|
||
|
|
animationDelay: `${index * 100}ms`,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div className="flex items-start justify-between mb-space-3">
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<div className={`p-2 rounded-lg ${card.isUp ? 'bg-semantic-green/10' : 'bg-semantic-red/10'}`}>
|
||
|
|
<card.icon className={`w-4 h-4 ${card.isUp ? 'text-semantic-green' : 'text-semantic-red'}`} />
|
||
|
|
</div>
|
||
|
|
<span className="text-text-slate text-sm">{card.title}</span>
|
||
|
|
</div>
|
||
|
|
{!card.isDistribution && card.change !== 0 && (
|
||
|
|
<div className={`flex items-center gap-1 text-xs ${card.change > 0 ? 'text-semantic-green' : 'text-semantic-red'}`}>
|
||
|
|
{card.change > 0 ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
|
||
|
|
<span>{card.change > 0 ? '+' : ''}{card.change}%</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mb-space-3">
|
||
|
|
<span className={`text-2xl font-bold font-mono ${card.isUp ? 'text-text-ink' : 'text-semantic-red'}`}>
|
||
|
|
{card.isDistribution ? (
|
||
|
|
<span className="flex items-baseline gap-2">
|
||
|
|
<span className="text-semantic-green">{data.upCount}</span>
|
||
|
|
<span className="text-text-slate text-lg">/</span>
|
||
|
|
<span className="text-semantic-red">{data.downCount}</span>
|
||
|
|
</span>
|
||
|
|
) : (
|
||
|
|
<>
|
||
|
|
<AnimatedNumber value={card.value} decimals={card.decimals} suffix={card.suffix} />
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="text-xs text-text-slate mb-space-3">{card.description}</div>
|
||
|
|
|
||
|
|
<MiniChart isUp={card.isUp} />
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|