Files
vf_react/src/views/StockOverview/index.js
zdl 3a5c1b9d9c refactor: 优化路由别名,统一路由规范
- 删除 /concept 别名路由,统一使用 /concepts
- 删除 /stock-overview 别名路由 (死代码,从未使用)
- 修改 StockOverview 中的链接: /concept → /concepts

优化收益:
- 路由配置从 18 个减少到 16 个
- 每个页面只有一个标准路径,避免混淆
- 统一使用复数形式 (concepts, stocks)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-30 14:43:39 +08:00

1123 lines
41 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,
useColorMode,
useToast,
Spinner,
Center,
Divider,
List,
ListItem,
Tooltip,
Menu,
MenuButton,
MenuList,
MenuItem,
useDisclosure,
Image,
Fade,
ScaleFade,
Collapse,
Stack,
Progress,
Tag,
TagLabel,
Skeleton,
SkeletonText,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
} from '@chakra-ui/react';
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react';
import * as echarts from 'echarts';
import { logger } from '../../utils/logger';
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
// Navigation bar now provided by MainLayout
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
// 动画定义
const pulseAnimation = keyframes`
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
`;
const floatAnimation = keyframes`
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
`;
const StockOverview = () => {
const navigate = useNavigate();
const toast = useToast();
const { colorMode, toggleColorMode } = useColorMode();
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 [isCalendarOpen, setIsCalendarOpen] = useState(false);
// 专业的颜色主题
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 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);
if (!selectedDate) setSelectedDate(data.trade_date);
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
}));
}
if (!selectedDate) setSelectedDate(data.trade_date);
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: prevStats?.rising_count,
falling_count: prevStats?.falling_count,
date: data.trade_date
};
setMarketStats(newStats);
setAvailableDates(data.available_dates || []);
if (!selectedDate) setSelectedDate(data.trade_date);
logger.debug('StockOverview', '市场统计数据加载成功', {
date: data.trade_date,
availableDatesCount: data.available_dates?.length || 0
});
// 🎯 追踪市场统计数据查看
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 handleDateChange = (date) => {
const previousDate = selectedDate;
// 🎯 追踪日期变化
trackDateChanged(date, previousDate);
setSelectedDate(date);
setIsCalendarOpen(false);
// 重新获取数据
fetchHeatmapData(date);
fetchMarketStats(date);
fetchTopConcepts(date);
};
// 格式化涨跌幅
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="hidden"
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)"
/>
{/* 日夜模式切换按钮 */}
<Box position="absolute" top={4} right={4}>
<IconButton
aria-label="Toggle color mode"
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
size="lg"
bg={colorMode === 'dark' ? '#1a1a1a' : 'white'}
color={colorMode === 'dark' ? goldColor : 'purple.600'}
border="2px solid"
borderColor={colorMode === 'dark' ? goldColor : 'purple.200'}
_hover={{
bg: colorMode === 'dark' ? '#2a2a2a' : 'purple.50',
transform: 'scale(1.1)'
}}
transition="all 0.3s"
/>
</Box>
<Container maxW="container.xl" position="relative">
<VStack spacing={8} align="center">
<VStack spacing={4} textAlign="center" maxW="3xl">
<HStack spacing={3} animation={`${floatAnimation} 3s ease-in-out infinite`}>
<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}>
<Popover isOpen={isCalendarOpen} onClose={() => setIsCalendarOpen(false)}>
<PopoverTrigger>
<Button
leftIcon={<CalendarIcon />}
onClick={() => setIsCalendarOpen(!isCalendarOpen)}
variant="outline"
size="md"
colorScheme={colorMode === 'dark' ? 'yellow' : 'purple'}
bg={colorMode === 'dark' ? '#1a1a1a' : 'white'}
border="2px solid"
borderColor={colorMode === 'dark' ? goldColor : 'purple.300'}
_hover={{
bg: colorMode === 'dark' ? '#2a2a2a' : 'purple.50'
}}
>
{selectedDate ?
`交易日期: ${selectedDate}` :
'选择交易日期'
}
</Button>
</PopoverTrigger>
<PopoverContent bg={cardBg} borderColor={borderColor} boxShadow="xl">
<PopoverBody p={4}>
<VStack align="start" spacing={3}>
<Text fontWeight="bold" color={textColor}>选择交易日期</Text>
<Divider />
{availableDates.length > 0 ? (
<VStack align="stretch" maxH="300px" overflowY="auto" spacing={1} w="100%">
{availableDates.map((date) => (
<Button
key={date}
size="sm"
variant={selectedDate === date ? 'solid' : 'ghost'}
colorScheme={selectedDate === date ? (colorMode === 'dark' ? 'yellow' : 'purple') : 'gray'}
onClick={() => handleDateChange(date)}
justifyContent="start"
w="100%"
>
{date}
</Button>
))}
</VStack>
) : (
<Text fontSize="sm" color={subTextColor}>
暂无可用日期
</Text>
)}
</VStack>
</PopoverBody>
</PopoverContent>
</Popover>
{selectedDate && (
<Text fontSize="sm" color={subTextColor} mt={2}>
当前显示 {selectedDate} 的市场数据
</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) => (
<ScaleFade in={true} initialScale={0.9} key={concept.concept_id}>
<Card
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"
animation={Math.abs(concept.change_percent) > 5 ? `${pulseAnimation} 2s infinite` : 'none'}
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%">
<Text fontSize="xs" color="gray.500" mb={2}>
包含 {concept.stock_count} 只个股
</Text>
{concept.stocks && concept.stocks.length > 0 && (
<Flex flexWrap="wrap" gap={2}>
{concept.stocks.map((stock, idx) => (
<Tag
key={idx}
size="sm"
colorScheme="purple"
variant="subtle"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
// 🎯 追踪概念下的股票标签点击
trackConceptStockClicked({
code: stock.stock_code,
name: stock.stock_name
}, concept.concept_name);
navigate(`/company?scode=${stock.stock_code}`);
}}
>
<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>
</ScaleFade>
))}
</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>
</Box>
);
};
export default StockOverview;