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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,6 +1,2 @@
|
||||
// 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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user