// src/views/Community/components/HeroPanel.js // 综合日历面板:融合涨停分析 + 投资日历 // 点击日期弹出详情弹窗(TAB切换历史涨停/未来事件) import React, { useEffect, useState, useCallback, useMemo, memo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { loadWatchlist, toggleWatchlist } from '@store/slices/stockSlice'; import { Box, Card, CardBody, Flex, VStack, HStack, Text, Heading, useColorModeValue, useDisclosure, Icon, Spinner, Center, Modal, ModalOverlay, ModalContent, ModalHeader, ModalBody, ModalCloseButton, Tooltip, Badge, SimpleGrid, IconButton, Drawer, DrawerOverlay, DrawerContent, DrawerHeader, DrawerBody, DrawerCloseButton, } from '@chakra-ui/react'; import { Table, Tabs, Tag, Space, Button, Spin, Typography, message } from 'antd'; import { CalendarOutlined, StarFilled, LinkOutlined, StockOutlined, TagsOutlined, ClockCircleOutlined, RobotOutlined, FireOutlined, LineChartOutlined, StarOutlined, } from '@ant-design/icons'; import { AlertCircle, Clock, Info, Calendar, ChevronLeft, ChevronRight, Flame, TrendingUp, TrendingDown, FileText, Star } from 'lucide-react'; import { GLASS_BLUR } from '@/constants/glassConfig'; import { eventService } from '@services/eventService'; import { getApiBase } from '@utils/apiConfig'; import ReactMarkdown from 'react-markdown'; import dayjs from 'dayjs'; import KLineChartModal from '@components/StockChart/KLineChartModal'; const { TabPane } = Tabs; const { Text: AntText } = Typography; // 定义动画和深色主题样式 const animations = ` @keyframes pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.6; transform: scale(1.1); } } @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } /* Ant Design 深色主题覆盖 - 弹窗专用 */ .hero-panel-modal .ant-tabs { color: rgba(255, 255, 255, 0.85); } .hero-panel-modal .ant-tabs-nav::before { border-color: rgba(255, 215, 0, 0.2) !important; } .hero-panel-modal .ant-tabs-tab { color: rgba(255, 255, 255, 0.65) !important; font-size: 15px !important; } .hero-panel-modal .ant-tabs-tab:hover { color: #FFD700 !important; } .hero-panel-modal .ant-tabs-tab-active .ant-tabs-tab-btn { color: #FFD700 !important; } .hero-panel-modal .ant-tabs-ink-bar { background: linear-gradient(90deg, #FFD700, #FFA500) !important; } /* 表格深色主题 */ .hero-panel-modal .ant-table { background: transparent !important; color: rgba(255, 255, 255, 0.85) !important; } .hero-panel-modal .ant-table-thead > tr > th { background: rgba(255, 215, 0, 0.1) !important; color: #FFD700 !important; border-bottom: 1px solid rgba(255, 215, 0, 0.2) !important; font-weight: 600 !important; font-size: 14px !important; } .hero-panel-modal .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid rgba(255, 255, 255, 0.08) !important; color: rgba(255, 255, 255, 0.85) !important; font-size: 14px !important; } .hero-panel-modal .ant-table-tbody > tr:hover > td { background: rgba(255, 215, 0, 0.08) !important; } .hero-panel-modal .ant-table-tbody > tr.ant-table-row:hover > td { background: rgba(255, 215, 0, 0.1) !important; } .hero-panel-modal .ant-table-cell-row-hover { background: rgba(255, 215, 0, 0.08) !important; } .hero-panel-modal .ant-table-placeholder { background: transparent !important; } .hero-panel-modal .ant-empty-description { color: rgba(255, 255, 255, 0.45) !important; } /* 滚动条样式 */ .hero-panel-modal .ant-table-body::-webkit-scrollbar { width: 6px; height: 6px; } .hero-panel-modal .ant-table-body::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.05); border-radius: 3px; } .hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb { background: rgba(255, 215, 0, 0.3); border-radius: 3px; } .hero-panel-modal .ant-table-body::-webkit-scrollbar-thumb:hover { background: rgba(255, 215, 0, 0.5); } /* Tag 样式优化 */ .hero-panel-modal .ant-tag { border-radius: 4px !important; } /* Button link 样式 */ .hero-panel-modal .ant-btn-link { color: #FFD700 !important; } .hero-panel-modal .ant-btn-link:hover { color: #FFA500 !important; } .hero-panel-modal .ant-btn-link:disabled { color: rgba(255, 255, 255, 0.25) !important; } /* Typography 样式 */ .hero-panel-modal .ant-typography { color: rgba(255, 255, 255, 0.85) !important; } .hero-panel-modal .ant-typography-secondary { color: rgba(255, 255, 255, 0.45) !important; } /* Spin 加载样式 */ .hero-panel-modal .ant-spin-text { color: #FFD700 !important; } .hero-panel-modal .ant-spin-dot-item { background-color: #FFD700 !important; } `; // 注入样式 if (typeof document !== 'undefined') { const styleId = 'hero-panel-animations'; if (!document.getElementById(styleId)) { const styleSheet = document.createElement('style'); styleSheet.id = styleId; styleSheet.innerText = animations; document.head.appendChild(styleSheet); } } /** * 判断当前是否在交易时间内 */ const isInTradingTime = () => { const now = new Date(); const timeInMinutes = now.getHours() * 60 + now.getMinutes(); return timeInMinutes >= 570 && timeInMinutes <= 900; }; // 主题色配置 const goldColors = { primary: '#D4AF37', light: '#F4D03F', dark: '#B8860B', glow: 'rgba(212, 175, 55, 0.4)', }; const textColors = { primary: '#ffffff', secondary: 'rgba(255, 255, 255, 0.85)', muted: 'rgba(255, 255, 255, 0.5)', }; // 热度级别配置 const HEAT_LEVELS = [ { key: 'high', threshold: 80, colors: { bg: 'rgba(147, 51, 234, 0.55)', text: '#d8b4fe', border: 'rgba(147, 51, 234, 0.65)' } }, { key: 'medium', threshold: 60, colors: { bg: 'rgba(239, 68, 68, 0.50)', text: '#fca5a5', border: 'rgba(239, 68, 68, 0.60)' } }, { key: 'low', threshold: 40, colors: { bg: 'rgba(251, 146, 60, 0.45)', text: '#fed7aa', border: 'rgba(251, 146, 60, 0.55)' } }, { key: 'cold', threshold: 0, colors: { bg: 'rgba(59, 130, 246, 0.35)', text: '#93c5fd', border: 'rgba(59, 130, 246, 0.45)' } }, ]; const DEFAULT_HEAT_COLORS = { bg: 'rgba(60, 60, 70, 0.12)', text: textColors.muted, border: 'transparent', }; const getHeatColor = (count) => { if (!count) return DEFAULT_HEAT_COLORS; const level = HEAT_LEVELS.find((l) => count >= l.threshold); return level?.colors || DEFAULT_HEAT_COLORS; }; // 日期格式化 const formatDateStr = (date) => { if (!date) return ''; const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}${month}${day}`; }; const WEEK_DAYS = ['日', '一', '二', '三', '四', '五', '六']; const MONTH_NAMES = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; /** * 趋势图标 */ const TrendIcon = memo(({ current, previous }) => { if (!current || !previous) return null; const diff = current - previous; if (diff === 0) return null; const isUp = diff > 0; return ( ); }); TrendIcon.displayName = 'TrendIcon'; /** * 日历单元格 - 显示涨停数和事件数(加大尺寸) */ const CalendarCell = memo(({ date, ztData, eventCount, previousZtData, isSelected, isToday, isWeekend, onClick }) => { if (!date) { return ; } const hasZtData = !!ztData; const hasEventData = eventCount > 0; const ztCount = ztData?.count || 0; const heatColors = getHeatColor(ztCount); const topSector = ztData?.top_sector || ''; // 周末无数据显示"休市" if (isWeekend && !hasZtData && !hasEventData) { return ( {date.getDate()} 休市 ); } // 正常日期 return ( {`${date.getMonth() + 1}月${date.getDate()}日`} {hasZtData && 涨停: {ztCount}家 {topSector && `| ${topSector}`}} {hasEventData && 未来事件: {eventCount}个} {!hasZtData && !hasEventData && 暂无数据} } placement="top" hasArrow bg="rgba(15, 15, 22, 0.95)" border="1px solid rgba(212, 175, 55, 0.3)" borderRadius="10px" > onClick && onClick(date)} w="full" minH="75px" > {/* 今天标记 */} {isToday && ( 今天 )} {/* 日期 */} {date.getDate()} {/* 涨停数 + 趋势 */} {hasZtData && ( {ztCount} )} {/* 事件数 */} {hasEventData && ( {eventCount} )} {/* 主要板块 */} {hasZtData && topSector && ( {topSector} )} ); }); CalendarCell.displayName = 'CalendarCell'; /** * 详情弹窗组件 - 完整展示涨停分析和事件详情 */ const DetailModal = ({ isOpen, onClose, selectedDate, ztDetail, events, loading }) => { const dispatch = useDispatch(); const reduxWatchlist = useSelector(state => state.stock.watchlist); const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); const [selectedContent, setSelectedContent] = useState(null); const [ztViewMode, setZtViewMode] = useState('sector'); // 'sector' | 'stock' const [stocksDrawerVisible, setStocksDrawerVisible] = useState(false); const [selectedEventStocks, setSelectedEventStocks] = useState([]); const [selectedEventTime, setSelectedEventTime] = useState(null); const [selectedEventTitle, setSelectedEventTitle] = useState(''); const [stockQuotes, setStockQuotes] = useState({}); const [stockQuotesLoading, setStockQuotesLoading] = useState(false); const [expandedReasons, setExpandedReasons] = useState({}); const [klineModalVisible, setKlineModalVisible] = useState(false); const [selectedKlineStock, setSelectedKlineStock] = useState(null); // 板块数据处理 - 必须在条件返回之前调用所有hooks const sectorList = useMemo(() => { if (!ztDetail?.sector_data) return []; return Object.entries(ztDetail.sector_data) .filter(([name]) => name !== '其他') .map(([name, data]) => ({ name, count: data.count, stocks: data.stock_codes || [], })) .sort((a, b) => b.count - a.count); }, [ztDetail]); // 股票详情数据处理 const stockList = useMemo(() => { if (!ztDetail?.stock_infos) return []; return ztDetail.stock_infos.map(stock => ({ ...stock, key: stock.scode, })); }, [ztDetail]); // 热门关键词 const hotKeywords = useMemo(() => { if (!ztDetail?.word_freq_data) return []; return ztDetail.word_freq_data.slice(0, 12); }, [ztDetail]); // 获取六位股票代码(去掉后缀)- 纯函数,不是hook const getSixDigitCode = (code) => { if (!code) return code; return code.split('.')[0]; }; // 检查股票是否已在自选中 - 必须在条件返回之前 const isStockInWatchlist = useCallback((stockCode) => { const sixDigitCode = getSixDigitCode(stockCode); return reduxWatchlist?.some(item => getSixDigitCode(item.stock_code) === sixDigitCode ); }, [reduxWatchlist]); // 条件返回必须在所有hooks之后 if (!selectedDate) return null; const dateStr = `${selectedDate.getFullYear()}年${selectedDate.getMonth() + 1}月${selectedDate.getDate()}日`; const isPastDate = selectedDate < new Date(new Date().setHours(0, 0, 0, 0)); // 渲染重要性星级 const renderStars = (star) => { const stars = []; for (let i = 1; i <= 5; i++) { stars.push( ); } return {stars}; }; // 显示内容详情 const showContentDetail = (content, title) => { setSelectedContent({ content, title }); setDetailDrawerVisible(true); }; // 加载股票行情 const loadStockQuotes = async (stocks) => { if (!stocks || stocks.length === 0) return; setStockQuotesLoading(true); const quotes = {}; for (const stock of stocks) { const code = getSixDigitCode(stock.code); try { const response = await fetch(`${getApiBase()}/api/market/trade/${code}?days=1`); if (response.ok) { const data = await response.json(); if (data.success && data.data && data.data.length > 0) { const latest = data.data[data.data.length - 1]; quotes[stock.code] = { price: latest.close, change: latest.change_amount, changePercent: latest.change_percent }; } } } catch (err) { console.error('加载股票行情失败:', code, err); } } setStockQuotes(quotes); setStockQuotesLoading(false); }; // 显示相关股票 const showRelatedStocks = (stocks, eventTime, eventTitle) => { if (!stocks || stocks.length === 0) return; // 归一化股票数据格式 const normalizedStocks = stocks.map(stock => { if (typeof stock === 'object' && !Array.isArray(stock)) { return { code: stock.code || stock.stock_code || '', name: stock.name || stock.stock_name || '', description: stock.description || stock.relation_desc || '', score: stock.score || 0, report: stock.report || null, }; } if (Array.isArray(stock)) { return { code: stock[0] || '', name: stock[1] || '', description: stock[2] || '', score: stock[3] || 0, report: null, }; } return null; }).filter(Boolean); // 按相关度排序 const sortedStocks = normalizedStocks.sort((a, b) => (b.score || 0) - (a.score || 0)); setSelectedEventStocks(sortedStocks); setSelectedEventTime(eventTime); setSelectedEventTitle(eventTitle); setStocksDrawerVisible(true); setExpandedReasons({}); loadStockQuotes(sortedStocks); }; // 添加交易所后缀 const addExchangeSuffix = (code) => { const sixDigitCode = getSixDigitCode(code); if (code.includes('.')) return code; if (sixDigitCode.startsWith('6')) { return `${sixDigitCode}.SH`; } else if (sixDigitCode.startsWith('0') || sixDigitCode.startsWith('3')) { return `${sixDigitCode}.SZ`; } return sixDigitCode; }; // 显示K线图 const showKline = (stock) => { const code = stock.code; const name = stock.name; const stockCode = addExchangeSuffix(code); setSelectedKlineStock({ stock_code: stockCode, stock_name: name, }); setKlineModalVisible(true); }; // 添加单只股票到自选 const addSingleToWatchlist = async (stock) => { const code = stock.code; const name = stock.name; const stockCode = getSixDigitCode(code); if (isStockInWatchlist(code)) { message.info(`${name} 已在自选中`); return; } try { await dispatch(toggleWatchlist({ stockCode, stockName: name, isInWatchlist: false })).unwrap(); message.success(`已将 ${name}(${stockCode}) 添加到自选`); } catch (error) { console.error('添加自选失败:', error); message.error('添加失败,请重试'); } }; // 相关股票表格列定义(和投资日历保持一致) const stockColumns = [ { title: '代码', dataIndex: 'code', key: 'code', width: 90, render: (code) => { const sixDigitCode = getSixDigitCode(code); return ( {sixDigitCode} ); } }, { title: '名称', dataIndex: 'name', key: 'name', width: 100, render: (name, record) => { const sixDigitCode = getSixDigitCode(record.code); return ( {name} ); } }, { title: '现价', key: 'price', width: 80, render: (_, record) => { const quote = stockQuotes[record.code]; if (quote && quote.price !== undefined) { return ( 0 ? 'danger' : 'success'}> {quote.price?.toFixed(2)} ); } return -; } }, { title: '涨跌幅', key: 'change', width: 100, render: (_, record) => { const quote = stockQuotes[record.code]; if (quote && quote.changePercent !== undefined) { const changePercent = quote.changePercent || 0; return ( 0 ? 'red' : changePercent < 0 ? 'green' : 'default'}> {changePercent > 0 ? '+' : ''}{changePercent.toFixed(2)}% ); } return -; } }, { title: '关联理由', dataIndex: 'description', key: 'reason', render: (description, record) => { const stockCode = record.code; const isExpanded = expandedReasons[stockCode] || false; const reason = typeof description === 'string' ? description : ''; const shouldTruncate = reason && reason.length > 80; const toggleExpanded = () => { setExpandedReasons(prev => ({ ...prev, [stockCode]: !prev[stockCode] })); }; return (
{isExpanded || !shouldTruncate ? reason || '-' : `${reason?.slice(0, 80)}...` } {shouldTruncate && ( )} {reason && (
(AI合成)
)}
); } }, { title: '研报引用', dataIndex: 'report', key: 'report', width: 180, render: (report) => { if (!report || !report.title) { return -; } return (
{report.title.length > 18 ? `${report.title.slice(0, 18)}...` : report.title} {report.author && ( {report.author} )} {report.declare_date && ( {dayjs(report.declare_date).format('YYYY-MM-DD')} )} {report.match_score && ( 匹配度: {report.match_score} )}
); } }, { title: 'K线图', key: 'kline', width: 80, render: (_, record) => ( ) }, { title: '操作', key: 'action', width: 90, render: (_, record) => { const inWatchlist = isStockInWatchlist(record.code); return ( ); } }, ]; // 涨停板块表格列 const sectorColumns = [ { title: '排名', key: 'rank', width: 55, align: 'center', render: (_, __, index) => ( {index + 1} ), }, { title: '板块', dataIndex: 'name', key: 'name', width: 120, render: (name) => ( {name} ), }, { title: '涨停数', dataIndex: 'count', key: 'count', width: 80, align: 'center', render: (count) => ( = 8 ? 'red' : count >= 5 ? 'volcano' : count >= 3 ? 'orange' : 'blue'}> {count} ), }, { title: '涨停股票', dataIndex: 'stocks', key: 'stocks', render: (stocks) => { // 根据股票代码查找股票名称 const getStockName = (code) => { const stockInfo = stockList.find(s => s.scode === code); return stockInfo?.sname || code; }; return ( {stocks.slice(0, 6).map((code) => ( {getStockName(code)} ))} {stocks.length > 6 && ( +{stocks.length - 6} )} ); }, }, ]; // 涨停股票详情表格列 const ztStockColumns = [ { title: '股票', key: 'stock', width: 120, fixed: 'left', render: (_, record) => ( {record.sname} ), }, { title: '涨停时间', dataIndex: 'formatted_time', key: 'time', width: 85, align: 'center', render: (time) => ( {time?.substring(0, 5) || '-'} ), }, { title: '连板', dataIndex: 'continuous_days', key: 'continuous', width: 75, align: 'center', render: (text) => { if (!text || text === '首板') return 首板; const match = text.match(/(\d+)/); const days = match ? parseInt(match[1]) : 1; return ( = 5 ? 'red' : days >= 3 ? 'volcano' : days >= 2 ? 'orange' : 'default'}> {text} ); }, }, { title: '核心板块', dataIndex: 'core_sectors', key: 'sectors', width: 180, render: (sectors) => ( {(sectors || []).slice(0, 3).map((sector, idx) => ( {sector} ))} ), }, { title: '涨停简报', dataIndex: 'brief', key: 'brief', ellipsis: true, render: (text) => { if (!text) return -; // 移除HTML标签 const cleanText = text.replace(//gi, ' ').replace(/<[^>]+>/g, ''); return ( ); }, }, ]; // 事件表格列(参考投资日历)- 去掉相关概念列 const eventColumns = [ { title: '时间', dataIndex: 'calendar_time', key: 'time', width: 80, render: (time) => ( {dayjs(time).format('HH:mm')} ), }, { title: '重要度', dataIndex: 'star', key: 'star', width: 120, render: renderStars, }, { title: '标题', dataIndex: 'title', key: 'title', ellipsis: true, render: (text) => ( {text} ), }, { title: '背景', dataIndex: 'former', key: 'former', width: 80, render: (text) => ( ), }, { title: '未来推演', dataIndex: 'forecast', key: 'forecast', width: 90, render: (text) => ( ), }, { title: '相关股票', dataIndex: 'related_stocks', key: 'stocks', width: 120, render: (stocks, record) => { const hasStocks = stocks && stocks.length > 0; if (!hasStocks) { return ; } return ( ); }, }, ]; return ( <> {dateStr} {isPastDate ? '历史数据' : '未来事件'} {ztDetail && ( 涨停 {ztDetail.total_stocks || 0} 家 )} {events?.length > 0 && ( 事件 {events.length} 个 )} {loading ? (
) : ( {/* 涨停分析 Tab */} 涨停分析 ({ztDetail?.total_stocks || 0}) } key="zt" disabled={!ztDetail} > {(sectorList.length > 0 || stockList.length > 0) ? ( {/* 热门关键词 */} {hotKeywords.length > 0 && ( 今日热词 {hotKeywords.map((kw, idx) => ( {kw.name} ))} )} {/* 视图切换按钮 */} 共 {ztDetail?.total_stocks || 0} 只涨停 {/* 板块视图 */} {ztViewMode === 'sector' && ( )} {/* 个股视图 */} {ztViewMode === 'stock' && (
)} ) : (
暂无涨停数据
)} {/* 未来事件 Tab */} 未来事件 ({events?.length || 0}) } key="event" disabled={!events?.length} > {events?.length > 0 ? (
) : (
暂无事件数据
)} )} {/* 内容详情抽屉 */} setDetailDrawerVisible(false)} > {selectedContent?.title} {typeof selectedContent?.content === 'string' ? selectedContent.content : selectedContent?.content?.data ? selectedContent.content.data.map(item => item.sentence || '').join('\n\n') : '暂无内容'} (AI合成内容) {/* 相关股票弹窗 */} { setStocksDrawerVisible(false); setExpandedReasons({}); }} size="6xl" scrollBehavior="inside" > 相关股票 {selectedEventTitle && ( {selectedEventTitle} )} {selectedEventStocks?.length || 0}只 {stockQuotesLoading && } {selectedEventStocks && selectedEventStocks.length > 0 ? (
record.code} size="middle" pagination={false} /> ) : (
暂无相关股票
)} {/* K线图弹窗 */} {selectedKlineStock && ( { setKlineModalVisible(false); setSelectedKlineStock(null); }} stock={selectedKlineStock} eventTime={selectedEventTime} size="5xl" /> )} ); }; /** * 综合日历组件(无左侧面板,点击弹窗) */ const CombinedCalendar = () => { const [currentMonth, setCurrentMonth] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(null); // 涨停数据 const [ztDatesData, setZtDatesData] = useState([]); const [ztDailyDetails, setZtDailyDetails] = useState({}); const [selectedZtDetail, setSelectedZtDetail] = useState(null); // 投资日历数据 const [eventCounts, setEventCounts] = useState([]); const [selectedEvents, setSelectedEvents] = useState([]); const [loading, setLoading] = useState(true); const [detailLoading, setDetailLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); // 加载涨停 dates.json useEffect(() => { const loadZtDatesData = async () => { try { const response = await fetch('/data/zt/dates.json'); if (response.ok) { const data = await response.json(); setZtDatesData(data.dates || []); } } catch (error) { console.error('Failed to load zt dates.json:', error); } }; loadZtDatesData(); }, []); // 加载投资日历事件数量 useEffect(() => { const loadEventCounts = async () => { try { const year = currentMonth.getFullYear(); const month = currentMonth.getMonth() + 1; const response = await eventService.calendar.getEventCounts(year, month); if (response.success) { setEventCounts(response.data || []); } } catch (error) { console.error('Failed to load event counts:', error); } finally { setLoading(false); } }; loadEventCounts(); }, [currentMonth]); // 获取当月涨停板块详情 useEffect(() => { const loadMonthZtDetails = async () => { const year = currentMonth.getFullYear(); const month = currentMonth.getMonth(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const details = {}; const promises = []; for (let day = 1; day <= daysInMonth; day++) { const dateStr = `${year}${String(month + 1).padStart(2, '0')}${String(day).padStart(2, '0')}`; const hasData = ztDatesData.some(d => d.date === dateStr); if (hasData && !ztDailyDetails[dateStr]) { promises.push( fetch(`/data/zt/daily/${dateStr}.json`) .then(res => res.ok ? res.json() : null) .then(data => { if (data && data.sector_data) { let maxSector = ''; let maxCount = 0; Object.entries(data.sector_data).forEach(([sector, info]) => { if (info.count > maxCount) { maxCount = info.count; maxSector = sector; } }); details[dateStr] = { top_sector: maxSector, fullData: data }; } }) .catch(() => null) ); } } if (promises.length > 0) { await Promise.all(promises); setZtDailyDetails(prev => ({ ...prev, ...details })); } }; if (ztDatesData.length > 0) { loadMonthZtDetails(); } }, [currentMonth, ztDatesData]); // 构建日期数据映射 const ztDataMap = useMemo(() => { const map = new Map(); ztDatesData.forEach(d => { const detail = ztDailyDetails[d.date] || {}; map.set(d.date, { date: d.date, count: d.count, top_sector: detail.top_sector, }); }); return map; }, [ztDatesData, ztDailyDetails]); const eventCountMap = useMemo(() => { const map = new Map(); eventCounts.forEach(d => { map.set(d.date, d.count); }); return map; }, [eventCounts]); // 生成日历天数 const days = useMemo(() => { const year = currentMonth.getFullYear(); const month = currentMonth.getMonth(); const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); const daysInMonth = lastDay.getDate(); const startingDayOfWeek = firstDay.getDay(); const result = []; for (let i = 0; i < startingDayOfWeek; i++) { result.push(null); } for (let i = 1; i <= daysInMonth; i++) { result.push(new Date(year, month, i)); } return result; }, [currentMonth]); // 预计算日历格子数据 const calendarCellsData = useMemo(() => { const today = new Date(); const todayStr = today.toDateString(); const selectedDateStr = selectedDate?.toDateString(); return days.map((date, index) => { if (!date) { return { key: `empty-${index}`, date: null }; } const ztDateStr = formatDateStr(date); const eventDateStr = dayjs(date).format('YYYY-MM-DD'); const ztData = ztDataMap.get(ztDateStr) || null; const eventCount = eventCountMap.get(eventDateStr) || 0; const dayOfWeek = date.getDay(); const prevDate = new Date(date); prevDate.setDate(prevDate.getDate() - 1); const previousZtData = ztDataMap.get(formatDateStr(prevDate)) || null; return { key: ztDateStr || `day-${index}`, date, ztData, eventCount, previousZtData, isSelected: date.toDateString() === selectedDateStr, isToday: date.toDateString() === todayStr, isWeekend: dayOfWeek === 0 || dayOfWeek === 6, }; }); }, [days, ztDataMap, eventCountMap, selectedDate]); // 处理日期点击 - 打开弹窗 const handleDateClick = useCallback(async (date) => { setSelectedDate(date); setModalOpen(true); setDetailLoading(true); const ztDateStr = formatDateStr(date); const eventDateStr = dayjs(date).format('YYYY-MM-DD'); // 加载涨停详情 const detail = ztDailyDetails[ztDateStr]; if (detail?.fullData) { setSelectedZtDetail(detail.fullData); } else { try { const response = await fetch(`/data/zt/daily/${ztDateStr}.json`); if (response.ok) { const data = await response.json(); setSelectedZtDetail(data); setZtDailyDetails(prev => ({ ...prev, [ztDateStr]: { ...prev[ztDateStr], fullData: data } })); } else { setSelectedZtDetail(null); } } catch { setSelectedZtDetail(null); } } // 加载事件详情 try { const response = await eventService.calendar.getEventsForDate(eventDateStr); if (response.success) { setSelectedEvents(response.data || []); } else { setSelectedEvents([]); } } catch { setSelectedEvents([]); } setDetailLoading(false); }, [ztDailyDetails]); // 月份导航 const handlePrevMonth = useCallback(() => { setCurrentMonth(prev => new Date(prev.getFullYear(), prev.getMonth() - 1)); }, []); const handleNextMonth = useCallback(() => { setCurrentMonth(prev => new Date(prev.getFullYear(), prev.getMonth() + 1)); }, []); return ( <> {/* 顶部装饰条 */} {/* 月份导航 */} } variant="ghost" size="md" color={textColors.secondary} _hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }} onClick={handlePrevMonth} aria-label="上个月" /> {currentMonth.getFullYear()}年{MONTH_NAMES[currentMonth.getMonth()]} 综合日历 } variant="ghost" size="md" color={textColors.secondary} _hover={{ color: goldColors.primary, bg: 'rgba(255, 255, 255, 0.05)' }} onClick={handleNextMonth} aria-label="下个月" /> {/* 星期标题 */} {WEEK_DAYS.map((day, idx) => ( {day} ))} {/* 日历格子 */} {loading ? (
) : ( {calendarCellsData.map((cellData) => ( ))} )} {/* 图例 */} 涨停≥80 涨停≥60 涨停≥40 未来事件
{/* 详情弹窗 */} setModalOpen(false)} selectedDate={selectedDate} ztDetail={selectedZtDetail} events={selectedEvents} loading={detailLoading} /> ); }; /** * 使用说明弹窗组件 */ const InfoModal = () => { const { isOpen, onOpen, onClose } = useDisclosure(); return ( <> 使用说明 事件中心使用指南 📅 综合日历 日历同时展示历史涨停数据未来事件, 点击日期查看详细信息。 🔥 涨停板块 点击历史日期,查看当日涨停板块排行、涨停数量、涨停股票代码,帮助理解市场主线。 📊 未来事件 点击未来日期,查看事件详情,包括背景分析未来推演相关股票等。 💡 颜色越深表示涨停数越多 · 绿色标记表示有未来事件 ); }; /** * 顶部说明面板主组件 */ const HeroPanel = () => { const gradientBg = 'linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 25%, #16213e 50%, #1a1a2e 75%, #0a0a0a 100%)'; const borderColor = useColorModeValue('rgba(255, 215, 0, 0.3)', 'rgba(255, 215, 0, 0.25)'); return ( {/* 装饰性光晕 */} {/* 标题行 */} 事件中心 {isInTradingTime() && ( 交易中 )} {/* 综合日历 */} ); }; export default HeroPanel;