* feature_bugfix/251201_vf_h5_ui: feat: 事件关注功能优化 - Redux 乐观更新 + Mock 数据状态同步 feat: 投资日历自选股功能优化 - Redux 集成 + 乐观更新 fix: 修复投资日历切换月份时自动打开事件弹窗的问题 fix: 修复 CompanyOverview 中 Hooks 顺序错误
998 lines
38 KiB
JavaScript
998 lines
38 KiB
JavaScript
// 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 (
|
||
<div className="calendar-events">
|
||
{/* 使用小圆点指示器,不遮挡日期数字 */}
|
||
<div className="event-indicators">
|
||
<div
|
||
className="event-dot"
|
||
style={{ backgroundColor: getEventCountColor(dayEvents.count) }}
|
||
title={`${dayEvents.count}个事件`}
|
||
/>
|
||
<span
|
||
className={`event-count ${dayEvents.count >= 10 ? 'many-events' : ''}`}
|
||
style={{ color: getEventCountColor(dayEvents.count) }}
|
||
>
|
||
{dayEvents.count > 99 ? '99+' : dayEvents.count}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
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(
|
||
<StarFilled
|
||
key={i}
|
||
style={{
|
||
color: i <= star ? '#faad14' : '#d9d9d9',
|
||
fontSize: '14px'
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
return <span>{stars}</span>;
|
||
};
|
||
|
||
/**
|
||
* 显示内容详情
|
||
* 支持两种数据格式:
|
||
* 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) => (
|
||
<Space>
|
||
<ClockCircleOutlined />
|
||
<Text>{dayjs(time).format('HH:mm')}</Text>
|
||
</Space>
|
||
)
|
||
},
|
||
{
|
||
title: '重要度',
|
||
dataIndex: 'star',
|
||
key: 'star',
|
||
width: 120,
|
||
render: renderStars
|
||
},
|
||
{
|
||
title: '标题',
|
||
dataIndex: 'title',
|
||
key: 'title',
|
||
ellipsis: true,
|
||
render: (text) => (
|
||
<Tooltip title={text}>
|
||
<Text strong>{text}</Text>
|
||
</Tooltip>
|
||
)
|
||
},
|
||
{
|
||
title: '背景',
|
||
dataIndex: 'former',
|
||
key: 'former',
|
||
width: 80,
|
||
render: (text) => (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<LinkOutlined />}
|
||
onClick={() => showContentDetail(text, '事件背景')}
|
||
disabled={!text}
|
||
>
|
||
查看
|
||
</Button>
|
||
)
|
||
},
|
||
{
|
||
title: '未来推演',
|
||
dataIndex: 'forecast',
|
||
key: 'forecast',
|
||
width: 80,
|
||
render: (text) => (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={<RobotOutlined />}
|
||
onClick={() => showContentDetail(text, '未来推演')}
|
||
disabled={!text}
|
||
>
|
||
{text ? '查看' : '无'}
|
||
</Button>
|
||
)
|
||
},
|
||
{
|
||
title: (
|
||
<span>
|
||
相关股票
|
||
{!hasFeatureAccess('related_stocks') && (
|
||
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6, color: '#faad14' }} />
|
||
)}
|
||
</span>
|
||
),
|
||
dataIndex: 'related_stocks',
|
||
key: 'stocks',
|
||
width: 100,
|
||
render: (stocks, record) => {
|
||
const hasStocks = stocks && stocks.length > 0;
|
||
const hasAccess = hasFeatureAccess('related_stocks');
|
||
|
||
return (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
icon={hasAccess ? <StockOutlined /> : <LockOutlined />}
|
||
onClick={() => showRelatedStocks(stocks, record.calendar_time)}
|
||
disabled={!hasStocks}
|
||
style={!hasAccess ? { color: '#faad14' } : {}}
|
||
>
|
||
{hasStocks ? (hasAccess ? `${stocks.length}只` : '🔒需Pro') : '无'}
|
||
</Button>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
title: '相关概念',
|
||
dataIndex: 'concepts',
|
||
key: 'concepts',
|
||
width: 200,
|
||
render: (concepts) => (
|
||
<Space wrap>
|
||
{concepts && concepts.length > 0 ? (
|
||
concepts.slice(0, 3).map((concept, index) => (
|
||
<Tag key={index} icon={<TagsOutlined />}>
|
||
{typeof concept === 'string'
|
||
? concept
|
||
: (concept?.concept || concept?.name || '未知')}
|
||
</Tag>
|
||
))
|
||
) : (
|
||
<Text type="secondary">无</Text>
|
||
)}
|
||
{concepts && concepts.length > 3 && (
|
||
<Tag>+{concepts.length - 3}</Tag>
|
||
)}
|
||
</Space>
|
||
)
|
||
},
|
||
{
|
||
title: '关注',
|
||
key: 'follow',
|
||
width: 60,
|
||
render: (_, record) => (
|
||
<Button
|
||
type={record.is_following ? "primary" : "default"}
|
||
icon={record.is_following ? <StarFilled /> : <StarOutlined />}
|
||
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 (
|
||
<a
|
||
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<Text code>{sixDigitCode}</Text>
|
||
</a>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
title: '名称',
|
||
dataIndex: 'name',
|
||
key: 'name',
|
||
width: 100,
|
||
render: (name, record) => {
|
||
const sixDigitCode = getSixDigitCode(record.code);
|
||
return (
|
||
<a
|
||
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<Text strong>{name}</Text>
|
||
</a>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
title: '现价',
|
||
key: 'price',
|
||
width: 80,
|
||
render: (_, record) => {
|
||
const quote = stockQuotes[record.code];
|
||
if (quote && quote.price !== undefined) {
|
||
return (
|
||
<Text type={quote.change > 0 ? 'danger' : 'success'}>
|
||
{quote.price?.toFixed(2)}
|
||
</Text>
|
||
);
|
||
}
|
||
return <Text>-</Text>;
|
||
}
|
||
},
|
||
{
|
||
title: '涨跌幅',
|
||
key: 'change',
|
||
width: 100,
|
||
render: (_, record) => {
|
||
const quote = stockQuotes[record.code];
|
||
if (quote && quote.changePercent !== undefined) {
|
||
const changePercent = quote.changePercent || 0;
|
||
return (
|
||
<Tag color={changePercent > 0 ? 'red' : 'green'}>
|
||
{changePercent > 0 ? '+' : ''}{changePercent.toFixed(2)}%
|
||
</Tag>
|
||
);
|
||
}
|
||
return <Text>-</Text>;
|
||
}
|
||
},
|
||
{
|
||
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 (
|
||
<div>
|
||
<div style={{ lineHeight: '1.6' }}>
|
||
{displaySegments.map((segment, index) => (
|
||
<React.Fragment key={segment.citationId}>
|
||
<Text>{segment.text}</Text>
|
||
<CitationMark
|
||
citationId={segment.citationId}
|
||
citation={processed.citations[segment.citationId]}
|
||
/>
|
||
{index < displaySegments.length - 1 && <Text>,</Text>}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
{shouldTruncateProcessed && (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={toggleExpanded}
|
||
style={{ padding: 0, marginLeft: 4 }}
|
||
>
|
||
({isExpanded ? '收起' : '展开'})
|
||
</Button>
|
||
)}
|
||
<div style={{ marginTop: 4 }}>
|
||
<Text type="secondary" style={{ fontSize: '12px' }}>(AI合成)</Text>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
// 降级显示:纯文本 + 展开/收起
|
||
return (
|
||
<div>
|
||
<Text>
|
||
{isExpanded || !shouldTruncate
|
||
? reason
|
||
: `${reason?.slice(0, 100)}...`
|
||
}
|
||
</Text>
|
||
{shouldTruncate && (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={toggleExpanded}
|
||
style={{ padding: 0, marginLeft: 4 }}
|
||
>
|
||
({isExpanded ? '收起' : '展开'})
|
||
</Button>
|
||
)}
|
||
<div style={{ marginTop: 4 }}>
|
||
<Text type="secondary" style={{ fontSize: '12px' }}>(AI合成)</Text>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
title: '研报引用',
|
||
dataIndex: 'report',
|
||
key: 'report',
|
||
width: 200,
|
||
render: (report, record) => {
|
||
if (!report || !report.title) {
|
||
return <Text type="secondary">-</Text>;
|
||
}
|
||
|
||
return (
|
||
<div style={{ fontSize: '12px' }}>
|
||
<Tooltip title={report.sentences || report.title}>
|
||
<div>
|
||
<Text strong style={{ display: 'block', marginBottom: 2 }}>
|
||
{report.title.length > 20 ? `${report.title.slice(0, 20)}...` : report.title}
|
||
</Text>
|
||
{report.author && (
|
||
<Text type="secondary" style={{ display: 'block', fontSize: '11px' }}>
|
||
{report.author}
|
||
</Text>
|
||
)}
|
||
{report.declare_date && (
|
||
<Text type="secondary" style={{ fontSize: '11px' }}>
|
||
{dayjs(report.declare_date).format('YYYY-MM-DD')}
|
||
</Text>
|
||
)}
|
||
{report.match_score && (
|
||
<Tag color={report.match_score === '好' ? 'green' : 'blue'} style={{ marginLeft: 4, fontSize: '10px' }}>
|
||
匹配度: {report.match_score}
|
||
</Tag>
|
||
)}
|
||
</div>
|
||
</Tooltip>
|
||
</div>
|
||
);
|
||
}
|
||
},
|
||
{
|
||
title: 'K线图',
|
||
key: 'kline',
|
||
width: 80,
|
||
render: (_, record) => (
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
onClick={() => showKline(record)}
|
||
>
|
||
查看
|
||
</Button>
|
||
)
|
||
},
|
||
{
|
||
title: '操作',
|
||
key: 'action',
|
||
width: 100,
|
||
render: (_, record) => {
|
||
const inWatchlist = isStockInWatchlist(record.code);
|
||
|
||
return (
|
||
<Button
|
||
type={inWatchlist ? "primary" : "default"}
|
||
size="small"
|
||
onClick={() => addSingleToWatchlist(record)}
|
||
disabled={inWatchlist}
|
||
>
|
||
{inWatchlist ? '已关注' : '加自选'}
|
||
</Button>
|
||
);
|
||
}
|
||
}
|
||
];
|
||
|
||
return (
|
||
<>
|
||
<Card
|
||
title={
|
||
<span>
|
||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||
投资日历
|
||
</span>
|
||
}
|
||
className="investment-calendar"
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<Calendar
|
||
fullscreen={false}
|
||
cellRender={cellRender}
|
||
onSelect={handleDateSelect}
|
||
onPanelChange={(date) => setCurrentMonth(date)}
|
||
/>
|
||
</Card>
|
||
|
||
{/* 事件列表模态框 */}
|
||
<Modal
|
||
title={
|
||
<Space>
|
||
<CalendarOutlined />
|
||
<span>{selectedDate?.format('YYYY年MM月DD日')} 投资事件</span>
|
||
</Space>
|
||
}
|
||
open={modalVisible}
|
||
onCancel={() => setModalVisible(false)}
|
||
width={1200}
|
||
footer={null}
|
||
styles={{ body: { padding: '24px' } }}
|
||
zIndex={1500}
|
||
>
|
||
<Spin spinning={loading}>
|
||
<Tabs defaultActiveKey="event">
|
||
<TabPane tab={`事件 (${selectedDateEvents.filter(e => e.type === 'event').length})`} key="event">
|
||
<Table
|
||
dataSource={selectedDateEvents.filter(e => e.type === 'event')}
|
||
columns={eventColumns}
|
||
rowKey="id"
|
||
size="middle"
|
||
pagination={false}
|
||
scroll={{ x: 1000 }}
|
||
/>
|
||
</TabPane>
|
||
<TabPane tab={`数据 (${selectedDateEvents.filter(e => e.type === 'data').length})`} key="data">
|
||
<Table
|
||
dataSource={selectedDateEvents.filter(e => e.type === 'data')}
|
||
columns={eventColumns}
|
||
rowKey="id"
|
||
size="middle"
|
||
pagination={false}
|
||
scroll={{ x: 1000 }}
|
||
/>
|
||
</TabPane>
|
||
</Tabs>
|
||
</Spin>
|
||
</Modal>
|
||
|
||
{/* 内容详情抽屉 */}
|
||
<Drawer
|
||
title={selectedDetail?.title}
|
||
placement="right"
|
||
width={600}
|
||
onClose={() => setDetailDrawerVisible(false)}
|
||
open={detailDrawerVisible}
|
||
zIndex={1500}
|
||
>
|
||
{selectedDetail?.content?.type === 'citation' ? (
|
||
<CitedContent
|
||
data={selectedDetail.content.content}
|
||
title={selectedDetail.title || '事件背景'}
|
||
/>
|
||
) : (
|
||
<div className="markdown-content">
|
||
<ReactMarkdown>{selectedDetail?.content?.content || '暂无内容'}</ReactMarkdown>
|
||
</div>
|
||
)}
|
||
</Drawer>
|
||
|
||
{/* 相关股票模态框 */}
|
||
<Modal
|
||
title={
|
||
<Space>
|
||
<StockOutlined />
|
||
<span>相关股票</span>
|
||
{!hasFeatureAccess('related_stocks') && (
|
||
<LockOutlined style={{ color: '#faad14' }} />
|
||
)}
|
||
</Space>
|
||
}
|
||
open={stockModalVisible}
|
||
onCancel={() => {
|
||
setStockModalVisible(false);
|
||
setExpandedReasons({}); // 清理展开状态
|
||
setAddingToWatchlist({}); // 清理加自选状态
|
||
}}
|
||
width={1000}
|
||
footer={
|
||
<Button onClick={() => setStockModalVisible(false)}>
|
||
关闭
|
||
</Button>
|
||
}
|
||
zIndex={1500}
|
||
>
|
||
{hasFeatureAccess('related_stocks') ? (
|
||
<Table
|
||
dataSource={selectedStocks}
|
||
columns={stockColumns}
|
||
rowKey={(record) => record.code}
|
||
size="middle"
|
||
pagination={false}
|
||
/>
|
||
) : (
|
||
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
|
||
<LockOutlined />
|
||
</div>
|
||
<Alert
|
||
message="相关股票功能已锁定"
|
||
description="此功能需要Pro版订阅才能使用"
|
||
type="warning"
|
||
showIcon
|
||
style={{ maxWidth: '400px', margin: '0 auto', marginBottom: '24px' }}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
onClick={() => setUpgradeModalOpen(true)}
|
||
>
|
||
升级到 Pro版
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
|
||
{/* K线图弹窗 */}
|
||
{selectedStock && (
|
||
<KLineChartModal
|
||
isOpen={klineModalVisible}
|
||
onClose={() => {
|
||
setKlineModalVisible(false);
|
||
setSelectedStock(null);
|
||
setSelectedEventTime(null);
|
||
}}
|
||
stock={selectedStock}
|
||
eventTime={selectedEventTime}
|
||
size="5xl"
|
||
/>
|
||
)}
|
||
|
||
{/* 订阅升级模态框 */}
|
||
<SubscriptionUpgradeModal
|
||
isOpen={upgradeModalOpen}
|
||
onClose={() => setUpgradeModalOpen(false)}
|
||
requiredLevel="pro"
|
||
featureName="相关股票分析"
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default InvestmentCalendar; |