// 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) => (
}
onClick={() => showContentDetail(text, '事件背景')}
disabled={!text}
>
查看
)
},
{
title: '未来推演',
dataIndex: 'forecast',
key: 'forecast',
width: 80,
render: (text) => (
}
onClick={() => showContentDetail(text, '未来推演')}
disabled={!text}
>
{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 (
: }
onClick={() => showRelatedStocks(stocks, record.calendar_time)}
disabled={!hasStocks}
style={!hasAccess ? { color: '#faad14' } : {}}
>
{hasStocks ? (hasAccess ? `${stocks.length}只` : '🔒需Pro') : '无'}
);
}
},
{
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) => (
: }
size="small"
onClick={() => handleFollowToggle(record.id)}
loading={followingIds.includes(record.id)}
/>
)
}
];
// 股票表格列定义(使用归一化后的对象格式)
const stockColumns = [
{
title: '代码',
dataIndex: 'code',
key: 'code',
width: 100,
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' : 'green'}>
{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 = description || '';
const shouldTruncate = reason && reason.length > 100;
const toggleExpanded = () => {
setExpandedReasons(prev => ({
...prev,
[stockCode]: !prev[stockCode]
}));
};
// 检查是否有引用数据
const citationData = description;
const hasCitation = citationData && citationData.data && Array.isArray(citationData.data);
if (hasCitation) {
// 使用引用组件,支持展开/收起
const processed = processCitationData(citationData);
if (processed) {
// 计算所有段落的总长度
const totalLength = processed.segments.reduce((sum, seg) => sum + seg.text.length, 0);
const shouldTruncateProcessed = totalLength > 100;
// 确定要显示的段落
let displaySegments = processed.segments;
if (shouldTruncateProcessed && !isExpanded) {
// 需要截断:计算应该显示到哪个段落
let charCount = 0;
displaySegments = [];
for (const seg of processed.segments) {
if (charCount + seg.text.length <= 100) {
// 完整显示这个段落
displaySegments.push(seg);
charCount += seg.text.length;
} else {
// 截断这个段落
const remainingChars = 100 - charCount;
if (remainingChars > 0) {
const truncatedText = seg.text.substring(0, remainingChars) + '...';
displaySegments.push({ ...seg, text: truncatedText });
}
break;
}
}
}
return (
{displaySegments.map((segment, index) => (
{segment.text}
{index < displaySegments.length - 1 && ,}
))}
{shouldTruncateProcessed && (
)}
(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;