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} + + {/* 讨论按钮 */} +
+ +
+
+ ); +}; + +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 + /> +
+
+
+
+ ); +}; + +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 ( +
+ + +
+ ); + }, + }, + ], [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()) + }; +};