refactor: 重构 Community 目录,将公共组件迁移到 src/components/
- 迁移 klineDataCache.js 到 src/utils/stock/(被 StockChart 使用) - 迁移 InvestmentCalendar 到 src/components/InvestmentCalendar/(被 Navbar、Dashboard 使用) - 迁移 DynamicNewsDetail 到 src/components/EventDetailPanel/(被 EventDetail 使用) - 更新所有相关导入路径,使用路径别名 - 保持 Community 目录其余结构不变 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
975
src/components/InvestmentCalendar/index.js
Normal file
975
src/components/InvestmentCalendar/index.js
Normal file
@@ -0,0 +1,975 @@
|
||||
// src/components/InvestmentCalendar/index.js
|
||||
import React, { useState, useEffect, useCallback } 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 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 = () => {
|
||||
// 权限控制
|
||||
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 [addingToWatchlist, setAddingToWatchlist] = useState({}); // 正在添加到自选的股票代码
|
||||
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('加载股票行情失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadEventCounts(currentMonth);
|
||||
}, [currentMonth, loadEventCounts]);
|
||||
|
||||
// 自定义日期单元格渲染(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'; // 蓝色 - 很少
|
||||
};
|
||||
|
||||
// 处理日期选择
|
||||
const handleDateSelect = (value) => {
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
||||
// 添加单只股票到自选(支持新旧格式)
|
||||
const addSingleToWatchlist = async (stock) => {
|
||||
// 兼容新旧格式
|
||||
const code = stock.code || stock[0];
|
||||
const name = stock.name || stock[1];
|
||||
const stockCode = getSixDigitCode(code);
|
||||
|
||||
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: name // 股票名称
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
message.success(`已将 ${name}(${stockCode}) 添加到自选`);
|
||||
} else {
|
||||
message.error(data.error || '添加失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentCalendar', 'addSingleToWatchlist', error, {
|
||||
stockCode,
|
||||
stockName: name
|
||||
});
|
||||
message.error('添加失败,请重试');
|
||||
} finally {
|
||||
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 事件表格列定义
|
||||
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 />}>
|
||||
{Array.isArray(concept) ? concept[0] : concept}
|
||||
</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 stockCode = getSixDigitCode(record.code);
|
||||
const isAdding = addingToWatchlist[stockCode] || false;
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
loading={isAdding}
|
||||
onClick={() => addSingleToWatchlist(record)}
|
||||
>
|
||||
加自选
|
||||
</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[0]}
|
||||
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;
|
||||
Reference in New Issue
Block a user