chore: 删除未使用的 StockDetailPanel 组件

- 删除 StockDetailPanel 主组件及样式(未被任何地方引用)
- 删除仅被主组件使用的 hooks: useWatchlist, useStockMonitoring
- 删除仅被主组件使用的子组件: RelatedStocksTab, LockedContent, StockSearchBar, StockTable
- 保留被其他模块使用的: klineDataCache, useEventStocks, MiniTimelineChart
- 更新 components/index.js 导出

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zdl
2025-12-08 11:19:29 +08:00
parent 62bcf15cdf
commit b4ddccfb92
9 changed files with 0 additions and 1439 deletions

View File

@@ -1,235 +0,0 @@
/* Drawer root */
.stock-detail-panel .ant-drawer-body {
padding: 24px 16px;
background: #ffffff;
}
/* Card common style */
.stock-detail-panel .ant-card {
border-radius: 8px;
}
.stock-detail-panel .ant-card-head-title {
font-weight: 600;
}
/* Stock list items */
.stock-item {
cursor: pointer;
transition: background-color 0.2s ease, border-left 0.2s ease;
border-left: 3px solid transparent;
padding-left: 4px; /* compensate for border shift */
}
.stock-item:hover {
background-color: #fafafa;
}
.stock-item.selected {
background-color: #e6f7ff;
border-left-color: #1890ff;
}
.stock-item .ant-list-item-meta-title {
font-size: 14px;
font-weight: 600;
}
.stock-item .ant-list-item-meta-description {
font-size: 12px;
line-height: 1.4;
}
.stock-item .ant-tag {
margin-left: 4px;
}
/* ReactECharts */
.stock-detail-panel .echarts-for-react {
width: 100%;
}
/* Card spacing */
.stock-detail-panel .ant-card:not(:last-child) {
margin-bottom: 16px;
}
/* Close icon */
.stock-detail-panel .anticon-close:hover {
color: #ff4d4f;
}
.row-hover {
background: #f5faff !important;
box-shadow: 0 2px 8px rgba(24,144,255,0.10);
transition: background 0.2s, box-shadow 0.2s;
z-index: 2;
}
/* 新增样式 - 相关标的Tab */
.stock-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
color: white;
}
.stock-header-icon {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.stock-search-bar {
background: #f8f9fa;
border-radius: 8px;
padding: 12px;
margin-bottom: 16px;
border: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.stock-search-input {
border-radius: 6px;
border: 1px solid #d9d9d9;
transition: all 0.3s;
max-width: 300px;
}
.stock-search-input:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.monitoring-button {
background: linear-gradient(135deg, #4caf50 0%, #45a049 100%);
border: none;
border-radius: 6px;
color: white;
font-weight: 500;
transition: all 0.3s;
padding: 4px 12px;
height: auto;
font-size: 12px;
}
.monitoring-button:hover {
background: linear-gradient(135deg, #45a049 0%, #3d8b40 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3);
}
.monitoring-button.monitoring {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
}
.monitoring-button.monitoring:hover {
background: linear-gradient(135deg, #ee5a52 0%, #d63031 100%);
}
.add-stock-button {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
border: none;
border-radius: 6px;
color: white;
font-weight: 500;
transition: all 0.3s;
padding: 4px 12px;
height: auto;
font-size: 12px;
}
.add-stock-button:hover {
background: linear-gradient(135deg, #096dd9 0%, #0050b3 100%);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
.refresh-button {
border-radius: 6px;
transition: all 0.3s;
padding: 4px 8px;
height: auto;
font-size: 12px;
}
.refresh-button:hover {
transform: rotate(180deg);
transition: transform 0.5s ease;
}
.monitoring-status {
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
border: 1px solid #4caf50;
border-radius: 6px;
padding: 8px 12px;
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.8; }
100% { opacity: 1; }
}
.stock-count {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}
.stock-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 4px;
}
/* 表格hover效果增强 */
.ant-table-tbody > tr.row-hover > td {
background: #f5faff !important;
border-color: #91d5ff;
}
.ant-table-tbody > tr.row-hover:hover > td {
background: #e6f7ff !important;
}
/* 搜索图标样式 */
.search-icon {
color: #666;
font-size: 16px;
margin-right: 8px;
}
/* 按钮组样式 */
.action-buttons {
display: flex;
gap: 8px;
align-items: center;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stock-search-bar {
flex-direction: column;
gap: 12px;
}
.action-buttons {
width: 100%;
justify-content: space-between;
}
}

View File

@@ -1,346 +0,0 @@
// src/views/Community/components/StockDetailPanel.js
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Drawer, Spin, Button, Alert } from 'antd';
import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons';
import { Tabs as AntdTabs } from 'antd';
import dayjs from 'dayjs';
// 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 './StockDetailPanel/hooks/useEventStocks';
import { useWatchlist } from './StockDetailPanel/hooks/useWatchlist';
import { useStockMonitoring } from './StockDetailPanel/hooks/useStockMonitoring';
// Components
import { RelatedStocksTab, LockedContent } from './StockDetailPanel/components';
import RelatedConcepts from '../../EventDetail/components/RelatedConcepts';
import HistoricalEvents from '../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../EventDetail/components/TransmissionChainAnalysis';
import EventDiscussionModal from './EventDiscussionModal';
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
import RiskDisclaimer from '../../../components/RiskDisclaimer';
// 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
});
// ==================== Hooks ====================
// 权限控制
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
// 事件数据管理 (Redux + Hooks)
const {
stocks,
stocksWithQuotes,
quotes,
eventDetail,
historicalEvents,
chainAnalysis,
expectationScore,
loading,
refreshAllData,
refreshQuotes
} = useEventStocks(event?.id, event?.start_time);
// 自选股管理(只在 Drawer 可见时加载)
const {
watchlistSet,
toggleWatchlist
} = useWatchlist(visible);
// 实时监控管理
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 handleUnfixChart = useCallback((stock) => {
setFixedCharts((prev) => prev.filter(item => item.stock.stock_code !== stock.stock_code));
}, []);
// 权限检查和升级提示
const handleUpgradeClick = useCallback((featureName) => {
const recommendation = getUpgradeRecommendation(featureName);
setUpgradeFeature(recommendation?.required || 'pro');
setUpgradeModalOpen(true);
}, [getUpgradeRecommendation]);
// 渲染锁定内容
const renderLockedContent = useCallback((featureName, description) => {
const recommendation = getUpgradeRecommendation(featureName);
const isProRequired = recommendation?.required === 'pro';
return (
<LockedContent
description={description}
isProRequired={isProRequired}
message={recommendation?.message}
onUpgradeClick={() => handleUpgradeClick(featureName)}
/>
);
}, [getUpgradeRecommendation, handleUpgradeClick]);
// 渲染固定图表
const renderFixedCharts = useMemo(() => {
if (fixedCharts.length === 0) return null;
const formattedEventTime = event?.start_time
? dayjs(event.start_time).format('YYYY-MM-DD HH:mm')
: undefined;
return fixedCharts.map(({ stock }, index) => (
<div key={`fixed-chart-${stock.stock_code}-${index}`}>
<StockChartAntdModal
open={true}
onCancel={() => handleUnfixChart(stock)}
stock={stock}
eventTime={formattedEventTime}
fixed={true}
width={800}
/>
</div>
));
}, [fixedCharts, event, handleUnfixChart]);
// ==================== Tab Items ====================
const tabItems = useMemo(() => [
{
key: 'stocks',
label: (
<span>
相关标的
{!hasFeatureAccess('related_stocks') && (
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
)}
</span>
),
children: hasFeatureAccess('related_stocks') ? (
<RelatedStocksTab
stocks={filteredStocks}
quotes={quotes}
eventTime={event?.start_time}
watchlistSet={watchlistSet}
searchText={searchText}
loading={loading.stocks || loading.quotes}
isMonitoring={isMonitoring}
onSearch={handleSearch}
onRefresh={handleRefresh}
onMonitoringToggle={handleMonitoringToggle}
onWatchlistToggle={handleWatchlistToggle}
onRowClick={handleRowClick}
onDiscussionClick={() => {
setDiscussionType('事件讨论');
setDiscussionModalVisible(true);
}}
fixedChartsContent={renderFixedCharts}
/>
) : renderLockedContent('related_stocks', '相关标的')
},
{
key: 'concepts',
label: (
<span>
相关概念
{!hasFeatureAccess('related_concepts') && (
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
)}
</span>
),
children: hasFeatureAccess('related_concepts') ? (
<Spin spinning={loading.eventDetail}>
<RelatedConcepts
eventTitle={event?.title}
eventDetail={eventDetail}
eventService={eventService}
/>
</Spin>
) : renderLockedContent('related_concepts', '相关概念')
},
{
key: 'historical',
label: (
<span>
历史事件对比
{!hasFeatureAccess('historical_events_full') && (
<LockOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
)}
</span>
),
children: hasFeatureAccess('historical_events_full') ? (
<Spin spinning={loading.historicalEvents}>
<HistoricalEvents
eventId={event?.id}
eventTitle={event?.title}
historicalEvents={historicalEvents}
eventService={eventService}
/>
</Spin>
) : renderLockedContent('historical_events_full', '历史事件对比')
},
{
key: 'chain',
label: (
<span>
传导链分析
{!hasFeatureAccess('transmission_chain') && (
<CrownOutlined style={{ marginLeft: '4px', fontSize: '12px', opacity: 0.6 }} />
)}
</span>
),
children: hasFeatureAccess('transmission_chain') ? (
<TransmissionChainAnalysis
eventId={event?.id}
eventService={eventService}
/>
) : renderLockedContent('transmission_chain', '传导链分析')
}
], [
hasFeatureAccess,
filteredStocks,
quotes,
event,
watchlistSet,
searchText,
loading,
isMonitoring,
eventDetail,
historicalEvents,
handleSearch,
handleRefresh,
handleMonitoringToggle,
handleWatchlistToggle,
handleRowClick,
renderFixedCharts,
renderLockedContent
]);
// ==================== Render ====================
return (
<>
<Drawer
title={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>{event?.title}</span>
<CloseOutlined onClick={onClose} style={{ cursor: 'pointer' }} />
</div>
}
placement="right"
width={900}
open={visible}
onClose={onClose}
closable={false}
className="stock-detail-panel"
>
<AntdTabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
{/* 风险提示 */}
<div style={{ marginTop: '24px', paddingBottom: '20px' }}>
<RiskDisclaimer variant="default" />
</div>
</Drawer>
{/* 事件讨论模态框 */}
<EventDiscussionModal
isOpen={discussionModalVisible}
onClose={() => setDiscussionModalVisible(false)}
eventId={event?.id}
eventTitle={event?.title}
discussionType={discussionType}
/>
{/* 订阅升级模态框 */}
<SubscriptionUpgradeModal
isOpen={upgradeModalOpen}
onClose={() => setUpgradeModalOpen(false)}
requiredLevel={upgradeFeature}
featureName={upgradeFeature === 'pro' ? '相关分析功能' : '传导链分析'}
/>
</>
);
}
export default StockDetailPanel;

View File

@@ -1,48 +0,0 @@
// 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;

View File

@@ -1,109 +0,0 @@
// 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;

View File

@@ -1,50 +0,0 @@
// 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;

View File

@@ -1,325 +0,0 @@
// src/views/Community/components/StockDetailPanel/components/StockTable.js
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { Table, Button } from 'antd';
import { StarFilled, StarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import MiniTimelineChart from './MiniTimelineChart';
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '../utils/klineDataCache';
import { logger } from '../../../../../utils/logger';
/**
* 标准化股票代码为6位格式
* @param {string} code - 股票代码
* @returns {string} 6位标准化代码
*/
const normalizeStockCode = (code) => {
if (!code) return '';
const s = String(code).trim();
const m = s.match(/(\d{6})/);
return m ? m[1] : s;
};
/**
* 股票列表表格组件
* 显示事件相关股票列表,包括分时图、涨跌幅、自选股操作等
*
* @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());
// K线数据状态{ [stockCode]: data[] }
const [klineDataMap, setKlineDataMap] = useState({});
const [klineLoading, setKlineLoading] = useState(false);
// 用于追踪当前正在加载的 stocksKey解决时序问题
const [loadingStocksKey, setLoadingStocksKey] = useState('');
// 稳定的事件时间,避免重复渲染
const stableEventTime = useMemo(() => {
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]);
// 批量加载K线数据
// 使用 stocks 的 JSON 字符串作为依赖项的 key避免引用变化导致重复加载
const stocksKey = useMemo(() => {
return stocks.map(s => s.stock_code).sort().join(',');
}, [stocks]);
// 计算是否应该显示 loading当前 stocksKey 和 loadingStocksKey 不匹配,或者正在加载
// 这样可以在 stocks 变化时立即显示 loading不需要等 useEffect
const shouldShowLoading = useMemo(() => {
if (stocks.length === 0) return false;
// 如果 stocksKey 变化了但 klineDataMap 还没更新,说明需要加载
const currentDataKeys = Object.keys(klineDataMap).sort().join(',');
if (stocksKey !== currentDataKeys && stocksKey !== loadingStocksKey) {
return true;
}
return klineLoading;
}, [stocks.length, stocksKey, klineDataMap, loadingStocksKey, klineLoading]);
useEffect(() => {
if (stocks.length === 0) {
setKlineDataMap({});
setKlineLoading(false);
setLoadingStocksKey('');
return;
}
// 立即设置 loading 状态和正在加载的 key
setKlineLoading(true);
setLoadingStocksKey(stocksKey);
const stockCodes = stocks.map(s => s.stock_code);
// 先检查缓存,只请求未缓存的
const cachedData = {};
const uncachedCodes = [];
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, stableEventTime, 'timeline');
const cached = klineDataCache.get(cacheKey);
if (cached !== undefined) {
cachedData[code] = cached;
} else {
uncachedCodes.push(code);
}
});
// 如果全部缓存命中,直接使用
if (uncachedCodes.length === 0) {
setKlineDataMap(cachedData);
setKlineLoading(false);
logger.debug('StockTable', 'K线数据全部来自缓存', { stockCount: stockCodes.length });
return;
}
logger.debug('StockTable', '批量加载K线数据', {
totalCount: stockCodes.length,
cachedCount: Object.keys(cachedData).length,
uncachedCount: uncachedCodes.length,
eventTime: stableEventTime
});
// 批量请求未缓存的数据
fetchBatchKlineData(stockCodes, stableEventTime, 'timeline')
.then((batchData) => {
// 合并缓存数据和新数据
setKlineDataMap({ ...cachedData, ...batchData });
setKlineLoading(false);
})
.catch((error) => {
logger.error('StockTable', '批量加载K线数据失败', error);
// 失败时使用已有的缓存数据
setKlineDataMap(cachedData);
setKlineLoading(false);
});
}, [stocksKey, stableEventTime]); // 使用 stocksKey 而非 stocks 对象引用
// 切换行展开状态
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}
preloadedData={klineDataMap[record.stock_code]}
loading={shouldShowLoading && !klineDataMap[record.stock_code]}
/>
),
},
{
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) => {
// 标准化代码后再比较,确保 600000.SH 和 600000 能匹配
const normalizedCode = normalizeStockCode(record.stock_code);
const isInWatchlist = watchlistSet.has(normalizedCode);
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, klineDataMap, shouldShowLoading]);
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;

View File

@@ -1,6 +1,2 @@
// src/views/Community/components/StockDetailPanel/components/index.js // src/views/Community/components/StockDetailPanel/components/index.js
export { default as MiniTimelineChart } from './MiniTimelineChart'; 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';

View File

@@ -1,159 +0,0 @@
// src/views/Community/components/StockDetailPanel/hooks/useStockMonitoring.js
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { useState, useEffect, useRef, useCallback } from 'react';
import { fetchStockQuotes } from '../../../../../store/slices/stockSlice';
import { message } from 'antd';
import { logger } from '../../../../../utils/logger';
/**
* 股票实时监控 Hook
* 提供定时刷新股票行情的功能
*
* @param {Array} stocks - 股票列表
* @param {string} eventTime - 事件时间
* @param {number} interval - 刷新间隔(毫秒),默认 5000ms
* @returns {Object} 监控状态和控制方法
*/
export const useStockMonitoring = (stocks = [], eventTime = null, interval = 5000) => {
const dispatch = useDispatch();
const [isMonitoring, setIsMonitoring] = useState(false);
const monitoringIntervalRef = useRef(null);
// 从 Redux 获取行情数据和加载状态
const quotes = useSelector(state => state.stock.quotes, shallowEqual);
const quotesLoading = useSelector(state => state.stock.loading.quotes);
/**
* 执行一次行情更新
*/
const updateQuotes = useCallback(() => {
if (stocks.length === 0) {
logger.warn('useStockMonitoring', '股票列表为空,跳过更新');
return;
}
const codes = stocks.map(s => s.stock_code);
logger.debug('useStockMonitoring', '更新行情数据', {
stockCount: codes.length,
eventTime,
timestamp: new Date().toISOString()
});
dispatch(fetchStockQuotes({ codes, eventTime }));
}, [dispatch, stocks, eventTime]);
/**
* 开启实时监控
*/
const startMonitoring = useCallback(() => {
if (isMonitoring) {
logger.warn('useStockMonitoring', '监控已经在运行中');
return;
}
if (stocks.length === 0) {
message.warning('暂无股票数据,无法开启监控');
return;
}
logger.info('useStockMonitoring', '开启实时监控', {
interval,
stockCount: stocks.length
});
setIsMonitoring(true);
message.success(`已开启实时监控,每${interval / 1000}秒自动更新`);
// 立即执行一次
updateQuotes();
}, [isMonitoring, stocks, interval, updateQuotes]);
/**
* 停止实时监控
*/
const stopMonitoring = useCallback(() => {
if (!isMonitoring) {
return;
}
logger.info('useStockMonitoring', '停止实时监控');
setIsMonitoring(false);
message.info('已停止实时监控');
}, [isMonitoring]);
/**
* 切换监控状态
*/
const toggleMonitoring = useCallback(() => {
if (isMonitoring) {
stopMonitoring();
} else {
startMonitoring();
}
}, [isMonitoring, startMonitoring, stopMonitoring]);
/**
* 手动刷新一次
*/
const manualRefresh = useCallback(() => {
logger.debug('useStockMonitoring', '手动刷新行情');
updateQuotes();
}, [updateQuotes]);
// 监控定时器效果
useEffect(() => {
// 清理旧的定时器
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current);
monitoringIntervalRef.current = null;
}
if (isMonitoring && stocks.length > 0) {
// 设置定时器
monitoringIntervalRef.current = setInterval(() => {
updateQuotes();
}, interval);
logger.debug('useStockMonitoring', '定时器已设置', {
interval,
stockCount: stocks.length
});
}
// 清理函数
return () => {
if (monitoringIntervalRef.current) {
clearInterval(monitoringIntervalRef.current);
monitoringIntervalRef.current = null;
logger.debug('useStockMonitoring', '定时器已清理');
}
};
}, [isMonitoring, stocks.length, interval]); // 注意:不依赖 updateQuotes避免重复创建定时器
// 组件卸载时自动停止监控
useEffect(() => {
return () => {
if (isMonitoring) {
logger.debug('useStockMonitoring', '组件卸载,自动停止监控');
setIsMonitoring(false);
}
};
}, []); // 只在卸载时执行
return {
// 状态
isMonitoring,
quotes,
quotesLoading,
// 控制方法
startMonitoring,
stopMonitoring,
toggleMonitoring,
manualRefresh,
// 工具方法
setIsMonitoring
};
};

View File

@@ -1,163 +0,0 @@
// src/views/Community/components/StockDetailPanel/hooks/useWatchlist.js
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { useEffect, useCallback, useMemo } from 'react';
import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '@store/slices/stockSlice';
import { message } from 'antd';
import { logger } from '@utils/logger';
/**
* 标准化股票代码为6位格式
* 支持: 600000, 600000.SH, 600000.SZ, SH600000 等格式
* @param {string} code - 股票代码
* @returns {string} 6位标准化代码
*/
const normalizeStockCode = (code) => {
if (!code) return '';
const s = String(code).trim().toUpperCase();
// 匹配6位数字可能带 .SH/.SZ 后缀)
const m1 = s.match(/^(\d{6})(?:\.(?:SH|SZ))?$/i);
if (m1) return m1[1];
// 匹配 SH/SZ 前缀格式
const m2 = s.match(/^(?:SH|SZ)(\d{6})$/i);
if (m2) return m2[1];
// 尝试提取任意6位数字
const m3 = s.match(/(\d{6})/);
if (m3) return m3[1];
return s;
};
/**
* 自选股管理 Hook
* 封装自选股的加载、添加、移除逻辑
*
* @param {boolean} shouldLoad - 是否立即加载自选股列表(默认 true
* @returns {Object} 自选股数据和操作方法
*/
export const useWatchlist = (shouldLoad = true) => {
const dispatch = useDispatch();
// 从 Redux 获取自选股列表
const watchlistArray = useSelector(state => state.stock.watchlist, shallowEqual);
const loading = useSelector(state => state.stock.loading.watchlist);
// 转换为 Set 方便快速查询标准化为6位代码
// 注意: watchlistArray 现在是 { stock_code, stock_name }[] 格式
const watchlistSet = useMemo(() => {
return new Set(watchlistArray.map(item => normalizeStockCode(item.stock_code)));
}, [watchlistArray]);
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时)
useEffect(() => {
if (shouldLoad) {
logger.debug('useWatchlist', '条件加载自选股列表', { shouldLoad });
dispatch(loadWatchlist());
}
}, [dispatch, shouldLoad]);
/**
* 检查股票是否在自选股中(支持带后缀的代码格式)
* @param {string} stockCode - 股票代码(支持 600000, 600000.SH 等格式)
* @returns {boolean}
*/
const isInWatchlist = useCallback((stockCode) => {
const normalized = normalizeStockCode(stockCode);
return watchlistSet.has(normalized);
}, [watchlistSet]);
/**
* 切换自选股状态
* @param {string} stockCode - 股票代码(支持带后缀格式,会自动标准化)
* @param {string} stockName - 股票名称
* @returns {Promise<boolean>} 操作是否成功
*/
const toggleWatchlist = useCallback(async (stockCode, stockName) => {
const normalized = normalizeStockCode(stockCode);
const wasInWatchlist = watchlistSet.has(normalized);
logger.debug('useWatchlist', '切换自选股状态', {
stockCode,
normalized,
stockName,
wasInWatchlist
});
try {
// 传递标准化后的6位代码给 Redux action
await dispatch(toggleWatchlistAction({
stockCode: normalized,
stockName,
isInWatchlist: wasInWatchlist
})).unwrap();
message.success(wasInWatchlist ? '已从自选股移除' : '已加入自选股');
return true;
} catch (error) {
logger.error('useWatchlist', '切换自选股失败', error, {
stockCode,
normalized,
stockName
});
message.error(error.message || '操作失败,请稍后重试');
return false;
}
}, [dispatch, watchlistSet]);
/**
* 批量添加到自选股
* @param {Array<{code: string, name: string}>} stocks - 股票列表
* @returns {Promise<number>} 成功添加的数量
*/
const batchAddToWatchlist = useCallback(async (stocks) => {
logger.debug('useWatchlist', '批量添加自选股', {
count: stocks.length
});
let successCount = 0;
const promises = stocks.map(async ({ code, name }) => {
const normalized = normalizeStockCode(code);
if (!watchlistSet.has(normalized)) {
try {
await dispatch(toggleWatchlistAction({
stockCode: normalized,
stockName: name,
isInWatchlist: false
})).unwrap();
successCount++;
} catch (error) {
logger.error('useWatchlist', '添加失败', error, { code, normalized, name });
}
}
});
await Promise.all(promises);
if (successCount > 0) {
message.success(`成功添加 ${successCount} 只股票到自选股`);
}
return successCount;
}, [dispatch, watchlistSet]);
/**
* 刷新自选股列表
*/
const refresh = useCallback(() => {
logger.debug('useWatchlist', '刷新自选股列表');
dispatch(loadWatchlist());
}, [dispatch]);
return {
// 数据
watchlist: watchlistArray,
watchlistSet,
loading,
// 查询方法
isInWatchlist,
// 操作方法
toggleWatchlist,
batchAddToWatchlist,
refresh
};
};