diff --git a/src/views/StockOverview/components/StockOverviewHeader/components/StatCard.tsx b/src/views/StockOverview/components/StockOverviewHeader/components/StatCard.tsx new file mode 100644 index 00000000..6f988849 --- /dev/null +++ b/src/views/StockOverview/components/StockOverviewHeader/components/StatCard.tsx @@ -0,0 +1,110 @@ +/** + * StatCard - 统计卡片原子组件 + * 用于展示单个统计指标,支持趋势显示和进度条 + */ +import React, { memo, useMemo } from 'react'; +import { + Box, + Text, + Icon, + HStack, + VStack, + Progress, + Skeleton, +} from '@chakra-ui/react'; +import { ArrowUp, ArrowDown } from 'lucide-react'; +import type { StatCardProps } from '../types'; +import { COLORS, statCardStyles, watermarkIconStyles } from '../styles'; + +const StatCard: React.FC = memo(({ + label, + value, + valueColor, + icon: IconComponent, + iconColor = COLORS.gold, + trend, + progressBar, + helpText, +}) => { + // 缓存趋势图标计算 + const TrendIcon = useMemo(() => { + if (!trend) return null; + if (trend.direction === 'up') return ArrowUp; + if (trend.direction === 'down') return ArrowDown; + return null; + }, [trend]); + + // 缓存趋势颜色 + const trendColor = useMemo(() => { + if (!trend) return undefined; + return trend.direction === 'up' ? COLORS.up : COLORS.down; + }, [trend]); + + return ( + + {/* 水印图标 */} + + + + + {label} + + + {value === null ? ( + + ) : ( + + + {value} + + {trend && TrendIcon && ( + + + {trend.percent.toFixed(1)}% + {trend.label && ( + + {trend.label} + + )} + + )} + + )} + + {helpText && ( + + {helpText} + + )} + + {progressBar && ( + + div': { + bg: progressBar.positiveColor, + }, + }} + /> + + )} + + + ); +}); + +StatCard.displayName = 'StatCard'; + +export default StatCard; diff --git a/src/views/StockOverview/components/StockOverviewHeader/components/index.ts b/src/views/StockOverview/components/StockOverviewHeader/components/index.ts new file mode 100644 index 00000000..1a24e30c --- /dev/null +++ b/src/views/StockOverview/components/StockOverviewHeader/components/index.ts @@ -0,0 +1 @@ +export { default as StatCard } from './StatCard'; diff --git a/src/views/StockOverview/components/StockOverviewHeader/index.tsx b/src/views/StockOverview/components/StockOverviewHeader/index.tsx new file mode 100644 index 00000000..9bc07bfb --- /dev/null +++ b/src/views/StockOverview/components/StockOverviewHeader/index.tsx @@ -0,0 +1,222 @@ +/** + * StockOverviewHeader - 个股中心头部组件 + * 左右分栏:左侧标题+统计指标,右侧热力图 + * + * 优化点: + * 1. 类型拆分到 types.ts + * 2. 样式常量拆分到 styles.ts + * 3. 工具函数拆分到 utils.ts + * 4. StatCard 拆分为原子组件 + * 5. 使用 useMemo 缓存计算结果 + */ +import React, { memo, useMemo } from 'react'; +import { + Box, + Flex, + Heading, + Text, + SimpleGrid, + Icon, + HStack, +} from '@chakra-ui/react'; +import { + TrendingUp, + Zap, + Scale, + Banknote, + Wallet, + Rocket, +} from 'lucide-react'; +import MarketHeatmap from '../MarketHeatmap'; + +import type { StockOverviewHeaderProps } from './types'; +import { StatCard } from './components'; +import { COLORS, heatmapContainerStyles } from './styles'; +import { getAmountTrend, getMarketCapTrend, formatChangePercent, formatAmount } from './utils'; + +const StockOverviewHeader: React.FC = ({ + hotspotData, + limitStats, + marketStats, + heatmapData, + loadingHeatmap, + onStockClick, +}) => { + // 缓存趋势计算 + const amountTrend = useMemo(() => getAmountTrend(marketStats), [marketStats]); + const marketCapTrend = useMemo(() => getMarketCapTrend(marketStats), [marketStats]); + + // 缓存涨跌幅显示值 + const changePercentValue = useMemo(() => { + if (hotspotData?.index?.change_pct == null) return null; + return formatChangePercent(hotspotData.index.change_pct); + }, [hotspotData?.index?.change_pct]); + + const changePercentColor = useMemo(() => { + return (hotspotData?.index?.change_pct ?? 0) >= 0 ? COLORS.up : COLORS.down; + }, [hotspotData?.index?.change_pct]); + + // 缓存涨停/跌停值 + const limitValue = useMemo(() => { + if (limitStats.limitUpCount > 0 || limitStats.limitDownCount > 0) { + return `${limitStats.limitUpCount}/${limitStats.limitDownCount}`; + } + return null; + }, [limitStats.limitUpCount, limitStats.limitDownCount]); + + const limitProgressBar = useMemo(() => { + if (limitStats.limitUpCount > 0 || limitStats.limitDownCount > 0) { + return { + value: limitStats.limitUpCount, + total: limitStats.limitDownCount || 1, + positiveColor: COLORS.up, + negativeColor: COLORS.down, + }; + } + return undefined; + }, [limitStats.limitUpCount, limitStats.limitDownCount]); + + // 缓存多空对比值 + const bullBearValue = useMemo(() => { + if (marketStats?.rising_count && marketStats?.falling_count) { + return `${marketStats.rising_count}/${marketStats.falling_count}`; + } + return null; + }, [marketStats?.rising_count, marketStats?.falling_count]); + + const bullBearProgressBar = useMemo(() => { + if (marketStats?.rising_count && marketStats?.falling_count) { + return { + value: marketStats.rising_count, + total: marketStats.falling_count, + positiveColor: COLORS.up, + negativeColor: COLORS.down, + }; + } + return undefined; + }, [marketStats?.rising_count, marketStats?.falling_count]); + + // 缓存成交额和市值显示值 + const amountValue = useMemo(() => { + return marketStats?.total_amount ? formatAmount(marketStats.total_amount) : null; + }, [marketStats?.total_amount]); + + const marketCapValue = useMemo(() => { + return marketStats?.total_market_cap ? formatAmount(marketStats.total_market_cap) : null; + }, [marketStats?.total_market_cap]); + + // 缓存连板龙头显示值 + const continuousValue = useMemo(() => { + return limitStats.limitUpCount > 0 ? `${limitStats.limitUpCount}只` : '暂无'; + }, [limitStats.limitUpCount]); + + const continuousHelpText = useMemo(() => { + return limitStats.maxContinuousDays > 1 + ? `最高${limitStats.maxContinuousDays}天` + : undefined; + }, [limitStats.maxContinuousDays]); + + return ( + + + {/* 左侧:标题 + 统计指标 (40%) */} + + {/* 标题区 */} + + + + 个股中心 + + + + 实时追踪市场动态,洞察投资机会 + + + {/* 统计指标网格 */} + + {/* 大盘涨跌幅 */} + + + {/* 涨停/跌停 */} + + + {/* 多空对比 */} + + + {/* 今日成交额 */} + + + {/* A股总市值 */} + + + {/* 连板龙头 */} + + + + + {/* 右侧:热力图 (60%) */} + + + + + + ); +}; + +export default memo(StockOverviewHeader); + +// 导出类型供外部使用 +export type { + StockOverviewHeaderProps, + HotspotData, + LimitStats, + MarketStats, + HeatmapDataItem, +} from './types'; diff --git a/src/views/StockOverview/components/StockOverviewHeader/styles.ts b/src/views/StockOverview/components/StockOverviewHeader/styles.ts new file mode 100644 index 00000000..56932aa3 --- /dev/null +++ b/src/views/StockOverview/components/StockOverviewHeader/styles.ts @@ -0,0 +1,60 @@ +/** + * StockOverviewHeader 样式常量 + */ +import type { SystemStyleObject } from '@chakra-ui/react'; + +/** 颜色常量 */ +export const COLORS = { + // 主题色 + gold: '#8b5cf6', + + // 文字颜色 + text: 'rgba(255, 255, 255, 0.95)', + subText: 'rgba(255, 255, 255, 0.6)', + + // 涨跌颜色 + up: '#ff4d4d', + down: '#22c55e', + + // 警告/连板颜色 + warning: '#f59e0b', + + // 背景与边框 + cardBg: 'rgba(255, 255, 255, 0.03)', + border: 'rgba(255, 255, 255, 0.08)', +} as const; + +/** StatCard 卡片样式 */ +export const statCardStyles: SystemStyleObject = { + bg: COLORS.cardBg, + borderWidth: '1px', + borderColor: COLORS.border, + borderRadius: 'xl', + p: 4, + position: 'relative', + overflow: 'hidden', + transition: 'all 0.2s', + _hover: { + borderColor: `${COLORS.gold}50`, + transform: 'translateY(-2px)', + }, +}; + +/** 水印图标样式 */ +export const watermarkIconStyles: SystemStyleObject = { + position: 'absolute', + right: 2, + bottom: 2, + boxSize: 12, + opacity: 0.1, +}; + +/** 热力图容器样式 */ +export const heatmapContainerStyles: SystemStyleObject = { + bg: COLORS.cardBg, + borderWidth: '1px', + borderColor: COLORS.border, + borderRadius: '2xl', + overflow: 'hidden', + p: 2, +}; diff --git a/src/views/StockOverview/components/StockOverviewHeader/types.ts b/src/views/StockOverview/components/StockOverviewHeader/types.ts new file mode 100644 index 00000000..fd8608d1 --- /dev/null +++ b/src/views/StockOverview/components/StockOverviewHeader/types.ts @@ -0,0 +1,81 @@ +/** + * StockOverviewHeader 类型定义 + */ + +/** 趋势方向类型 */ +export type TrendDirection = 'up' | 'down' | 'flat'; + +/** 热点数据 */ +export interface HotspotData { + index?: { + change_pct?: number; + }; +} + +/** 涨跌停统计 */ +export interface LimitStats { + limitUpCount: number; + limitDownCount: number; + continuousLimitCount: number; + maxContinuousDays: number; +} + +/** 市场统计数据 */ +export interface MarketStats { + rising_count?: number; + falling_count?: number; + total_amount?: number; + total_market_cap?: number; + yesterday?: { + total_amount?: number; + total_market_cap?: number; + }; +} + +/** 热力图数据项 */ +export interface HeatmapDataItem { + stock_code: string; + stock_name: string; + change_percent: number; + market_cap: number; + amount: number; + industry?: string; + province?: string; +} + +/** StockOverviewHeader 组件 Props */ +export interface StockOverviewHeaderProps { + hotspotData: HotspotData | null; + limitStats: LimitStats; + marketStats: MarketStats | null; + heatmapData: HeatmapDataItem[]; + loadingHeatmap: boolean; + onStockClick?: (code: string, name: string) => void; +} + +/** 趋势配置 */ +export interface TrendConfig { + direction: TrendDirection; + percent: number; + label?: string; +} + +/** 进度条配置 */ +export interface ProgressBarConfig { + value: number; + total: number; + positiveColor: string; + negativeColor: string; +} + +/** StatCard 组件 Props */ +export interface StatCardProps { + label: string; + value: string | null; + valueColor?: string; + icon: React.ElementType; + iconColor?: string; + trend?: TrendConfig; + progressBar?: ProgressBarConfig; + helpText?: string; +} diff --git a/src/views/StockOverview/components/StockOverviewHeader/utils.ts b/src/views/StockOverview/components/StockOverviewHeader/utils.ts new file mode 100644 index 00000000..b0a643a8 --- /dev/null +++ b/src/views/StockOverview/components/StockOverviewHeader/utils.ts @@ -0,0 +1,81 @@ +/** + * StockOverviewHeader 工具函数 + */ +import type { MarketStats, TrendConfig, TrendDirection } from './types'; + +/** + * 获取趋势方向 + * @param change 变化百分比 + * @returns 趋势方向 + */ +export const getTrendDirection = (change: number): TrendDirection => { + if (change > 0.01) return 'up'; + if (change < -0.01) return 'down'; + return 'flat'; +}; + +/** + * 计算成交额趋势 + * @param marketStats 市场统计数据 + * @returns 趋势配置 + */ +export const getAmountTrend = (marketStats: MarketStats | null): TrendConfig | undefined => { + if (!marketStats?.yesterday?.total_amount || !marketStats.total_amount) { + return undefined; + } + + const change = + ((marketStats.total_amount - marketStats.yesterday.total_amount) / + marketStats.yesterday.total_amount) * + 100; + + const direction = getTrendDirection(change); + + return { + direction, + percent: Math.abs(change), + label: change > 5 ? '放量' : change < -5 ? '缩量' : undefined, + }; +}; + +/** + * 计算市值趋势 + * @param marketStats 市场统计数据 + * @returns 趋势配置 + */ +export const getMarketCapTrend = (marketStats: MarketStats | null): TrendConfig | undefined => { + if (!marketStats?.yesterday?.total_market_cap || !marketStats.total_market_cap) { + return undefined; + } + + const change = + ((marketStats.total_market_cap - marketStats.yesterday.total_market_cap) / + marketStats.yesterday.total_market_cap) * + 100; + + const direction = getTrendDirection(change); + + return { + direction, + percent: Math.abs(change), + }; +}; + +/** + * 格式化涨跌幅显示 + * @param value 涨跌幅数值 + * @returns 格式化后的字符串 + */ +export const formatChangePercent = (value: number): string => { + const sign = value >= 0 ? '+' : ''; + return `${sign}${value.toFixed(2)}%`; +}; + +/** + * 格式化金额(单位:万亿) + * @param value 金额数值(单位:亿) + * @returns 格式化后的字符串 + */ +export const formatAmount = (value: number): string => { + return `${(value / 10000).toFixed(1)}万亿`; +};