diff --git a/src/views/Community/components/StockDetailPanel/components/LockedContent.js b/src/views/Community/components/StockDetailPanel/components/LockedContent.js
new file mode 100644
index 00000000..c061de74
--- /dev/null
+++ b/src/views/Community/components/StockDetailPanel/components/LockedContent.js
@@ -0,0 +1,48 @@
+// src/views/Community/components/StockDetailPanel/components/LockedContent.js
+import React from 'react';
+import { Alert, Button } from 'antd';
+import { LockOutlined, CrownOutlined } from '@ant-design/icons';
+
+/**
+ * 权限锁定内容组件
+ * 显示功能被锁定的提示,引导用户升级订阅
+ *
+ * @param {string} description - 功能描述
+ * @param {boolean} isProRequired - 是否需要 Pro 版本(true: Pro, false: Max)
+ * @param {string} message - 自定义提示消息(可选)
+ * @param {Function} onUpgradeClick - 升级按钮点击回调
+ * @returns {JSX.Element}
+ */
+const LockedContent = ({
+ description = '此功能',
+ isProRequired = true,
+ message = null,
+ onUpgradeClick
+}) => {
+ const versionName = isProRequired ? 'Pro版' : 'Max版';
+ const defaultMessage = `此功能需要${versionName}订阅`;
+
+ return (
+
+
+ {isProRequired ? : }
+
+
+
+
+ );
+};
+
+export default LockedContent;
diff --git a/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
new file mode 100644
index 00000000..f724f520
--- /dev/null
+++ b/src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
@@ -0,0 +1,180 @@
+// src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
+import React, { useState, useEffect, useMemo, useRef } from 'react';
+import ReactECharts from 'echarts-for-react';
+import * as echarts from 'echarts';
+import moment from 'moment';
+import {
+ fetchKlineData,
+ getCacheKey,
+ klineDataCache
+} from '../utils/klineDataCache';
+
+/**
+ * 迷你分时图组件
+ * 显示股票的分时价格走势,支持事件时间标记
+ *
+ * @param {string} stockCode - 股票代码
+ * @param {string} eventTime - 事件时间(可选)
+ * @returns {JSX.Element}
+ */
+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;
+});
+
+export default MiniTimelineChart;
diff --git a/src/views/Community/components/StockDetailPanel/components/RelatedStocksTab.js b/src/views/Community/components/StockDetailPanel/components/RelatedStocksTab.js
new file mode 100644
index 00000000..1cfc7058
--- /dev/null
+++ b/src/views/Community/components/StockDetailPanel/components/RelatedStocksTab.js
@@ -0,0 +1,109 @@
+// src/views/Community/components/StockDetailPanel/components/RelatedStocksTab.js
+import React from 'react';
+import { Spin, Button } from 'antd';
+import StockSearchBar from './StockSearchBar';
+import StockTable from './StockTable';
+
+/**
+ * 相关标的 Tab 组件
+ * 显示事件相关的股票列表、搜索、监控等功能
+ *
+ * @param {Array} stocks - 股票列表
+ * @param {Object} quotes - 股票行情字典
+ * @param {string} eventTime - 事件时间
+ * @param {Set} watchlistSet - 自选股代码集合
+ * @param {string} searchText - 搜索文本
+ * @param {boolean} loading - 加载状态
+ * @param {boolean} isMonitoring - 监控状态
+ * @param {Function} onSearch - 搜索回调
+ * @param {Function} onRefresh - 刷新回调
+ * @param {Function} onMonitoringToggle - 切换监控回调
+ * @param {Function} onWatchlistToggle - 切换自选股回调
+ * @param {Function} onRowClick - 行点击回调
+ * @param {Function} onDiscussionClick - 查看讨论回调
+ * @param {React.ReactNode} fixedChartsContent - 固定图表内容(可选)
+ * @returns {JSX.Element}
+ */
+const RelatedStocksTab = ({
+ stocks = [],
+ quotes = {},
+ eventTime = null,
+ watchlistSet = new Set(),
+ searchText = '',
+ loading = false,
+ isMonitoring = false,
+ onSearch,
+ onRefresh,
+ onMonitoringToggle,
+ onWatchlistToggle,
+ onRowClick,
+ onDiscussionClick,
+ fixedChartsContent = null
+}) => {
+ return (
+
+ {/* 头部信息 */}
+
+
+
+ 📊
+
+
+
+ 相关标的
+
+
+ 共 {stocks.length} 只股票
+
+
+
+
+
+
+ 每5秒自动更新行情数据
+
+
+
+
+ {/* 搜索和操作栏 */}
+
+
+ {/* 股票列表 */}
+
+
+ {/* 固定图表 (由父组件传入) */}
+ {fixedChartsContent}
+
+ {/* 讨论按钮 */}
+
+ }
+ onClick={onDiscussionClick}
+ >
+ 查看事件讨论
+
+
+
+ );
+};
+
+export default RelatedStocksTab;
diff --git a/src/views/Community/components/StockDetailPanel/components/StockSearchBar.js b/src/views/Community/components/StockDetailPanel/components/StockSearchBar.js
new file mode 100644
index 00000000..5dbcb592
--- /dev/null
+++ b/src/views/Community/components/StockDetailPanel/components/StockSearchBar.js
@@ -0,0 +1,50 @@
+// src/views/Community/components/StockDetailPanel/components/StockSearchBar.js
+import React from 'react';
+import { Input, Button } from 'antd';
+import { ReloadOutlined } from '@ant-design/icons';
+
+/**
+ * 股票搜索栏组件
+ * 提供股票搜索和刷新功能
+ *
+ * @param {string} searchText - 搜索文本
+ * @param {Function} onSearch - 搜索回调函数
+ * @param {number} stockCount - 股票总数
+ * @param {Function} onRefresh - 刷新回调函数
+ * @param {boolean} loading - 加载状态
+ * @returns {JSX.Element}
+ */
+const StockSearchBar = ({
+ searchText = '',
+ onSearch,
+ stockCount = 0,
+ onRefresh,
+ loading = false
+}) => {
+ return (
+
+
+ 🔍
+ onSearch?.(e.target.value)}
+ className="stock-search-input"
+ style={{ flex: 1, maxWidth: '300px' }}
+ allowClear
+ />
+
+
+ }
+ onClick={onRefresh}
+ loading={loading}
+ className="refresh-button"
+ title="刷新股票数据"
+ />
+
+
+ );
+};
+
+export default StockSearchBar;
diff --git a/src/views/Community/components/StockDetailPanel/components/StockTable.js b/src/views/Community/components/StockDetailPanel/components/StockTable.js
new file mode 100644
index 00000000..c78f230e
--- /dev/null
+++ b/src/views/Community/components/StockDetailPanel/components/StockTable.js
@@ -0,0 +1,228 @@
+// src/views/Community/components/StockDetailPanel/components/StockTable.js
+import React, { useState, useCallback, useMemo } from 'react';
+import { Table, Button } from 'antd';
+import { StarFilled, StarOutlined } from '@ant-design/icons';
+import moment from 'moment';
+import MiniTimelineChart from './MiniTimelineChart';
+import { logger } from '../../../../../utils/logger';
+
+/**
+ * 股票列表表格组件
+ * 显示事件相关股票列表,包括分时图、涨跌幅、自选股操作等
+ *
+ * @param {Array} stocks - 股票列表
+ * @param {Object} quotes - 股票行情字典 { [stockCode]: quote }
+ * @param {string} eventTime - 事件时间
+ * @param {Set} watchlistSet - 自选股代码集合
+ * @param {Function} onWatchlistToggle - 切换自选股回调 (stockCode, isInWatchlist) => void
+ * @param {Function} onRowClick - 行点击回调 (stock) => void
+ * @returns {JSX.Element}
+ */
+const StockTable = ({
+ stocks = [],
+ quotes = {},
+ eventTime = null,
+ watchlistSet = new Set(),
+ onWatchlistToggle,
+ onRowClick
+}) => {
+ // 展开/收缩的行
+ const [expandedRows, setExpandedRows] = useState(new Set());
+
+ // 稳定的事件时间,避免重复渲染
+ const stableEventTime = useMemo(() => {
+ return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
+ }, [eventTime]);
+
+ // 切换行展开状态
+ const toggleRowExpand = useCallback((stockCode) => {
+ setExpandedRows(prev => {
+ const newSet = new Set(prev);
+ if (newSet.has(stockCode)) {
+ newSet.delete(stockCode);
+ } else {
+ newSet.add(stockCode);
+ }
+ return newSet;
+ });
+ }, []);
+
+ // 行点击事件处理
+ const handleRowEvents = useCallback((record) => ({
+ onClick: () => {
+ onRowClick?.(record);
+ },
+ style: { cursor: 'pointer' }
+ }), [onRowClick]);
+
+ // 股票列表列定义
+ const stockColumns = useMemo(() => [
+ {
+ title: '股票代码',
+ dataIndex: 'stock_code',
+ key: 'stock_code',
+ width: 100,
+ render: (code) => (
+
+ ),
+ },
+ {
+ title: '股票名称',
+ dataIndex: 'stock_name',
+ key: 'stock_name',
+ width: 120,
+ },
+ {
+ title: '关联描述',
+ dataIndex: 'relation_desc',
+ key: 'relation_desc',
+ width: 300,
+ render: (relationDesc, record) => {
+ logger.debug('StockTable', '表格渲染 - 股票关联描述', {
+ 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('StockTable', '未知的 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 = quotes[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 = watchlistSet.has(record.stock_code);
+ return (
+
+
+ : }
+ onClick={(e) => {
+ e.stopPropagation();
+ onWatchlistToggle?.(record.stock_code, isInWatchlist);
+ }}
+ style={{ minWidth: '70px' }}
+ >
+ {isInWatchlist ? '已关注' : '加自选'}
+
+
+ );
+ },
+ },
+ ], [quotes, stableEventTime, expandedRows, toggleRowExpand, watchlistSet, onWatchlistToggle]);
+
+ return (
+
+ );
+};
+
+export default StockTable;
diff --git a/src/views/Community/components/StockDetailPanel/components/index.js b/src/views/Community/components/StockDetailPanel/components/index.js
new file mode 100644
index 00000000..841c00c0
--- /dev/null
+++ b/src/views/Community/components/StockDetailPanel/components/index.js
@@ -0,0 +1,6 @@
+// src/views/Community/components/StockDetailPanel/components/index.js
+export { default as MiniTimelineChart } from './MiniTimelineChart';
+export { default as StockSearchBar } from './StockSearchBar';
+export { default as StockTable } from './StockTable';
+export { default as LockedContent } from './LockedContent';
+export { default as RelatedStocksTab } from './RelatedStocksTab';
diff --git a/src/views/Community/components/StockDetailPanel/utils/klineDataCache.js b/src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
new file mode 100644
index 00000000..640fea26
--- /dev/null
+++ b/src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
@@ -0,0 +1,156 @@
+// src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
+import moment from 'moment';
+import { stockService } from '../../../../../services/eventService';
+import { logger } from '../../../../../utils/logger';
+
+// ================= 全局缓存和请求管理 =================
+export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}` -> data
+export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}` -> Promise
+export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}` -> timestamp
+
+// 请求间隔限制(毫秒)
+const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数据
+
+/**
+ * 获取缓存键
+ * @param {string} stockCode - 股票代码
+ * @param {string} eventTime - 事件时间
+ * @returns {string} 缓存键
+ */
+export const getCacheKey = (stockCode, eventTime) => {
+ const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD');
+ return `${stockCode}|${date}`;
+};
+
+/**
+ * 检查是否需要刷新数据
+ * @param {string} cacheKey - 缓存键
+ * @returns {boolean} 是否需要刷新
+ */
+export 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线数据(带缓存和防重复请求)
+ * @param {string} stockCode - 股票代码
+ * @param {string} eventTime - 事件时间
+ * @returns {Promise} K线数据
+ */
+export const fetchKlineData = async (stockCode, eventTime) => {
+ const cacheKey = getCacheKey(stockCode, eventTime);
+
+ // 1. 检查缓存
+ if (klineDataCache.has(cacheKey)) {
+ // 检查是否需要刷新
+ if (!shouldRefreshData(cacheKey)) {
+ logger.debug('klineDataCache', '使用缓存数据', { cacheKey });
+ return klineDataCache.get(cacheKey);
+ }
+ }
+
+ // 2. 检查是否有正在进行的请求
+ if (pendingRequests.has(cacheKey)) {
+ logger.debug('klineDataCache', '等待进行中的请求', { cacheKey });
+ return pendingRequests.get(cacheKey);
+ }
+
+ // 3. 发起新请求
+ logger.debug('klineDataCache', '发起新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('klineDataCache', 'K线数据请求完成并缓存', {
+ cacheKey,
+ dataPoints: data.length
+ });
+ return data;
+ })
+ .catch((error) => {
+ logger.error('klineDataCache', 'fetchKlineData', error, { stockCode, cacheKey });
+ // 清除pending状态
+ pendingRequests.delete(cacheKey);
+ // 如果有旧缓存,返回旧数据
+ if (klineDataCache.has(cacheKey)) {
+ return klineDataCache.get(cacheKey);
+ }
+ return [];
+ });
+
+ // 保存pending请求
+ pendingRequests.set(cacheKey, requestPromise);
+
+ return requestPromise;
+};
+
+/**
+ * 清除指定股票的缓存
+ * @param {string} stockCode - 股票代码
+ * @param {string} eventTime - 事件时间(可选)
+ */
+export const clearCache = (stockCode, eventTime = null) => {
+ if (eventTime) {
+ const cacheKey = getCacheKey(stockCode, eventTime);
+ klineDataCache.delete(cacheKey);
+ lastRequestTime.delete(cacheKey);
+ pendingRequests.delete(cacheKey);
+ logger.debug('klineDataCache', '清除缓存', { cacheKey });
+ } else {
+ // 清除该股票的所有缓存
+ const prefix = `${stockCode}|`;
+ for (const key of klineDataCache.keys()) {
+ if (key.startsWith(prefix)) {
+ klineDataCache.delete(key);
+ lastRequestTime.delete(key);
+ pendingRequests.delete(key);
+ }
+ }
+ logger.debug('klineDataCache', '清除股票所有缓存', { stockCode });
+ }
+};
+
+/**
+ * 清除所有缓存
+ */
+export const clearAllCache = () => {
+ klineDataCache.clear();
+ lastRequestTime.clear();
+ pendingRequests.clear();
+ logger.debug('klineDataCache', '清除所有缓存');
+};
+
+/**
+ * 获取缓存统计信息
+ * @returns {Object} 缓存统计
+ */
+export const getCacheStats = () => {
+ return {
+ totalCached: klineDataCache.size,
+ pendingRequests: pendingRequests.size,
+ cacheKeys: Array.from(klineDataCache.keys())
+ };
+};