// src/views/Community/components/InvestmentCalendar.js
import React, { useState, useEffect } from 'react';
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 moment from 'moment';
import ReactMarkdown from 'react-markdown';
import { eventService, stockService } from '../../../services/eventService';
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
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 = () => {
// 权限控制
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(moment());
// 新增状态
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 [followingIds, setFollowingIds] = useState([]); // 正在处理关注的事件ID列表
const [addingToWatchlist, setAddingToWatchlist] = useState({}); // 正在添加到自选的股票代码
const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态
// 加载月度事件统计
const loadEventCounts = 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
});
}
};
// 加载指定日期的事件
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];
};
// 加载股票行情
const loadStockQuotes = async (stocks, eventTime) => {
try {
const codes = stocks.map(stock => getSixDigitCode(stock[0])); // 确保使用六位代码
const quotes = {};
// 使用市场API获取最新行情数据
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
const originalCode = stocks[i][0]; // 保持原始代码作为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('加载股票行情失败');
}
};
useEffect(() => {
loadEventCounts(currentMonth);
}, [currentMonth]);
// 自定义日期单元格渲染
const dateCellRender = (value) => {
const dateStr = value.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'; // 蓝色 - 很少
};
// 处理日期选择
const handleDateSelect = (value) => {
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 sortedStocks = [...stocks].sort((a, b) => (b[3] || 0) - (a[3] || 0));
setSelectedStocks(sortedStocks);
setStockModalVisible(true);
loadStockQuotes(sortedStocks, eventTime);
};
// 显示K线图
const showKline = (stock) => {
setSelectedStock({
code: getSixDigitCode(stock[0]), // 确保使用六位代码
name: stock[1]
});
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));
}
};
// 添加单只股票到自选
const addSingleToWatchlist = async (stock) => {
const stockCode = getSixDigitCode(stock[0]);
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: true }));
try {
const response = await fetch('/api/account/watchlist', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
stock_code: stockCode, // 使用六位代码
stock_name: stock[1] // 股票名称
})
});
const data = await response.json();
if (data.success) {
message.success(`已将 ${stock[1]}(${stockCode}) 添加到自选`);
} else {
message.error(data.error || '添加失败');
}
} catch (error) {
logger.error('InvestmentCalendar', 'addSingleToWatchlist', error, {
stockCode,
stockName: stock[1]
});
message.error('添加失败,请重试');
} finally {
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: false }));
}
};
// 事件表格列定义
const eventColumns = [
{
title: '时间',
dataIndex: 'calendar_time',
key: 'time',
width: 80,
render: (time) => (
{moment(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: (
相关股票
{!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) => (
}>
{Array.isArray(concept) ? concept[0] : concept}
))
) : (
无
)}
{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: '0',
key: 'code',
width: 100,
render: (code) => {
const sixDigitCode = getSixDigitCode(code);
return (
{sixDigitCode}
);
}
},
{
title: '名称',
dataIndex: '1',
key: 'name',
width: 100,
render: (name, record) => {
const sixDigitCode = getSixDigitCode(record[0]);
return (
{name}
);
}
},
{
title: '现价',
key: 'price',
width: 80,
render: (_, record) => {
const quote = stockQuotes[record[0]];
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[0]];
if (quote && quote.changePercent !== undefined) {
const changePercent = quote.changePercent || 0;
return (
0 ? 'red' : 'green'}>
{changePercent > 0 ? '+' : ''}{changePercent.toFixed(2)}%
);
}
return -;
}
},
{
title: '关联理由',
dataIndex: '2',
key: 'reason',
render: (reason, record) => {
const stockCode = record[0];
const isExpanded = expandedReasons[stockCode] || false;
const shouldTruncate = reason && reason.length > 100;
const toggleExpanded = () => {
setExpandedReasons(prev => ({
...prev,
[stockCode]: !prev[stockCode]
}));
};
// 检查是否有引用数据(reason 就是 record[2])
const citationData = reason;
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 shouldTruncate = totalLength > 100;
// 确定要显示的段落
let displaySegments = processed.segments;
if (shouldTruncate && !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 && ,}
))}
{shouldTruncate && (
)}
(AI合成)
);
}
}
// 降级显示:纯文本 + 展开/收起
return (
{isExpanded || !shouldTruncate
? reason
: `${reason?.slice(0, 100)}...`
}
{shouldTruncate && (
)}
(AI合成)
);
}
},
{
title: 'K线图',
key: 'kline',
width: 80,
render: (_, record) => (
)
},
{
title: '操作',
key: 'action',
width: 100,
render: (_, record) => {
const stockCode = getSixDigitCode(record[0]);
const isAdding = addingToWatchlist[stockCode] || false;
return (
);
}
}
];
return (
<>
投资日历
}
className="investment-calendar"
style={{ marginBottom: 16 }}
>
setCurrentMonth(date)}
/>
{/* 事件列表模态框 */}
{selectedDate?.format('YYYY年MM月DD日')} 投资事件
}
visible={modalVisible}
onCancel={() => setModalVisible(false)}
width={1200}
footer={null}
bodyStyle={{ padding: '24px' }}
>
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)}
visible={detailDrawerVisible}
>
{selectedDetail?.content?.type === 'citation' ? (
) : (
{selectedDetail?.content?.content || '暂无内容'}
)}
{/* 相关股票模态框 */}
相关股票
{!hasFeatureAccess('related_stocks') && (
)}
}
visible={stockModalVisible}
onCancel={() => {
setStockModalVisible(false);
setExpandedReasons({}); // 清理展开状态
setAddingToWatchlist({}); // 清理加自选状态
}}
width={1000}
footer={
}
>
{hasFeatureAccess('related_stocks') ? (
record[0]}
size="middle"
pagination={false}
/>
) : (
)}
{/* K线图模态框 */}
{klineModalVisible && selectedStock && (
setKlineModalVisible(false)}
/>
)}
{/* 订阅升级模态框 */}
setUpgradeModalOpen(false)}
requiredLevel="pro"
featureName="相关股票分析"
/>
>
);
};
export default InvestmentCalendar;