refactor(StockDetailPanel): 提取5个UI组件和工具函数
**新增组件**:
- MiniTimelineChart.js (175行) - K线分时图组件
- StockSearchBar.js (50行) - 股票搜索栏
- StockTable.js (230行) - 股票列表表格
- LockedContent.js (50行) - 权限锁定提示
- RelatedStocksTab.js (110行) - 关联股票Tab
**新增工具**:
- klineDataCache.js (160行) - K线数据缓存管理
- 智能刷新策略:交易时段30秒,非交易时段1小时
- 请求去重机制
✨ 特性:
- 保持100%原有功能
- 遵循单一职责原则
- 支持组件复用
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||||
|
<div style={{ padding: '40px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px', opacity: 0.3 }}>
|
||||||
|
{isProRequired ? <LockOutlined /> : <CrownOutlined />}
|
||||||
|
</div>
|
||||||
|
<Alert
|
||||||
|
message={`${description}功能已锁定`}
|
||||||
|
description={message || defaultMessage}
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ maxWidth: '400px', margin: '0 auto', marginBottom: '24px' }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
onClick={onUpgradeClick}
|
||||||
|
>
|
||||||
|
升级到 {versionName}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LockedContent;
|
||||||
@@ -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 (
|
||||||
|
<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;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MiniTimelineChart;
|
||||||
@@ -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 (
|
||||||
|
<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">
|
||||||
|
共 {stocks.length} 只股票
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 8 }}>
|
||||||
|
<Button
|
||||||
|
className={`monitoring-button ${isMonitoring ? 'monitoring' : ''}`}
|
||||||
|
onClick={onMonitoringToggle}
|
||||||
|
>
|
||||||
|
{isMonitoring ? '停止监控' : '实时监控'}
|
||||||
|
</Button>
|
||||||
|
<div style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.8)' }}>
|
||||||
|
每5秒自动更新行情数据
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索和操作栏 */}
|
||||||
|
<StockSearchBar
|
||||||
|
searchText={searchText}
|
||||||
|
onSearch={onSearch}
|
||||||
|
stockCount={stocks.length}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 股票列表 */}
|
||||||
|
<StockTable
|
||||||
|
stocks={stocks}
|
||||||
|
quotes={quotes}
|
||||||
|
eventTime={eventTime}
|
||||||
|
watchlistSet={watchlistSet}
|
||||||
|
onWatchlistToggle={onWatchlistToggle}
|
||||||
|
onRowClick={onRowClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 固定图表 (由父组件传入) */}
|
||||||
|
{fixedChartsContent}
|
||||||
|
|
||||||
|
{/* 讨论按钮 */}
|
||||||
|
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<Button.Group />}
|
||||||
|
onClick={onDiscussionClick}
|
||||||
|
>
|
||||||
|
查看事件讨论
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RelatedStocksTab;
|
||||||
@@ -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 (
|
||||||
|
<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) => onSearch?.(e.target.value)}
|
||||||
|
className="stock-search-input"
|
||||||
|
style={{ flex: 1, maxWidth: '300px' }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="action-buttons">
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={onRefresh}
|
||||||
|
loading={loading}
|
||||||
|
className="refresh-button"
|
||||||
|
title="刷新股票数据"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockSearchBar;
|
||||||
@@ -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) => (
|
||||||
|
<Button type="link">{code}</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<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 = quotes[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 = watchlistSet.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();
|
||||||
|
onWatchlistToggle?.(record.stock_code, isInWatchlist);
|
||||||
|
}}
|
||||||
|
style={{ minWidth: '70px' }}
|
||||||
|
>
|
||||||
|
{isInWatchlist ? '已关注' : '加自选'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
], [quotes, stableEventTime, expandedRows, toggleRowExpand, watchlistSet, onWatchlistToggle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<Table
|
||||||
|
columns={stockColumns}
|
||||||
|
dataSource={stocks}
|
||||||
|
rowKey="stock_code"
|
||||||
|
onRow={handleRowEvents}
|
||||||
|
pagination={false}
|
||||||
|
size="middle"
|
||||||
|
bordered
|
||||||
|
scroll={{ x: 920 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StockTable;
|
||||||
@@ -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';
|
||||||
@@ -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<Array>} 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())
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user