// src/components/InvestmentCalendar/index.js import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { loadWatchlist, toggleWatchlist } from '@store/slices/stockSlice'; import { Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty, Drawer, Typography, Divider, Space, Tooltip, message, Alert } from 'antd'; import { StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined, TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined } from '@ant-design/icons'; import dayjs from 'dayjs'; import ReactMarkdown from 'react-markdown'; import { eventService, stockService } from '@services/eventService'; import KLineChartModal from '@components/StockChart/KLineChartModal'; import { useSubscription } from '@hooks/useSubscription'; import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal'; import CitationMark from '@components/Citation/CitationMark'; import CitedContent from '@components/Citation/CitedContent'; import { processCitationData } from '@utils/citationUtils'; import { logger } from '@utils/logger'; import './InvestmentCalendar.css'; const { TabPane } = Tabs; const { Text, Title, Paragraph } = Typography; const InvestmentCalendar = () => { // Redux 状态 const dispatch = useDispatch(); const reduxWatchlist = useSelector(state => state.stock.watchlist); // 权限控制 const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription(); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [eventCounts, setEventCounts] = useState([]); const [selectedDate, setSelectedDate] = useState(null); const [selectedDateEvents, setSelectedDateEvents] = useState([]); const [modalVisible, setModalVisible] = useState(false); const [loading, setLoading] = useState(false); const [currentMonth, setCurrentMonth] = useState(dayjs()); // 新增状态 const [detailDrawerVisible, setDetailDrawerVisible] = useState(false); const [selectedDetail, setSelectedDetail] = useState(null); const [stockModalVisible, setStockModalVisible] = useState(false); const [selectedStocks, setSelectedStocks] = useState([]); const [stockQuotes, setStockQuotes] = useState({}); const [klineModalVisible, setKlineModalVisible] = useState(false); const [selectedStock, setSelectedStock] = useState(null); const [selectedEventTime, setSelectedEventTime] = useState(null); // 记录事件时间 const [followingIds, setFollowingIds] = useState([]); // 正在处理关注的事件ID列表 const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态 // 加载月度事件统计 const loadEventCounts = useCallback(async (date) => { try { const year = date.year(); const month = date.month() + 1; const response = await eventService.calendar.getEventCounts(year, month); if (response.success) { setEventCounts(response.data); } } catch (error) { logger.error('InvestmentCalendar', 'loadEventCounts', error, { year: date.year(), month: date.month() + 1 }); } }, []); // eventService 是外部导入的稳定引用,不需要作为依赖 // 加载指定日期的事件 const loadDateEvents = async (date) => { setLoading(true); try { const dateStr = date.format('YYYY-MM-DD'); const response = await eventService.calendar.getEventsForDate(dateStr); if (response.success) { setSelectedDateEvents(response.data); } } catch (error) { logger.error('InvestmentCalendar', 'loadDateEvents', error, { dateStr: date.format('YYYY-MM-DD') }); setSelectedDateEvents([]); } finally { setLoading(false); } }; // 获取六位股票代码(去掉后缀) const getSixDigitCode = (code) => { if (!code) return code; // 如果有.SH或.SZ后缀,去掉 return code.split('.')[0]; }; /** * 归一化股票数据格式 * 支持两种格式: * 1. 旧格式数组:[code, name, description, score] * 2. 新格式对象:{ code, name, description, score, report } * 返回统一的对象格式 */ const normalizeStock = (stock) => { if (!stock) return null; // 新格式:对象 if (typeof stock === 'object' && !Array.isArray(stock)) { return { code: stock.code || '', name: stock.name || '', description: stock.description || '', score: stock.score || 0, report: stock.report || null // 研报引用信息 }; } // 旧格式:数组 [code, name, description, score] if (Array.isArray(stock)) { return { code: stock[0] || '', name: stock[1] || '', description: stock[2] || '', score: stock[3] || 0, report: null }; } return null; }; /** * 归一化股票列表 */ const normalizeStocks = (stocks) => { if (!stocks || !Array.isArray(stocks)) return []; return stocks.map(normalizeStock).filter(Boolean); }; // 加载股票行情 const loadStockQuotes = async (stocks, eventTime) => { try { const normalizedStocks = normalizeStocks(stocks); const codes = normalizedStocks.map(stock => getSixDigitCode(stock.code)); const quotes = {}; // 使用市场API获取最新行情数据 for (let i = 0; i < codes.length; i++) { const code = codes[i]; const originalCode = normalizedStocks[i].code; // 使用归一化后的代码作为key try { const response = await fetch(`/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[originalCode] = { price: latest.close, change: latest.change_amount, changePercent: latest.change_percent }; } } } catch (err) { logger.error('InvestmentCalendar', 'loadStockQuotes.fetchQuote', err, { code }); } } setStockQuotes(quotes); } catch (error) { logger.error('InvestmentCalendar', 'loadStockQuotes', error, { stockCount: stocks.length }); message.error('加载股票行情失败'); } }; // 使用 ref 确保只加载一次自选股 const watchlistLoadedRef = useRef(false); // 组件挂载时加载自选股列表(仅加载一次) useEffect(() => { if (!watchlistLoadedRef.current) { watchlistLoadedRef.current = true; dispatch(loadWatchlist()); } }, [dispatch]); useEffect(() => { loadEventCounts(currentMonth); }, [currentMonth, loadEventCounts]); // 检查股票是否已在自选中 const isStockInWatchlist = useCallback((stockCode) => { const sixDigitCode = getSixDigitCode(stockCode); return reduxWatchlist.some(item => getSixDigitCode(item.stock_code) === sixDigitCode ); }, [reduxWatchlist]); // 自定义日期单元格渲染(Ant Design 5.x API) const cellRender = (current, info) => { // 只处理日期单元格,月份单元格返回默认 if (info.type !== 'date') return info.originNode; const dateStr = current.format('YYYY-MM-DD'); const dayEvents = eventCounts.find(item => item.date === dateStr); if (dayEvents && dayEvents.count > 0) { return (
{/* 使用小圆点指示器,不遮挡日期数字 */}
= 10 ? 'many-events' : ''}`} style={{ color: getEventCountColor(dayEvents.count) }} > {dayEvents.count > 99 ? '99+' : dayEvents.count}
); } return null; }; // 根据事件数量获取颜色 - 更丰富的渐进色彩 const getEventCountColor = (count) => { if (count >= 15) return '#f5222d'; // 深红色 - 非常多 if (count >= 10) return '#fa541c'; // 橙红色 - 很多 if (count >= 8) return '#fa8c16'; // 橙色 - 较多 if (count >= 5) return '#faad14'; // 金黄色 - 中等 if (count >= 3) return '#52c41a'; // 绿色 - 少量 return '#1890ff'; // 蓝色 - 很少 }; // 处理日期选择 // info.source 区分选择来源:'date' = 点击日期,'month'/'year' = 切换月份/年份 const handleDateSelect = (value, info) => { // 只有点击日期单元格时才打开弹窗,切换月份/年份时不打开 if (info?.source !== 'date') { return; } setSelectedDate(value); loadDateEvents(value); setModalVisible(true); }; // 渲染重要性星级 const renderStars = (star) => { const stars = []; for (let i = 1; i <= 5; i++) { stars.push( ); } return {stars}; }; /** * 显示内容详情 * 支持两种数据格式: * 1. 字符串格式:直接显示文本,自动添加"(AI合成)"标识 * 例如:showContentDetail("这是事件背景内容", "事件背景") * * 2. 引用格式:使用CitedContent组件渲染,显示引用来源 * 例如:showContentDetail({ * data: [ * { sentence: "第一句话", citation: { source: "来源1", url: "..." } }, * { sentence: "第二句话", citation: { source: "来源2", url: "..." } } * ] * }, "事件背景") * * 后端API返回数据格式说明: * - 字符串格式:former字段直接返回字符串 * - 引用格式:former字段返回 { data: [...] } 对象,其中data是引用数组 */ const showContentDetail = (content, title) => { let processedContent; // 判断content类型:字符串或引用格式 if (typeof content === 'string') { // 字符串类型:添加AI合成标识 processedContent = { type: 'text', content: content + (content ? '\n\n(AI合成)' : '') }; } else if (content && content.data && Array.isArray(content.data)) { // 引用格式:使用CitedContent渲染 processedContent = { type: 'citation', content: content }; } else { // 其他情况:转为字符串并添加AI标识 processedContent = { type: 'text', content: String(content || '') + '\n\n(AI合成)' }; } setSelectedDetail({ content: processedContent, title }); setDetailDrawerVisible(true); }; // 显示相关股票 const showRelatedStocks = (stocks, eventTime) => { // 检查权限 if (!hasFeatureAccess('related_stocks')) { setUpgradeModalOpen(true); return; } if (!stocks || stocks.length === 0) { message.info('暂无相关股票'); return; } // 归一化数据后按相关度排序(降序) const normalizedList = normalizeStocks(stocks); const sortedStocks = normalizedList.sort((a, b) => (b.score || 0) - (a.score || 0)); setSelectedStocks(sortedStocks); setStockModalVisible(true); loadStockQuotes(stocks, eventTime); // 传原始数据给 loadStockQuotes,它内部会归一化 }; // 添加交易所后缀 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`; // 深圳 } else if (sixDigitCode.startsWith('688')) { return `${sixDigitCode}.SH`; // 科创板 } return sixDigitCode; }; // 显示K线图(支持新旧格式) const showKline = (stock) => { // 兼容新旧格式 const code = stock.code || stock[0]; const name = stock.name || stock[1]; const stockCode = addExchangeSuffix(code); // 将 selectedDate 转换为 YYYY-MM-DD 格式(日K线只需要日期,不需要时间) const formattedEventTime = selectedDate ? selectedDate.format('YYYY-MM-DD') : null; console.log('[InvestmentCalendar] 打开K线图:', { originalCode: code, processedCode: stockCode, stockName: name, selectedDate: selectedDate?.format('YYYY-MM-DD'), formattedEventTime: formattedEventTime }); setSelectedStock({ stock_code: stockCode, // 添加交易所后缀 stock_name: name }); setSelectedEventTime(formattedEventTime); setKlineModalVisible(true); }; // 处理关注切换 const handleFollowToggle = async (eventId) => { setFollowingIds(prev => [...prev, eventId]); try { const response = await eventService.calendar.toggleFollow(eventId); if (response.success) { // 更新本地事件列表的关注状态 setSelectedDateEvents(prev => prev.map(event => event.id === eventId ? { ...event, is_following: response.data.is_following } : event ) ); message.success(response.data.is_following ? '关注成功' : '取消关注成功'); } else { message.error(response.error || '操作失败'); } } catch (error) { logger.error('InvestmentCalendar', 'handleFollowToggle', error, { eventId }); message.error('操作失败,请重试'); } finally { setFollowingIds(prev => prev.filter(id => id !== eventId)); } }; // 添加单只股票到自选(乐观更新,无需 loading 状态) const addSingleToWatchlist = async (stock) => { // 兼容新旧格式 const code = stock.code || stock[0]; const name = stock.name || stock[1]; const stockCode = getSixDigitCode(code); // 检查是否已在自选中 if (isStockInWatchlist(code)) { message.info(`${name} 已在自选中`); return; } try { // 乐观更新:dispatch 后 Redux 立即更新状态,UI 立即响应 await dispatch(toggleWatchlist({ stockCode, stockName: name, isInWatchlist: false // false 表示添加 })).unwrap(); message.success(`已将 ${name}(${stockCode}) 添加到自选`); } catch (error) { // 失败时 Redux 会自动回滚状态 logger.error('InvestmentCalendar', 'addSingleToWatchlist', error, { stockCode, stockName: name }); message.error('添加失败,请重试'); } }; // 事件表格列定义 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: 80, render: (text) => ( ) }, { title: ( 相关股票 {!hasFeatureAccess('related_stocks') && ( )} ), dataIndex: 'related_stocks', key: 'stocks', width: 100, render: (stocks, record) => { const hasStocks = stocks && stocks.length > 0; const hasAccess = hasFeatureAccess('related_stocks'); return ( ); } }, { title: '相关概念', dataIndex: 'concepts', key: 'concepts', width: 200, render: (concepts) => ( {concepts && concepts.length > 0 ? ( concepts.slice(0, 3).map((concept, index) => ( }> {typeof concept === 'string' ? concept : (concept?.concept || concept?.name || '未知')} )) ) : ( )} {concepts && concepts.length > 3 && ( +{concepts.length - 3} )} ) }, { title: '关注', key: 'follow', width: 60, render: (_, record) => ( )}
(AI合成)
); } } // 降级显示:纯文本 + 展开/收起 return (
{isExpanded || !shouldTruncate ? reason : `${reason?.slice(0, 100)}...` } {shouldTruncate && ( )}
(AI合成)
); } }, { title: '研报引用', dataIndex: 'report', key: 'report', width: 200, render: (report, record) => { if (!report || !report.title) { return -; } return (
{report.title.length > 20 ? `${report.title.slice(0, 20)}...` : 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: 100, render: (_, record) => { const inWatchlist = isStockInWatchlist(record.code); return ( ); } } ]; return ( <> 投资日历 } className="investment-calendar" style={{ marginBottom: 16 }} > setCurrentMonth(date)} /> {/* 事件列表模态框 */} {selectedDate?.format('YYYY年MM月DD日')} 投资事件 } open={modalVisible} onCancel={() => setModalVisible(false)} width={1200} footer={null} styles={{ body: { padding: '24px' } }} zIndex={1500} > e.type === 'event').length})`} key="event"> e.type === 'event')} columns={eventColumns} rowKey="id" size="middle" pagination={false} scroll={{ x: 1000 }} /> e.type === 'data').length})`} key="data">
e.type === 'data')} columns={eventColumns} rowKey="id" size="middle" pagination={false} scroll={{ x: 1000 }} /> {/* 内容详情抽屉 */} setDetailDrawerVisible(false)} open={detailDrawerVisible} zIndex={1500} > {selectedDetail?.content?.type === 'citation' ? ( ) : (
{selectedDetail?.content?.content || '暂无内容'}
)}
{/* 相关股票模态框 */} 相关股票 {!hasFeatureAccess('related_stocks') && ( )} } open={stockModalVisible} onCancel={() => { setStockModalVisible(false); setExpandedReasons({}); // 清理展开状态 setAddingToWatchlist({}); // 清理加自选状态 }} width={1000} footer={ } zIndex={1500} > {hasFeatureAccess('related_stocks') ? (
record.code} size="middle" pagination={false} /> ) : (
)} {/* K线图弹窗 */} {selectedStock && ( { setKlineModalVisible(false); setSelectedStock(null); setSelectedEventTime(null); }} stock={selectedStock} eventTime={selectedEventTime} size="5xl" /> )} {/* 订阅升级模态框 */} setUpgradeModalOpen(false)} requiredLevel="pro" featureName="相关股票分析" /> ); }; export default InvestmentCalendar;