refactor(StockOverviewHeader): 模块化重构与性能优化

目录结构拆分:
- types.ts: 提取 6 个 TypeScript 接口定义
- styles.ts: 提取颜色常量和样式对象
- utils.ts: 提取趋势计算函数 (getAmountTrend, getMarketCapTrend)
- components/StatCard.tsx: 提取统计卡片原子组件

性能优化:
- StatCard 使用 memo + useMemo 缓存趋势图标和颜色
- 主组件使用 useMemo 缓存所有计算值
- 趋势函数外移避免每次渲染重新创建

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-31 17:38:49 +08:00
parent 21b58c7c68
commit f4c194881f
6 changed files with 555 additions and 0 deletions

View File

@@ -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<StatCardProps> = 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 (
<Box sx={statCardStyles}>
{/* 水印图标 */}
<Icon
as={IconComponent}
sx={watermarkIconStyles}
color={iconColor}
/>
<VStack align="start" spacing={2}>
<Text fontSize="xs" color={COLORS.subText} fontWeight="medium">
{label}
</Text>
{value === null ? (
<Skeleton height="28px" width="80px" />
) : (
<HStack spacing={2} align="baseline">
<Text
fontSize="xl"
fontWeight="bold"
color={valueColor || COLORS.text}
>
{value}
</Text>
{trend && TrendIcon && (
<HStack spacing={1} fontSize="xs" color={trendColor}>
<Icon as={TrendIcon} boxSize={3} />
<Text>{trend.percent.toFixed(1)}%</Text>
{trend.label && (
<Text color={trendColor}>
{trend.label}
</Text>
)}
</HStack>
)}
</HStack>
)}
{helpText && (
<Text fontSize="xs" color={COLORS.subText}>
{helpText}
</Text>
)}
{progressBar && (
<Box w="100%" mt={1}>
<Progress
value={(progressBar.value / (progressBar.value + progressBar.total)) * 100}
size="xs"
borderRadius="full"
bg={progressBar.negativeColor}
sx={{
'& > div': {
bg: progressBar.positiveColor,
},
}}
/>
</Box>
)}
</VStack>
</Box>
);
});
StatCard.displayName = 'StatCard';
export default StatCard;

View File

@@ -0,0 +1 @@
export { default as StatCard } from './StatCard';

View File

@@ -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<StockOverviewHeaderProps> = ({
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 (
<Box position="relative" zIndex={1} pt={8} pb={6} px={6}>
<Flex
direction={{ base: 'column', md: 'row' }}
gap={6}
align="stretch"
>
{/* 左侧:标题 + 统计指标 (40%) */}
<Box flex="4" minW={0}>
{/* 标题区 */}
<HStack spacing={3} mb={4}>
<Icon as={TrendingUp} boxSize={8} color={COLORS.gold} />
<Heading
size="xl"
bgGradient={`linear(to-r, ${COLORS.gold}, white)`}
bgClip="text"
>
</Heading>
</HStack>
<Text color={COLORS.subText} fontSize="md" mb={6}>
</Text>
{/* 统计指标网格 */}
<SimpleGrid columns={{ base: 2, md: 3 }} spacing={4}>
{/* 大盘涨跌幅 */}
<StatCard
label="大盘涨跌幅"
value={changePercentValue}
valueColor={changePercentColor}
icon={TrendingUp}
/>
{/* 涨停/跌停 */}
<StatCard
label="涨停/跌停"
value={limitValue}
icon={Zap}
iconColor={COLORS.up}
progressBar={limitProgressBar}
/>
{/* 多空对比 */}
<StatCard
label="多空对比"
value={bullBearValue}
icon={Scale}
progressBar={bullBearProgressBar}
/>
{/* 今日成交额 */}
<StatCard
label="今日成交额"
value={amountValue}
icon={Banknote}
trend={amountTrend}
/>
{/* A股总市值 */}
<StatCard
label="A股总市值"
value={marketCapValue}
icon={Wallet}
trend={marketCapTrend}
/>
{/* 连板龙头 */}
<StatCard
label="连板龙头"
value={continuousValue}
valueColor={COLORS.warning}
icon={Rocket}
iconColor={COLORS.warning}
helpText={continuousHelpText}
/>
</SimpleGrid>
</Box>
{/* 右侧:热力图 (60%) */}
<Box flex="6" minH="400px" sx={heatmapContainerStyles}>
<MarketHeatmap
data={heatmapData}
height="384px"
loading={loadingHeatmap}
onStockClick={onStockClick}
showLegend={true}
/>
</Box>
</Flex>
</Box>
);
};
export default memo(StockOverviewHeader);
// 导出类型供外部使用
export type {
StockOverviewHeaderProps,
HotspotData,
LimitStats,
MarketStats,
HeatmapDataItem,
} from './types';

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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)}万亿`;
};