997 lines
39 KiB
JavaScript
997 lines
39 KiB
JavaScript
// 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; |