diff --git a/src/views/Community/components/StockDetailPanel.js b/src/views/Community/components/StockDetailPanel.js
index 8af9007b..ade3fd51 100644
--- a/src/views/Community/components/StockDetailPanel.js
+++ b/src/views/Community/components/StockDetailPanel.js
@@ -1,1068 +1,346 @@
// 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 { Drawer, Spin, Button, Alert } from 'antd';
+import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons';
import { Tabs as AntdTabs } from 'antd';
-import ReactDOM from 'react-dom';
+import moment from 'moment';
+
+// Services and Utils
+import { eventService } from '../../../services/eventService';
+import { logger } from '../../../utils/logger';
+import { getApiBase } from '../../../utils/apiConfig';
+
+// Custom Hooks
+import { useSubscription } from '../../../hooks/useSubscription';
+import { useEventStocks } from './hooks/useEventStocks';
+import { useWatchlist } from './hooks/useWatchlist';
+import { useStockMonitoring } from './hooks/useStockMonitoring';
+
+// Components
+import { RelatedStocksTab, LockedContent } from './components';
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 StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
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 (
-
- );
-};
+// Styles
+import './StockDetailPanel.css';
+/**
+ * 股票详情 Drawer 组件
+ * 显示事件相关的股票、概念、历史事件、传导链等信息
+ *
+ * @param {boolean} visible - 是否显示
+ * @param {Object} event - 事件对象
+ * @param {Function} onClose - 关闭回调
+ */
function StockDetailPanel({ visible, event, onClose }) {
- logger.debug('StockDetailPanel', '组件加载', {
- visible,
- eventId: event?.id,
- eventTitle: event?.title
+ logger.debug('StockDetailPanel', '组件加载', {
+ visible,
+ eventId: event?.id,
+ eventTitle: event?.title
+ });
+
+ // ==================== Hooks ====================
+
+ // 权限控制
+ const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
+
+ // 事件数据管理 (Redux + Hooks)
+ const {
+ stocks,
+ stocksWithQuotes,
+ quotes,
+ eventDetail,
+ historicalEvents,
+ chainAnalysis,
+ expectationScore,
+ loading,
+ refreshAllData,
+ refreshQuotes
+ } = useEventStocks(event?.id, event?.start_time);
+
+ // 自选股管理
+ const {
+ watchlistSet,
+ toggleWatchlist
+ } = useWatchlist();
+
+ // 实时监控管理
+ const {
+ isMonitoring,
+ toggleMonitoring,
+ manualRefresh: refreshMonitoring
+ } = useStockMonitoring(stocks, event?.start_time);
+
+ // ==================== Local State ====================
+
+ const [activeTab, setActiveTab] = useState('stocks');
+ const [searchText, setSearchText] = useState('');
+ const [filteredStocks, setFilteredStocks] = useState([]);
+ const [fixedCharts, setFixedCharts] = useState([]);
+ const [discussionModalVisible, setDiscussionModalVisible] = useState(false);
+ const [discussionType, setDiscussionType] = useState('事件讨论');
+ const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
+ const [upgradeFeature, setUpgradeFeature] = useState('');
+
+ // ==================== Effects ====================
+
+ // 过滤股票列表
+ useEffect(() => {
+ if (!searchText.trim()) {
+ setFilteredStocks(stocks);
+ } else {
+ const filtered = stocks.filter(stock =>
+ stock.stock_code.toLowerCase().includes(searchText.toLowerCase()) ||
+ stock.stock_name.toLowerCase().includes(searchText.toLowerCase())
+ );
+ setFilteredStocks(filtered);
+ }
+ }, [searchText, stocks]);
+
+ // ==================== Event Handlers ====================
+
+ // 搜索处理
+ const handleSearch = useCallback((value) => {
+ setSearchText(value);
+ }, []);
+
+ // 刷新数据
+ const handleRefresh = useCallback(() => {
+ logger.debug('StockDetailPanel', '手动刷新数据');
+ refreshAllData();
+ refreshQuotes();
+ }, [refreshAllData, refreshQuotes]);
+
+ // 切换监控
+ const handleMonitoringToggle = useCallback(() => {
+ toggleMonitoring();
+ }, [toggleMonitoring]);
+
+ // 自选股切换
+ const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
+ const stockName = stocks.find(s => s.stock_code === stockCode)?.stock_name || '';
+ await toggleWatchlist(stockCode, stockName);
+ }, [stocks, toggleWatchlist]);
+
+ // 行点击 - 显示固定图表
+ const handleRowClick = useCallback((stock) => {
+ setFixedCharts((prev) => {
+ if (prev.find(item => item.stock.stock_code === stock.stock_code)) return prev;
+ return [...prev, { stock, chartType: 'timeline' }];
});
+ }, []);
- // 权限控制
- const { hasFeatureAccess, getRequiredLevel, getUpgradeRecommendation } = useSubscription();
- const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
- const [upgradeFeature, setUpgradeFeature] = useState('');
+ // 移除固定图表
+ const handleUnfixChart = useCallback((stock) => {
+ setFixedCharts((prev) => prev.filter(item => item.stock.stock_code !== stock.stock_code));
+ }, []);
- // 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()); // 自选股列表
+ // 权限检查和升级提示
+ const handleUpgradeClick = useCallback((featureName) => {
+ const recommendation = getUpgradeRecommendation(featureName);
+ setUpgradeFeature(recommendation?.required || 'pro');
+ setUpgradeModalOpen(true);
+ }, [getUpgradeRecommendation]);
- // 清理函数
- 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 (
-
-
- : }
- onClick={(e) => {
- e.stopPropagation();
- handleWatchlistToggle(record.stock_code, isInWatchlist);
- }}
- style={{ minWidth: '70px' }}
- >
- {isInWatchlist ? '已关注' : '加自选'}
-
-
- );
- },
- },
- ], [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
- />
-
-
- }
- onClick={handleRefresh}
- loading={loading}
- className="refresh-button"
- />
-
-
-
- {/* 股票列表 */}
-
-
- {/* 固定图表 */}
- {fixedCharts.map(({ stock }, index) =>
-
- {renderCharts(stock, 'timeline', () => handleUnfixChart(stock), true)}
-
- )}
-
- {/* 讨论按钮 */}
-
- }
- onClick={() => {
- setDiscussionType('事件讨论');
- setDiscussionModalVisible(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', '传导链分析')
- }
- ];
+ // 渲染锁定内容
+ const renderLockedContent = useCallback((featureName, description) => {
+ const recommendation = getUpgradeRecommendation(featureName);
+ const isProRequired = recommendation?.required === 'pro';
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' ? '相关分析功能' : '传导链分析'}
- />
- >
+ handleUpgradeClick(featureName)}
+ />
);
+ }, [getUpgradeRecommendation, handleUpgradeClick]);
+
+ // 渲染固定图表
+ const renderFixedCharts = useMemo(() => {
+ if (fixedCharts.length === 0) return null;
+
+ const formattedEventTime = event?.start_time
+ ? moment(event.start_time).format('YYYY-MM-DD HH:mm')
+ : undefined;
+
+ return fixedCharts.map(({ stock }, index) => (
+
+ handleUnfixChart(stock)}
+ stock={stock}
+ eventTime={formattedEventTime}
+ fixed={true}
+ width={800}
+ />
+
+ ));
+ }, [fixedCharts, event, handleUnfixChart]);
+
+ // ==================== Tab Items ====================
+
+ const tabItems = useMemo(() => [
+ {
+ key: 'stocks',
+ label: (
+
+ 相关标的
+ {!hasFeatureAccess('related_stocks') && (
+
+ )}
+
+ ),
+ children: hasFeatureAccess('related_stocks') ? (
+ {
+ setDiscussionType('事件讨论');
+ setDiscussionModalVisible(true);
+ }}
+ fixedChartsContent={renderFixedCharts}
+ />
+ ) : renderLockedContent('related_stocks', '相关标的')
+ },
+ {
+ key: 'concepts',
+ label: (
+
+ 相关概念
+ {!hasFeatureAccess('related_concepts') && (
+
+ )}
+
+ ),
+ children: hasFeatureAccess('related_concepts') ? (
+
+
+
+ ) : renderLockedContent('related_concepts', '相关概念')
+ },
+ {
+ key: 'historical',
+ label: (
+
+ 历史事件对比
+ {!hasFeatureAccess('historical_events_full') && (
+
+ )}
+
+ ),
+ children: hasFeatureAccess('historical_events_full') ? (
+
+
+
+ ) : renderLockedContent('historical_events_full', '历史事件对比')
+ },
+ {
+ key: 'chain',
+ label: (
+
+ 传导链分析
+ {!hasFeatureAccess('transmission_chain') && (
+
+ )}
+
+ ),
+ children: hasFeatureAccess('transmission_chain') ? (
+
+ ) : renderLockedContent('transmission_chain', '传导链分析')
+ }
+ ], [
+ hasFeatureAccess,
+ filteredStocks,
+ quotes,
+ event,
+ watchlistSet,
+ searchText,
+ loading,
+ isMonitoring,
+ eventDetail,
+ historicalEvents,
+ handleSearch,
+ handleRefresh,
+ handleMonitoringToggle,
+ handleWatchlistToggle,
+ handleRowClick,
+ renderFixedCharts,
+ renderLockedContent
+ ]);
+
+ // ==================== Render ====================
+
+ 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;
\ No newline at end of file
+export default StockDetailPanel;