// 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 && (
)}
);
}
},
{
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) => (
}
onClick={() => showKline(record)}
>
查看
)
},
{
title: '操作',
key: 'action',
width: 90,
render: (_, record) => {
const inWatchlist = isStockInWatchlist(record.code);
return (
: }
onClick={() => addSingleToWatchlist(record)}
disabled={inWatchlist}
>
{inWatchlist ? '已添加' : '加自选'}
);
}
},
];
// 涨停板块表格列
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) => (
}
onClick={() => showContentDetail(text, '事件背景')}
disabled={!text}
>
查看
),
},
{
title: '未来推演',
dataIndex: 'forecast',
key: 'forecast',
width: 90,
render: (text) => (
}
onClick={() => showContentDetail(text, '未来推演')}
disabled={!text}
>
{text ? '查看' : '无'}
),
},
{
title: '相关股票',
dataIndex: 'related_stocks',
key: 'stocks',
width: 120,
render: (stocks, record) => {
const hasStocks = stocks && stocks.length > 0;
if (!hasStocks) {
return 无;
}
return (
}
onClick={() => showRelatedStocks(stocks, record.calendar_time, record.title)}
>
{stocks.length}只
);
},
},
];
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;