Hero Section 的 overflow: hidden 会裁剪超出边界的搜索下拉框, 改为 overflow: visible 解决此问题。 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1079 lines
39 KiB
JavaScript
1079 lines
39 KiB
JavaScript
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;
|