Files
vf_react/src/views/StockOverview/index.js
zdl 915ac2ebd3 fix: 修复个股搜索下拉弹窗被遮挡的问题
Hero Section 的 overflow: hidden 会裁剪超出边界的搜索下拉框,
改为 overflow: visible 解决此问题。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 19:03:14 +08:00

1079 lines
39 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,
useColorModeValue,
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 } from 'react-icons/fa';
import ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker';
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 = 'light'; // 固定为 light 模式
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 = useColorModeValue('white', '#0a0a0a');
const cardBg = useColorModeValue('white', '#1a1a1a');
const borderColor = useColorModeValue('gray.200', '#333333');
const hoverBg = useColorModeValue('gray.50', '#2a2a2a');
const searchBg = useColorModeValue('white', '#1a1a1a');
const textColor = useColorModeValue('gray.800', 'white');
const subTextColor = useColorModeValue('gray.600', 'gray.400');
const goldColor = useColorModeValue('#FFD700', '#FFC107');
const accentColor = useColorModeValue('purple.600', goldColor);
const heroBg = useColorModeValue('linear(to-br, purple.600, pink.500)', 'linear(to-br, #0a0a0a, #1a1a1a)');
// 打开个股列表弹窗
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) {
const newStats = {
...data.summary,
// 保留之前从 heatmap 接口获取的上涨/下跌家数
rising_count: marketStats?.rising_count,
falling_count: marketStats?.falling_count,
date: data.trade_date
};
setMarketStats(newStats);
// 日期和可选日期列表由 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}>
{/* 导航栏已由 MainLayout 提供 */}
{/* Hero Section */}
<Box
position="relative"
bgGradient={heroBg}
color="white"
overflow="visible"
pt={{ base: 20, md: 24 }}
pb={{ base: 16, md: 20 }}
borderBottom={colorMode === 'dark' ? `2px solid ${goldColor}` : 'none'}
>
{/* 背景装饰 */}
<Box
position="absolute"
top="-20%"
right="-10%"
width="40%"
height="120%"
bg={colorMode === 'dark' ? `${goldColor}10` : 'whiteAlpha.100'}
transform="rotate(12deg)"
borderRadius="full"
filter="blur(40px)"
/>
<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}>
{/* 日期选择器 */}
<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="交易日期"
/>
</Flex>
{selectedDate && (
<Text fontSize="sm" color={subTextColor} mt={2}>
当前显示 {selectedDate.toISOString().split('T')[0]} 的市场数据
</Text>
)}
</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>
<Text fontSize="sm" color={subTextColor} noOfLines={2}>
{concept.description || '暂无描述'}
</Text>
<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.map((stock, idx) => (
<Tag
key={idx}
size="sm"
colorScheme="purple"
variant="subtle"
flexShrink={0}
>
<TagLabel>{stock.stock_name}</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;