Files
vf_react/src/views/StockOverview/index.js
2025-12-11 15:01:54 +08:00

1190 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Container,
Heading,
Text,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
IconButton,
Button,
SimpleGrid,
Card,
CardBody,
CardHeader,
VStack,
HStack,
Badge,
Stat,
StatLabel,
StatNumber,
StatHelpText,
StatArrow,
Flex,
Spacer,
Icon,
useToast,
Spinner,
Center,
Divider,
List,
ListItem,
Tooltip,
Menu,
MenuButton,
MenuList,
MenuItem,
useDisclosure,
Image,
Fade,
Collapse,
Stack,
Progress,
Tag,
TagLabel,
Skeleton,
SkeletonText,
} from '@chakra-ui/react';
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar, FaTag, FaLayerGroup, FaBolt } from 'react-icons/fa';
import ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker';
import HotspotOverview from './components/HotspotOverview';
import FlexScreen from './components/FlexScreen';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import * as echarts from 'echarts';
import { logger } from '../../utils/logger';
import tradingDays from '../../data/tradingDays.json';
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
// 交易日 Set用于快速查找
const tradingDaysSet = new Set(tradingDays);
// Navigation bar now provided by MainLayout
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
const StockOverview = () => {
const navigate = useNavigate();
const toast = useToast();
const colorMode = 'dark'; // 固定为 dark 深色模式
const heatmapRef = useRef(null);
const heatmapChart = useRef(null);
// 🎯 事件追踪 Hook
const {
trackMarketStatsViewed,
trackSearchInitiated,
trackStockSearched,
trackSearchResultClicked,
trackConceptClicked,
trackConceptStockClicked,
trackHeatmapStockClicked,
trackStockDetailViewed,
trackConceptDetailViewed,
trackDateChanged,
} = useStockOverviewEvents({ navigate });
// 状态管理
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [showResults, setShowResults] = useState(false);
const [topConcepts, setTopConcepts] = useState([]);
const [heatmapData, setHeatmapData] = useState([]);
const [loadingConcepts, setLoadingConcepts] = useState(true);
const [loadingHeatmap, setLoadingHeatmap] = useState(true);
const [selectedDate, setSelectedDate] = useState(null);
const [marketStats, setMarketStats] = useState(null);
const [availableDates, setAvailableDates] = useState([]);
// 个股列表弹窗状态
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
const [selectedConcept, setSelectedConcept] = useState(null);
// 深色主题配色 - 参考概念中心
const bgColor = '#0a0a0f'; // 深色背景
const cardBg = 'rgba(255, 255, 255, 0.03)'; // 玻璃态卡片背景
const borderColor = 'rgba(255, 255, 255, 0.08)'; // 边框
const hoverBg = 'rgba(255, 255, 255, 0.06)'; // 悬停背景
const searchBg = 'rgba(255, 255, 255, 0.15)'; // 搜索框背景(调亮)
const textColor = 'rgba(255, 255, 255, 0.95)'; // 主文字
const subTextColor = 'rgba(255, 255, 255, 0.6)'; // 次要文字
const goldColor = '#8b5cf6'; // 使用紫色作为强调色
const accentColor = '#8b5cf6'; // 紫色强调
const heroBg = 'linear(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%)'; // 深色极光背景
// 打开个股列表弹窗
const handleViewStocks = useCallback((e, concept) => {
e.stopPropagation();
setSelectedConcept(concept);
setIsStockModalOpen(true);
}, []);
// 防抖搜索
const debounceSearch = useCallback(
(() => {
let timeoutId;
return (query) => {
clearTimeout(timeoutId);
if (!query.trim()) {
setSearchResults([]);
setShowResults(false);
return;
}
timeoutId = setTimeout(() => {
searchStocks(query);
}, 300);
};
})(),
[]
);
// 搜索股票
const searchStocks = async (query) => {
setIsSearching(true);
try {
logger.debug('StockOverview', '开始搜索股票', { query });
const response = await fetch(`/api/stocks/search?q=${encodeURIComponent(query)}&limit=10`);
const data = await response.json();
logger.debug('StockOverview', 'API返回数据', {
status: response.status,
resultCount: data.data?.length || 0
});
if (data.success) {
const results = data.data || [];
setSearchResults(results);
setShowResults(true);
// 🎯 追踪搜索查询
trackStockSearched(query, results.length);
} else {
logger.warn('StockOverview', '搜索失败', data.error || '请稍后重试', { query });
// ❌ 移除搜索失败 toast非关键操作
// 🎯 追踪搜索无结果
trackStockSearched(query, 0);
}
} catch (error) {
logger.error('StockOverview', 'searchStocks', error, { query });
// ❌ 移除搜索失败 toast非关键操作
} finally {
setIsSearching(false);
}
};
// 获取每日涨幅靠前的概念
const fetchTopConcepts = async (date = null) => {
setLoadingConcepts(true);
try {
const url = date ? `/api/concepts/daily-top?limit=6&date=${date}` : '/api/concepts/daily-top?limit=6';
const response = await fetch(url);
const data = await response.json();
if (data.success) {
setTopConcepts(data.data);
// 使用概念接口的日期作为统一数据源(数据最新)
setSelectedDate(new Date(data.trade_date));
// 基于交易日历生成可选日期列表
if (data.trade_date && tradingDays.length > 0) {
// 找到当前日期或最近的交易日
let targetDate = data.trade_date;
if (!tradingDaysSet.has(data.trade_date)) {
for (let i = tradingDays.length - 1; i >= 0; i--) {
if (tradingDays[i] <= data.trade_date) {
targetDate = tradingDays[i];
break;
}
}
}
const idx = tradingDays.indexOf(targetDate);
if (idx !== -1) {
const startIdx = Math.max(0, idx - 19);
const dates = tradingDays.slice(startIdx, idx + 1).reverse();
setAvailableDates(dates);
}
}
logger.debug('StockOverview', '热门概念加载成功', {
count: data.data?.length || 0,
date: data.trade_date
});
}
} catch (error) {
logger.error('StockOverview', 'fetchTopConcepts', error, { date });
} finally {
setLoadingConcepts(false);
}
};
// 获取热力图数据
const fetchHeatmapData = async (date = null) => {
setLoadingHeatmap(true);
try {
const url = date ? `/api/market/heatmap?limit=500&date=${date}` : '/api/market/heatmap?limit=500';
const response = await fetch(url);
const data = await response.json();
if (data.success) {
setHeatmapData(data.data);
// 保存统计数据
if (data.statistics) {
setMarketStats(prevStats => ({
...(prevStats || {}),
rising_count: data.statistics.rising_count,
falling_count: data.statistics.falling_count
}));
}
// 日期由 fetchTopConcepts 统一设置,这里不再设置
logger.debug('StockOverview', '热力图数据加载成功', {
count: data.data?.length || 0,
date: data.trade_date
});
// 延迟渲染热力图确保DOM已经准备好
setTimeout(() => renderHeatmap(data.data), 100);
}
} catch (error) {
logger.error('StockOverview', 'fetchHeatmapData', error, { date });
} finally {
setLoadingHeatmap(false);
}
};
// 获取市场统计数据
const fetchMarketStats = async (date = null) => {
try {
const url = date ? `/api/market/statistics?date=${date}` : '/api/market/statistics';
const response = await fetch(url);
const data = await response.json();
if (data.success) {
// 使用函数式更新,避免 race condition 导致覆盖 heatmap 接口设置的 rising_count/falling_count
setMarketStats(prevStats => ({
...data.summary,
// 保留之前从 heatmap 接口获取的上涨/下跌家数
rising_count: prevStats?.rising_count,
falling_count: prevStats?.falling_count,
date: data.trade_date
}));
const newStats = {
...data.summary,
date: data.trade_date
};
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
logger.debug('StockOverview', '市场统计数据加载成功', {
date: data.trade_date
});
// 🎯 追踪市场统计数据查看
trackMarketStatsViewed(newStats);
}
} catch (error) {
logger.error('StockOverview', 'fetchMarketStats', error, { date });
// ❌ 移除统计数据加载失败 toast非关键操作
}
};
// 渲染热力图
const renderHeatmap = useCallback((data) => {
if (!heatmapRef.current || !data || !data.length) return;
try {
// 初始化或获取ECharts实例
if (!heatmapChart.current) {
heatmapChart.current = echarts.init(heatmapRef.current, colorMode === 'dark' ? 'dark' : null);
}
// 按市值分组
const groupedData = {};
data.forEach(item => {
const capRange = getMarketCapRange(item.market_cap);
if (!groupedData[capRange]) {
groupedData[capRange] = [];
}
groupedData[capRange].push(item);
});
// 构建树图数据 - 修复格式问题
const treeData = Object.entries(groupedData).map(([range, stocks]) => ({
name: range,
children: stocks.map(stock => {
const change = stock.change_percent || 0;
let color = colorMode === 'dark' ? '#333333' : '#9ca3af'; // 默认灰色
if (change > 0) {
const intensity = Math.min(change / 10, 1);
if (colorMode === 'dark') {
// 夜间模式:红色带金色调
color = `rgba(255, 77, 77, ${0.4 + intensity * 0.6})`;
} else {
color = `rgba(239, 68, 68, ${0.3 + intensity * 0.7})`;
}
} else if (change < 0) {
const intensity = Math.min(Math.abs(change) / 10, 1);
if (colorMode === 'dark') {
// 夜间模式:绿色带暗色调
color = `rgba(34, 197, 94, ${0.3 + intensity * 0.5})`;
} else {
color = `rgba(34, 197, 94, ${0.3 + intensity * 0.7})`;
}
}
return {
name: stock.stock_name,
value: Math.abs(stock.market_cap),
change: stock.change_percent,
code: stock.stock_code,
amount: stock.amount,
industry: stock.industry,
province: stock.province,
itemStyle: {
color: color
}
};
})
}));
const option = {
backgroundColor: colorMode === 'dark' ? '#0a0a0a' : 'transparent',
tooltip: {
backgroundColor: colorMode === 'dark' ? '#1a1a1a' : 'white',
borderColor: colorMode === 'dark' ? goldColor : '#ccc',
borderWidth: colorMode === 'dark' ? 2 : 1,
textStyle: {
color: colorMode === 'dark' ? 'white' : '#333'
},
formatter: function(info) {
const data = info.data;
const isDark = colorMode === 'dark';
// 如果是父节点(市值分组)
if (data.children) {
return `
<div style="padding: 10px; color: ${isDark ? 'white' : '#333'};">
<div style="font-weight: bold; margin-bottom: 6px; font-size: 14px; color: ${isDark ? goldColor : '#333'};">${data.name}</div>
<div style="color: ${isDark ? '#ccc' : '#666'};">包含 ${data.children.length} 只股票</div>
<div style="color: ${isDark ? '#ccc' : '#666'};">总市值: <span style="color: ${isDark ? goldColor : '#333'}; font-weight: bold;">${data.children.reduce((sum, item) => sum + item.value, 0).toFixed(2)}</span> 亿元</div>
</div>
`;
}
// 个股详情
return `
<div style="padding: 10px; color: ${isDark ? 'white' : '#333'};">
<div style="font-weight: bold; margin-bottom: 6px; font-size: 14px; color: ${isDark ? goldColor : '#333'};">${data.name}</div>
<div style="color: ${isDark ? '#ccc' : '#666'};">代码: ${data.code || '-'}</div>
<div style="color: ${isDark ? '#ccc' : '#666'};">涨跌幅: <span style="color: ${data.change > 0 ? '#ff4d4d' : '#22c55e'}; font-weight: bold;">
${data.change > 0 ? '+' : ''}${data.change?.toFixed(2) || 0}%
</span></div>
<div style="color: ${isDark ? '#ccc' : '#666'};">市值: <span style="font-weight: bold;">${data.value?.toFixed(2) || 0}</span> 亿元</div>
<div style="color: ${isDark ? '#ccc' : '#666'};">成交额: <span style="font-weight: bold;">${data.amount?.toFixed(2) || 0}</span> 亿元</div>
<div style="color: ${isDark ? '#ccc' : '#666'};">行业: ${data.industry || '未知'}</div>
<div style="color: ${isDark ? '#ccc' : '#666'};">地区: ${data.province || '未知'}</div>
</div>
`;
}
},
series: [{
name: 'A股市场',
type: 'treemap',
data: treeData,
leafDepth: 1,
roam: false,
breadcrumb: {
show: false
},
levels: [
{
itemStyle: {
borderColor: colorMode === 'dark' ? '#1a1a1a' : '#fff',
borderWidth: 3,
gapWidth: 3
}
},
{
itemStyle: {
borderColor: colorMode === 'dark' ? '#0a0a0a' : '#fff',
borderWidth: 1,
gapWidth: 1
}
}
],
itemStyle: {
borderColor: colorMode === 'dark' ? '#0a0a0a' : '#fff',
borderWidth: 1
},
label: {
show: true,
formatter: function(params) {
const data = params.data;
// 父节点(市值分组)显示名称
if (data.children) {
return params.name;
}
// 子节点(个股)根据市值大小决定是否显示
return data.value > 5 ? data.name : '';
},
fontSize: 12,
color: function(params) {
if (colorMode === 'dark') {
// 夜间模式:根据背景色调整文字颜色
const change = params.data.change || 0;
if (Math.abs(change) > 5) {
return 'white';
}
return '#ccc';
}
return '#333';
}
}
}]
};
// 设置配置项
heatmapChart.current.setOption(option);
// 先移除之前的点击事件,避免重复绑定
heatmapChart.current.off('click');
// 添加点击事件
heatmapChart.current.on('click', function(params) {
// 只有点击个股有code的节点才跳转
if (params.data && params.data.code && !params.data.children) {
const stock = {
code: params.data.code,
name: params.data.name,
change_percent: params.data.change
};
const marketCapRange = getMarketCapRange(params.data.value);
// 🎯 追踪热力图股票点击
trackHeatmapStockClicked(stock, marketCapRange);
navigate(`/company?scode=${params.data.code}`);
}
});
} catch (error) {
logger.error('StockOverview', 'renderHeatmap', error, {
dataLength: data?.length || 0
});
// ❌ 移除热力图渲染失败 toast非关键操作
}
}, [colorMode, goldColor, navigate, trackHeatmapStockClicked]); // ✅ 添加追踪函数依赖
// 获取市值区间
const getMarketCapRange = (cap) => {
if (cap >= 1000) return '超大盘股(>1000亿)';
if (cap >= 500) return '大盘股(500-1000亿)';
if (cap >= 100) return '中盘股(100-500亿)';
if (cap >= 50) return '小盘股(50-100亿)';
return '微盘股(<50亿)';
};
// 处理搜索输入
const handleSearchChange = (e) => {
const value = e.target.value;
setSearchQuery(value);
// 🎯 追踪搜索开始(首次输入时)
if (value && !searchQuery) {
trackSearchInitiated();
}
debounceSearch(value);
};
// 清空搜索
const handleClearSearch = () => {
setSearchQuery('');
setSearchResults([]);
setShowResults(false);
};
// 选择股票
const handleSelectStock = (stock, index = 0) => {
// 🎯 追踪搜索结果点击
trackSearchResultClicked(stock, index);
navigate(`/company?scode=${stock.stock_code}`);
handleClearSearch();
};
// 查看概念详情模仿概念中心打开对应HTML页
const handleConceptClick = (concept, rank = 0) => {
// 🎯 追踪概念点击
trackConceptClicked(concept, rank);
const htmlPath = `/htmls/${concept.concept_name}.html`;
window.open(htmlPath, '_blank');
};
// 格式化涨跌幅
const formatChangePercent = (value) => {
if (value === null || value === undefined) return '0.00%';
const formatted = value.toFixed(2);
return formatted > 0 ? `+${formatted}%` : `${formatted}%`;
};
// 获取涨跌幅颜色
const getChangeColor = (value) => {
if (value === null || value === undefined) return 'gray';
return value > 0 ? 'red' : value < 0 ? 'green' : 'gray';
};
// 组件挂载时获取数据
useEffect(() => {
fetchTopConcepts();
fetchHeatmapData();
fetchMarketStats();
// 监听窗口大小变化,重新渲染热力图
const handleResize = () => {
if (heatmapChart.current) {
heatmapChart.current.resize();
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (heatmapChart.current) {
heatmapChart.current.dispose();
}
};
}, []);
// 监听colorMode和heatmapData变化重新渲染热力图
useEffect(() => {
if (heatmapData.length > 0) {
// 如果已有实例,先销毁再重新创建
if (heatmapChart.current) {
heatmapChart.current.dispose();
heatmapChart.current = null;
}
renderHeatmap(heatmapData);
}
}, [heatmapData, colorMode, renderHeatmap]);
// 概念卡片骨架屏
const ConceptSkeleton = () => (
<Card bg={bgColor} borderWidth="1px" borderColor={borderColor}>
<CardBody>
<Skeleton height="20px" mb={2} />
<SkeletonText mt={4} noOfLines={2} spacing={2} />
<Skeleton height="40px" mt={4} />
</CardBody>
</Card>
);
return (
<Box minH="100vh" bg={bgColor} position="relative" overflow="hidden">
{/* 全局极光背景 */}
<Box
position="fixed"
inset={0}
bgGradient="radial(ellipse at 20% 0%, rgba(139, 92, 246, 0.15) 0%, transparent 50%)"
pointerEvents="none"
zIndex={0}
/>
<Box
position="fixed"
inset={0}
bgGradient="radial(ellipse at 80% 100%, rgba(59, 130, 246, 0.1) 0%, transparent 50%)"
pointerEvents="none"
zIndex={0}
/>
<Box
position="fixed"
inset={0}
bgGradient="radial(ellipse at 50% 50%, rgba(236, 72, 153, 0.05) 0%, transparent 70%)"
pointerEvents="none"
zIndex={0}
/>
{/* Hero Section */}
<Box
position="relative"
bgGradient={heroBg}
color="white"
overflow="visible"
pt={{ base: 20, md: 24 }}
pb={{ base: 16, md: 20 }}
borderBottom={`1px solid rgba(139, 92, 246, 0.3)`}
zIndex={1}
>
{/* 背景装饰 */}
<Box
position="absolute"
top="-20%"
right="-10%"
width="40%"
height="120%"
bg={`${goldColor}15`}
transform="rotate(12deg)"
borderRadius="full"
filter="blur(60px)"
/>
<Container maxW="container.xl" position="relative">
<VStack spacing={8} align="center">
<VStack spacing={4} textAlign="center" maxW="3xl">
<HStack spacing={3}>
<Icon as={BsGraphUp} boxSize={12} color={colorMode === 'dark' ? goldColor : 'white'} />
<Heading
as="h1"
size="2xl"
fontWeight="bold"
bgGradient={colorMode === 'dark' ? `linear(to-r, ${goldColor}, white)` : 'none'}
bgClip={colorMode === 'dark' ? 'text' : 'none'}
>
个股中心
</Heading>
</HStack>
<Text fontSize="xl" opacity={0.9} color={colorMode === 'dark' ? 'gray.300' : 'white'}>
实时追踪市场动态洞察投资机会
</Text>
</VStack>
{/* 搜索框 */}
<Box w="100%" maxW="2xl" position="relative">
<InputGroup
size="lg"
bg={searchBg}
borderRadius="full"
boxShadow="2xl"
border="2px solid"
borderColor={colorMode === 'dark' ? goldColor : 'transparent'}
>
<InputLeftElement pointerEvents="none">
<SearchIcon color={colorMode === 'dark' ? goldColor : 'gray.400'} />
</InputLeftElement>
<Input
placeholder="搜索股票代码、名称或拼音首字母..."
value={searchQuery}
onChange={handleSearchChange}
borderRadius="full"
border="none"
color={textColor}
bg="transparent"
_placeholder={{ color: colorMode === 'dark' ? 'gray.500' : 'gray.400' }}
_focus={{
boxShadow: 'none',
borderColor: 'transparent',
bg: colorMode === 'dark' ? 'whiteAlpha.50' : 'transparent'
}}
pr={searchQuery ? "3rem" : "1rem"}
/>
{searchQuery && (
<InputRightElement>
<IconButton
size="sm"
icon={<CloseIcon />}
variant="ghost"
onClick={handleClearSearch}
aria-label="清空搜索"
color={colorMode === 'dark' ? goldColor : 'gray.600'}
_hover={{
bg: colorMode === 'dark' ? 'whiteAlpha.100' : 'gray.100'
}}
/>
</InputRightElement>
)}
</InputGroup>
{/* 搜索结果下拉 */}
<Collapse in={showResults} animateOpacity>
<Box
position="absolute"
top="100%"
left={0}
right={0}
mt={2}
bg={searchBg}
borderRadius="xl"
boxShadow="2xl"
border="1px solid"
borderColor={borderColor}
maxH="400px"
overflowY="auto"
zIndex={10}
>
{isSearching ? (
<Center p={4}>
<Spinner color={accentColor} />
</Center>
) : searchResults.length > 0 ? (
<List spacing={0}>
{searchResults.map((stock, index) => (
<ListItem
key={stock.stock_code}
p={4}
cursor="pointer"
_hover={{ bg: hoverBg }}
onClick={() => handleSelectStock(stock, index)}
borderBottomWidth={index < searchResults.length - 1 ? "1px" : "0"}
borderColor={borderColor}
>
<Flex align="center" justify="space-between">
<VStack align="start" spacing={1} flex={1}>
<Text fontWeight="bold" color={textColor}>{stock.stock_name}</Text>
<HStack spacing={2}>
<Text fontSize="sm" color={subTextColor}>{stock.stock_code}</Text>
{stock.pinyin_abbr && (
<Text fontSize="xs" color={subTextColor}>({stock.pinyin_abbr.toUpperCase()})</Text>
)}
{stock.exchange && (
<Tag
size="sm"
bg={colorMode === 'dark' ? '#2a2a2a' : 'blue.50'}
color={colorMode === 'dark' ? goldColor : 'blue.600'}
border="1px solid"
borderColor={colorMode === 'dark' ? goldColor : 'blue.200'}
>
{stock.exchange}
</Tag>
)}
</HStack>
</VStack>
<Button
size="sm"
rightIcon={<ArrowForwardIcon />}
variant="ghost"
colorScheme={colorMode === 'dark' ? 'yellow' : 'purple'}
_hover={{
bg: colorMode === 'dark' ? 'whiteAlpha.100' : 'purple.50'
}}
>
查看
</Button>
</Flex>
</ListItem>
))}
</List>
) : (
<Center p={4}>
<Text color={subTextColor}>未找到相关股票</Text>
</Center>
)}
</Box>
</Collapse>
</Box>
{/* 统计数据 - 使用市场统计API数据 */}
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={6} w="100%" maxW="4xl">
<Stat
textAlign="center"
bg={colorMode === 'dark' ? 'whiteAlpha.100' : 'whiteAlpha.800'}
p={4}
borderRadius="lg"
border={colorMode === 'dark' ? `1px solid ${goldColor}50` : 'none'}
>
<StatLabel color={colorMode === 'dark' ? goldColor : 'purple.700'} fontWeight="bold">A股总市值</StatLabel>
<StatNumber fontSize="2xl" color={colorMode === 'dark' ? 'white' : 'purple.800'}>
{marketStats ?
`${(marketStats.total_market_cap / 10000).toFixed(1)}万亿`
: '-'
}
</StatNumber>
</Stat>
<Stat
textAlign="center"
bg={colorMode === 'dark' ? 'whiteAlpha.100' : 'whiteAlpha.800'}
p={4}
borderRadius="lg"
border={colorMode === 'dark' ? `1px solid ${goldColor}50` : 'none'}
>
<StatLabel color={colorMode === 'dark' ? goldColor : 'purple.700'} fontWeight="bold">今日成交额</StatLabel>
<StatNumber fontSize="2xl" color={colorMode === 'dark' ? 'white' : 'purple.800'}>
{marketStats ?
`${(marketStats.total_amount / 10000).toFixed(1)}万亿`
: '-'
}
</StatNumber>
</Stat>
<Stat
textAlign="center"
bg={colorMode === 'dark' ? 'whiteAlpha.100' : 'whiteAlpha.800'}
p={4}
borderRadius="lg"
border={colorMode === 'dark' ? `1px solid ${goldColor}50` : 'none'}
>
<StatLabel color={colorMode === 'dark' ? goldColor : 'purple.700'} fontWeight="bold">上涨家数</StatLabel>
<StatNumber fontSize="2xl" color={colorMode === 'dark' ? '#ff4d4d' : 'red.500'}>
{marketStats && marketStats.rising_count !== undefined && marketStats.rising_count !== null ?
marketStats.rising_count.toLocaleString() : '-'
}
</StatNumber>
</Stat>
<Stat
textAlign="center"
bg={colorMode === 'dark' ? 'whiteAlpha.100' : 'whiteAlpha.800'}
p={4}
borderRadius="lg"
border={colorMode === 'dark' ? `1px solid ${goldColor}50` : 'none'}
>
<StatLabel color={colorMode === 'dark' ? goldColor : 'purple.700'} fontWeight="bold">下跌家数</StatLabel>
<StatNumber fontSize="2xl" color="green.400">
{marketStats && marketStats.falling_count !== undefined && marketStats.falling_count !== null ?
marketStats.falling_count.toLocaleString() : '-'
}
</StatNumber>
</Stat>
</SimpleGrid>
</VStack>
</Container>
</Box>
{/* 主内容区 */}
<Container maxW="container.xl" py={10} position="relative" zIndex={1}>
{/* 日期选择器 */}
<Box mb={6}>
<Flex align="center" gap={4} flexWrap="wrap">
<TradeDatePicker
value={selectedDate}
onChange={(date) => {
const dateStr = date.toISOString().split('T')[0];
const previousDateStr = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
trackDateChanged(dateStr, previousDateStr);
setSelectedDate(date);
fetchHeatmapData(dateStr);
fetchMarketStats(dateStr);
fetchTopConcepts(dateStr);
}}
latestTradeDate={null}
minDate={tradingDays.length > 0 ? new Date(tradingDays[0]) : undefined}
maxDate={tradingDays.length > 0 ? new Date(tradingDays[tradingDays.length - 1]) : undefined}
label="交易日期"
isDarkMode={true}
/>
</Flex>
{selectedDate && (
<Text fontSize="sm" color={subTextColor} mt={2}>
当前显示 {selectedDate.toISOString().split('T')[0]} 的市场数据
</Text>
)}
</Box>
{/* 热点概览 - 大盘走势 + 概念异动 */}
{/* 只在 selectedDate 确定后渲染,避免 null → 日期 的双重请求 */}
<Box mb={10}>
{selectedDate ? (
<HotspotOverview selectedDate={selectedDate} />
) : (
<Card
bg={cardBg}
backdropFilter="blur(20px)"
borderWidth="1px"
borderColor={borderColor}
borderRadius="24px"
boxShadow="0 8px 32px rgba(0, 0, 0, 0.3)"
>
<CardBody>
<Center h="400px">
<VStack spacing={4}>
<Spinner size="xl" color="#8b5cf6" thickness="4px" />
<Text color={subTextColor}>加载热点概览数据...</Text>
</VStack>
</Center>
</CardBody>
</Card>
)}
</Box>
{/* 灵活屏 - 实时行情监控 */}
<Box mb={10}>
<FlexScreen />
</Box>
{/* 今日热门概念 */}
<Box mb={10}>
<Flex align="center" mb={6}>
<HStack spacing={3}>
<Icon as={FaFire} boxSize={6} color={colorMode === 'dark' ? goldColor : 'orange.500'} />
<Heading size="lg" color={textColor}>今日热门概念</Heading>
</HStack>
<Spacer />
<Button
size="sm"
variant="ghost"
rightIcon={<ChevronRightIcon />}
onClick={() => navigate('/concepts')}
color={colorMode === 'dark' ? goldColor : 'purple.600'}
_hover={{
bg: colorMode === 'dark' ? 'whiteAlpha.100' : 'purple.50'
}}
>
查看更多
</Button>
</Flex>
{loadingConcepts ? (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{[...Array(6)].map((_, i) => (
<ConceptSkeleton key={i} />
))}
</SimpleGrid>
) : (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{topConcepts.map((concept, index) => (
<Card
key={concept.concept_id}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
_hover={{
transform: 'translateY(-4px)',
boxShadow: colorMode === 'dark' ? `0 10px 30px -5px ${goldColor}30` : 'lg',
borderColor: colorMode === 'dark' ? goldColor : 'purple.300',
}}
transition="all 0.3s"
cursor="pointer"
onClick={() => handleConceptClick(concept, index)}
position="relative"
overflow="hidden"
>
{/* 排名标签 */}
<Badge
position="absolute"
top={2}
left={2}
bg={colorMode === 'dark' ? goldColor : 'purple.500'}
color={colorMode === 'dark' ? 'black' : 'white'}
fontSize="xs"
px={2}
py={1}
borderRadius="full"
fontWeight="bold"
>
TOP {index + 1}
</Badge>
{/* 涨跌幅标签 */}
<Badge
position="absolute"
top={2}
right={2}
colorScheme={getChangeColor(concept.change_percent)}
fontSize="sm"
px={3}
py={1}
borderRadius="full"
border={colorMode === 'dark' ? '1px solid' : 'none'}
borderColor={colorMode === 'dark' ? concept.change_percent > 0 ? '#ff4d4d' : '#22c55e' : 'transparent'}
>
<HStack spacing={1}>
<Icon
as={concept.change_percent > 0 ? FaArrowUp : FaArrowDown}
boxSize={3}
/>
<Text>{formatChangePercent(concept.change_percent)}</Text>
</HStack>
</Badge>
<CardBody pt={12}>
<VStack align="start" spacing={3}>
{/* 概念名称 */}
<Heading size="md" noOfLines={1} color={textColor}>
{concept.concept_name}
</Heading>
{/* 层级信息 */}
{concept.hierarchy && (
<HStack spacing={1} flexWrap="wrap">
<Icon as={FaLayerGroup} boxSize={3} color="gray.400" />
<Text fontSize="xs" color="gray.500">
{[concept.hierarchy.lv1, concept.hierarchy.lv2, concept.hierarchy.lv3]
.filter(Boolean)
.join(' > ')}
</Text>
</HStack>
)}
{/* 描述 */}
<Text fontSize="sm" color={subTextColor} noOfLines={2}>
{concept.description || '暂无描述'}
</Text>
{/* 标签 */}
{concept.tags && concept.tags.length > 0 && (
<Flex flexWrap="wrap" gap={1}>
{concept.tags.slice(0, 4).map((tag, idx) => (
<Tag
key={idx}
size="sm"
variant="outline"
colorScheme="blue"
borderRadius="full"
>
<Icon as={FaTag} boxSize={2} mr={1} />
<TagLabel fontSize="xs">{tag}</TagLabel>
</Tag>
))}
{concept.tags.length > 4 && (
<Tag size="sm" variant="ghost" colorScheme="gray">
<TagLabel fontSize="xs">+{concept.tags.length - 4}</TagLabel>
</Tag>
)}
</Flex>
)}
{/* 爆发日期 */}
{concept.outbreak_dates && concept.outbreak_dates.length > 0 && (
<HStack spacing={2} fontSize="xs" color="orange.500">
<Icon as={FaBolt} />
<Text>
近期爆发: {concept.outbreak_dates.slice(0, 2).join(', ')}
{concept.outbreak_dates.length > 2 && `${concept.outbreak_dates.length}`}
</Text>
</HStack>
)}
<Divider />
{/* 相关股票 */}
<Box
w="100%"
cursor="pointer"
onClick={(e) => handleViewStocks(e, concept)}
_hover={{ bg: hoverBg }}
p={2}
borderRadius="md"
transition="background 0.2s"
>
<Text fontSize="xs" color="gray.500" mb={2}>
包含 {concept.stock_count} 只个股
</Text>
{concept.stocks && concept.stocks.length > 0 && (
<Flex
flexWrap="nowrap"
gap={2}
overflow="hidden"
maxH="24px"
>
{concept.stocks.slice(0, 5).map((stock, idx) => (
<Tag
key={idx}
size="sm"
colorScheme="purple"
variant="subtle"
flexShrink={0}
>
<TagLabel>{stock.stock_name || stock.name}</TagLabel>
</Tag>
))}
{concept.stocks.length > 5 && (
<Tag size="sm" variant="ghost" colorScheme="gray" flexShrink={0}>
<TagLabel>+{concept.stocks.length - 5}</TagLabel>
</Tag>
)}
</Flex>
)}
</Box>
<HStack spacing={2} w="100%">
<Button
size="sm"
colorScheme="purple"
variant="ghost"
rightIcon={<FaChevronRight />}
onClick={(e) => {
e.stopPropagation();
handleConceptClick(concept, index);
}}
>
查看详情
</Button>
</HStack>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
)}
</Box>
{/* 市值热力图 */}
<Box mb={10}>
<Flex align="center" mb={6}>
<HStack spacing={3}>
<Icon as={FaChartBar} boxSize={6} color={accentColor} />
<Heading size="lg" color={textColor}>市值热力图</Heading>
</HStack>
<Spacer />
<Tooltip label="基于市值大小和涨跌幅展示的市场全景图">
<Icon as={InfoIcon} color={subTextColor} />
</Tooltip>
</Flex>
<Card
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
boxShadow={colorMode === 'dark' ? `0 0 20px ${goldColor}15` : 'lg'}
p={6}
>
{loadingHeatmap ? (
<Center h="500px">
<VStack spacing={4}>
<Spinner size="xl" color={accentColor} thickness="4px" />
<Text color={subTextColor}>正在加载热力图数据...</Text>
</VStack>
</Center>
) : (
<Box>
{/* 图例说明 */}
<HStack spacing={8} mb={6} justify="center">
<HStack>
<Box
w={4}
h={4}
bg={colorMode === 'dark' ? '#ff4d4d' : 'red.500'}
borderRadius="sm"
boxShadow={colorMode === 'dark' ? `0 0 10px #ff4d4d50` : 'none'}
/>
<Text fontSize="sm" color={textColor} fontWeight="medium">上涨</Text>
</HStack>
<HStack>
<Box
w={4}
h={4}
bg={colorMode === 'dark' ? '#333333' : 'gray.400'}
borderRadius="sm"
/>
<Text fontSize="sm" color={textColor} fontWeight="medium">平盘</Text>
</HStack>
<HStack>
<Box
w={4}
h={4}
bg="#22c55e"
borderRadius="sm"
boxShadow={colorMode === 'dark' ? `0 0 10px #22c55e50` : 'none'}
/>
<Text fontSize="sm" color={textColor} fontWeight="medium">下跌</Text>
</HStack>
</HStack>
{/* 热力图容器 */}
<Box ref={heatmapRef} h="500px" w="100%" />
</Box>
)}
</Card>
</Box>
</Container>
{/* 个股列表弹窗 */}
<ConceptStocksModal
isOpen={isStockModalOpen}
onClose={() => setIsStockModalOpen(false)}
concept={selectedConcept}
/>
</Box>
);
};
export default StockOverview;