Files
vf_react/src/views/Community/components/StockDetailPanel.js
2025-10-11 16:16:02 +08:00

997 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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';
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)) {
console.log(`使用缓存数据: ${cacheKey}`);
return klineDataCache.get(cacheKey);
}
}
// 2. 检查是否有正在进行的请求
if (pendingRequests.has(cacheKey)) {
console.log(`等待进行中的请求: ${cacheKey}`);
return pendingRequests.get(cacheKey);
}
// 3. 发起新请求
console.log(`发起新请求: ${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);
console.log(`请求完成并缓存: ${cacheKey}`);
return data;
})
.catch((error) => {
console.error(`获取${stockCode}的K线数据失败:`, error);
// 清除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 (
<div style={{ width: 140, height: 40 }}>
<ReactECharts
option={chartOption}
style={{ width: '100%', height: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
</div>
);
}, (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 (
<StockChartAntdModal
open={true}
onCancel={onClose}
stock={stock}
eventTime={eventTime}
fixed={fixed}
width={800}
/>
);
};
function StockDetailPanel({ visible, event, onClose }) {
console.log('StockDetailPanel 组件已加载visible:', visible, 'event:', event?.id);
// 权限控制
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 => console.error('更新行情失败:', error));
};
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 = isProduction ? '' : process.env.REACT_APP_API_URL || '';
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);
}
} catch (error) {
console.error('加载自选股列表失败:', error);
}
}, []);
// 加入/移除自选股
const handleWatchlistToggle = async (stockCode, isInWatchlist) => {
try {
const isProduction = process.env.NODE_ENV === 'production';
const apiBase = isProduction ? '' : process.env.REACT_APP_API_URL || '';
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(() => {
if (visible && event) {
setActiveTab('stocks');
loadAllData();
}
}, [visible, event]);
// 加载所有数据的函数
const loadAllData = useCallback(() => {
if (!event) return;
// 加载自选股列表
loadWatchlist();
// 加载相关标的
setLoading(true);
eventService.getRelatedStocks(event.id)
.then(res => {
if (res.success) {
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 => console.error('加载行情失败:', error));
}
}
})
.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 <StockDetailModal
stock={stock}
onClose={onClose}
fixed={fixed}
eventTime={formattedEventTime}
/>;
}, [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) => (
<Button type="link">{code}</Button>
),
},
{
title: '股票名称',
dataIndex: 'stock_name',
key: 'stock_name',
width: 120,
},
{
title: '关联描述',
dataIndex: 'relation_desc',
key: 'relation_desc',
width: 200,
render: (text, record) => {
if (!text) return '--';
const isExpanded = expandedRows.has(record.stock_code);
const maxLength = 30; // 收缩时显示的最大字符数
const needTruncate = text.length > maxLength;
return (
<div style={{ position: 'relative' }}>
<div style={{
whiteSpace: isExpanded ? 'normal' : 'nowrap',
overflow: isExpanded ? 'visible' : 'hidden',
textOverflow: isExpanded ? 'clip' : 'ellipsis',
paddingRight: needTruncate ? '20px' : '0',
fontSize: '12px',
lineHeight: '1.5',
color: '#666'
}}>
{isExpanded ? text : (needTruncate ? text.substring(0, maxLength) + '...' : text)}
</div>
{needTruncate && (
<Button
type="link"
size="small"
onClick={(e) => {
e.stopPropagation(); // 防止触发行点击事件
toggleRowExpand(record.stock_code);
}}
style={{
position: isExpanded ? 'static' : 'absolute',
right: 0,
top: 0,
padding: '0 4px',
fontSize: '12px',
marginTop: isExpanded ? '4px' : '0'
}}
>
{isExpanded ? '收起' : '展开'}
</Button>
)}
</div>
);
},
},
{
title: '分时图',
key: 'timeline',
width: 150,
render: (_, record) => (
<MiniTimelineChart
stockCode={record.stock_code}
eventTime={stableEventTime}
/>
),
},
{
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 <span style={{ color }}>{quote.change > 0 ? '+' : ''}{quote.change?.toFixed(2)}%</span>;
},
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right',
render: (_, record) => {
const isInWatchlist = watchlistStocks.has(record.stock_code);
return (
<div style={{ display: 'flex', gap: '4px' }}>
<Button
type="primary"
size="small"
onClick={(e) => {
e.stopPropagation();
const stockCode = record.stock_code.split('.')[0];
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
}}
>
股票详情
</Button>
<Button
type={isInWatchlist ? 'default' : 'primary'}
size="small"
icon={isInWatchlist ? <StarFilled /> : <StarOutlined />}
onClick={(e) => {
e.stopPropagation();
handleWatchlistToggle(record.stock_code, isInWatchlist);
}}
style={{ minWidth: '70px' }}
>
{isInWatchlist ? '已关注' : '加自选'}
</Button>
</div>
);
},
},
], [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 (
<div style={{ padding: '40px', textAlign: 'center' }}>
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
{isProRequired ? <LockOutlined /> : <CrownOutlined />}
</div>
<Alert
message={`${description}功能已锁定`}
description={recommendation?.message || `此功能需要${isProRequired ? 'Pro版' : 'Max版'}订阅`}
type="warning"
showIcon
style={{ maxWidth: '400px', margin: '0 auto', marginBottom: '24px' }}
/>
<Button
type="primary"
size="large"
onClick={() => {
setUpgradeFeature(recommendation?.required || 'pro');
setUpgradeModalOpen(true);
}}
>
升级到 {isProRequired ? 'Pro版' : 'Max版'}
</Button>
</div>
);
};
// 5. tabItems数组
const tabItems = [
{
key: 'stocks',
label: (
<span>
相关标的
{!hasFeatureAccess('related_stocks') && (
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
)}
</span>
),
children: hasFeatureAccess('related_stocks') ? (
<Spin spinning={loading}>
{/* 头部信息 */}
<div className="stock-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div className="stock-header-icon">
<span>📊</span>
</div>
<div>
<div className="stock-title">
相关标的
</div>
<div className="stock-count">
{filteredStocks.length} 只股票
</div>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
<Button
className={`monitoring-button ${isMonitoring ? 'monitoring' : ''}`}
onClick={handleMonitoringToggle}
>
{isMonitoring ? '停止监控' : '实时监控'}
</Button>
<div style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.8)' }}>
每5秒自动更新行情数据
</div>
</div>
</div>
{/* 搜索和操作栏 */}
<div className="stock-search-bar">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
<span className="search-icon">🔍</span>
<Input
placeholder="搜索股票代码或名称..."
value={searchText}
onChange={(e) => handleSearch(e.target.value)}
className="stock-search-input"
style={{ flex: 1, maxWidth: '300px' }}
allowClear
/>
</div>
<div className="action-buttons">
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
className="refresh-button"
/>
</div>
</div>
{/* 股票列表 */}
<div ref={tableRef} style={{ position: 'relative' }}>
<Table
columns={stockColumns}
dataSource={filteredStocks}
rowKey="stock_code"
onRow={handleRowEvents}
pagination={false}
size="middle"
bordered
scroll={{ x: 920 }} // 设置横向滚动,因为操作列变宽了
/>
</div>
{/* 固定图表 */}
{fixedCharts.map(({ stock }, index) =>
<div key={`fixed-chart-${stock.stock_code}-${index}`}>
{renderCharts(stock, 'timeline', () => handleUnfixChart(stock), true)}
</div>
)}
{/* 讨论按钮 */}
<div style={{ marginTop: '20px', textAlign: 'center' }}>
<Button
type="primary"
icon={<Button.Group />}
onClick={() => {
setDiscussionType('事件讨论');
setDiscussionModalVisible(true);
}}
>
查看事件讨论
</Button>
</div>
</Spin>
) : renderLockedContent('related_stocks', '相关标的')
},
{
key: 'concepts',
label: (
<span>
相关概念
{!hasFeatureAccess('related_concepts') && (
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
)}
</span>
),
children: hasFeatureAccess('related_concepts') ? (
<Spin spinning={detailLoading}>
<RelatedConcepts
eventTitle={event?.title}
eventTime={event?.created_at || event?.start_time}
eventId={event?.id}
loading={detailLoading}
error={null}
/>
<div style={{ marginTop: '20px', textAlign: 'center' }}>
<Button
type="primary"
onClick={() => {
setDiscussionType('事件讨论');
setDiscussionModalVisible(true);
}}
>
查看事件讨论
</Button>
</div>
</Spin>
) : renderLockedContent('related_concepts', '相关概念')
},
{
key: 'history',
label: (
<span>
历史事件对比
{!hasFeatureAccess('historical_events_full') && (
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
)}
</span>
),
children: (
<Spin spinning={detailLoading}>
<HistoricalEvents
events={historicalEvents}
loading={detailLoading}
error={null}
expectationScore={expectationScore}
/>
<div style={{ marginTop: '20px', textAlign: 'center' }}>
<Button
type="primary"
onClick={() => {
setDiscussionType('事件讨论');
setDiscussionModalVisible(true);
}}
>
查看事件讨论
</Button>
</div>
</Spin>
)
},
{
key: 'chain',
label: (
<span>
传导链分析
{!hasFeatureAccess('transmission_chain') && (
<CrownOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
)}
</span>
),
children: hasFeatureAccess('transmission_chain') ? (
<TransmissionChainAnalysis eventId={event?.id} eventService={eventService} />
) : renderLockedContent('transmission_chain', '传导链分析')
}
];
return (
<>
<Drawer
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{event?.title}</span>
<CloseOutlined onClick={onClose} style={{ cursor: 'pointer' }} />
</div>
}
placement="right"
width={900}
open={visible}
onClose={onClose}
closable={false}
className="stock-detail-panel"
>
<AntdTabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</Drawer>
{/* 事件讨论模态框 */}
<EventDiscussionModal
isOpen={discussionModalVisible}
onClose={() => setDiscussionModalVisible(false)}
eventId={event?.id}
eventTitle={event?.title}
discussionType={discussionType}
/>
{/* 订阅升级模态框 */}
<SubscriptionUpgradeModal
isOpen={upgradeModalOpen}
onClose={() => setUpgradeModalOpen(false)}
requiredLevel={upgradeFeature}
featureName={upgradeFeature === 'pro' ? '相关分析功能' : '传导链分析'}
/>
</>
);
}
export default StockDetailPanel;