// src/views/Community/components/StockDetailPanel.js import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { Drawer, List, Card, Tag, Spin, Empty, Typography, Row, Col, Statistic, Tabs, Descriptions, Badge, message, Table, Modal, Button, Input, Alert } from 'antd'; import { CloseOutlined, RiseOutlined, FallOutlined, CloseCircleOutlined, PushpinOutlined, ReloadOutlined, StarOutlined, StarFilled, LockOutlined, CrownOutlined } from '@ant-design/icons'; import { eventService, stockService } from '../../../services/eventService'; import ReactECharts from 'echarts-for-react'; import * as echarts from 'echarts'; import './StockDetailPanel.css'; import { Tabs as AntdTabs } from 'antd'; import ReactDOM from 'react-dom'; import RelatedConcepts from '../../EventDetail/components/RelatedConcepts'; import HistoricalEvents from '../../EventDetail/components/HistoricalEvents'; import TransmissionChainAnalysis from '../../EventDetail/components/TransmissionChainAnalysis'; import EventDiscussionModal from './EventDiscussionModal'; import { useSubscription } from '../../../hooks/useSubscription'; import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal'; import moment from 'moment'; import { logger } from '../../../utils/logger'; import { getApiBase } from '../../../utils/apiConfig'; import RiskDisclaimer from '../../../components/RiskDisclaimer'; const { Title, Text } = Typography; const { TabPane } = Tabs; // ================= 全局缓存和请求管理 ================= const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}` -> data const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}` -> Promise const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}` -> timestamp // 请求间隔限制(毫秒) const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数据 // 获取缓存key const getCacheKey = (stockCode, eventTime) => { const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD'); return `${stockCode}|${date}`; }; // 检查是否需要刷新数据 const shouldRefreshData = (cacheKey) => { const lastTime = lastRequestTime.get(cacheKey); if (!lastTime) return true; const now = Date.now(); const elapsed = now - lastTime; // 如果是今天的数据且交易时间内,允许更频繁的更新 const today = moment().format('YYYY-MM-DD'); const isToday = cacheKey.includes(today); const currentHour = new Date().getHours(); const isTradingHours = currentHour >= 9 && currentHour < 16; if (isToday && isTradingHours) { return elapsed > REQUEST_INTERVAL; } // 历史数据不需要频繁更新 return elapsed > 3600000; // 1小时 }; // 获取K线数据(带缓存和防重复请求) const fetchKlineData = async (stockCode, eventTime) => { const cacheKey = getCacheKey(stockCode, eventTime); // 1. 检查缓存 if (klineDataCache.has(cacheKey)) { // 检查是否需要刷新 if (!shouldRefreshData(cacheKey)) { logger.debug('StockDetailPanel', '使用缓存数据', { cacheKey }); return klineDataCache.get(cacheKey); } } // 2. 检查是否有正在进行的请求 if (pendingRequests.has(cacheKey)) { logger.debug('StockDetailPanel', '等待进行中的请求', { cacheKey }); return pendingRequests.get(cacheKey); } // 3. 发起新请求 logger.debug('StockDetailPanel', '发起新K线数据请求', { cacheKey }); const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined; const requestPromise = stockService .getKlineData(stockCode, 'minute', normalizedEventTime) .then((res) => { const data = Array.isArray(res?.data) ? res.data : []; // 更新缓存 klineDataCache.set(cacheKey, data); lastRequestTime.set(cacheKey, Date.now()); // 清除pending状态 pendingRequests.delete(cacheKey); logger.debug('StockDetailPanel', 'K线数据请求完成并缓存', { cacheKey, dataPoints: data.length }); return data; }) .catch((error) => { logger.error('StockDetailPanel', 'fetchKlineData', error, { stockCode, cacheKey }); // 清除pending状态 pendingRequests.delete(cacheKey); // 如果有旧缓存,返回旧数据 if (klineDataCache.has(cacheKey)) { return klineDataCache.get(cacheKey); } return []; }); // 保存pending请求 pendingRequests.set(cacheKey, requestPromise); return requestPromise; }; // ================= 优化后的迷你分时图组件 ================= const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime }) { const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const mountedRef = useRef(true); const loadedRef = useRef(false); // 标记是否已加载过数据 const dataFetchedRef = useRef(false); // 防止重复请求的标记 // 稳定的事件时间,避免因为格式化导致的重复请求 const stableEventTime = useMemo(() => { return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : ''; }, [eventTime]); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); useEffect(() => { if (!stockCode) { setData([]); loadedRef.current = false; dataFetchedRef.current = false; return; } // 如果已经请求过数据,不再重复请求 if (dataFetchedRef.current) { return; } // 检查缓存 const cacheKey = getCacheKey(stockCode, stableEventTime); const cachedData = klineDataCache.get(cacheKey); // 如果有缓存数据,直接使用 if (cachedData && cachedData.length > 0) { setData(cachedData); loadedRef.current = true; dataFetchedRef.current = true; return; } // 标记正在请求 dataFetchedRef.current = true; setLoading(true); // 使用全局的fetchKlineData函数 fetchKlineData(stockCode, stableEventTime) .then((result) => { if (mountedRef.current) { setData(result); setLoading(false); loadedRef.current = true; } }) .catch(() => { if (mountedRef.current) { setData([]); setLoading(false); loadedRef.current = true; } }); }, [stockCode, stableEventTime]); // 注意这里使用 stableEventTime const chartOption = useMemo(() => { const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number'); const times = data.map(item => item.time); const hasData = prices.length > 0; if (!hasData) { return { title: { text: loading ? '加载中...' : '无数据', left: 'center', top: 'middle', textStyle: { color: '#999', fontSize: 10 } } }; } const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); const isUp = prices[prices.length - 1] >= prices[0]; const lineColor = isUp ? '#ef5350' : '#26a69a'; // 计算事件时间对应的分时索引 let eventMarkLineData = []; if (stableEventTime && Array.isArray(times) && times.length > 0) { try { const eventMinute = moment(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm'); const parseMinuteTime = (timeStr) => { const [h, m] = String(timeStr).split(':').map(Number); return h * 60 + m; }; const eventMin = parseMinuteTime(eventMinute); let nearestIdx = 0; for (let i = 1; i < times.length; i++) { if (Math.abs(parseMinuteTime(times[i]) - eventMin) < Math.abs(parseMinuteTime(times[nearestIdx]) - eventMin)) { nearestIdx = i; } } eventMarkLineData.push({ xAxis: nearestIdx, lineStyle: { color: '#FFD700', type: 'solid', width: 1.5 }, label: { show: false } }); } catch (e) { // 忽略事件时间解析异常 } } return { grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false }, xAxis: { type: 'category', data: times, show: false, boundaryGap: false }, yAxis: { type: 'value', show: false, min: minPrice * 0.995, max: maxPrice * 1.005, scale: true }, series: [{ data: prices, type: 'line', smooth: true, symbol: 'none', lineStyle: { color: lineColor, width: 2 }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: isUp ? 'rgba(239, 83, 80, 0.3)' : 'rgba(38, 166, 154, 0.3)' }, { offset: 1, color: isUp ? 'rgba(239, 83, 80, 0.05)' : 'rgba(38, 166, 154, 0.05)' } ]) }, markLine: { silent: true, symbol: 'none', label: { show: false }, data: [ ...(prices.length ? [{ yAxis: prices[0], lineStyle: { color: '#aaa', type: 'dashed', width: 1 } }] : []), ...eventMarkLineData ] } }], tooltip: { show: false }, animation: false }; }, [data, loading, stableEventTime]); return (
); }, (prevProps, nextProps) => { // 自定义比较函数,只有当stockCode或eventTime变化时才重新渲染 return prevProps.stockCode === nextProps.stockCode && prevProps.eventTime === nextProps.eventTime; }); import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal'; // 使用统一的股票详情组件 const StockDetailModal = ({ stock, onClose, fixed, eventTime }) => { return ( ); }; function StockDetailPanel({ visible, event, onClose }) { logger.debug('StockDetailPanel', '组件加载', { visible, eventId: event?.id, eventTitle: event?.title }); // 权限控制 const { hasFeatureAccess, getRequiredLevel, getUpgradeRecommendation } = useSubscription(); const [upgradeModalOpen, setUpgradeModalOpen] = useState(false); const [upgradeFeature, setUpgradeFeature] = useState(''); // 1. hooks const [activeTab, setActiveTab] = useState('stocks'); const [loading, setLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [relatedStocks, setRelatedStocks] = useState([]); const [stockQuotes, setStockQuotes] = useState({}); const [selectedStock, setSelectedStock] = useState(null); const [chartData, setChartData] = useState(null); const [eventDetail, setEventDetail] = useState(null); const [historicalEvents, setHistoricalEvents] = useState([]); const [chainAnalysis, setChainAnalysis] = useState(null); const [posts, setPosts] = useState([]); // 移除悬浮相关的state // const [hoveredStock, setHoveredStock] = useState(null); const [fixedCharts, setFixedCharts] = useState([]); // [{stock, chartType}] // const [hoveredRowIndex, setHoveredRowIndex] = useState(null); // const [tableRect, setTableRect] = useState(null); const tableRef = React.useRef(); // 讨论模态框相关状态 const [discussionModalVisible, setDiscussionModalVisible] = useState(false); const [discussionType, setDiscussionType] = useState('事件讨论'); // 移除滚动相关的ref // const isScrollingRef = React.useRef(false); // const scrollStopTimerRef = React.useRef(null); // const hoverTimerRef = React.useRef(null); // const [hoverTab, setHoverTab] = useState('stock'); const [searchText, setSearchText] = useState(''); // 搜索文本 const [isMonitoring, setIsMonitoring] = useState(false); // 实时监控状态 const [filteredStocks, setFilteredStocks] = useState([]); // 过滤后的股票列表 const [expectationScore, setExpectationScore] = useState(null); // 超预期得分 const monitoringIntervalRef = useRef(null); // 监控定时器引用 const [watchlistStocks, setWatchlistStocks] = useState(new Set()); // 自选股列表 // 清理函数 useEffect(() => { return () => { // 组件卸载时清理定时器 if (monitoringIntervalRef.current) { clearInterval(monitoringIntervalRef.current); } }; }, []); // 过滤股票列表 useEffect(() => { if (!searchText.trim()) { setFilteredStocks(relatedStocks); } else { const filtered = relatedStocks.filter(stock => stock.stock_code.toLowerCase().includes(searchText.toLowerCase()) || stock.stock_name.toLowerCase().includes(searchText.toLowerCase()) ); setFilteredStocks(filtered); } }, [searchText, relatedStocks]); // 实时监控定时器 - 优化版本 useEffect(() => { // 清理旧的定时器 if (monitoringIntervalRef.current) { clearInterval(monitoringIntervalRef.current); monitoringIntervalRef.current = null; } if (isMonitoring && relatedStocks.length > 0) { // 立即执行一次 const updateQuotes = () => { const codes = relatedStocks.map(s => s.stock_code); stockService.getQuotes(codes, event?.created_at) .then(quotes => setStockQuotes(quotes)) .catch(error => logger.error('StockDetailPanel', 'updateQuotes', error, { stockCodes: codes, eventTime: event?.created_at })); }; updateQuotes(); // 设置定时器 monitoringIntervalRef.current = setInterval(updateQuotes, 5000); } return () => { if (monitoringIntervalRef.current) { clearInterval(monitoringIntervalRef.current); monitoringIntervalRef.current = null; } }; }, [isMonitoring, relatedStocks, event]); // 加载用户自选股列表 const loadWatchlist = useCallback(async () => { try { const isProduction = process.env.NODE_ENV === 'production'; const apiBase = getApiBase(); const response = await fetch(`${apiBase}/api/account/watchlist`, { credentials: 'include' // 确保发送cookies }); const data = await response.json(); if (data.success && data.data) { const watchlistSet = new Set(data.data.map(item => item.stock_code)); setWatchlistStocks(watchlistSet); logger.debug('StockDetailPanel', '自选股列表加载成功', { count: watchlistSet.size }); } } catch (error) { logger.error('StockDetailPanel', 'loadWatchlist', error); } }, []); // 加入/移除自选股 const handleWatchlistToggle = async (stockCode, isInWatchlist) => { try { const isProduction = process.env.NODE_ENV === 'production'; const apiBase = getApiBase(); let response; if (isInWatchlist) { // 移除自选股 response = await fetch(`${apiBase}/api/account/watchlist/${stockCode}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json', }, credentials: 'include' // 确保发送cookies }); } else { // 添加自选股 const stockInfo = relatedStocks.find(s => s.stock_code === stockCode); response = await fetch(`${apiBase}/api/account/watchlist`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, credentials: 'include', // 确保发送cookies body: JSON.stringify({ stock_code: stockCode, stock_name: stockInfo?.stock_name || stockCode }), }); } const data = await response.json(); if (data.success) { message.success(isInWatchlist ? '已从自选股移除' : '已加入自选股'); // 更新本地状态 setWatchlistStocks(prev => { const newSet = new Set(prev); if (isInWatchlist) { newSet.delete(stockCode); } else { newSet.add(stockCode); } return newSet; }); } else { message.error(data.error || '操作失败'); } } catch (error) { message.error('操作失败,请稍后重试'); } }; // 初始化数据加载 useEffect(() => { logger.debug('StockDetailPanel', 'useEffect 触发', { visible, eventId: event?.id }); if (visible && event) { setActiveTab('stocks'); loadAllData(); } }, [visible, event]); // 加载所有数据的函数 const loadAllData = useCallback(() => { logger.debug('StockDetailPanel', 'loadAllData 被调用', { eventId: event?.id }); if (!event) return; // 加载自选股列表 loadWatchlist(); // 加载相关标的 setLoading(true); eventService.getRelatedStocks(event.id) .then(res => { logger.debug('StockDetailPanel', '接收到事件相关股票数据', { eventId: event.id, success: res.success, stockCount: res.data?.length || 0 }); if (res.success) { if (res.data && res.data[0]) { logger.debug('StockDetailPanel', '第一只股票数据', { stockCode: res.data[0].stock_code, stockName: res.data[0].stock_name, hasRelationDesc: !!res.data[0].relation_desc }); } setRelatedStocks(res.data); if (res.data.length > 0) { const codes = res.data.map(s => s.stock_code); stockService.getQuotes(codes, event.created_at) .then(quotes => setStockQuotes(quotes)) .catch(error => logger.error('StockDetailPanel', 'getQuotes', error, { stockCodes: codes, eventTime: event.created_at })); } } }) .finally(() => setLoading(false)); // 加载详细信息 setDetailLoading(true); eventService.getEventDetail(event.id) .then(res => { if (res.success) setEventDetail(res.data); }) .finally(() => setDetailLoading(false)); // 加载历史事件 eventService.getHistoricalEvents(event.id) .then(res => { if (res.success) setHistoricalEvents(res.data); }); // 加载传导链分析 eventService.getTransmissionChainAnalysis(event.id) .then(res => { if (res.success) setChainAnalysis(res.data); }); // 加载社区讨论 if (eventService.getPosts) { eventService.getPosts(event.id) .then(res => { if (res.success) setPosts(res.data); }); } // 加载超预期得分 if (eventService.getExpectationScore) { eventService.getExpectationScore(event.id) .then(res => { if (res.success) setExpectationScore(res.data.score); }) .catch(() => setExpectationScore(null)); } }, [event, loadWatchlist]); // 2. renderCharts函数 const renderCharts = useCallback((stock, chartType, onClose, fixed) => { // 保证事件时间格式为 'YYYY-MM-DD HH:mm' const formattedEventTime = event?.start_time ? moment(event.start_time).format('YYYY-MM-DD HH:mm') : undefined; return ; }, [event]); // 3. 简化handleRowEvents函数 - 只处理点击事件 const handleRowEvents = useCallback((record) => ({ onClick: () => { // 点击行时显示详情弹窗 setFixedCharts((prev) => { if (prev.find(item => item.stock.stock_code === record.stock_code)) return prev; return [...prev, { stock: record, chartType: 'timeline' }]; }); }, style: { cursor: 'pointer' } // 添加手型光标提示可点击 }), []); // 展开/收缩的行 const [expandedRows, setExpandedRows] = useState(new Set()); // 稳定的事件时间,避免重复渲染 const stableEventTime = useMemo(() => { return event?.start_time ? moment(event.start_time).format('YYYY-MM-DD HH:mm') : ''; }, [event?.start_time]); // 切换行展开状态 const toggleRowExpand = useCallback((stockCode) => { setExpandedRows(prev => { const newSet = new Set(prev); if (newSet.has(stockCode)) { newSet.delete(stockCode); } else { newSet.add(stockCode); } return newSet; }); }, []); // 4. stockColumns数组 - 使用优化后的 MiniTimelineChart const stockColumns = useMemo(() => [ { title: '股票代码', dataIndex: 'stock_code', key: 'stock_code', width: 100, render: (code, record) => ( ), }, { title: '股票名称', dataIndex: 'stock_name', key: 'stock_name', width: 120, }, { title: '关联描述', dataIndex: 'relation_desc', key: 'relation_desc', width: 300, render: (relationDesc, record) => { logger.debug('StockDetailPanel', '表格渲染 - 股票关联描述', { stockCode: record.stock_code, hasRelationDesc: !!relationDesc }); // 处理 relation_desc 的两种格式 let text = ''; if (!relationDesc) { return '--'; } else if (typeof relationDesc === 'string') { // 旧格式:直接是字符串 text = relationDesc; } else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) { // 新格式:{data: [{query_part: "...", sentences: "..."}]} // 提取所有 query_part,用逗号连接 text = relationDesc.data .map(item => item.query_part || item.sentences || '') .filter(s => s) .join(';') || '--'; } else { logger.warn('StockDetailPanel', '未知的 relation_desc 格式', { stockCode: record.stock_code, relationDescType: typeof relationDesc }); return '--'; } if (!text || text === '--') return '--'; const isExpanded = expandedRows.has(record.stock_code); const maxLength = 30; // 收缩时显示的最大字符数 const needTruncate = text.length > maxLength; return (
{isExpanded ? text : (needTruncate ? text.substring(0, maxLength) + '...' : text)}
{needTruncate && ( )}
); }, }, { title: '分时图', key: 'timeline', width: 150, render: (_, record) => ( ), }, { title: '涨跌幅', key: 'change', width: 100, render: (_, record) => { const quote = stockQuotes[record.stock_code]; if (!quote) return '--'; const color = quote.change > 0 ? 'red' : quote.change < 0 ? 'green' : 'inherit'; return {quote.change > 0 ? '+' : ''}{quote.change?.toFixed(2)}%; }, }, { title: '操作', key: 'action', width: 150, fixed: 'right', render: (_, record) => { const isInWatchlist = watchlistStocks.has(record.stock_code); return (
); }, }, ], [stockQuotes, stableEventTime, expandedRows, toggleRowExpand, watchlistStocks, handleWatchlistToggle, relatedStocks]); // 注意这里依赖改为 stableEventTime // 处理搜索 const handleSearch = (value) => { setSearchText(value); }; // 处理实时监控切换 const handleMonitoringToggle = () => { setIsMonitoring(!isMonitoring); if (!isMonitoring) { message.info('已开启实时监控,每5秒自动更新'); } else { message.info('已停止实时监控'); } }; // 处理刷新 - 只清理当天数据的缓存 const handleRefresh = useCallback(() => { // 手动刷新分时图缓存 const today = moment().format('YYYY-MM-DD'); relatedStocks.forEach(stock => { const cacheKey = getCacheKey(stock.stock_code, stableEventTime); // 如果是今天的数据,强制刷新 if (cacheKey.includes(today)) { lastRequestTime.delete(cacheKey); klineDataCache.delete(cacheKey); // 清除缓存数据 } }); // 重新加载数据 loadAllData(); }, [relatedStocks, stableEventTime, loadAllData]); // 固定图表关闭 const handleUnfixChart = useCallback((stock) => { setFixedCharts((prev) => prev.filter(item => item.stock.stock_code !== stock.stock_code)); }, []); // 权限检查函数 const handleTabAccess = (featureName, tabKey) => { if (!hasFeatureAccess(featureName)) { const recommendation = getUpgradeRecommendation(featureName); setUpgradeFeature(recommendation?.required || 'pro'); setUpgradeModalOpen(true); return false; } setActiveTab(tabKey); return true; }; // 渲染锁定内容 const renderLockedContent = (featureName, description) => { const recommendation = getUpgradeRecommendation(featureName); const isProRequired = recommendation?.required === 'pro'; return (
{isProRequired ? : }
); }; // 5. tabItems数组 const tabItems = [ { key: 'stocks', label: ( 相关标的 {!hasFeatureAccess('related_stocks') && ( )} ), children: hasFeatureAccess('related_stocks') ? ( {/* 头部信息 */}
📊
相关标的
共 {filteredStocks.length} 只股票
每5秒自动更新行情数据
{/* 搜索和操作栏 */}
🔍 handleSearch(e.target.value)} className="stock-search-input" style={{ flex: 1, maxWidth: '300px' }} allowClear />
{/* 股票列表 */}
{/* 固定图表 */} {fixedCharts.map(({ stock }, index) =>
{renderCharts(stock, 'timeline', () => handleUnfixChart(stock), true)}
)} {/* 讨论按钮 */}
) : renderLockedContent('related_stocks', '相关标的') }, { key: 'concepts', label: ( 相关概念 {!hasFeatureAccess('related_concepts') && ( )} ), children: hasFeatureAccess('related_concepts') ? (
) : renderLockedContent('related_concepts', '相关概念') }, { key: 'history', label: ( 历史事件对比 {!hasFeatureAccess('historical_events_full') && ( )} ), children: (
) }, { key: 'chain', label: ( 传导链分析 {!hasFeatureAccess('transmission_chain') && ( )} ), children: hasFeatureAccess('transmission_chain') ? ( ) : renderLockedContent('transmission_chain', '传导链分析') } ]; return ( <> {event?.title} } placement="right" width={900} open={visible} onClose={onClose} closable={false} className="stock-detail-panel" > {/* 风险提示 */}
{/* 事件讨论模态框 */} setDiscussionModalVisible(false)} eventId={event?.id} eventTitle={event?.title} discussionType={discussionType} /> {/* 订阅升级模态框 */} setUpgradeModalOpen(false)} requiredLevel={upgradeFeature} featureName={upgradeFeature === 'pro' ? '相关分析功能' : '传导链分析'} /> ); } export default StockDetailPanel;