Compare commits

...

5 Commits

Author SHA1 Message Date
zdl
334a4b7e50 fix: 修复 Community 目录迁移后的导入路径错误
修复 7 处导入路径问题:
- EventHeaderInfo.js: StockChangeIndicators 和 EventFollowButton 路径
- klineDataCache.js: stockService 和 logger 路径别名
- EventDescriptionSection.js: professionalTheme 路径别名
- CollapsibleSection.js: professionalTheme 路径别名
- RelatedConceptsSection/index.js: logger 路径别名
- CompactMetaBar.js: EventFollowButton 路径
- EventDetailScrollPanel.js: DynamicNewsDetailPanel 路径

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 13:24:35 +08:00
zdl
ab8a450a5c feat: 调整h5登陆 2025-12-08 13:18:31 +08:00
zdl
ee33f7ffd7 refactor: 重构 Community 目录,将公共组件迁移到 src/components/
- 迁移 klineDataCache.js 到 src/utils/stock/(被 StockChart 使用)
- 迁移 InvestmentCalendar 到 src/components/InvestmentCalendar/(被 Navbar、Dashboard 使用)
- 迁移 DynamicNewsDetail 到 src/components/EventDetailPanel/(被 EventDetail 使用)
- 更新所有相关导入路径,使用路径别名
- 保持 Community 目录其余结构不变

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 12:09:24 +08:00
zdl
b4ddccfb92 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>
2025-12-08 11:19:29 +08:00
zdl
62bcf15cdf fix: 补全 auth-check-end 性能标记(AbortError 路径)
- 在 checkSession 的 AbortError 分支添加缺失的 auth-check-end 标记
- 确保所有代码路径都正确标记性能监控点

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 10:24:55 +08:00
43 changed files with 108 additions and 1508 deletions

55
app.py
View File

@@ -160,10 +160,16 @@ SMS_SIGN_NAME = "价值前沿科技"
SMS_TEMPLATE_REGISTER = "2386557" # 注册模板
SMS_TEMPLATE_LOGIN = "2386540" # 登录模板
# 微信开放平台配置
WECHAT_APPID = 'wxa8d74c47041b5f87'
WECHAT_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc'
WECHAT_REDIRECT_URI = 'http://valuefrontier.cn/api/auth/wechat/callback'
# 微信开放平台配置PC 扫码登录用)
WECHAT_OPEN_APPID = 'wxa8d74c47041b5f87'
WECHAT_OPEN_APPSECRET = 'eedef95b11787fd7ca7f1acc6c9061bc'
# 微信公众号配置H5 网页授权用)
WECHAT_MP_APPID = 'wx4e4b759f8fa9e43a'
WECHAT_MP_APPSECRET = 'ef1ca9064af271bb0405330efbc495aa'
# 微信回调地址
WECHAT_REDIRECT_URI = 'https://valuefrontier.cn/api/auth/wechat/callback'
# 邮件服务配置QQ企业邮箱
MAIL_SERVER = 'smtp.exmail.qq.com'
@@ -3382,12 +3388,18 @@ def register_with_email():
return jsonify({'success': False, 'error': '注册失败,请重试'}), 500
def get_wechat_access_token(code):
"""通过code获取微信access_token"""
def get_wechat_access_token(code, appid=None, appsecret=None):
"""通过code获取微信access_token
Args:
code: 微信授权后返回的 code
appid: 微信 AppID可选默认使用开放平台配置
appsecret: 微信 AppSecret可选默认使用开放平台配置
"""
url = "https://api.weixin.qq.com/sns/oauth2/access_token"
params = {
'appid': WECHAT_APPID,
'secret': WECHAT_APPSECRET,
'appid': appid or WECHAT_OPEN_APPID,
'secret': appsecret or WECHAT_OPEN_APPSECRET,
'code': code,
'grant_type': 'authorization_code'
}
@@ -3449,10 +3461,10 @@ def get_wechat_qrcode():
# URL编码回调地址
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
# 构建微信授权URL
# 构建微信授权URLPC 扫码登录使用开放平台 AppID
wechat_auth_url = (
f"https://open.weixin.qq.com/connect/qrconnect?"
f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}"
f"appid={WECHAT_OPEN_APPID}&redirect_uri={redirect_uri}"
f"&response_type=code&scope=snsapi_login&state={state}"
"#wechat_redirect"
)
@@ -3490,10 +3502,10 @@ def get_wechat_h5_auth_url():
# 编码回调地址
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
# 构建授权 URL使用 snsapi_userinfo 获取用户信息,仅限微信内 H5 使用
# 构建授权 URLH5 网页授权使用公众号 AppID
auth_url = (
f"https://open.weixin.qq.com/connect/oauth2/authorize?"
f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}"
f"appid={WECHAT_MP_APPID}&redirect_uri={redirect_uri}"
f"&response_type=code&scope=snsapi_userinfo&state={state}"
"#wechat_redirect"
)
@@ -3527,10 +3539,10 @@ def get_wechat_bind_qrcode():
# URL编码回调地址
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
# 构建微信授权URL
# 构建微信授权URLPC 扫码绑定使用开放平台 AppID
wechat_auth_url = (
f"https://open.weixin.qq.com/connect/qrconnect?"
f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}"
f"appid={WECHAT_OPEN_APPID}&redirect_uri={redirect_uri}"
f"&response_type=code&scope=snsapi_login&state={state}"
"#wechat_redirect"
)
@@ -3641,8 +3653,19 @@ def wechat_callback():
session_data['status'] = 'scanned'
print(f"✅ 微信扫码回调: state={state}, code={code[:10]}...")
# 步骤2: 获取access_token
token_data = get_wechat_access_token(code)
# 步骤2: 根据授权模式选择对应的 AppID/AppSecret
# H5 模式使用公众号配置PC 扫码和绑定模式使用开放平台配置
if session_data.get('mode') == 'h5':
appid = WECHAT_MP_APPID
appsecret = WECHAT_MP_APPSECRET
print(f"📱 H5 模式授权,使用公众号配置")
else:
appid = WECHAT_OPEN_APPID
appsecret = WECHAT_OPEN_APPSECRET
print(f"💻 PC 模式授权,使用开放平台配置")
# 步骤3: 获取access_token
token_data = get_wechat_access_token(code, appid, appsecret)
if not token_data:
session_data['status'] = 'auth_failed'
session_data['error'] = '获取访问令牌失败'

View File

@@ -0,0 +1 @@
k8W6K1RDHiU4U3nw

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleHeader.js
// src/components/EventDetailPanel/CollapsibleHeader.js
// 可折叠模块标题组件
import React from 'react';

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
// src/components/EventDetailPanel/CollapsibleSection.js
// 通用可折叠区块组件
import React, { useState } from 'react';
@@ -8,7 +8,7 @@ import {
useColorModeValue,
} from '@chakra-ui/react';
import CollapsibleHeader from './CollapsibleHeader';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
/**
* 通用可折叠区块组件

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/CompactMetaBar.js
// src/components/EventDetailPanel/CompactMetaBar.js
// 精简信息栏组件(无头部模式下右上角显示)
import React from 'react';
@@ -10,7 +10,7 @@ import {
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import EventFollowButton from '../EventCard/EventFollowButton';
import EventFollowButton from '@views/Community/components/EventCard/EventFollowButton';
/**
* 精简信息栏组件

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/CompactStockItem.js
// src/components/EventDetailPanel/CompactStockItem.js
// 精简模式股票卡片组件(浮动卡片样式)
import React from 'react';

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
// src/components/EventDetailPanel/DynamicNewsDetailPanel.js
// 动态新闻详情面板主组件(组装所有子组件)
import React, { useState, useCallback, useEffect, useReducer } from 'react';
@@ -16,7 +16,7 @@ import {
} from '@chakra-ui/react';
import { getImportanceConfig } from '@constants/importanceLevels';
import { eventService } from '@services/eventService';
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
import { useEventStocks } from '@views/Community/components/StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
import { useAuth } from '@contexts/AuthContext';
import EventHeaderInfo from './EventHeaderInfo';

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/EventDescriptionSection.js
// src/components/EventDetailPanel/EventDescriptionSection.js
// 事件描述区组件
import React from 'react';
@@ -8,7 +8,7 @@ import {
Text,
useColorModeValue,
} from '@chakra-ui/react';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
/**
* 事件描述区组件

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/EventHeaderInfo.js
// src/components/EventDetailPanel/EventHeaderInfo.js
// 事件头部信息区组件
import React from 'react';
@@ -14,8 +14,8 @@ import {
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
import dayjs from 'dayjs';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
import EventFollowButton from '../EventCard/EventFollowButton';
import StockChangeIndicators from '../StockChangeIndicators';
import EventFollowButton from '@views/Community/components/EventCard/EventFollowButton';
/**
* 事件头部信息区组件

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
// src/components/EventDetailPanel/MiniKLineChart.js
import React, { useState, useEffect, useMemo, useRef } from 'react';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
@@ -6,7 +6,7 @@ import {
fetchKlineData,
getCacheKey,
klineDataCache
} from '../StockDetailPanel/utils/klineDataCache';
} from '@utils/stock/klineDataCache';
/**
* 迷你K线图组件

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/MiniLineChart.js
// src/components/EventDetailPanel/MiniLineChart.js
// Mini 折线图组件(用于股票卡片)
import React from 'react';

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/ConceptStockItem.js
// src/components/EventDetailPanel/RelatedConceptsSection/ConceptStockItem.js
// 概念股票列表项组件
import React from 'react';

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/DetailedConceptCard.js
// src/components/EventDetailPanel/RelatedConceptsSection/DetailedConceptCard.js
// 详细概念卡片组件
import React from 'react';

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/SimpleConceptCard.js
// src/components/EventDetailPanel/RelatedConceptsSection/SimpleConceptCard.js
// 简单概念卡片组件(横向卡片)
import React from 'react';

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/TradingDateInfo.js
// src/components/EventDetailPanel/RelatedConceptsSection/TradingDateInfo.js
// 交易日期信息提示组件
import React from 'react';

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/index.js
// src/components/EventDetailPanel/RelatedConceptsSection/index.js
// 相关概念区组件(主组件)
import React, { useState, useEffect } from 'react';
@@ -20,7 +20,7 @@ import dayjs from 'dayjs';
import SimpleConceptCard from './SimpleConceptCard';
import DetailedConceptCard from './DetailedConceptCard';
import TradingDateInfo from './TradingDateInfo';
import { logger } from '../../../../../utils/logger';
import { logger } from '@utils/logger';
/**
* 相关概念区组件

View File

@@ -1,12 +1,12 @@
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
// src/components/EventDetailPanel/RelatedStocksSection.js
// 相关股票列表区组件(纯内容,不含标题)
import React, { useState, useEffect, useMemo } from 'react';
import { VStack } from '@chakra-ui/react';
import dayjs from 'dayjs';
import StockListItem from './StockListItem';
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '../StockDetailPanel/utils/klineDataCache';
import { logger } from '../../../../utils/logger';
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '@utils/stock/klineDataCache';
import { logger } from '@utils/logger';
/**
* 相关股票列表区组件纯内容部分

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/DynamicNewsDetail/StockListItem.js
// src/components/EventDetailPanel/StockListItem.js
// 股票卡片组件(融合表格功能的卡片样式)
import React, { useState } from 'react';
@@ -20,7 +20,7 @@ import { StarIcon } from '@chakra-ui/icons';
import { Tag } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import { selectIsMobile } from '@store/slices/deviceSlice';
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
import MiniTimelineChart from '@views/Community/components/StockDetailPanel/components/MiniTimelineChart';
import MiniKLineChart from './MiniKLineChart';
import TimelineChartModal from '@components/StockChart/TimelineChartModal';
import KLineChartModal from '@components/StockChart/KLineChartModal';

View File

@@ -0,0 +1,6 @@
// src/components/EventDetailPanel/index.js
// 统一导出事件详情面板组件
export { default } from './DynamicNewsDetailPanel';
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';
export { default as EventDetailPanel } from './DynamicNewsDetailPanel';

View File

@@ -1,4 +1,4 @@
// src/views/Community/components/InvestmentCalendar.js
// src/components/InvestmentCalendar/index.js
import React, { useState, useEffect, useCallback } from 'react';
import {
Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty,
@@ -10,14 +10,14 @@ import {
} from '@ant-design/icons';
import dayjs from 'dayjs';
import ReactMarkdown from 'react-markdown';
import { eventService, stockService } from '../../../services/eventService';
import KLineChartModal from '../../../components/StockChart/KLineChartModal';
import { useSubscription } from '../../../hooks/useSubscription';
import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeModal';
import CitationMark from '../../../components/Citation/CitationMark';
import CitedContent from '../../../components/Citation/CitedContent';
import { processCitationData } from '../../../utils/citationUtils';
import { logger } from '../../../utils/logger';
import { eventService, stockService } from '@services/eventService';
import KLineChartModal from '@components/StockChart/KLineChartModal';
import { useSubscription } from '@hooks/useSubscription';
import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal';
import CitationMark from '@components/Citation/CitationMark';
import CitedContent from '@components/Citation/CitedContent';
import { processCitationData } from '@utils/citationUtils';
import { logger } from '@utils/logger';
import './InvestmentCalendar.css';
const { TabPane } = Tabs;

View File

@@ -10,7 +10,7 @@ import {
ModalCloseButton
} from '@chakra-ui/react';
import { FiCalendar } from 'react-icons/fi';
import InvestmentCalendar from '../../../views/Community/components/InvestmentCalendar';
import InvestmentCalendar from '@components/InvestmentCalendar';
/**
* 投资日历按钮组件

View File

@@ -19,7 +19,7 @@ import {
} from '@chakra-ui/react';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
import { klineDataCache, getCacheKey, fetchKlineData } from '@utils/stock/klineDataCache';
import { selectIsMobile } from '@store/slices/deviceSlice';
import { StockInfo } from './types';

View File

@@ -9,7 +9,7 @@ import type { Chart } from 'klinecharts';
import type { ChartType, KLineDataPoint, RawDataPoint } from '../types';
import { processChartData } from '../utils/dataAdapter';
import { stockService } from '@services/eventService';
import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
import { klineDataCache, getCacheKey } from '@utils/stock/klineDataCache';
export interface UseKLineDataOptions {
/** KLineChart 实例 */

View File

@@ -171,6 +171,8 @@ export const AuthProvider = ({ children }) => {
reason: error.message || 'Request cancelled',
isTimeout: error.message?.includes('timeout')
});
// ⚡ 性能标记认证检查结束AbortError 情况)
performanceMonitor.mark('auth-check-end');
// AbortError不改变登录状态保持原状态
return;
}

12
src/utils/stock/index.js Normal file
View File

@@ -0,0 +1,12 @@
// src/utils/stock/index.js
// K线数据缓存工具
export {
klineDataCache,
getCacheKey,
fetchKlineData,
fetchBatchKlineData,
preloadBatchKlineData,
clearCache,
clearAllCache,
getCacheStats,
} from './klineDataCache';

View File

@@ -1,7 +1,7 @@
// src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
// src/utils/stock/klineDataCache.js
import dayjs from 'dayjs';
import { stockService } from '../../../../../services/eventService';
import { logger } from '../../../../../utils/logger';
import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
// ================= 全局缓存和请求管理 =================
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}|${chartType}` -> data

View File

@@ -34,7 +34,7 @@ import { useNotification } from '../../../contexts/NotificationContext';
import EventScrollList from './DynamicNewsCard/EventScrollList';
import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons';
import PaginationControl from './DynamicNewsCard/PaginationControl';
import DynamicNewsDetailPanel from './DynamicNewsDetail';
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
import CompactSearchBox from './CompactSearchBox';
import {
fetchDynamicNews,

View File

@@ -3,7 +3,7 @@
import React from 'react';
import { Box, Center, VStack, Text } from '@chakra-ui/react';
import DynamicNewsDetailPanel from '../DynamicNewsDetail';
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
/**
* 事件详情滚动面板

View File

@@ -1,5 +0,0 @@
// src/views/Community/components/DynamicNewsDetail/index.js
// 统一导出 DynamicNewsDetailPanel 组件
export { default } from './DynamicNewsDetailPanel';
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';

View File

@@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
import { Drawer } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { selectIsMobile } from '@store/slices/deviceSlice';
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
import DynamicNewsDetailPanel from '@components/EventDetailPanel';
import './EventDetailModal.less';
interface EventDetailModalProps {

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

@@ -8,7 +8,7 @@ import {
getCacheKey,
klineDataCache,
batchPendingRequests
} from '../utils/klineDataCache';
} from '@utils/stock/klineDataCache';
/**
* 迷你分时图组件

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
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
};
};

View File

@@ -29,7 +29,7 @@ import type { InvestmentEvent } from '@/types';
import './InvestmentCalendar.less';
// 懒加载投资日历组件
const InvestmentCalendar = lazy(() => import('@/views/Community/components/InvestmentCalendar'));
const InvestmentCalendar = lazy(() => import('@components/InvestmentCalendar'));
dayjs.locale('zh-cn');

View File

@@ -12,7 +12,7 @@ import {
} from '@chakra-ui/react';
import { decodeEventId } from '@/utils/idEncoder';
import { eventService } from '@/services/eventService';
import { DynamicNewsDetailPanel } from '@/views/Community/components/DynamicNewsDetail';
import { DynamicNewsDetailPanel } from '@components/EventDetailPanel';
import { logger } from '@/utils/logger';
import ErrorPage from '@/components/ErrorPage';