Compare commits

...

40 Commits

Author SHA1 Message Date
zdl
20994cfb13 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  fix: 事件详情唔错页面UI调整
  fix: 调整事件详情页面
  feat: 事件详情页 URL ID 加密,防止用户遍历
  style: 首页整体尺寸缩小约 67%
  fix: 调整客服弹窗 将 PC 端聊天窗口从 380×640 调整为 450×750。 H5 端:宽度占满,高度根据宽度等比缩放
  fix: ICP 备案号现在可以点击跳转到 https://beian.miit.gov.cn/
  feat: 田间mock数据
  feat: 个股中心复用 TradeDatePicker 日期选择器组件
  feat: 概念中心历史时间轴弹窗UI调整
  feat: 提取日历选择器组件
  refactor: 提取 ConceptStocksModal 为通用组件,统一概念中心和个股中心弹窗
  refactor: 事件详情弹窗改用 Drawer 组件从底部弹出
  fix: 在 viewport meta 标签中添加了 viewport-fit=cover,这样浏览器会将页面内容延伸到曲面屏边缘,同时启用 safe-area-inset-* CSS 环境变量 在普通设备上保持至少 16px 的右侧内边距 在华为 MATE70 PRO 等曲面屏设备上,使用系统提供的安全区域值,避免右侧导航被遮挡
  fix: 概念中心H5端卡片尺寸优化,一屏可显示更多内容
  fix: 修复自选股添加失败 405 错误
  fix: H5端热门事件移除Tooltip避免黑色悬浮框无法消除
2025-12-05 09:42:52 +08:00
zdl
7c1fe55a5f fix: 事件详情唔错页面UI调整 2025-12-04 19:45:21 +08:00
zdl
1d5d06c567 fix: 调整事件详情页面 2025-12-04 19:01:35 +08:00
zdl
f64c1ffb19 feat: 事件详情页 URL ID 加密,防止用户遍历
- 新增 idEncoder.ts 工具:使用 Base64 + 前缀混淆加密 ID
- 路由改为查询参数形式:/event-detail?id=xxx
- 更新所有入口使用 getEventDetailUrl() 生成加密链接
- 兼容旧链接:纯数字 ID 仍可正常访问

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 18:26:01 +08:00
zdl
6cf92b6851 style: 首页整体尺寸缩小约 67%
- useHomeResponsive: 标题尺寸 4xl→2xl,正文 xl→md
- HomePage: VStack/SimpleGrid 间距缩小
- HeroHeader: spacing/padding 缩小,maxW 调整
- FeaturedFeatureCard: 图标、标题、按钮尺寸缩小
- FeatureCard: 卡片高度 180→120px,整体元素尺寸缩小

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 17:35:43 +08:00
zdl
ae42024ec0 fix: 调整客服弹窗 将 PC 端聊天窗口从 380×640 调整为 450×750。
H5 端:宽度占满,高度根据宽度等比缩放
2025-12-04 17:26:16 +08:00
zdl
dafeab0fa3 fix: ICP 备案号现在可以点击跳转到 https://beian.miit.gov.cn/ 2025-12-04 16:57:57 +08:00
zdl
846ed816e5 feat: 田间mock数据 2025-12-04 16:51:07 +08:00
zdl
4a97f87ee5 feat: 个股中心复用 TradeDatePicker 日期选择器组件
- StockOverview: 替换 Popover 日期选择器为 TradeDatePicker
- StockOverview: 修复 selectedDate 类型从字符串改为 Date 对象
- StockOverview: 隐藏"最新交易日期"提示
- TradeDatePicker: 新增 minDate 属性支持日期范围限制
- 日期选择器可选范围限制为 tradingDays 数据范围

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 16:47:44 +08:00
zdl
b5d054d89f feat: 概念中心历史时间轴弹窗UI调整 2025-12-04 16:26:52 +08:00
zdl
b66c1585f7 feat: 提取日历选择器组件 2025-12-04 16:20:58 +08:00
zdl
5efd598694 refactor: 提取 ConceptStocksModal 为通用组件,统一概念中心和个股中心弹窗
- 将 ConceptStocksModal 从 StockOverview/components 移到 components 目录
- 概念中心复用 ConceptStocksModal,删除冗余的 renderStockTable 函数(约100行)
- 统一 H5 端弹窗体验:响应式尺寸、高度限制(70vh)、左右滑动、垂直居中
- 移除重复的底部关闭按钮,只保留右上角关闭按钮
- 添加"板块原因"列,表头改为中文
- 使用 @components 路径别名

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 15:57:32 +08:00
zdl
b1d5b217d3 refactor: 事件详情弹窗改用 Drawer 组件从底部弹出
- EventDetailModal: Modal 替换为 Drawer,placement="bottom"
- 使用 destroyOnHidden 替代已弃用的 destroyOnClose
- 关闭按钮改用 CloseOutlined 图标,移到右上角
- 简化 Less 文件,删除与 TSX styles 属性重复的配置
- BytedeskWidget: H5 端降低 z-index,避免遮挡发布按钮

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 15:45:48 +08:00
zdl
5f6b933172 fix: 在 viewport meta 标签中添加了 viewport-fit=cover,这样浏览器会将页面内容延伸到曲面屏边缘,同时启用 safe-area-inset-* CSS 环境变量
在普通设备上保持至少 16px 的右侧内边距
在华为 MATE70 PRO 等曲面屏设备上,使用系统提供的安全区域值,避免右侧导航被遮挡
2025-12-04 14:53:17 +08:00
zdl
0c291de182 fix: 概念中心H5端卡片尺寸优化,一屏可显示更多内容
- H5端改为两列布局,间距从6改为3
    - 卡片背景高度从180px减小到100px
    - Logo尺寸从120px减小到60px
    - 内容区域padding和间距响应式调整
    - 描述文字H5端显示1行
    - 时间轴按钮尺寸H5端缩小
2025-12-04 14:47:36 +08:00
zdl
61ed1510c2 fix: 修复自选股添加失败 405 错误
- useWatchlist.js: 修正 API 路径从 /api/account/watchlist/add 改为 /api/account/watchlist
- account.js: 同步修改 mock handler 路径

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 14:40:35 +08:00
zdl
0edc6a5e00 fix: H5端热门事件移除Tooltip避免黑色悬浮框无法消除
- 使用 useBreakpointValue 检测移动端设备
- H5端不显示标题和描述的 Tooltip 提示
- PC端保留 Tooltip 功能不变

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 14:38:14 +08:00
zdl
bad5290fe2 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  feat: 日k 和 分时h5UI调整
  fix: 弹窗固定高度
  feat: K线添加mock数据
  feat: 添加批量获取K线数据的 mock handler
2025-12-04 14:12:10 +08:00
zdl
a569a63a85 feat: 日k 和 分时h5UI调整 2025-12-04 14:11:37 +08:00
zdl
77af61a93a fix: 弹窗固定高度 2025-12-04 14:02:21 +08:00
zdl
999fd9b0a3 feat: K线添加mock数据 2025-12-04 14:02:03 +08:00
zdl
8d3e92dfaf feat: 添加批量获取K线数据的 mock handler
- 新增 /api/stock/batch-kline POST 接口 mock
- 支持批量获取多只股票的分时图和日K线数据
- 修复事件详情页面相关股票的K线和分时图无数据问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:46:47 +08:00
zdl
daee0427e4 fix: 修复 useWatchlist.js 合并冲突遗留问题
- 移除重复的 handleRemoveFromWatchlist 导出
- 移除 JSDoc 中重复的类型声明
- 清理残留的错误注释

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:35:51 +08:00
zdl
e8c21f7863 refactor: DynamicNewsDetailPanel 组件优化
- 使用 useReducer 整合 7 个折叠状态为统一的 sectionState
- 提取自选股逻辑到 useWatchlist Hook,移除 70 行重复代码
- 扩展 useWatchlist 添加 handleAddToWatchlist、isInWatchlist 方法
- 清理未使用的导入(HStack、useColorModeValue)
- 移除调试 console.log 日志
- RelatedStocksSection 改用 isInWatchlist 函数替代 watchlistSet

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:29:59 +08:00
zdl
3f518def09 fix: 预加载行业数据(解决第一次点击无数据问题) 2025-12-04 12:33:59 +08:00
zdl
f521b89c27 fix:修复添加自选股没反应 2025-12-04 12:20:27 +08:00
zdl
ac421011eb fix:修复事件中心刚进页面向上滚动了一部分 2025-12-04 11:57:30 +08:00
zdl
2a653afea1 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  fix: 导航效果UI修复
  feat: 个股添加个股列表弹窗
  fix: 概念中心UI
  fix: 个股中心页面日期数据源统一
  fix: 修改的后端代码 /api/market/statistics 接口 添加日期格式化逻辑 //api/concepts/daily-top 添加日期格式化逻辑 /api/market/heatmap 接口 已经有正确的格式化
2025-12-04 11:53:37 +08:00
zdl
6628ddc7b2 fix: 导航效果UI修复 2025-12-04 11:52:44 +08:00
zdl
5dc480f5f4 feat: 个股添加个股列表弹窗 2025-12-04 11:51:21 +08:00
zdl
99f102a213 fix: 概念中心UI 2025-12-04 11:35:29 +08:00
a37206ec97 update pay ui 2025-12-04 10:58:30 +08:00
zdl
9f6c98135f fix: 个股中心页面日期数据源统一
- fetchTopConcepts: 始终设置 selectedDate 和 availableDates
- fetchHeatmapData: 移除 setSelectedDate
- fetchMarketStats: 移除 setSelectedDate 和 setAvailableDates
- 新增 src/data/tradingDays.json: 交易日历数据(从 tdays.csv 转换)
- availableDates 基于交易日历生成,确保日期列表完整且包含最新日期

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:57:03 +08:00
5e5e2160b0 update pay ui 2025-12-04 10:43:17 +08:00
zdl
f0074bca42 fix: 修改的后端代码
/api/market/statistics 接口 添加日期格式化逻辑
//api/concepts/daily-top 添加日期格式化逻辑
/api/market/heatmap 接口 已经有正确的格式化
2025-12-04 10:20:42 +08:00
zdl
e8285599e8 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  fix: 去除个股中心动画,添加mock数据
  feat: 首页代码优化
2025-12-03 18:31:27 +08:00
0eb760fa31 update pay ui 2025-12-03 17:40:57 +08:00
805b897afa update pay ui 2025-12-03 17:13:49 +08:00
2988af9806 Merge branch 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251201_py_h5_ui 2025-12-03 16:50:45 +08:00
63023adcf3 update pay ui 2025-12-03 16:50:39 +08:00
48 changed files with 1782 additions and 1678 deletions

30
app.py
View File

@@ -12232,12 +12232,19 @@ def get_market_statistics():
available_dates = [str(row.TRADEDATE) for row in available_dates_result]
# 格式化日期为 YYYY-MM-DD
formatted_trade_date = trade_date.strftime('%Y-%m-%d') if hasattr(trade_date, 'strftime') else str(trade_date).split(' ')[0][:10]
formatted_available_dates = [
d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d).split(' ')[0][:10]
for d in [row.TRADEDATE for row in available_dates_result]
]
return jsonify({
'success': True,
'trade_date': str(trade_date),
'trade_date': formatted_trade_date,
'summary': summary,
'details': list(statistics.values()),
'available_dates': available_dates
'available_dates': formatted_available_dates
})
except Exception as e:
@@ -12277,19 +12284,30 @@ def get_daily_top_concepts():
top_concepts = []
for concept in data.get('results', []):
# 保持与 /concept-api/search 相同的字段结构
top_concepts.append({
'concept_id': concept.get('concept_id'),
'concept_name': concept.get('concept'),
'concept': concept.get('concept'), # 原始字段名
'concept_name': concept.get('concept'), # 兼容旧字段名
'description': concept.get('description'),
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0),
'stock_count': concept.get('stock_count', 0),
'stocks': concept.get('stocks', [])[:5] # 只返回前5只股票
'score': concept.get('score'),
'match_type': concept.get('match_type'),
'price_info': concept.get('price_info', {}), # 完整的价格信息
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 兼容旧字段
'happened_times': concept.get('happened_times', []), # 历史触发时间
'stocks': concept.get('stocks', []), # 返回完整股票列表
'hot_score': concept.get('hot_score')
})
# 格式化日期为 YYYY-MM-DD
price_date = data.get('price_date', '')
formatted_date = str(price_date).split(' ')[0][:10] if price_date else ''
return jsonify({
'success': True,
'data': top_concepts,
'trade_date': data.get('price_date'),
'trade_date': formatted_date,
'count': len(top_concepts)
})
else:

View File

@@ -1999,7 +1999,7 @@ class MCPAgentIntegrated:
model=self.kimi_model,
messages=messages,
temperature=1.0, # Kimi 推荐
max_tokens=8192, # 足够容纳 reasoning_content
max_tokens=128000, # 足够容纳 reasoning_content
)
choice = response.choices[0]
@@ -2074,7 +2074,7 @@ class MCPAgentIntegrated:
model=self.deepmoney_model,
messages=messages,
temperature=0.7,
max_tokens=8192,
max_tokens=32784,
)
summary = response.choices[0].message.content
@@ -2268,7 +2268,7 @@ class MCPAgentIntegrated:
model="kimi-k2-turbo-preview", # 使用非思考模型,更快
messages=messages,
temperature=0.7,
max_tokens=8192, # 增加 token 限制以支持图表配置
max_tokens=128000, # 增加 token 限制以支持图表配置
)
summary = response.choices[0].message.content
@@ -2355,7 +2355,7 @@ class MCPAgentIntegrated:
model=self.deepmoney_model,
messages=messages,
temperature=0.3,
max_tokens=4096,
max_tokens=32768,
)
title = response.choices[0].message.content.strip()
@@ -2450,7 +2450,7 @@ class MCPAgentIntegrated:
model=planning_model,
messages=messages,
temperature=1.0,
max_tokens=8192,
max_tokens=32768,
stream=True, # 启用流式输出
)
@@ -2494,7 +2494,7 @@ class MCPAgentIntegrated:
model=self.deepmoney_model,
messages=messages,
temperature=0.7,
max_tokens=8192,
max_tokens=32768,
)
plan_content = fallback_response.choices[0].message.content
@@ -2690,7 +2690,7 @@ class MCPAgentIntegrated:
model="kimi-k2-turbo-preview",
messages=messages,
temperature=0.7,
max_tokens=8192,
max_tokens=32768,
stream=True, # 启用流式输出
)
@@ -2724,7 +2724,7 @@ class MCPAgentIntegrated:
model=self.deepmoney_model,
messages=messages,
temperature=0.7,
max_tokens=8192,
max_tokens=32768,
)
final_summary = fallback_response.choices[0].message.content
@@ -3676,7 +3676,7 @@ async def stream_role_response(
tool_choice="auto",
stream=False, # 工具调用不使用流式
temperature=0.7,
max_tokens=8192, # 增大 token 限制以避免输出被截断
max_tokens=32768, # 增大 token 限制以避免输出被截断
)
assistant_message = response.choices[0].message

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#000000" />

View File

@@ -75,9 +75,11 @@ const BytedeskWidget = ({
const rightVal = parseInt(style.right);
const bottomVal = parseInt(style.bottom);
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
// H5 端设置按钮尺寸为 48x48只执行一次
// H5 端设置按钮尺寸为 48x48 并降低 z-index(只执行一次)
if (isMobile && !el.dataset.bytedeskStyled) {
el.dataset.bytedeskStyled = 'true';
// 降低 z-index避免遮挡页面内的发布按钮等交互元素
el.style.zIndex = 10;
const button = el.querySelector('button');
if (button) {
button.style.width = '48px';

View File

@@ -55,10 +55,17 @@ export const bytedeskConfig = {
t: '1', // 类型: 1=人工客服, 2=机器人
sid: 'df_wg_uid', // 工作组ID
},
window: window.innerWidth <= 768 ? {
width: window.innerWidth - 1,
height: Math.min(window.innerWidth * 640/380, window.innerHeight - 200)
} : { width: 380, height: 640 }
};
/**
* 获取Bytedesk配置根据环境自动切换
* - H5 端:宽度占满,高度根据宽度等比缩放
* - PC 端:固定宽高 380x640
*
* @returns {Object} Bytedesk配置对象
*/

View File

@@ -0,0 +1,239 @@
import React, { useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalBody,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
TableContainer,
Box,
HStack,
Text,
Icon,
Spinner,
useColorModeValue,
useBreakpointValue,
} from '@chakra-ui/react';
import { FaTable } from 'react-icons/fa';
import marketService from '@services/marketService';
import { logger } from '@utils/logger';
// 股票信息类型
interface StockInfo {
stock_code: string;
stock_name: string;
reason?: string;
change_pct?: number;
[key: string]: unknown;
}
// 概念信息类型
export interface ConceptInfo {
concept_id?: string;
concept_name: string;
stock_count?: number;
stocks?: StockInfo[];
[key: string]: unknown;
}
// 行情数据类型
interface MarketData {
stock_code: string;
close?: number;
change_percent?: number;
[key: string]: unknown;
}
interface ConceptStocksModalProps {
isOpen: boolean;
onClose: () => void;
concept: ConceptInfo | null;
}
const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
isOpen,
onClose,
concept,
}) => {
const navigate = useNavigate();
// 状态
const [stockMarketData, setStockMarketData] = useState<Record<string, MarketData>>({});
const [loadingStockData, setLoadingStockData] = useState(false);
// 颜色主题
const cardBg = useColorModeValue('white', '#1a1a1a');
const hoverBg = useColorModeValue('gray.50', '#2a2a2a');
// 响应式配置 - 添加 fallback 避免首次渲染时返回 undefined 导致弹窗异常
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
// H5 使用 xl 而非 full配合 maxH 限制高度
const modalSize = useBreakpointValue({ base: 'xl', md: '4xl' }, { fallback: 'md' });
const tableMaxH = useBreakpointValue({ base: '45vh', md: '60vh' }, { fallback: 'md' });
// 批量获取股票行情数据
const fetchStockMarketData = useCallback(async (stocks: StockInfo[]) => {
if (!stocks || stocks.length === 0) return;
setLoadingStockData(true);
const newMarketData: Record<string, MarketData> = {};
try {
const batchSize = 5;
for (let i = 0; i < stocks.length; i += batchSize) {
const batch = stocks.slice(i, i + batchSize);
const promises = batch.map(async (stock) => {
if (!stock.stock_code) return null;
const seccode = stock.stock_code.substring(0, 6);
try {
const response = await marketService.getTradeData(seccode, 1);
if (response.success && response.data?.length > 0) {
const latestData = response.data[response.data.length - 1];
return { stock_code: stock.stock_code, ...latestData };
}
} catch (error) {
logger.warn('ConceptStocksModal', '获取股票行情失败', { stockCode: seccode });
}
return null;
});
const results = await Promise.all(promises);
results.forEach((result) => {
if (result) newMarketData[result.stock_code] = result;
});
}
setStockMarketData(newMarketData);
} catch (error) {
logger.error('ConceptStocksModal', 'fetchStockMarketData', error);
} finally {
setLoadingStockData(false);
}
}, []);
// 弹窗打开时加载数据
React.useEffect(() => {
if (isOpen && concept?.stocks) {
setStockMarketData({});
fetchStockMarketData(concept.stocks);
}
}, [isOpen, concept, fetchStockMarketData]);
// 点击股票行
const handleStockClick = (stockCode: string) => {
navigate(`/company?scode=${stockCode}`);
onClose();
};
const stocks = concept?.stocks || [];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
size={modalSize}
scrollBehavior="inside"
isCentered
>
<ModalOverlay />
<ModalContent bg={cardBg} maxH={isMobile ? '70vh' : undefined}>
<ModalHeader bg="purple.500" color="white" borderTopRadius="md">
<HStack>
<Icon as={FaTable} />
<Text>{concept?.concept_name} - </Text>
</HStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6}>
{stocks.length === 0 ? (
<Text color="gray.500" textAlign="center"></Text>
) : (
<Box>
{loadingStockData && (
<HStack justify="center" mb={4}>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="gray.500">...</Text>
</HStack>
)}
<TableContainer maxH={tableMaxH} overflowY="auto" overflowX="auto">
<Table variant="simple" size="sm" minW={isMobile ? '600px' : undefined}>
<Thead position="sticky" top={0} bg={cardBg} zIndex={1}>
<Tr>
<Th whiteSpace="nowrap"></Th>
<Th whiteSpace="nowrap"></Th>
<Th isNumeric whiteSpace="nowrap"></Th>
<Th isNumeric whiteSpace="nowrap"></Th>
<Th whiteSpace="nowrap" minW="200px"></Th>
</Tr>
</Thead>
<Tbody>
{stocks.map((stock, idx) => {
const marketData = stockMarketData[stock.stock_code];
const changePercent = marketData?.change_percent;
return (
<Tr
key={idx}
_hover={{ bg: hoverBg }}
cursor="pointer"
onClick={() => handleStockClick(stock.stock_code)}
>
<Td color="blue.500" fontWeight="medium">
{stock.stock_name}
</Td>
<Td>{stock.stock_code}</Td>
<Td isNumeric>
{loadingStockData ? (
<Spinner size="xs" />
) : marketData?.close ? (
`¥${marketData.close.toFixed(2)}`
) : (
'-'
)}
</Td>
<Td
isNumeric
fontWeight="bold"
color={
changePercent && changePercent > 0
? 'red.500'
: changePercent && changePercent < 0
? 'green.500'
: 'gray.500'
}
>
{loadingStockData ? (
<Spinner size="xs" />
) : changePercent !== undefined ? (
`${changePercent > 0 ? '+' : ''}${changePercent.toFixed(2)}%`
) : (
'-'
)}
</Td>
<Td fontSize="xs" color="gray.600" maxW="300px">
<Text noOfLines={2}>{stock.reason || '-'}</Text>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
</Box>
)}
</ModalBody>
</ModalContent>
</Modal>
);
};
export default ConceptStocksModal;

View File

@@ -0,0 +1,138 @@
/**
* ErrorPage - 通用错误页面组件
* 用于显示加载失败、网络错误等异常状态
* 设计风格:黑色背景 + 金色边框
*/
import React from 'react';
import {
Box,
Center,
Circle,
Text,
Button,
VStack,
HStack,
Icon,
} from '@chakra-ui/react';
import { WarningIcon } from '@chakra-ui/icons';
// 主题色
const GOLD_COLOR = '#D4A574';
const BG_COLOR = '#1A202C'; // 与页面背景一致
interface ErrorPageProps {
/** 错误标题,默认"加载失败" */
title?: string;
/** 错误描述信息 */
description?: string;
/** 详细信息如事件ID、订单号等 */
detail?: string;
/** 详细信息标签,默认"ID" */
detailLabel?: string;
/** 是否显示重试按钮 */
showRetry?: boolean;
/** 重试回调函数 */
onRetry?: () => void;
/** 是否显示返回按钮 */
showBack?: boolean;
/** 返回回调函数 */
onBack?: () => void;
/** 是否全屏显示,默认 true */
fullScreen?: boolean;
}
const ErrorPage: React.FC<ErrorPageProps> = ({
title = '加载失败',
description,
detail,
detailLabel = 'ID',
showRetry = false,
onRetry,
showBack = false,
onBack,
fullScreen = true,
}) => {
const hasButtons = (showRetry && onRetry) || (showBack && onBack);
return (
<Box
h={fullScreen ? '100vh' : '60vh'}
w="100%"
display="flex"
alignItems="center"
justifyContent="center"
>
<Box
bg={BG_COLOR}
border="1px solid"
borderColor={GOLD_COLOR}
borderRadius="lg"
p={8}
maxW="400px"
w="90%"
textAlign="center"
>
{/* 金色圆形图标 + 黑色感叹号 */}
<Circle size="50px" bg={GOLD_COLOR} mx="auto" mb={4}>
<Icon as={WarningIcon} color={BG_COLOR} boxSize={5} />
</Circle>
{/* 金色标题 */}
<Text color={GOLD_COLOR} fontSize="lg" fontWeight="medium" mb={2}>
{title}
</Text>
{/* 描述信息 */}
{description && (
<Text color="gray.400" fontSize="sm" mb={2}>
{description}
</Text>
)}
{/* 详情 */}
{detail && (
<Text color="gray.500" fontSize="sm" mb={4}>
{detailLabel}: {detail}
</Text>
)}
{/* 按钮组 */}
{hasButtons && (
<HStack justify="center" spacing={3} mt={4}>
{showBack && onBack && (
<Button
variant="outline"
borderColor={GOLD_COLOR}
color={GOLD_COLOR}
size="sm"
px={6}
_hover={{ bg: GOLD_COLOR, color: 'black' }}
onClick={onBack}
>
</Button>
)}
{showRetry && onRetry && (
<Button
bg={GOLD_COLOR}
color={BG_COLOR}
borderColor={GOLD_COLOR}
border="1px solid"
size="sm"
px={6}
fontWeight="medium"
_hover={{ bg: '#C49A6C' }}
onClick={onRetry}
>
</Button>
)}
</HStack>
)}
</Box>
</Box>
);
};
export default ErrorPage;

View File

@@ -161,7 +161,7 @@ export default function HomeNavbar() {
borderColor={navbarBorder}
py={{ base: 2, md: 3 }}
>
<Container maxW="container.xl" px={{ base: 3, md: 4 }}>
<Container maxW="container.xl" px={{ base: 3, md: 4 }} style={{ paddingRight: 'max(16px, env(safe-area-inset-right))' }}>
<Flex justify="space-between" align="center">
{/* Logo - 价小前投研 */}
<BrandLogo />

View File

@@ -21,6 +21,7 @@ import { ChevronDownIcon } from '@chakra-ui/icons';
import { FiCalendar } from 'react-icons/fi';
import { useNavigate } from 'react-router-dom';
import { useFollowingEvents } from '../../../../hooks/useFollowingEvents';
import { getEventDetailUrl } from '@/utils/idEncoder';
/**
* 关注事件下拉菜单组件
@@ -86,7 +87,7 @@ const FollowingEventsMenu = memo(() => {
<MenuItem
key={ev.id}
_hover={{ bg: 'gray.50' }}
onClick={() => navigate(`/event-detail/${ev.id}`)}
onClick={() => navigate(getEventDetailUrl(ev.id))}
>
<HStack justify="space-between" w="100%">
<Box flex={1} minW={0}>

View File

@@ -61,8 +61,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
borderColor={isActive(['/community', '/concepts']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={highFreqMenu.handleMouseEnter}
@@ -128,8 +128,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
borderColor={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={marketReviewMenu.handleMouseEnter}
@@ -204,8 +204,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/agent-chat', '/value-forum']) ? '2px solid' : 'none'}
borderColor={isActive(['/agent-chat', '/value-forum']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={agentCommunityMenu.handleMouseEnter}

View File

@@ -1,9 +1,11 @@
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { stockService } from '@services/eventService';
import { selectIsMobile } from '@store/slices/deviceSlice';
/**
* 股票信息
@@ -83,6 +85,9 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
const [earliestDate, setEarliestDate] = useState<string | null>(null);
const [totalDaysLoaded, setTotalDaysLoaded] = useState(0);
// H5 响应式适配
const isMobile = useSelector(selectIsMobile);
// 调试日志
console.log('[KLineChartModal] 渲染状态:', {
isOpen,
@@ -296,16 +301,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
}
}
// 图表配置
// 图表配置H5 响应式)
const option: echarts.EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
left: 'center',
top: 10,
top: isMobile ? 5 : 10,
textStyle: {
color: '#e0e0e0',
fontSize: 18,
fontSize: isMobile ? 14 : 18,
fontWeight: 'bold',
},
},
@@ -370,16 +375,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
grid: [
{
left: '5%',
right: '5%',
top: '12%',
height: '60%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '12%' : '12%',
height: isMobile ? '55%' : '60%',
},
{
left: '5%',
right: '5%',
top: '77%',
height: '18%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '72%' : '77%',
height: isMobile ? '20%' : '18%',
},
],
xAxis: [
@@ -394,7 +399,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(dates.length / 8),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
},
splitLine: {
show: false,
@@ -411,7 +417,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(dates.length / 8),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
},
},
],
@@ -419,6 +426,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
{
scale: true,
gridIndex: 0,
splitNumber: isMobile ? 4 : 5,
splitLine: {
show: true,
lineStyle: {
@@ -432,12 +440,14 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => value.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitNumber: isMobile ? 2 : 3,
splitLine: {
show: false,
},
@@ -448,6 +458,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => {
if (value >= 100000000) {
return (value / 100000000).toFixed(1) + '亿';
@@ -545,7 +556,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
return () => clearTimeout(retryTimer);
}
}, [data, stock]);
}, [data, stock, isMobile]);
// 加载数据
useEffect(() => {
@@ -600,13 +611,13 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '1400px',
maxHeight: '85vh',
width: isMobile ? '96vw' : '90vw',
maxWidth: isMobile ? 'none' : '1400px',
maxHeight: isMobile ? '85vh' : '85vh',
backgroundColor: '#1a1a1a',
border: '2px solid #ffd700',
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
borderRadius: '8px',
borderRadius: isMobile ? '12px' : '8px',
zIndex: 10002,
display: 'flex',
flexDirection: 'column',
@@ -616,7 +627,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
{/* Header */}
<div
style={{
padding: '16px 24px',
padding: isMobile ? '12px 16px' : '16px 24px',
borderBottom: '1px solid #404040',
display: 'flex',
justifyContent: 'space-between',
@@ -624,18 +635,18 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '12px', flexWrap: isMobile ? 'wrap' : 'nowrap' }}>
<span style={{ fontSize: isMobile ? '14px' : '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</span>
{data.length > 0 && (
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666', fontStyle: 'italic' }}>
{data.length}
{hasMore ? '(向左滑动加载更多)' : '(已加载全部)'}
</span>
)}
{loadingMore && (
<span style={{ fontSize: '12px', color: '#3182ce', display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#3182ce', display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{
width: '12px',
height: '12px',
@@ -649,10 +660,10 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
<span style={{ fontSize: '14px', color: '#999' }}>K线图</span>
<span style={{ fontSize: '12px', color: '#666' }}>
💡 |
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '16px', marginTop: '4px' }}>
<span style={{ fontSize: isMobile ? '12px' : '14px', color: '#999' }}>K线图</span>
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666' }}>
💡 {isMobile ? '滚轮缩放 | 拖动查看' : '鼠标滚轮缩放 | 拖动查看不同时间段'}
</span>
</div>
</div>
@@ -675,26 +686,33 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
</div>
{/* Body */}
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
<div style={{
padding: isMobile ? '8px' : '16px',
flex: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}>
{error && (
<div
style={{
backgroundColor: '#2a1a1a',
border: '1px solid #ef5350',
borderRadius: '4px',
padding: '12px 16px',
marginBottom: '16px',
padding: isMobile ? '8px 12px' : '12px 16px',
marginBottom: isMobile ? '8px' : '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span style={{ color: '#ef5350' }}></span>
<span style={{ color: '#e0e0e0' }}>{error}</span>
<span style={{ color: '#e0e0e0', fontSize: isMobile ? '12px' : '14px' }}>{error}</span>
</div>
)}
<div style={{ position: 'relative', height: '680px', width: '100%' }}>
<div style={{ position: 'relative', height: isMobile ? '450px' : '680px', width: '100%' }}>
{loading && (
<div
style={{

View File

@@ -1,5 +1,6 @@
// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件
import React, { useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import {
Modal,
ModalOverlay,
@@ -19,6 +20,7 @@ import {
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
import { selectIsMobile } from '@store/slices/deviceSlice';
/**
* 股票信息
@@ -68,6 +70,9 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<TimelineDataPoint[]>([]);
// H5 响应式适配
const isMobile = useSelector(selectIsMobile);
// 加载分时图数据(优先使用缓存)
const loadData = async () => {
if (!stock?.stock_code) return;
@@ -187,16 +192,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
}
}
// 图表配置
// 图表配置H5 响应式)
const option: echarts.EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
left: 'center',
top: 10,
top: isMobile ? 5 : 10,
textStyle: {
color: '#e0e0e0',
fontSize: 18,
fontSize: isMobile ? 14 : 18,
fontWeight: 'bold',
},
},
@@ -247,16 +252,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
grid: [
{
left: '5%',
right: '5%',
top: '15%',
height: '55%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '12%' : '15%',
height: isMobile ? '58%' : '55%',
},
{
left: '5%',
right: '5%',
top: '75%',
height: '15%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '75%' : '75%',
height: isMobile ? '18%' : '15%',
},
],
xAxis: [
@@ -271,7 +276,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(times.length / 6),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
},
splitLine: {
show: true,
@@ -291,7 +297,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(times.length / 6),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
},
},
],
@@ -299,6 +306,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
{
scale: true,
gridIndex: 0,
splitNumber: isMobile ? 4 : 5,
splitLine: {
show: true,
lineStyle: {
@@ -312,12 +320,14 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => value.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitNumber: isMobile ? 2 : 3,
splitLine: {
show: false,
},
@@ -328,6 +338,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => {
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万';
@@ -443,7 +454,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
return () => clearTimeout(retryTimer);
}
}, [data, stock]);
}, [data, stock, isMobile]);
// 加载数据
useEffect(() => {
@@ -455,29 +466,30 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
if (!stock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size={size}>
<Modal isOpen={isOpen} onClose={onClose} size={size} isCentered>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent
maxW="90vw"
maxW={isMobile ? '96vw' : '90vw'}
maxH="85vh"
borderRadius={isMobile ? '12px' : '8px'}
bg="#1a1a1a"
borderColor="#404040"
borderWidth="1px"
border="2px solid #ffd700"
boxShadow="0 0 30px rgba(255, 215, 0, 0.5)"
>
<ModalHeader pb={3} borderBottomWidth="1px" borderColor="#404040">
<VStack align="flex-start" spacing={1}>
<ModalHeader pb={isMobile ? 2 : 3} borderBottomWidth="1px" borderColor="#404040">
<VStack align="flex-start" spacing={0}>
<HStack>
<Text fontSize="lg" fontWeight="bold" color="#e0e0e0">
<Text fontSize={isMobile ? 'md' : 'lg'} fontWeight="bold" color="#e0e0e0">
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</Text>
</HStack>
<Text fontSize="sm" color="#999">
<Text fontSize={isMobile ? 'xs' : 'sm'} color="#999">
</Text>
</VStack>
</ModalHeader>
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
<ModalBody p={4}>
<ModalBody p={isMobile ? 2 : 4}>
{error && (
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
<AlertIcon color="#ef5350" />
@@ -485,7 +497,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
</Alert>
)}
<Box position="relative" h="600px" w="100%">
<Box position="relative" h={isMobile ? '400px' : '600px'} w="100%">
{loading && (
<Flex
position="absolute"

View File

@@ -0,0 +1,135 @@
import React from 'react';
import {
HStack,
Input,
Text,
Icon,
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import { InfoIcon } from '@chakra-ui/icons';
import { FaCalendarAlt } from 'react-icons/fa';
export interface TradeDatePickerProps {
/** 当前选中的日期 */
value: Date | null;
/** 日期变化回调 */
onChange: (date: Date) => void;
/** 默认日期(组件初始化时使用) */
defaultDate?: Date;
/** 最新交易日期(用于显示提示) */
latestTradeDate?: Date | null;
/** 最小可选日期 */
minDate?: Date;
/** 最大可选日期,默认今天 */
maxDate?: Date;
/** 标签文字,默认"交易日期" */
label?: string;
/** 输入框宽度 */
inputWidth?: string | object;
/** 是否显示标签图标 */
showIcon?: boolean;
}
/**
* 交易日期选择器组件
*
* 提供日期输入框和最新交易日期提示,供概念中心、个股中心等页面复用。
* 快捷按钮(今天、昨天等)由各页面自行实现。
*/
const TradeDatePicker: React.FC<TradeDatePickerProps> = ({
value,
onChange,
defaultDate,
latestTradeDate,
minDate,
maxDate,
label = '交易日期',
inputWidth = { base: '100%', lg: '200px' },
showIcon = true,
}) => {
// 颜色主题
const labelColor = useColorModeValue('purple.700', 'purple.300');
const iconColor = useColorModeValue('purple.500', 'purple.400');
const inputBorderColor = useColorModeValue('purple.200', 'purple.600');
const tipBg = useColorModeValue('blue.50', 'blue.900');
const tipBorderColor = useColorModeValue('blue.200', 'blue.600');
const tipTextColor = useColorModeValue('blue.600', 'blue.200');
const tipIconColor = useColorModeValue('blue.500', 'blue.300');
// 使用默认日期初始化(仅在 value 为 null 且有 defaultDate 时)
React.useEffect(() => {
if (value === null && defaultDate) {
onChange(defaultDate);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 处理日期变化
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const dateStr = e.target.value;
if (dateStr) {
const date = new Date(dateStr);
onChange(date);
}
};
// 格式化日期为 YYYY-MM-DD
const formatDateValue = (date: Date | null): string => {
if (!date) return '';
return date.toISOString().split('T')[0];
};
// 计算日期范围
const minDateStr = minDate ? formatDateValue(minDate) : undefined;
const maxDateStr = maxDate
? formatDateValue(maxDate)
: new Date().toISOString().split('T')[0];
return (
<>
{/* 标签 */}
<HStack spacing={3}>
{showIcon && <Icon as={FaCalendarAlt} color={iconColor} boxSize={5} />}
<Text fontWeight="bold" color={labelColor}>
{label}
</Text>
</HStack>
{/* 日期输入框 */}
<Input
type="date"
value={formatDateValue(value)}
onChange={handleDateChange}
min={minDateStr}
max={maxDateStr}
width={inputWidth}
focusBorderColor="purple.500"
borderColor={inputBorderColor}
borderRadius="lg"
fontWeight="medium"
/>
{/* 最新交易日期提示 */}
{latestTradeDate && (
<Tooltip label="数据库中最新的交易日期">
<HStack
spacing={2}
bg={tipBg}
px={3}
py={1.5}
borderRadius="full"
border="1px solid"
borderColor={tipBorderColor}
>
<Icon as={InfoIcon} color={tipIconColor} boxSize={3} />
<Text fontSize="sm" color={tipTextColor} fontWeight="medium">
: {latestTradeDate.toLocaleDateString('zh-CN')}
</Text>
</HStack>
</Tooltip>
)}
</>
);
};
export default TradeDatePicker;

View File

@@ -19,6 +19,7 @@ import { notificationMetricsService } from '../services/notificationMetricsServi
import { notificationHistoryService } from '../services/notificationHistoryService';
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes';
import { usePermissionGuide, GUIDE_TYPES } from '../hooks/usePermissionGuide';
import { getEventDetailUrl } from '@/utils/idEncoder';
// 连接状态枚举
const CONNECTION_STATUS = {
@@ -460,7 +461,7 @@ export const NotificationProvider = ({ children }) => {
timestamp: Date.now(),
isAIGenerated: event.is_ai_generated || false,
clickable: true,
link: `/event-detail/${event.id}`,
link: getEventDetailUrl(event.id),
autoClose: autoClose,
extra: {
eventId: event.id,

File diff suppressed because one or more lines are too long

View File

@@ -18,21 +18,21 @@ export const useHomeResponsive = (): ResponsiveConfig => {
});
const headingSize = useBreakpointValue({
base: 'xl',
md: '3xl',
lg: '4xl'
base: 'lg',
md: 'xl',
lg: '2xl'
});
const headingLetterSpacing = useBreakpointValue({
base: '-1px',
md: '-1.5px',
lg: '-2px'
base: '-0.5px',
md: '-1px',
lg: '-1.5px'
});
const heroTextSize = useBreakpointValue({
base: 'md',
md: 'lg',
lg: 'xl'
base: 'xs',
md: 'sm',
lg: 'md'
});
const containerPx = useBreakpointValue({

View File

@@ -1,16 +1,19 @@
// src/hooks/useWatchlist.js
// 自选股管理自定义 Hook
// 自选股管理自定义 Hook(导航栏专用,与 Redux 状态同步)
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger';
import { getApiBase } from '../utils/apiConfig';
import { toggleWatchlist as toggleWatchlistAction, loadWatchlist } from '../store/slices/stockSlice';
const WATCHLIST_PAGE_SIZE = 10;
/**
* 自选股管理 Hook
* 自选股管理 Hook(导航栏专用)
* 提供自选股加载、分页、移除等功能
* 监听 Redux 中的 watchlist 变化,自动刷新行情数据
*
* @returns {{
* watchlistQuotes: Array,
@@ -19,14 +22,39 @@ const WATCHLIST_PAGE_SIZE = 10;
* setWatchlistPage: Function,
* WATCHLIST_PAGE_SIZE: number,
* loadWatchlistQuotes: Function,
* handleRemoveFromWatchlist: Function
* followingEvents: Array,
* handleAddToWatchlist: Function,
* handleRemoveFromWatchlist: Function,
* isInWatchlist: Function
* }}
*/
export const useWatchlist = () => {
const toast = useToast();
const dispatch = useDispatch();
const [watchlistQuotes, setWatchlistQuotes] = useState([]);
const [watchlistLoading, setWatchlistLoading] = useState(false);
const [watchlistPage, setWatchlistPage] = useState(1);
const [followingEvents, setFollowingEvents] = useState([]);
// 从 Redux 获取自选股列表长度(用于监听变化)
// 使用 length 作为依赖,避免数组引用变化导致不必要的重新渲染
const reduxWatchlistLength = useSelector(state => state.stock.watchlist?.length || 0);
// 检查 Redux watchlist 是否已初始化(加载状态)
const reduxWatchlistLoading = useSelector(state => state.stock.loading?.watchlist);
// 用于跟踪上一次的 watchlist 长度
const prevWatchlistLengthRef = useRef(-1); // 初始设为 -1确保第一次变化也能检测到
// 初始化时加载 Redux watchlist确保 Redux 状态被初始化)
const hasInitializedRef = useRef(false);
useEffect(() => {
if (!hasInitializedRef.current) {
hasInitializedRef.current = true;
logger.debug('useWatchlist', '初始化 Redux watchlist');
dispatch(loadWatchlist());
}
}, [dispatch]);
// 加载自选股实时行情
const loadWatchlistQuotes = useCallback(async () => {
@@ -42,6 +70,7 @@ export const useWatchlist = () => {
const data = await resp.json();
if (data && data.success && Array.isArray(data.data)) {
setWatchlistQuotes(data.data);
logger.debug('useWatchlist', '自选股行情加载成功', { count: data.data.length });
} else {
setWatchlistQuotes([]);
}
@@ -58,35 +87,108 @@ export const useWatchlist = () => {
}
}, []);
// 监听 Redux watchlist 长度变化,自动刷新行情数据
useEffect(() => {
const currentLength = reduxWatchlistLength;
const prevLength = prevWatchlistLengthRef.current;
// 只有当 watchlist 长度发生变化时才刷新
// prevLength = -1 表示初始状态,此时不触发刷新(由菜单打开时触发)
if (prevLength !== -1 && currentLength !== prevLength) {
logger.debug('useWatchlist', 'Redux watchlist 长度变化,刷新行情', {
prevLength,
currentLength
});
// 延迟一小段时间再刷新,确保后端数据已更新
const timer = setTimeout(() => {
logger.debug('useWatchlist', '执行 loadWatchlistQuotes');
loadWatchlistQuotes();
}, 500);
prevWatchlistLengthRef.current = currentLength;
return () => clearTimeout(timer);
}
// 更新 ref
prevWatchlistLengthRef.current = currentLength;
}, [reduxWatchlistLength, loadWatchlistQuotes]);
// 添加到自选股
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
try {
const base = getApiBase();
const resp = await fetch(base + '/api/account/watchlist', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.success) {
// 刷新自选股列表
loadWatchlistQuotes();
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
return true;
} else {
toast({ title: '添加失败', status: 'error', duration: 2000 });
return false;
}
} catch (e) {
toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 });
return false;
}
}, [toast, loadWatchlistQuotes]);
// 从自选股移除
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
try {
const base = getApiBase();
const resp = await fetch(base + `/api/account/watchlist/${stockCode}`, {
method: 'DELETE',
credentials: 'include'
// 找到股票名称
const stockItem = watchlistQuotes.find(item => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
return normalize6(item.stock_code) === normalize6(stockCode);
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data && data.success !== false) {
setWatchlistQuotes((prev) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
setWatchlistPage((p) => Math.min(p, newMaxPage));
return updated;
});
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
} else {
toast({ title: '移除失败', status: 'error', duration: 2000 });
}
const stockName = stockItem?.stock_name || '';
// 通过 Redux action 移除(会同步更新 Redux 状态)
await dispatch(toggleWatchlistAction({
stockCode,
stockName,
isInWatchlist: true // 表示当前在自选股中,需要移除
})).unwrap();
// 更新本地状态(立即响应 UI
setWatchlistQuotes((prev) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
setWatchlistPage((p) => Math.min(p, newMaxPage));
return updated;
});
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
} catch (e) {
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
logger.error('useWatchlist', '移除自选股失败', e);
toast({ title: e.message || '移除失败', status: 'error', duration: 2000 });
}
}, [toast]);
}, [dispatch, watchlistQuotes, toast]);
// 判断股票是否在自选股中
const isInWatchlist = useCallback((stockCode) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
return watchlistQuotes.some(item => normalize6(item.stock_code) === target);
}, [watchlistQuotes]);
return {
watchlistQuotes,
@@ -95,6 +197,9 @@ export const useWatchlist = () => {
setWatchlistPage,
WATCHLIST_PAGE_SIZE,
loadWatchlistQuotes,
handleRemoveFromWatchlist
followingEvents,
handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist
};
};

View File

@@ -23,7 +23,13 @@ const AppFooter = () => {
>
京公网安备11010802046286号
</Link>
<Text>京ICP备2025107343号-1</Text>
<Link
href="https://beian.miit.gov.cn/"
isExternal
_hover={{ color: 'gray.600' }}
>
京ICP备2025107343号-1
</Link>
</HStack>
</VStack>
</Container>

View File

@@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter);
*/
export default function MainLayout() {
return (
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column">
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column" bg="#1A202C">
{/* 导航栏 - 在所有页面间共享memo 后不会在路由切换时重新渲染 */}
<MemoizedHomeNavbar />
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
<Box flex="1" pt="60px">
<Box flex="1" pt="60px" bg="#1A202C">
<ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />

View File

@@ -61,6 +61,20 @@ export const generateDailyData = (indexCode, days = 30) => {
return data;
};
/**
* 计算简单移动均价(用于分时图均价线)
* @param {Array} data - 已有数据
* @param {number} currentPrice - 当前价格
* @param {number} period - 均线周期默认5
* @returns {number} 均价
*/
function calculateAvgPrice(data, currentPrice, period = 5) {
const recentPrices = data.slice(-period).map(d => d.price || d.close);
recentPrices.push(currentPrice);
const sum = recentPrices.reduce((acc, p) => acc + p, 0);
return parseFloat((sum / recentPrices.length).toFixed(2));
}
/**
* 生成时间范围内的数据
*/
@@ -80,6 +94,11 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
// ✅ 修复:为分时图添加完整的 OHLC 字段
const closePrice = parseFloat(price.toFixed(2));
// 计算均价和涨跌幅
const avgPrice = calculateAvgPrice(data, closePrice);
const changePercent = parseFloat(((closePrice - basePrice) / basePrice * 100).toFixed(2));
data.push({
time: formatTime(current),
timestamp: current.getTime(), // ✅ 新增:毫秒时间戳
@@ -88,6 +107,8 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘)
close: closePrice, // ✅ 保留:收盘价
price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用)
avg_price: avgPrice, // ✅ 新增:均价(供 TimelineChartModal 使用)
change_percent: changePercent, // ✅ 新增:涨跌幅(供 TimelineChartModal 使用)
volume: volume,
prev_close: basePrice
});

View File

@@ -159,7 +159,7 @@ export const accountHandlers = [
}),
// 6. 添加自选股
http.post('/api/account/watchlist/add', async ({ request }) => {
http.post('/api/account/watchlist', async ({ request }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
@@ -188,6 +188,22 @@ export const accountHandlers = [
mockWatchlist.push(newItem);
// 同步添加到 mockRealtimeQuotes导航栏自选股菜单使用此数组
mockRealtimeQuotes.push({
stock_code: stock_code,
stock_name: stock_name,
current_price: null,
change_percent: 0,
change: 0,
volume: 0,
turnover: 0,
high: 0,
low: 0,
open: 0,
prev_close: 0,
update_time: new Date().toTimeString().slice(0, 8)
});
return HttpResponse.json({
success: true,
message: '添加成功',
@@ -210,9 +226,20 @@ export const accountHandlers = [
const { id } = params;
console.log('[Mock] 删除自选股:', id);
const index = mockWatchlist.findIndex(item => item.id === parseInt(id));
// 支持按 stock_code 或 id 匹配删除
const index = mockWatchlist.findIndex(item =>
item.stock_code === id || item.id === parseInt(id)
);
if (index !== -1) {
const stockCode = mockWatchlist[index].stock_code;
mockWatchlist.splice(index, 1);
// 同步从 mockRealtimeQuotes 移除
const quotesIndex = mockRealtimeQuotes.findIndex(item => item.stock_code === stockCode);
if (quotesIndex !== -1) {
mockRealtimeQuotes.splice(quotesIndex, 1);
}
}
return HttpResponse.json({

View File

@@ -255,6 +255,48 @@ export const eventHandlers = [
// ==================== 事件详情相关 ====================
// 获取事件详情
http.get('/api/events/:eventId', async ({ params }) => {
await delay(200);
const { eventId } = params;
console.log('[Mock] 获取事件详情, eventId:', eventId);
try {
// 返回模拟的事件详情数据
return HttpResponse.json({
success: true,
data: {
id: parseInt(eventId),
title: `测试事件 ${eventId} - 重大政策发布`,
description: '这是一个模拟的事件描述,用于开发测试。该事件涉及重要政策变化,可能对相关板块产生显著影响。建议关注后续发展动态。',
importance: ['S', 'A', 'B', 'C'][Math.floor(Math.random() * 4)],
created_at: new Date().toISOString(),
trading_date: new Date().toISOString().split('T')[0],
event_type: ['政策', '财报', '行业', '宏观'][Math.floor(Math.random() * 4)],
related_avg_chg: parseFloat((Math.random() * 10 - 5).toFixed(2)),
follower_count: Math.floor(Math.random() * 500) + 50,
view_count: Math.floor(Math.random() * 5000) + 100,
is_following: false,
post_count: Math.floor(Math.random() * 50),
expectation_surprise_score: parseFloat((Math.random() * 100).toFixed(1)),
},
message: '获取成功'
});
} catch (error) {
console.error('[Mock] 获取事件详情失败:', error);
return HttpResponse.json(
{
success: false,
error: '获取事件详情失败',
data: null
},
{ status: 500 }
);
}
}),
// 获取事件相关股票
http.get('/api/events/:eventId/stocks', async ({ params }) => {
await delay(300);

View File

@@ -94,7 +94,7 @@ export const marketHandlers = [
{ name: '机器人', desc: '人形机器人产业化加速,特斯拉、小米等巨头入局,产业链迎来发展机遇。' },
];
// 股票池
// 股票池(扩展到足够多的股票)
const stockPool = [
{ stock_code: '600519', stock_name: '贵州茅台' },
{ stock_code: '300750', stock_name: '宁德时代' },
@@ -104,30 +104,102 @@ export const marketHandlers = [
{ stock_code: '300274', stock_name: '阳光电源' },
{ stock_code: '688981', stock_name: '中芯国际' },
{ stock_code: '000725', stock_name: '京东方A' },
{ stock_code: '600036', stock_name: '招商银行' },
{ stock_code: '000858', stock_name: '五粮液' },
{ stock_code: '601166', stock_name: '兴业银行' },
{ stock_code: '600276', stock_name: '恒瑞医药' },
{ stock_code: '000333', stock_name: '美的集团' },
{ stock_code: '600887', stock_name: '伊利股份' },
{ stock_code: '002415', stock_name: '海康威视' },
{ stock_code: '601888', stock_name: '中国中免' },
{ stock_code: '300059', stock_name: '东方财富' },
{ stock_code: '002475', stock_name: '立讯精密' },
{ stock_code: '600900', stock_name: '长江电力' },
{ stock_code: '601398', stock_name: '工商银行' },
{ stock_code: '600030', stock_name: '中信证券' },
{ stock_code: '000568', stock_name: '泸州老窖' },
{ stock_code: '002352', stock_name: '顺丰控股' },
{ stock_code: '600809', stock_name: '山西汾酒' },
{ stock_code: '300015', stock_name: '爱尔眼科' },
{ stock_code: '002142', stock_name: '宁波银行' },
{ stock_code: '601899', stock_name: '紫金矿业' },
{ stock_code: '600309', stock_name: '万华化学' },
{ stock_code: '002304', stock_name: '洋河股份' },
{ stock_code: '600585', stock_name: '海螺水泥' },
{ stock_code: '601288', stock_name: '农业银行' },
{ stock_code: '600050', stock_name: '中国联通' },
{ stock_code: '000001', stock_name: '平安银行' },
{ stock_code: '601668', stock_name: '中国建筑' },
{ stock_code: '600028', stock_name: '中国石化' },
{ stock_code: '601857', stock_name: '中国石油' },
{ stock_code: '600000', stock_name: '浦发银行' },
{ stock_code: '601328', stock_name: '交通银行' },
{ stock_code: '000002', stock_name: '万科A' },
{ stock_code: '600104', stock_name: '上汽集团' },
{ stock_code: '601601', stock_name: '中国太保' },
{ stock_code: '600016', stock_name: '民生银行' },
{ stock_code: '601628', stock_name: '中国人寿' },
{ stock_code: '600031', stock_name: '三一重工' },
{ stock_code: '002230', stock_name: '科大讯飞' },
{ stock_code: '300124', stock_name: '汇川技术' },
{ stock_code: '002049', stock_name: '紫光国微' },
{ stock_code: '688012', stock_name: '中微公司' },
{ stock_code: '688008', stock_name: '澜起科技' },
{ stock_code: '603501', stock_name: '韦尔股份' },
];
// 生成历史触发时间
const generateHappenedTimes = (seed) => {
const times = [];
const count = 3 + (seed % 3); // 3-5个时间点
for (let k = 0; k < count; k++) {
const daysAgo = 30 + (seed * 7 + k * 11) % 330;
const d = new Date();
d.setDate(d.getDate() - daysAgo);
times.push(d.toISOString().split('T')[0]);
}
return times.sort().reverse();
};
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
// 生成概念数据
const concepts = [];
for (let i = 0; i < Math.min(limit, conceptPool.length); i++) {
const concept = conceptPool[i];
const changePercent = parseFloat((Math.random() * 8 - 1).toFixed(2)); // -1% ~ 7%
const stockCount = Math.floor(Math.random() * 40) + 20; // 20-60只股票
const stockCount = Math.floor(Math.random() * 20) + 15; // 15-35只股票
// 随机选取3-4只相关股票
// 生成与 stockCount 一致的股票列表(包含完整字段)
const relatedStocks = [];
const stockIndices = new Set();
while (stockIndices.size < Math.min(4, stockPool.length)) {
stockIndices.add(Math.floor(Math.random() * stockPool.length));
for (let j = 0; j < stockCount; j++) {
const idx = (i * 7 + j) % stockPool.length;
const stock = stockPool[idx];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.stock_name,
reason: `作为行业龙头企业,${stock.stock_name}在该领域具有核心竞争优势,市场份额领先。`,
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
});
}
stockIndices.forEach(idx => relatedStocks.push(stockPool[idx]));
concepts.push({
concept_id: `CONCEPT_${1001 + i}`,
concept_name: concept.name,
change_percent: changePercent,
stock_count: stockCount,
concept: concept.name, // 原始字段名
concept_name: concept.name, // 兼容字段名
description: concept.desc,
stocks: relatedStocks
stock_count: stockCount,
score: parseFloat((Math.random() * 5 + 3).toFixed(2)), // 3-8 分数
match_type: matchTypes[i % 3],
price_info: {
avg_change_pct: changePercent,
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
},
change_percent: changePercent, // 兼容字段
happened_times: generateHappenedTimes(i),
stocks: relatedStocks,
hot_score: Math.floor(Math.random() * 100)
});
}

View File

@@ -123,6 +123,45 @@ const generateStockList = () => {
// 股票相关的 Handlers
export const stockHandlers = [
// 搜索股票(个股中心页面使用)
http.get('/api/stocks/search', async ({ request }) => {
await delay(200);
const url = new URL(request.url);
const query = url.searchParams.get('q') || '';
const limit = parseInt(url.searchParams.get('limit') || '10');
console.log('[Mock Stock] 搜索股票:', { query, limit });
const stocks = generateStockList();
// 如果没有搜索词,返回空结果
if (!query.trim()) {
return HttpResponse.json({
success: true,
data: []
});
}
// 过滤匹配的股票
const results = stocks.filter(s =>
s.code.includes(query) || s.name.includes(query)
).slice(0, limit);
// 返回格式化数据
return HttpResponse.json({
success: true,
data: results.map(s => ({
stock_code: s.code,
stock_name: s.name,
market: s.code.startsWith('6') ? 'SH' : 'SZ',
industry: ['银行', '证券', '保险', '白酒', '医药', '科技', '新能源', '汽车', '地产', '家电'][Math.floor(Math.random() * 10)],
change_pct: parseFloat((Math.random() * 10 - 3).toFixed(2)),
price: parseFloat((Math.random() * 100 + 5).toFixed(2))
}))
});
}),
// 获取所有股票列表
http.get('/api/stocklist', async () => {
await delay(200);
@@ -224,4 +263,129 @@ export const stockHandlers = [
);
}
}),
// 批量获取股票K线数据
http.post('/api/stock/batch-kline', async ({ request }) => {
await delay(400);
try {
const body = await request.json();
const { codes, type = 'timeline', event_time } = body;
console.log('[Mock Stock] 批量获取K线数据:', {
stockCount: codes?.length,
type,
eventTime: event_time
});
if (!codes || !Array.isArray(codes) || codes.length === 0) {
return HttpResponse.json(
{ error: '股票代码列表不能为空' },
{ status: 400 }
);
}
// 为每只股票生成数据
const batchData = {};
codes.forEach(stockCode => {
let data;
if (type === 'timeline') {
data = generateTimelineData('000001.SH');
} else if (type === 'daily') {
data = generateDailyData('000001.SH', 60);
} else {
data = [];
}
batchData[stockCode] = {
success: true,
data: data,
stock_code: stockCode
};
});
return HttpResponse.json({
success: true,
data: batchData,
type: type,
message: '批量获取成功'
});
} catch (error) {
console.error('[Mock Stock] 批量获取K线数据失败:', error);
return HttpResponse.json(
{ error: '批量获取K线数据失败' },
{ status: 500 }
);
}
}),
// 获取股票报价(批量)
http.post('/api/stock/quotes', async ({ request }) => {
await delay(200);
try {
const body = await request.json();
const { codes, event_time } = body;
console.log('[Mock Stock] 获取股票报价:', {
stockCount: codes?.length,
eventTime: event_time
});
if (!codes || !Array.isArray(codes) || codes.length === 0) {
return HttpResponse.json(
{ success: false, error: '股票代码列表不能为空' },
{ status: 400 }
);
}
// 生成股票列表用于查找名称
const stockList = generateStockList();
const stockMap = {};
stockList.forEach(s => {
stockMap[s.code] = s.name;
});
// 为每只股票生成报价数据
const quotesData = {};
codes.forEach(stockCode => {
// 生成基础价格10-200之间
const basePrice = parseFloat((Math.random() * 190 + 10).toFixed(2));
// 涨跌幅(-10% 到 +10%
const changePercent = parseFloat((Math.random() * 20 - 10).toFixed(2));
// 涨跌额
const change = parseFloat((basePrice * changePercent / 100).toFixed(2));
// 昨收
const prevClose = parseFloat((basePrice - change).toFixed(2));
quotesData[stockCode] = {
code: stockCode,
name: stockMap[stockCode] || `股票${stockCode}`,
price: basePrice,
change: change,
change_percent: changePercent,
prev_close: prevClose,
open: parseFloat((prevClose * (1 + (Math.random() * 0.02 - 0.01))).toFixed(2)),
high: parseFloat((basePrice * (1 + Math.random() * 0.05)).toFixed(2)),
low: parseFloat((basePrice * (1 - Math.random() * 0.05)).toFixed(2)),
volume: Math.floor(Math.random() * 100000000),
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
update_time: new Date().toISOString()
};
});
return HttpResponse.json({
success: true,
data: quotesData,
message: '获取成功'
});
} catch (error) {
console.error('[Mock Stock] 获取股票报价失败:', error);
return HttpResponse.json(
{ success: false, error: '获取股票报价失败' },
{ status: 500 }
);
}
}),
];

View File

@@ -98,7 +98,7 @@ export const routeConfig = [
// ==================== 事件模块 ====================
{
path: 'event-detail/:eventId',
path: 'event-detail',
component: lazyComponents.EventDetail,
protection: PROTECTION_MODES.REDIRECT,
layout: 'main',

View File

@@ -21,12 +21,9 @@
iframe[src*="bytedesk"],
iframe[src*="/chat/"],
iframe[src*="/visitor/"] {
position: fixed !important;
z-index: 999999 !important;
max-height: 80vh !important; /* 限制最大高度为视口的80% */
max-width: 40vh !important; /* 限制最大高度为视口的80% */
bottom: 10px !important; /* 确保底部有足够空间 */
right: 10px !important; /* 右侧边距 */
width: 100% !important; /* 填满外层容器 */
height: 100% !important; /* 填满外层容器 */
}
/* Bytedesk 覆盖层(如果存在) */

View File

@@ -27,6 +27,18 @@ import { MainPanelComponent } from "./additions/layout/MainPanel";
import { PanelContentComponent } from "./additions/layout/PanelContent";
import { PanelContainerComponent } from "./additions/layout/PanelContainer";
// import { mode } from "@chakra-ui/theme-tools";
// Container 组件样式覆盖 - 移除默认背景色
const ContainerComponent = {
components: {
Container: {
baseStyle: {
bg: "1A202C",
},
},
},
};
export default extendTheme(
{ breakpoints }, // Breakpoints
globalStyles,
@@ -37,5 +49,6 @@ export default extendTheme(
CardComponent, // Card component
MainPanelComponent, // Main Panel component
PanelContentComponent, // Panel Content component
PanelContainerComponent // Panel Container component
PanelContainerComponent, // Panel Container component
ContainerComponent // Container 背景透明
);

55
src/utils/idEncoder.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* ID 加密/解密工具
* 用于隐藏 URL 中的真实 ID防止用户猜测遍历
*
* 使用 Base64 编码 + 前缀混淆
* 例如: 15901 -> "ZXYtMTU5MDE"
*/
const SECRET_PREFIX = 'ev-';
/**
* 加密事件 ID
* @param id - 原始 ID
* @returns 加密后的字符串
*/
export const encodeEventId = (id: number | string): string => {
if (id === null || id === undefined) return '';
return btoa(SECRET_PREFIX + String(id));
};
/**
* 解密事件 ID
* @param encoded - 加密后的字符串
* @returns 原始 ID解密失败返回 null
*/
export const decodeEventId = (encoded: string): string | null => {
if (!encoded) return null;
try {
const decoded = atob(encoded);
if (decoded.startsWith(SECRET_PREFIX)) {
return decoded.slice(SECRET_PREFIX.length);
}
// 兼容:如果是纯数字(旧链接),直接返回
if (/^\d+$/.test(encoded)) {
return encoded;
}
return null;
} catch {
// Base64 解码失败,可能是旧的纯数字链接
if (/^\d+$/.test(encoded)) {
return encoded;
}
return null;
}
};
/**
* 生成事件详情页 URL
* @param eventId - 事件 ID
* @returns 完整路径
*/
export const getEventDetailUrl = (eventId: number | string): string => {
return `/event-detail?id=${encodeEventId(eventId)}`;
};

View File

@@ -93,6 +93,13 @@ const CompactSearchBox = ({
loadStocks();
}, []);
// 预加载行业数据(解决第一次点击无数据问题)
useEffect(() => {
if (!industryData || industryData.length === 0) {
dispatch(fetchIndustryData());
}
}, [dispatch, industryData]);
// 初始化筛选条件
const findIndustryPath = useCallback((targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;

View File

@@ -1,27 +1,24 @@
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
// 动态新闻详情面板主组件(组装所有子组件)
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useReducer } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Box,
Card,
CardBody,
VStack,
HStack,
Text,
Spinner,
Center,
Wrap,
WrapItem,
useColorModeValue,
useToast,
Box,
} from '@chakra-ui/react';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { eventService } from '../../../../services/eventService';
import { getImportanceConfig } from '@constants/importanceLevels';
import { eventService } from '@services/eventService';
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
import { useAuth } from '../../../../contexts/AuthContext';
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
import { useAuth } from '@contexts/AuthContext';
import EventHeaderInfo from './EventHeaderInfo';
import CompactMetaBar from './CompactMetaBar';
import EventDescriptionSection from './EventDescriptionSection';
@@ -29,12 +26,56 @@ import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection';
import CompactStockItem from './CompactStockItem';
import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
import EventCommentSection from '../../../../components/EventCommentSection';
import HistoricalEvents from '@views/EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '@views/EventDetail/components/TransmissionChainAnalysis';
import SubscriptionBadge from '@components/SubscriptionBadge';
import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import { useWatchlist } from '@hooks/useWatchlist';
import EventCommentSection from '@components/EventCommentSection';
// 折叠区块状态管理 - 使用 useReducer 整合
const initialSectionState = {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
const sectionReducer = (state, action) => {
switch (action.type) {
case 'TOGGLE':
return {
...state,
[action.section]: {
...state[action.section],
isOpen: !state[action.section].isOpen
}
};
case 'SET_LOADED':
return {
...state,
[action.section]: {
...state[action.section],
hasLoaded: true
}
};
case 'SET_QUOTES_LOADED':
return {
...state,
stocks: { ...state.stocks, hasLoadedQuotes: true }
};
case 'RESET_ALL':
return {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
default:
return state;
}
};
/**
* 动态新闻详情面板主组件
@@ -48,7 +89,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default;
const textColor = PROFESSIONAL_COLORS.text.secondary;
const toast = useToast();
// 使用 useWatchlist Hook 管理自选股
const {
handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist,
loadWatchlistQuotes
} = useWatchlist();
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type
const userTier = user?.subscription_type || 'free';
@@ -101,11 +149,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const response = await eventService.getEventDetail(event.id);
if (response.success) {
setFullEventDetail(response.data);
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
eventId: event.id,
viewCount: response.data.view_count,
title: response.data.title
});
}
} catch (error) {
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
@@ -122,30 +165,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const canAccessHistorical = hasAccess('pro');
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪
// 相关股票默认展开
const [isStocksOpen, setIsStocksOpen] = useState(true);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
// 历史事件默认折叠,但预加载数量
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
// 自选股管理(使用 localStorage
const [watchlistSet, setWatchlistSet] = useState(() => {
try {
const saved = localStorage.getItem('stock_watchlist');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
// 子区块折叠状态管理 - 使用 useReducer 整合
const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
// 锁定点击处理 - 弹出升级弹窗
const handleLockedClick = useCallback((featureName, requiredLevel) => {
@@ -166,87 +187,62 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
}, []);
// 相关股票 - 展开时加载行情(需要 PRO 权限)
// 股票列表在事件切换时预加载(显示数量),行情在展开时才加载
const handleStocksToggle = useCallback(() => {
const newState = !isStocksOpen;
setIsStocksOpen(newState);
const willOpen = !sectionState.stocks.isOpen;
dispatchSection({ type: 'TOGGLE', section: 'stocks' });
// 展开时加载行情数据(如果还没加载过)
if (newState && !hasLoadedQuotes && stocks.length > 0) {
console.log('%c📈 [相关股票] 首次展开,加载行情数据', 'color: #10B981; font-weight: bold;', {
eventId: event?.id,
stockCount: stocks.length
});
if (willOpen && !sectionState.stocks.hasLoadedQuotes && stocks.length > 0) {
refreshQuotes();
setHasLoadedQuotes(true);
dispatchSection({ type: 'SET_QUOTES_LOADED' });
}
}, [isStocksOpen, hasLoadedQuotes, stocks.length, refreshQuotes, event?.id]);
}, [sectionState.stocks, stocks.length, refreshQuotes]);
// 相关概念 - 展开/收起(无需加载)
const handleConceptsToggle = useCallback(() => {
setIsConceptsOpen(!isConceptsOpen);
}, [isConceptsOpen]);
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
}, []);
// 历史事件对比 - 数据已预加载,只需切换展开状态
const handleHistoricalToggle = useCallback(() => {
const newState = !isHistoricalOpen;
setIsHistoricalOpen(newState);
// 数据已在事件切换时预加载,这里只需展开
if (newState) {
console.log('%c📜 [历史事件] 展开(数据已预加载)', 'color: #3B82F6; font-weight: bold;', {
eventId: event?.id,
count: historicalEvents?.length || 0
});
}
}, [isHistoricalOpen, event?.id, historicalEvents?.length]);
dispatchSection({ type: 'TOGGLE', section: 'historical' });
}, []);
// 传导链分析 - 展开时加载
const handleTransmissionToggle = useCallback(() => {
const newState = !isTransmissionOpen;
setIsTransmissionOpen(newState);
const willOpen = !sectionState.transmission.isOpen;
dispatchSection({ type: 'TOGGLE', section: 'transmission' });
if (newState && !hasLoadedTransmission) {
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
if (willOpen && !sectionState.transmission.hasLoaded) {
loadChainAnalysis();
setHasLoadedTransmission(true);
dispatchSection({ type: 'SET_LOADED', section: 'transmission' });
}
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]);
}, [sectionState.transmission, loadChainAnalysis]);
// 事件切换时重置所有子模块状态
useEffect(() => {
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
// 🎯 加载事件详情(增加浏览量)
// 加载事件详情(增加浏览量)
loadEventDetail();
// 重置所有加载状态
setHasLoadedStocks(false);
setHasLoadedQuotes(false); // 重置行情加载状态
setHasLoadedHistorical(false);
setHasLoadedTransmission(false);
// 加载自选股数据(用于判断股票是否已关注)
loadWatchlistQuotes();
// 重置所有折叠区块状态
dispatchSection({ type: 'RESET_ALL' });
// 相关股票默认展开,预加载股票列表和行情数据
setIsStocksOpen(true);
if (canAccessStocks) {
console.log('%c📊 [相关股票] 事件切换,预加载股票列表和行情数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
loadStocksData();
setHasLoadedStocks(true);
// 由于默认展开,直接加载行情数据
setHasLoadedQuotes(true);
dispatchSection({ type: 'SET_LOADED', section: 'stocks' });
dispatchSection({ type: 'SET_QUOTES_LOADED' });
}
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
setIsHistoricalOpen(false);
if (canAccessHistorical) {
console.log('%c📜 [历史事件] 事件切换,预加载历史事件(获取数量)', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
loadHistoricalData();
setHasLoadedHistorical(true);
dispatchSection({ type: 'SET_LOADED', section: 'historical' });
}
setIsConceptsOpen(false);
setIsTransmissionOpen(false);
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail]);
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail, loadWatchlistQuotes]);
// 切换关注状态
const handleToggleFollow = useCallback(async () => {
@@ -254,42 +250,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
dispatch(toggleEventFollow(event.id));
}, [dispatch, event?.id]);
// 切换自选股
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
try {
const newWatchlist = new Set(watchlistSet);
if (isInWatchlist) {
newWatchlist.delete(stockCode);
toast({
title: '已移除自选股',
status: 'info',
duration: 2000,
isClosable: true,
});
} else {
newWatchlist.add(stockCode);
toast({
title: '已添加至自选股',
status: 'success',
duration: 2000,
isClosable: true,
});
}
setWatchlistSet(newWatchlist);
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
} catch (error) {
console.error('切换自选股失败:', error);
toast({
title: '操作失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
// 切换自选股(使用 useWatchlist Hook
const handleWatchlistToggle = useCallback(async (stockCode, stockName, currentlyInWatchlist) => {
if (currentlyInWatchlist) {
await handleRemoveFromWatchlist(stockCode);
} else {
await handleAddToWatchlist(stockCode, stockName);
}
}, [watchlistSet, toast]);
}, [handleAddToWatchlist, handleRemoveFromWatchlist]);
// 空状态
if (!event) {
@@ -338,15 +306,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
<CollapsibleSection
title="相关股票"
isOpen={isStocksOpen}
isOpen={sectionState.stocks.isOpen}
onToggle={handleStocksToggle}
count={stocks?.length || 0}
subscriptionBadge={(() => {
if (!canAccessStocks) {
return <SubscriptionBadge tier="pro" size="sm" />;
}
return null;
})()}
subscriptionBadge={!canAccessStocks ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessStocks}
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
showModeToggle={canAccessStocks}
@@ -381,7 +344,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
stocks={stocks}
quotes={quotes}
eventTime={event.created_at}
watchlistSet={watchlistSet}
isInWatchlist={isInWatchlist}
onWatchlistToggle={handleWatchlistToggle}
/>
)}
@@ -392,7 +355,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at}
isOpen={isConceptsOpen}
isOpen={sectionState.concepts.isOpen}
onToggle={handleConceptsToggle}
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessConcepts}
@@ -402,7 +365,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
<CollapsibleSection
title="历史事件对比"
isOpen={isHistoricalOpen}
isOpen={sectionState.historical.isOpen}
onToggle={handleHistoricalToggle}
count={historicalEvents?.length || 0}
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
@@ -425,7 +388,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
<CollapsibleSection
title="传导链分析"
isOpen={isTransmissionOpen}
isOpen={sectionState.transmission.isOpen}
onToggle={handleTransmissionToggle}
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
isLocked={!canAccessTransmission}
@@ -453,7 +416,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
featureName={upgradeModal.featureName}
currentLevel={userTier}
/>
): null }
) : null}
</Card>
);
};

View File

@@ -15,14 +15,14 @@ import { logger } from '../../../../utils/logger';
* @param {Array<Object>} props.stocks - 股票数组
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
* @param {string} props.eventTime - 事件时间
* @param {Set} props.watchlistSet - 自选股代码集合
* @param {Function} props.isInWatchlist - 检查股票是否在自选股中的函数
* @param {Function} props.onWatchlistToggle - 切换自选股回调
*/
const RelatedStocksSection = ({
stocks,
quotes = {},
eventTime = null,
watchlistSet = new Set(),
isInWatchlist = () => false,
onWatchlistToggle
}) => {
// 分时图数据状态:{ [stockCode]: data[] }
@@ -167,7 +167,7 @@ const RelatedStocksSection = ({
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
isInWatchlist={isInWatchlist(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
timelineData={timelineDataMap[stock.stock_code]}
timelineLoading={shouldShowTimelineLoading && !timelineDataMap[stock.stock_code]}

View File

@@ -74,7 +74,7 @@ const StockListItem = ({
const handleWatchlistClick = (e) => {
e.stopPropagation();
onWatchlistToggle?.(stock.stock_code, isInWatchlist);
onWatchlistToggle?.(stock.stock_code, stock.stock_name, isInWatchlist);
};
// 格式化涨跌幅显示

View File

@@ -1,36 +1,8 @@
.event-detail-modal {
top: 20% !important;
margin: 0 auto !important;
padding-bottom: 0 !important;
.ant-modal-content {
border-radius: 24px !important;
background: transparent;
}
// 标题样式 - 深色文字(白色背景)
.ant-modal-title {
// 事件详情抽屉样式(从底部弹出)
// 注意:大部分样式已在 TSX 的 styles 属性中配置,这里只保留必要的覆盖
.event-detail-drawer {
// 标题样式
.ant-drawer-title {
color: #1A202C;
}
// 关闭按钮样式 - 深色(白色背景)
.ant-modal-close {
color: #4A5568;
&:hover {
color: #1A202C;
}
}
}
// 自底向上滑入动画
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Modal } from 'antd';
import { Drawer } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { selectIsMobile } from '@store/slices/deviceSlice';
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
import './EventDetailModal.less';
@@ -15,7 +16,7 @@ interface EventDetailModalProps {
}
/**
* 事件详情弹窗组件
* 事件详情抽屉组件(从底部弹出)
*/
const EventDetailModal: React.FC<EventDetailModalProps> = ({
open,
@@ -25,23 +26,35 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
const isMobile = useSelector(selectIsMobile);
return (
<Modal
<Drawer
open={open}
onCancel={onClose}
footer={null}
onClose={onClose}
placement="bottom"
height={isMobile ? 'calc(100vh - 60px)' : 'calc(100vh - 100px)'}
width={isMobile ? '100%' : '70vw'}
title={event?.title || '事件详情'}
width='100vw'
destroyOnClose
className="event-detail-modal"
destroyOnHidden
rootClassName="event-detail-drawer"
closeIcon={null}
extra={
<CloseOutlined
onClick={onClose}
style={{ cursor: 'pointer', fontSize: 16, color: '#4A5568' }}
/>
}
styles={{
mask: { background: 'transparent' },
content: { borderRadius: 24, padding: 0, maxWidth: 1400, background: 'transparent', margin: '0 auto' },
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px', borderRadius: '24px 24px 0 0', margin: 0 },
body: { padding: 0 },
wrapper: isMobile ? {} : {
maxWidth: 1400,
margin: '0 auto',
borderRadius: '16px 16px 0 0',
},
content: { borderRadius: '16px 16px 0 0' },
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px' },
body: { padding: 0, background: '#FFFFFF' },
}}
>
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
</Modal>
</Drawer>
);
};

View File

@@ -2,7 +2,7 @@
import React, { useState } from 'react';
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import { useDisclosure } from '@chakra-ui/react';
import { useDisclosure, useBreakpointValue } from '@chakra-ui/react';
import EventDetailModal from './EventDetailModal';
import dayjs from 'dayjs';
import './HotEvents.css';
@@ -31,6 +31,8 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
const [currentSlide, setCurrentSlide] = useState(0);
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
const [modalEvent, setModalEvent] = useState(null);
// H5 端不显示 Tooltip避免触摸触发后无法消除的黑色悬浮框
const isMobile = useBreakpointValue({ base: true, md: false });
const renderPriceChange = (value) => {
if (value === null || value === undefined) {
@@ -154,21 +156,33 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
>
{/* Custom layout without Card.Meta */}
<div className="event-header">
<Tooltip title={event.title}>
{isMobile ? (
<span className="event-title">
{event.title}
</span>
</Tooltip>
) : (
<Tooltip title={event.title}>
<span className="event-title">
{event.title}
</span>
</Tooltip>
)}
<span className="event-tag">
{renderPriceChange(event.related_avg_chg)}
</span>
</div>
<Tooltip title={event.description}>
{isMobile ? (
<div className="event-description">
{event.description}
</div>
</Tooltip>
) : (
<Tooltip title={event.description}>
<div className="event-description">
{event.description}
</div>
</Tooltip>
)}
<div className="event-footer">
<span className="creator">{event.creator?.username || 'Anonymous'}</span>

View File

@@ -7,6 +7,18 @@ 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;
};
/**
* 股票列表表格组件
* 显示事件相关股票列表,包括分时图、涨跌幅、自选股操作等
@@ -260,7 +272,9 @@ const StockTable = ({
width: 150,
fixed: 'right',
render: (_, record) => {
const isInWatchlist = watchlistSet.has(record.stock_code);
// 标准化代码后再比较,确保 600000.SH 和 600000 能匹配
const normalizedCode = normalizeStockCode(record.stock_code);
const isInWatchlist = watchlistSet.has(normalizedCode);
return (
<div style={{ display: 'flex', gap: '4px' }}>
<Button

View File

@@ -5,6 +5,27 @@ import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '../../.
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
* 封装自选股的加载、添加、移除逻辑
@@ -19,9 +40,9 @@ export const useWatchlist = (shouldLoad = true) => {
const watchlistArray = useSelector(state => state.stock.watchlist, shallowEqual);
const loading = useSelector(state => state.stock.loading.watchlist);
// 转换为 Set 方便快速查询
// 转换为 Set 方便快速查询标准化为6位代码
const watchlistSet = useMemo(() => {
return new Set(watchlistArray);
return new Set(watchlistArray.map(normalizeStockCode));
}, [watchlistArray]);
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时)
@@ -33,32 +54,36 @@ export const useWatchlist = (shouldLoad = true) => {
}, [dispatch, shouldLoad]);
/**
* 检查股票是否在自选股中
* @param {string} stockCode - 股票代码
* 检查股票是否在自选股中(支持带后缀的代码格式)
* @param {string} stockCode - 股票代码(支持 600000, 600000.SH 等格式)
* @returns {boolean}
*/
const isInWatchlist = useCallback((stockCode) => {
return watchlistSet.has(stockCode);
const normalized = normalizeStockCode(stockCode);
return watchlistSet.has(normalized);
}, [watchlistSet]);
/**
* 切换自选股状态
* @param {string} stockCode - 股票代码
* @param {string} stockCode - 股票代码(支持带后缀格式,会自动标准化)
* @param {string} stockName - 股票名称
* @returns {Promise<boolean>} 操作是否成功
*/
const toggleWatchlist = useCallback(async (stockCode, stockName) => {
const wasInWatchlist = watchlistSet.has(stockCode);
const normalized = normalizeStockCode(stockCode);
const wasInWatchlist = watchlistSet.has(normalized);
logger.debug('useWatchlist', '切换自选股状态', {
stockCode,
normalized,
stockName,
wasInWatchlist
});
try {
// 传递标准化后的6位代码给 Redux action
await dispatch(toggleWatchlistAction({
stockCode,
stockCode: normalized,
stockName,
isInWatchlist: wasInWatchlist
})).unwrap();
@@ -68,6 +93,7 @@ export const useWatchlist = (shouldLoad = true) => {
} catch (error) {
logger.error('useWatchlist', '切换自选股失败', error, {
stockCode,
normalized,
stockName
});
message.error(error.message || '操作失败,请稍后重试');
@@ -87,16 +113,17 @@ export const useWatchlist = (shouldLoad = true) => {
let successCount = 0;
const promises = stocks.map(async ({ code, name }) => {
if (!watchlistSet.has(code)) {
const normalized = normalizeStockCode(code);
if (!watchlistSet.has(normalized)) {
try {
await dispatch(toggleWatchlistAction({
stockCode: code,
stockCode: normalized,
stockName: name,
isInWatchlist: false
})).unwrap();
successCount++;
} catch (error) {
logger.error('useWatchlist', '添加失败', error, { code, name });
logger.error('useWatchlist', '添加失败', error, { code, normalized, name });
}
}
});

View File

@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router-dom';
import { logger } from '../../../utils/logger';
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
import { RETENTION_EVENTS } from '../../../lib/constants';
import { getEventDetailUrl } from '@/utils/idEncoder';
/**
* 事件筛选逻辑 Hook
@@ -145,7 +146,7 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
});
if (navigate) {
navigate(`/event-detail/${eventId}`);
navigate(getEventDetailUrl(eventId));
}
}, [navigate, track]);

View File

@@ -107,28 +107,6 @@ const Community = () => {
}
}, [events, loading, pagination, filters]);
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
const hasScrolled = useRef(false);
useEffect(() => {
// 只在第一次挂载时执行滚动
if (hasScrolled.current) return;
// 延迟执行确保DOM已完全渲染
const timer = setTimeout(() => {
if (containerRef.current) {
hasScrolled.current = true;
// 滚动到容器顶部,自动考虑导航栏的高度
containerRef.current.scrollIntoView({
behavior: 'auto',
block: 'start',
inline: 'nearest'
});
}
}, 100);
return () => clearTimeout(timer);
}, []); // 空依赖数组,只在组件挂载时执行一次
/**
* ⚡ 【核心逻辑】注册 Socket 新事件回调 - 当收到新事件时智能刷新列表
*

View File

@@ -31,6 +31,7 @@ import {
useDisclosure,
SimpleGrid,
Tooltip,
useBreakpointValue,
} from '@chakra-ui/react';
import {
ChevronDownIcon,
@@ -111,6 +112,9 @@ const ConceptTimelineModal = ({
const [selectedNews, setSelectedNews] = useState(null);
const [isNewsModalOpen, setIsNewsModalOpen] = useState(false);
// 响应式配置
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
// 辅助函数:格式化日期显示(包含年份)
const formatDateDisplay = (dateStr) => {
const date = new Date(dateStr);
@@ -602,37 +606,41 @@ const ConceptTimelineModal = ({
onClose={onClose}
size="full"
scrollBehavior="inside"
isCentered
>
<ModalOverlay />
<ModalContent maxW="1400px" m={4}>
<ModalContent maxW="1400px" m={{ base: 0, md: 'auto' }} mx="auto">
<ModalHeader
bgGradient="linear(135deg, purple.600 0%, purple.500 50%, pink.500 100%)"
color="white"
position="sticky"
top={0}
zIndex={10}
py={6}
py={{ base: 3, md: 6 }}
px={{ base: 3, md: 6 }}
boxShadow="lg"
>
<HStack spacing={4} flexWrap="wrap">
<HStack spacing={{ base: 2, md: 4 }} flexWrap="wrap">
<Icon
as={FaChartLine}
boxSize={6}
boxSize={{ base: 4, md: 6 }}
filter="drop-shadow(0 2px 4px rgba(0,0,0,0.2))"
/>
<Text
fontSize="xl"
fontSize={{ base: 'md', md: 'xl' }}
fontWeight="bold"
textShadow="0 2px 4px rgba(0,0,0,0.2)"
noOfLines={1}
maxW={{ base: '120px', md: 'none' }}
>
{conceptName} - 历史时间轴
</Text>
<Badge
colorScheme="yellow"
px={3}
px={{ base: 2, md: 3 }}
py={1}
borderRadius="full"
fontSize="sm"
fontSize={{ base: 'xs', md: 'sm' }}
boxShadow="md"
>
最近100天
@@ -640,20 +648,29 @@ const ConceptTimelineModal = ({
<Badge
bg="whiteAlpha.300"
color="white"
px={3}
px={{ base: 2, md: 3 }}
py={1}
borderRadius="full"
fontSize="xs"
backdropFilter="blur(10px)"
display={{ base: 'none', sm: 'flex' }}
>
🔥 Max版功能
</Badge>
</HStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalCloseButton
color="white"
size="lg"
top={{ base: 2, md: 4 }}
right={{ base: 2, md: 4 }}
_hover={{ bg: 'whiteAlpha.300' }}
zIndex={20}
/>
<ModalBody
py={6}
py={{ base: 2, md: 6 }}
px={{ base: 0, md: 6 }}
bg="gray.50"
css={{
'&::-webkit-scrollbar': {
@@ -680,103 +697,116 @@ const ConceptTimelineModal = ({
</VStack>
</Center>
) : timelineData.length > 0 ? (
<Box position="relative" maxW="1200px" mx="auto" px={4}>
{/* 图例说明 */}
<Flex justify="center" mb={6} flexWrap="wrap" gap={4}>
<Box position="relative" maxW="1200px" mx="auto" px={{ base: 2, md: 4 }}>
{/* 图例说明 - H5端保持一行 */}
<Flex
justify="center"
mb={{ base: 3, md: 6 }}
flexWrap={{ base: 'nowrap', md: 'wrap' }}
gap={{ base: 1, md: 4 }}
overflowX={{ base: 'auto', md: 'visible' }}
pb={{ base: 2, md: 0 }}
css={{
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}}
>
<HStack
spacing={2}
px={4}
py={2}
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="purple.50"
borderRadius="lg"
border="1px solid"
borderColor="purple.200"
boxShadow="sm"
transition="all 0.2s"
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
flexShrink={0}
>
<Box w={3} h={3} bg="#9F7AEA" borderRadius="full" boxShadow="sm" />
<Text fontSize="sm" fontWeight="medium" color="gray.700">📰 新闻</Text>
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#9F7AEA" borderRadius="full" />
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">📰 新闻</Text>
</HStack>
<HStack
spacing={2}
px={4}
py={2}
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="purple.50"
borderRadius="lg"
border="1px solid"
borderColor="purple.300"
boxShadow="sm"
transition="all 0.2s"
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
flexShrink={0}
>
<Box w={3} h={3} bg="#805AD5" borderRadius="full" boxShadow="sm" />
<Text fontSize="sm" fontWeight="medium" color="gray.700">📊 研报</Text>
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#805AD5" borderRadius="full" />
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">📊 研报</Text>
</HStack>
<HStack
spacing={2}
px={4}
py={2}
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="red.50"
borderRadius="lg"
border="1px solid"
borderColor="red.200"
boxShadow="sm"
transition="all 0.2s"
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
flexShrink={0}
>
<Icon as={FaArrowUp} color="red.500" boxSize={3} />
<Text fontSize="sm" fontWeight="medium" color="gray.700">上涨</Text>
<Icon as={FaArrowUp} color="red.500" boxSize={{ base: 2, md: 3 }} />
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">上涨</Text>
</HStack>
<HStack
spacing={2}
px={4}
py={2}
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="green.50"
borderRadius="lg"
border="1px solid"
borderColor="green.200"
boxShadow="sm"
transition="all 0.2s"
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
flexShrink={0}
>
<Icon as={FaArrowDown} color="green.500" boxSize={3} />
<Text fontSize="sm" fontWeight="medium" color="gray.700">下跌</Text>
<Icon as={FaArrowDown} color="green.500" boxSize={{ base: 2, md: 3 }} />
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">下跌</Text>
</HStack>
<HStack
spacing={2}
px={4}
py={2}
spacing={{ base: 1, md: 2 }}
px={{ base: 2, md: 4 }}
py={{ base: 1, md: 2 }}
bg="orange.50"
borderRadius="lg"
border="1px solid"
borderColor="orange.200"
boxShadow="sm"
transition="all 0.2s"
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
flexShrink={0}
>
<Text fontSize="sm" fontWeight="bold">🔥</Text>
<Text fontSize="sm" fontWeight="medium" color="gray.700">涨3%+</Text>
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="bold">🔥</Text>
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">涨3%+</Text>
</HStack>
</Flex>
{/* FullCalendar 日历组件 */}
<Box
height={{ base: '600px', md: '700px' }}
height={{ base: '500px', md: '700px' }}
bg="white"
borderRadius="xl"
boxShadow="lg"
p={4}
borderRadius={{ base: 'none', md: 'xl' }}
boxShadow={{ base: 'none', md: 'lg' }}
p={{ base: 1, md: 4 }}
sx={{
// FullCalendar 样式定制
'.fc': {
height: '100%',
},
'.fc-header-toolbar': {
marginBottom: '1.5rem',
marginBottom: { base: '0.5rem', md: '1.5rem' },
padding: { base: '0 4px', md: '0' },
flexWrap: 'nowrap',
gap: { base: '4px', md: '8px' },
},
'.fc-toolbar-chunk': {
display: 'flex',
alignItems: 'center',
},
'.fc-toolbar-title': {
fontSize: '1.5rem',
fontSize: { base: '1rem', md: '1.5rem' },
fontWeight: 'bold',
color: 'purple.600',
},
@@ -784,6 +814,8 @@ const ConceptTimelineModal = ({
backgroundColor: '#9F7AEA',
borderColor: '#9F7AEA',
color: 'white',
padding: { base: '4px 8px', md: '6px 12px' },
fontSize: { base: '12px', md: '14px' },
'&:hover': {
backgroundColor: '#805AD5',
borderColor: '#805AD5',
@@ -806,14 +838,18 @@ const ConceptTimelineModal = ({
},
},
'.fc-daygrid-day-number': {
padding: '4px',
fontSize: '0.875rem',
padding: { base: '2px', md: '4px' },
fontSize: { base: '0.75rem', md: '0.875rem' },
},
'.fc-col-header-cell-cushion': {
fontSize: { base: '0.75rem', md: '0.875rem' },
padding: { base: '4px 2px', md: '8px' },
},
'.fc-event': {
cursor: 'pointer',
border: 'none',
padding: '2px 4px',
fontSize: '0.75rem',
padding: { base: '1px 2px', md: '2px 4px' },
fontSize: { base: '0.65rem', md: '0.75rem' },
fontWeight: 'bold',
borderRadius: '4px',
transition: 'all 0.2s',
@@ -823,7 +859,13 @@ const ConceptTimelineModal = ({
},
},
'.fc-daygrid-event-harness': {
marginBottom: '2px',
marginBottom: { base: '1px', md: '2px' },
},
// H5 端隐藏事件文字,只显示色块
'@media (max-width: 768px)': {
'.fc-event-title': {
fontSize: '0.6rem',
},
},
}}
>
@@ -882,32 +924,11 @@ const ConceptTimelineModal = ({
)}
{/* 风险提示 */}
<Box px={6}>
<Box px={{ base: 2, md: 6 }}>
<RiskDisclaimer variant="default" />
</Box>
</ModalBody>
<ModalFooter
borderTop="2px solid"
borderColor="purple.100"
bg="gray.50"
py={4}
>
<Button
colorScheme="purple"
size="lg"
px={8}
onClick={onClose}
boxShadow="md"
_hover={{
transform: 'translateY(-2px)',
boxShadow: 'lg',
}}
transition="all 0.2s"
>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)}

View File

@@ -78,6 +78,7 @@ import {
MenuList,
MenuItem,
Collapse,
useBreakpointValue,
} from '@chakra-ui/react';
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa';
@@ -85,6 +86,8 @@ import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react';
import ConceptTimelineModal from './ConceptTimelineModal';
import ConceptStatsPanel from './components/ConceptStatsPanel';
import ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker';
// 导航栏已由 MainLayout 提供,无需在此导入
// 导入订阅权限管理
import { useSubscription } from '../../hooks/useSubscription';
@@ -527,109 +530,6 @@ const ConceptCenter = () => {
return `https://valuefrontier.cn/company?scode=${seccode}`;
};
// 渲染动态表格列
const renderStockTable = () => {
if (!selectedConceptStocks || selectedConceptStocks.length === 0) {
return <Text>暂无相关股票数据</Text>;
}
const allFields = new Set();
selectedConceptStocks.forEach(stock => {
Object.keys(stock).forEach(key => allFields.add(key));
});
// 定义固定的列顺序,包含新增的现价和涨跌幅列
const orderedFields = ['stock_name', 'stock_code', 'current_price', 'change_percent'];
allFields.forEach(field => {
if (!orderedFields.includes(field)) {
orderedFields.push(field);
}
});
return (
<Box>
{loadingStockData && (
<Box mb={4} textAlign="center">
<HStack justify="center" spacing={2}>
<Spinner size="sm" color="purple.500" />
<Text fontSize="sm" color="gray.600">正在获取行情数据...</Text>
</HStack>
</Box>
)}
<TableContainer maxH="60vh" overflowY="auto">
<Table variant="simple" size="sm">
<Thead position="sticky" top={0} bg="white" zIndex={1}>
<Tr>
{orderedFields.map(field => (
<Th key={field}>
{field === 'stock_name' ? '股票名称' :
field === 'stock_code' ? '股票代码' :
field === 'current_price' ? '现价' :
field === 'change_percent' ? '当日涨跌幅' : field}
</Th>
))}
</Tr>
</Thead>
<Tbody>
{selectedConceptStocks.map((stock, idx) => {
const marketData = stockMarketData[stock.stock_code];
const companyLink = generateCompanyLink(stock.stock_code);
return (
<Tr key={idx} _hover={{ bg: 'gray.50' }}>
{orderedFields.map(field => {
let cellContent = stock[field] || '-';
let cellProps = {};
// 处理特殊字段
if (field === 'current_price') {
cellContent = marketData ? formatPrice(marketData.close) : (loadingStockData ? <Spinner size="xs" /> : '-');
} else if (field === 'change_percent') {
if (marketData) {
cellContent = formatStockChangePercent(marketData.change_percent);
cellProps.color = `${getStockChangeColor(marketData.change_percent)}.500`;
cellProps.fontWeight = 'bold';
} else {
cellContent = loadingStockData ? <Spinner size="xs" /> : '-';
}
} else if (field === 'stock_name' || field === 'stock_code') {
// 添加超链接
cellContent = (
<Text
as="a"
href={companyLink}
target="_blank"
rel="noopener noreferrer"
color="blue.600"
textDecoration="underline"
_hover={{
color: 'blue.800',
textDecoration: 'underline'
}}
cursor="pointer"
>
{stock[field] || '-'}
</Text>
);
}
return (
<Td key={field} {...cellProps}>
{cellContent}
</Td>
);
})}
</Tr>
);
})}
</Tbody>
</Table>
</TableContainer>
</Box>
);
};
// 格式化添加日期显示
const formatAddedDate = (concept) => {
// 优先使用 created_at 或 added_date 字段
@@ -672,6 +572,10 @@ const ConceptCenter = () => {
const changePercent = concept.price_info?.avg_change_pct;
const changeColor = getChangeColor(changePercent);
const hasChange = changePercent !== null && changePercent !== undefined;
// H5 端使用更紧凑的尺寸
const isMobile = useBreakpointValue({ base: true, md: false });
const coverHeight = useBreakpointValue({ base: '100px', md: '180px' });
const logoSize = useBreakpointValue({ base: '60px', md: '120px' });
// 生成随机涨幅数字背景
const generateNumbersBackground = () => {
@@ -705,7 +609,7 @@ const ConceptCenter = () => {
boxShadow="0 4px 12px rgba(0, 0, 0, 0.1)"
>
{/* 毛玻璃涨幅数字背景 */}
<Box position="relative" height="180px" overflow="hidden">
<Box position="relative" height={coverHeight} overflow="hidden">
{/* 渐变背景层 */}
<Box
position="absolute"
@@ -757,8 +661,8 @@ const ConceptCenter = () => {
top="50%"
left="50%"
transform="translate(-50%, -50%)"
width="120px"
height="120px"
width={logoSize}
height={logoSize}
opacity={0.15}
>
<Image
@@ -849,11 +753,11 @@ const ConceptCenter = () => {
</Badge>
</Box>
<CardBody p={4}>
<VStack align="start" spacing={2}>
<CardBody p={{ base: 3, md: 4 }}>
<VStack align="start" spacing={{ base: 1, md: 2 }}>
{/* 概念名称 */}
<Heading
size="sm"
size={{ base: 'xs', md: 'sm' }}
color="gray.800"
noOfLines={1}
bgGradient="linear(to-r, purple.600, pink.600)"
@@ -863,15 +767,15 @@ const ConceptCenter = () => {
{concept.concept}
</Heading>
{/* 描述信息 */}
<Text color="gray.600" fontSize="xs" noOfLines={2} minH="32px">
{/* 描述信息 - H5端显示1行 */}
<Text color="gray.600" fontSize="xs" noOfLines={isMobile ? 1 : 2} minH={{ base: '16px', md: '32px' }}>
{concept.description || '暂无描述信息'}
</Text>
{concept.stocks && concept.stocks.length > 0 && (
<Box
width="100%"
p={3}
p={{ base: 2, md: 3 }}
bg="linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(168, 85, 247, 0.05) 100%)"
borderRadius="lg"
cursor="pointer"
@@ -886,7 +790,7 @@ const ConceptCenter = () => {
>
<Flex align="center" justify="space-between">
<Box flex={1}>
<HStack spacing={2} mb={2}>
<HStack spacing={2} mb={{ base: 1, md: 2 }}>
<Icon as={FaChartLine} boxSize={3} color="purple.500" />
<Text fontSize="xs" color="purple.700" fontWeight="bold">
热门个股
@@ -942,20 +846,20 @@ const ConceptCenter = () => {
</Box>
)}
<Divider borderColor="purple.100" />
<Divider borderColor="purple.100" my={{ base: 1, md: 0 }} />
<Flex width="100%" justify="space-between" align="center">
{formatAddedDate(concept)}
<Button
size="sm"
size={{ base: 'xs', md: 'sm' }}
leftIcon={<FaHistory />}
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
variant="solid"
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)}
borderRadius="full"
px={4}
px={{ base: 2, md: 4 }}
fontWeight="medium"
boxShadow="0 4px 12px rgba(139, 92, 246, 0.3)"
_hover={{
@@ -1179,23 +1083,23 @@ const ConceptCenter = () => {
align={{ base: 'stretch', lg: 'center' }}
gap={4}
>
<HStack spacing={3}>
<Icon as={FaCalendarAlt} color="purple.500" boxSize={5} />
<Text fontWeight="bold" color="purple.700">交易日期</Text>
</HStack>
<Input
type="date"
value={selectedDate ? selectedDate.toISOString().split('T')[0] : ''}
onChange={handleDateChange}
max={new Date().toISOString().split('T')[0]}
width={{ base: '100%', lg: '200px' }}
focusBorderColor="purple.500"
borderColor="purple.200"
borderRadius="lg"
fontWeight="medium"
{/* 使用通用日期选择器组件 */}
<TradeDatePicker
value={selectedDate}
onChange={(date) => {
const dateStr = date.toISOString().split('T')[0];
const previousDate = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
trackFilterApplied('date', dateStr, previousDate);
setSelectedDate(date);
setCurrentPage(1);
updateUrlParams({ date: dateStr, page: 1 });
fetchConcepts(searchQuery, 1, date, sortBy);
}}
latestTradeDate={latestTradeDate}
label="交易日期"
/>
{/* 快捷按钮保留在页面内 */}
<ButtonGroup size="sm" variant="outline" flexWrap="wrap">
<Button
onClick={() => handleQuickDateSelect(0)}
@@ -1246,25 +1150,6 @@ const ConceptCenter = () => {
一月前
</Button>
</ButtonGroup>
{latestTradeDate && (
<Tooltip label="数据库中最新的交易日期">
<HStack
spacing={2}
bg="blue.50"
px={3}
py={1.5}
borderRadius="full"
border="1px solid"
borderColor="blue.200"
>
<Icon as={InfoIcon} color="blue.500" boxSize={3} />
<Text fontSize="sm" color="blue.600" fontWeight="medium">
最新: {latestTradeDate.toLocaleDateString('zh-CN')}
</Text>
</HStack>
</Tooltip>
)}
</Flex>
</Box>
);
@@ -1463,7 +1348,7 @@ const ConceptCenter = () => {
fontSize="md"
transition="all 0.2s"
border="none"
height="100%"
alignSelf="stretch"
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
>
搜索
@@ -1598,7 +1483,7 @@ const ConceptCenter = () => {
)}
{loading ? (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}>
{[...Array(12)].map((_, i) => (
<SkeletonCard key={i} />
))}
@@ -1606,7 +1491,7 @@ const ConceptCenter = () => {
) : concepts.length > 0 ? (
<>
{viewMode === 'grid' ? (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6} className="concept-grid">
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }} className="concept-grid">
{concepts.map((concept, index) => (
<Box key={concept.concept_id} className="concept-item" role="group">
<ConceptCard concept={concept} position={index} />
@@ -1758,32 +1643,15 @@ const ConceptCenter = () => {
</Flex>
</Container>
{/* 股票详情Modal */}
<Modal
{/* 股票详情Modal - 复用通用组件 */}
<ConceptStocksModal
isOpen={isStockModalOpen}
onClose={() => setIsStockModalOpen(false)}
size="6xl"
scrollBehavior="inside"
>
<ModalOverlay />
<ModalContent>
<ModalHeader bg="purple.500" color="white">
<HStack>
<Icon as={FaTable} />
<Text>{selectedConceptName} - 相关个股</Text>
</HStack>
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody py={6}>
{renderStockTable()}
</ModalBody>
<ModalFooter>
<Button colorScheme="purple" onClick={() => setIsStockModalOpen(false)}>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>
concept={{
concept_name: selectedConceptName,
stocks: selectedConceptStocks
}}
/>
{/* 时间轴Modal */}
<ConceptTimelineModal
isOpen={isTimelineModalOpen}

View File

@@ -62,6 +62,7 @@ import {
} from 'react-icons/fi';
import MyFutureEvents from './components/MyFutureEvents';
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
import { getEventDetailUrl } from '@/utils/idEncoder';
export default function CenterDashboard() {
const { user } = useAuth();
@@ -441,7 +442,7 @@ export default function CenterDashboard() {
<VStack align="stretch" spacing={3}>
<LinkOverlay
as={Link}
to={`/event-detail/${event.id}`}
to={getEventDetailUrl(event.id)}
>
<Text fontWeight="medium" fontSize="md" noOfLines={2}>
{event.title}

View File

@@ -1,909 +1,88 @@
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useLocation } from 'react-router-dom';
/**
* EventDetail - 事件详情页面
* 使用 DynamicNewsDetailPanel 组件展示事件详情
*/
import React, { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import {
Box,
Container,
VStack,
HStack,
Spinner,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Flex,
useColorModeValue,
Grid,
GridItem,
Icon,
Text,
Badge,
Divider,
useDisclosure,
Button,
Heading,
Stat,
StatLabel,
StatNumber,
StatHelpText,
SimpleGrid,
Tabs,
TabList,
TabPanels,
Tab,
TabPanel,
Textarea,
Avatar,
IconButton,
Input,
Collapse,
Center,
useToast,
Skeleton,
Box,
Spinner,
Center,
} from '@chakra-ui/react';
import { FiLock } from 'react-icons/fi';
import {
FiTrendingUp,
FiActivity,
FiMessageSquare,
FiClock,
FiBarChart2,
FiLink,
FiZap,
FiGlobe,
FiHeart,
FiTrash2,
FiChevronDown,
FiChevronUp,
} from 'react-icons/fi';
import { FaHeart, FaRegHeart, FaComment } from 'react-icons/fa';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
// 导入新建的业务组件
import EventHeader from './components/EventHeader';
import RelatedConcepts from './components/RelatedConcepts';
import HistoricalEvents from './components/HistoricalEvents';
import RelatedStocks from './components/RelatedStocks';
// Navigation bar now provided by MainLayout
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
import { useAuth } from '../../contexts/AuthContext';
import { useSubscription } from '../../hooks/useSubscription';
import TransmissionChainAnalysis from './components/TransmissionChainAnalysis';
// 导入你的 Flask API 服务
import { eventService } from '../../services/eventService';
import { debugEventService } from '../../utils/debugEventService';
import { logger } from '../../utils/logger';
import { useEventDetailEvents } from './hooks/useEventDetailEvents';
// 临时调试代码 - 生产环境测试后请删除
if (typeof window !== 'undefined') {
logger.debug('EventDetail', '调试 eventService');
debugEventService();
}
// 统计卡片组件 - 更简洁的设计
const StatCard = ({ icon, label, value, color }) => {
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const iconColor = useColorModeValue(`${color}.500`, `${color}.300`);
return (
<Stat
p={6}
bg={bg}
borderRadius="lg"
borderWidth="1px"
borderColor={borderColor}
_hover={{ shadow: 'md' }}
transition="all 0.2s"
>
<HStack spacing={3} align="flex-start">
<Icon as={icon} boxSize={5} color={iconColor} mt={1} />
<Box flex={1}>
<StatLabel color="gray.500" fontSize="sm">{label}</StatLabel>
<StatNumber fontSize="2xl" color={iconColor}>{value}</StatNumber>
</Box>
</HStack>
</Stat>
);
};
// 帖子组件
const PostItem = ({ post, onRefresh, eventEvents }) => {
const [showComments, setShowComments] = useState(false);
const [comments, setComments] = useState([]);
const [newComment, setNewComment] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [liked, setLiked] = useState(post.liked || false);
const [likesCount, setLikesCount] = useState(post.likes_count || 0);
const toast = useToast();
const { user } = useAuth();
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
const loadComments = async () => {
if (!showComments) {
setShowComments(true);
setIsLoading(true);
try {
const result = await eventService.getPostComments(post.id);
if (result.success) {
setComments(result.data);
}
} catch (error) {
logger.error('PostItem', 'loadComments', error, { postId: post.id });
} finally {
setIsLoading(false);
}
} else {
setShowComments(false);
}
};
const handleLike = async () => {
try {
const result = await eventService.likePost(post.id);
if (result.success) {
const newLikedState = result.liked;
setLiked(newLikedState);
setLikesCount(result.likes_count);
// 🎯 追踪评论点赞
if (eventEvents && eventEvents.trackCommentLiked) {
eventEvents.trackCommentLiked(post.id, newLikedState);
}
}
} catch (error) {
toast({
title: '操作失败',
status: 'error',
duration: 2000,
});
}
};
const handleAddComment = async () => {
if (!newComment.trim()) return;
try {
const result = await eventService.addPostComment(post.id, {
content: newComment,
});
if (result.success) {
// 🎯 追踪添加评论
if (eventEvents && eventEvents.trackCommentAdded) {
eventEvents.trackCommentAdded(
result.data?.id || post.id,
newComment.length
);
}
toast({
title: '评论发表成功',
status: 'success',
duration: 2000,
});
setNewComment('');
// 重新加载评论
const commentsResult = await eventService.getPostComments(post.id);
if (commentsResult.success) {
setComments(commentsResult.data);
}
}
} catch (error) {
toast({
title: '评论失败',
status: 'error',
duration: 2000,
});
}
};
const handleDelete = async () => {
if (window.confirm('确定要删除这个帖子吗?')) {
try {
const result = await eventService.deletePost(post.id);
if (result.success) {
// 🎯 追踪删除评论
if (eventEvents && eventEvents.trackCommentDeleted) {
eventEvents.trackCommentDeleted(post.id);
}
toast({
title: '删除成功',
status: 'success',
duration: 2000,
});
onRefresh();
}
} catch (error) {
toast({
title: '删除失败',
status: 'error',
duration: 2000,
});
}
}
};
return (
<Box
bg={bg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="lg"
p={6}
mb={4}
>
{/* 帖子头部 */}
<HStack justify="space-between" mb={4}>
<HStack spacing={3}>
<Avatar
size="sm"
name={post.user?.username}
src={post.user?.avatar_url}
/>
<VStack align="start" spacing={0}>
<Text fontWeight="medium">{post.user?.username || '匿名用户'}</Text>
<Text fontSize="sm" color="gray.500">
{format(new Date(post.created_at), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
</Text>
</VStack>
</HStack>
<IconButton
icon={<FiTrash2 />}
variant="ghost"
size="sm"
onClick={handleDelete}
/>
</HStack>
{/* 帖子内容 */}
{post.title && (
<Heading size="md" mb={2}>
{post.title}
</Heading>
)}
<Text mb={4} whiteSpace="pre-wrap">
{post.content}
</Text>
{/* 操作栏 */}
<HStack spacing={4}>
<Button
size="sm"
variant="ghost"
leftIcon={liked ? <FaHeart /> : <FaRegHeart />}
color={liked ? 'red.500' : 'gray.500'}
onClick={handleLike}
>
{likesCount}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<FaComment />}
rightIcon={showComments ? <FiChevronUp /> : <FiChevronDown />}
onClick={loadComments}
>
{post.comments_count || 0} 评论
</Button>
</HStack>
{/* 评论区 */}
<Collapse in={showComments} animateOpacity>
<Box mt={4} pt={4} borderTopWidth="1px" borderColor={borderColor}>
{/* 评论输入 */}
<HStack mb={4}>
<Textarea
placeholder="写下你的评论..."
size="sm"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
rows={2}
/>
<Button
colorScheme="blue"
size="sm"
onClick={handleAddComment}
isDisabled={!newComment.trim()}
>
评论
</Button>
</HStack>
{/* 评论列表 */}
{isLoading ? (
<Center py={4}>
<Spinner size="sm" />
</Center>
) : (
<VStack align="stretch" spacing={3}>
{comments.map((comment) => (
<Box key={comment.id} pl={4} borderLeftWidth="2px" borderColor="gray.200">
<HStack mb={1}>
<Text fontWeight="medium" fontSize="sm">
{comment.user?.username || '匿名用户'}
</Text>
<Text fontSize="xs" color="gray.500">
{format(new Date(comment.created_at), 'MM-dd HH:mm')}
</Text>
</HStack>
<Text fontSize="sm">{comment.content}</Text>
</Box>
))}
{comments.length === 0 && (
<Text color="gray.500" textAlign="center" py={2}>
暂无评论
</Text>
)}
</VStack>
)}
</Box>
</Collapse>
</Box>
);
};
import { decodeEventId } from '@/utils/idEncoder';
import { eventService } from '@/services/eventService';
import { DynamicNewsDetailPanel } from '@/views/Community/components/DynamicNewsDetail';
import { logger } from '@/utils/logger';
import ErrorPage from '@/components/ErrorPage';
const EventDetail = () => {
const { eventId } = useParams();
const location = useLocation();
const bgColor = useColorModeValue('gray.50', 'gray.900');
const toast = useToast();
const { eventId: pathEventId } = useParams();
const [searchParams] = useSearchParams();
// 用户认证和权限控制
const { user } = useAuth();
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
// 优先从查询参数获取加密 ID兼容旧的路径参数
const encodedId = searchParams.get('id');
const eventId = encodedId ? decodeEventId(encodedId) : pathEventId;
// 滚动位置管理
const scrollPositionRef = useRef(0);
// State hooks
const [eventData, setEventData] = useState(null);
const [relatedStocks, setRelatedStocks] = useState([]);
const [relatedConcepts, setRelatedConcepts] = useState([]);
const [historicalEvents, setHistoricalEvents] = useState([]);
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [postsLoading, setPostsLoading] = useState(false);
const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState(0);
// 🎯 初始化事件详情埋点Hook传入event对象
const eventEvents = useEventDetailEvents({
event: eventData ? {
id: eventData.id,
title: eventData.title,
importance: eventData.importance
} : null
});
const [newPostContent, setNewPostContent] = useState('');
const [newPostTitle, setNewPostTitle] = useState('');
const [submitting, setSubmitting] = useState(false);
const [upgradeModal, setUpgradeModal] = useState({ isOpen: false, feature: '功能', required: 'pro' });
// 从URL路径中提取eventId处理多种URL格式
const getEventIdFromPath = () => {
const pathParts = location.pathname.split('/');
const lastPart = pathParts[pathParts.length - 1];
const secondLastPart = pathParts[pathParts.length - 2];
if (!isNaN(lastPart) && lastPart) {
return lastPart;
}
if (!isNaN(secondLastPart) && secondLastPart) {
return secondLastPart;
}
return eventId;
};
const actualEventId = getEventIdFromPath();
// 保存当前滚动位置
const saveScrollPosition = () => {
scrollPositionRef.current = window.scrollY || window.pageYOffset;
};
// 恢复滚动位置
const restoreScrollPosition = () => {
window.scrollTo(0, scrollPositionRef.current);
};
// 状态
const [eventData, setEventData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// 加载事件基础数据
useEffect(() => {
const loadEventData = async () => {
try {
setLoading(true);
setError(null);
if (!eventId) {
setError('无效的事件ID');
setLoading(false);
return;
}
// 加载基本事件信息(免费用户也可以访问)
const eventResponse = await eventService.getEventDetail(actualEventId);
setEventData(eventResponse.data);
// 总是尝试加载相关股票(权限在组件内部检查)
let stocksCount = 0;
try {
const stocksResponse = await eventService.getRelatedStocks(actualEventId);
setRelatedStocks(stocksResponse.data || []);
stocksCount = stocksResponse.data?.length || 0;
} catch (e) {
logger.warn('EventDetail', '加载相关股票失败', { eventId: actualEventId, error: e.message });
setRelatedStocks([]);
}
// 根据权限决定是否加载相关概念
if (hasFeatureAccess('related_concepts')) {
try {
const conceptsResponse = await eventService.getRelatedConcepts(actualEventId);
setRelatedConcepts(conceptsResponse.data || []);
} catch (e) {
logger.warn('EventDetail', '加载相关概念失败', { eventId: actualEventId, error: e.message });
}
}
// 历史事件所有用户都可以访问但免费用户只看到前2条
let timelineCount = 0;
try {
const eventsResponse = await eventService.getHistoricalEvents(actualEventId);
setHistoricalEvents(eventsResponse.data || []);
timelineCount = eventsResponse.data?.length || 0;
} catch (e) {
logger.warn('EventDetail', '历史事件加载失败', { eventId: actualEventId, error: e.message });
}
// 🎯 追踪事件分析内容查看(数据加载完成后)
if (eventResponse.data && eventEvents) {
eventEvents.trackEventAnalysisViewed({
type: 'overview',
relatedStockCount: stocksCount,
timelineEventCount: timelineCount,
marketImpact: eventResponse.data.market_impact
});
}
} catch (err) {
logger.error('EventDetail', 'loadEventData', err, { eventId: actualEventId });
setError(err.message || '加载事件数据失败');
} finally {
setLoading(false);
}
try {
setLoading(true);
setError(null);
const response = await eventService.getEventDetail(eventId);
setEventData(response.data);
} catch (err) {
logger.error('EventDetail', 'loadEventData', err, { eventId });
setError(err.message || '加载事件数据失败');
} finally {
setLoading(false);
}
};
const refetchStocks = async () => {
if (!hasFeatureAccess('related_stocks')) return;
try {
const stocksResponse = await eventService.getRelatedStocks(actualEventId);
setRelatedStocks(stocksResponse.data);
} catch (err) {
logger.error('EventDetail', 'refetchStocks', err, { eventId: actualEventId });
}
};
loadEventData();
}, [eventId]);
const handleFollowToggle = async () => {
try {
await eventService.toggleFollow(actualEventId, eventData.is_following);
setEventData(prev => ({
...prev,
is_following: !prev.is_following,
follower_count: prev.is_following
? prev.follower_count - 1
: prev.follower_count + 1
}));
} catch (err) {
logger.error('EventDetail', 'handleFollowToggle', err, { eventId: actualEventId });
}
};
// 加载帖子列表
const loadPosts = async () => {
setPostsLoading(true);
try {
const result = await eventService.getPosts(actualEventId);
if (result.success) {
setPosts(result.data || []);
}
} catch (err) {
logger.error('EventDetail', 'loadPosts', err, { eventId: actualEventId });
} finally {
setPostsLoading(false);
}
};
// 创建新帖子
const handleCreatePost = async () => {
if (!newPostContent.trim()) return;
setSubmitting(true);
try {
const result = await eventService.createPost(actualEventId, {
title: newPostTitle.trim(),
content: newPostContent.trim(),
content_type: 'text',
});
if (result.success) {
toast({
title: '帖子发布成功',
status: 'success',
duration: 2000,
});
setNewPostContent('');
setNewPostTitle('');
loadPosts();
// 更新帖子数
setEventData(prev => ({
...prev,
post_count: (prev.post_count || 0) + 1
}));
}
} catch (err) {
toast({
title: '发布失败',
description: err.message,
status: 'error',
duration: 3000,
});
} finally {
setSubmitting(false);
}
};
// Effect hook - must be called after all state hooks
useEffect(() => {
if (actualEventId) {
// 保存当前滚动位置
saveScrollPosition();
loadEventData();
loadPosts();
// 数据加载完成后恢复滚动位置
// 使用 setTimeout 确保 DOM 已更新
const timer = setTimeout(() => {
restoreScrollPosition();
}, 100);
return () => clearTimeout(timer);
} else {
setError('无效的事件ID');
setLoading(false);
}
}, [actualEventId, location.pathname]);
// 加载状态
if (loading) {
return (
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
<Container maxW="7xl" py={8}>
<VStack spacing={6}>
<Skeleton height="150px" borderRadius="lg" />
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} w="100%">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} height="80px" borderRadius="md" />
))}
</SimpleGrid>
<Grid templateColumns={{ base: "1fr", lg: "1fr 1fr" }} gap={6} w="100%">
<Skeleton height="300px" borderRadius="lg" />
<Skeleton height="300px" borderRadius="lg" />
</Grid>
</VStack>
</Container>
</Box>
);
}
// 错误状态
if (error) {
return (
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
<Container maxW="7xl" py={8}>
<Center minH="60vh">
<Alert
status="error"
borderRadius="lg"
maxW="md"
flexDirection="column"
textAlign="center"
p={6}
>
<AlertIcon boxSize="40px" mr={0} />
<AlertTitle mt={4} mb={2} fontSize="lg">
加载失败
</AlertTitle>
<AlertDescription maxWidth="sm">
{error}
{actualEventId && (
<Text mt={2} fontSize="sm" color="gray.500">
事件ID: {actualEventId}
</Text>
)}
</AlertDescription>
</Alert>
</Center>
</Container>
</Box>
);
}
// 主要内容
// 加载状态
if (loading) {
return (
<Box bg={bgColor} minH="100vh" w="100%">
{/* Navigation bar provided by MainLayout */}
<Container maxW="7xl" py={8}>
<VStack spacing={6} align="stretch">
{/* 事件基本信息 */}
<Box
bg={useColorModeValue('white', 'gray.800')}
borderRadius="lg"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
p={6}
>
<EventHeader
event={eventData}
onFollowToggle={handleFollowToggle}
/>
</Box>
{/* 统计数据 */}
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
<StatCard
icon={FiTrendingUp}
label="关注度"
value={eventData?.follower_count || 0}
color="blue"
/>
<StatCard
icon={hasFeatureAccess('related_stocks') ? FiActivity : FiLock}
label="相关标的"
value={hasFeatureAccess('related_stocks') ? relatedStocks.length : '🔒需Pro'}
color={hasFeatureAccess('related_stocks') ? "green" : "orange"}
/>
<StatCard
icon={FiZap}
label="预期偏离度"
value={`${(eventData?.expectation_surprise_score || 0).toFixed(1)}%`}
color="purple"
/>
<StatCard
icon={FiMessageSquare}
label="讨论数"
value={eventData?.post_count || 0}
color="orange"
/>
</SimpleGrid>
{/* 主要内容标签页 */}
<Tabs colorScheme="blue" size="md">
<TabList>
<Tab>
相关标的
{!hasFeatureAccess('related_stocks') && (
<Icon as={FiLock} ml={1} boxSize={3} color="orange.400" />
)}
</Tab>
<Tab>
相关概念
{!hasFeatureAccess('related_concepts') && (
<Icon as={FiLock} ml={1} boxSize={3} color="orange.400" />
)}
</Tab>
<Tab>历史事件</Tab>
<Tab>
传导链分析
{!hasFeatureAccess('transmission_chain') && (
<Icon as={FiLock} ml={1} boxSize={3} color="purple.400" />
)}
</Tab>
<Tab>讨论区</Tab>
</TabList>
<TabPanels>
{/* 相关标的标签页 */}
<TabPanel px={0}>
<Box
bg={useColorModeValue('white', 'gray.800')}
borderRadius="lg"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
p={6}
>
{!hasFeatureAccess('related_stocks') ? (
<VStack spacing={3} align="center" py={8}>
<Icon as={FiLock} boxSize={8} color="orange.400" />
<Text>该功能为Pro专享请升级订阅后查看相关标的</Text>
<Button colorScheme="blue" onClick={() => setUpgradeModal({ isOpen: true, feature: '相关标的', required: 'pro' })}>升级到Pro版</Button>
</VStack>
) : (
<RelatedStocks
eventId={actualEventId}
eventTime={eventData?.created_at}
stocks={relatedStocks}
loading={false}
error={null}
onStockAdded={refetchStocks}
onStockDeleted={refetchStocks}
/>
)}
</Box>
</TabPanel>
{/* 相关概念标签页 */}
<TabPanel px={0}>
<Box
bg={useColorModeValue('white', 'gray.800')}
borderRadius="lg"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
p={6}
>
{!hasFeatureAccess('related_concepts') ? (
<VStack spacing={3} align="center" py={8}>
<Icon as={FiLock} boxSize={8} color="orange.400" />
<Text>该功能为Pro专享请升级订阅后查看相关概念</Text>
<Button colorScheme="blue" onClick={() => setUpgradeModal({ isOpen: true, feature: '相关概念', required: 'pro' })}>升级到Pro版</Button>
</VStack>
) : (
<RelatedConcepts
eventTitle={eventData?.title}
eventTime={eventData?.created_at}
eventId={actualEventId}
loading={loading}
error={error}
/>
)}
</Box>
</TabPanel>
{/* 历史事件标签页 */}
<TabPanel px={0}>
<Box
bg={useColorModeValue('white', 'gray.800')}
borderRadius="lg"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
p={6}
>
<HistoricalEvents
events={historicalEvents}
expectationScore={eventData?.expectation_surprise_score}
loading={false}
error={null}
/>
{!hasFeatureAccess('historical_events_full') && historicalEvents.length > 0 && (
<Box mt={4} p={3} bg="orange.50" borderRadius="md" border="1px solid" borderColor="orange.200">
<HStack>
<Icon as={FiLock} color="orange.400" />
<Text color="orange.700" fontSize="sm">
免费版仅展示前2条历史事件
<Button
variant="link"
colorScheme="orange"
size="sm"
onClick={() => setUpgradeModal({ isOpen: true, feature: '完整历史事件', required: 'pro' })}
>
升级Pro版
</Button>
可查看全部
</Text>
</HStack>
</Box>
)}
</Box>
</TabPanel>
{/* 传导链分析标签页 */}
<TabPanel px={0}>
<Box
bg={useColorModeValue('white', 'gray.800')}
borderRadius="lg"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
p={6}
>
{!hasFeatureAccess('transmission_chain') ? (
<VStack spacing={3} align="center" py={8}>
<Icon as={FiLock} boxSize={8} color="purple.400" />
<Text>传导链分析为Max专享请升级订阅后查看</Text>
<Button colorScheme="purple" onClick={() => setUpgradeModal({ isOpen: true, feature: '传导链分析', required: 'max' })}>升级到Max版</Button>
</VStack>
) : (
<TransmissionChainAnalysis
eventId={actualEventId}
eventService={eventService}
/>
)}
</Box>
</TabPanel>
{/* 讨论区标签页 */}
<TabPanel px={0}>
<VStack spacing={6}>
{/* 发布新帖子 */}
{user && (
<Box
bg={useColorModeValue('white', 'gray.800')}
borderRadius="lg"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
p={6}
w="100%"
>
<VStack spacing={4}>
<Input
placeholder="帖子标题(可选)"
value={newPostTitle}
onChange={(e) => setNewPostTitle(e.target.value)}
/>
<Textarea
placeholder="分享你的想法..."
value={newPostContent}
onChange={(e) => setNewPostContent(e.target.value)}
rows={4}
/>
<HStack w="100%" justify="flex-end">
<Button
colorScheme="blue"
onClick={handleCreatePost}
isLoading={submitting}
isDisabled={!newPostContent.trim()}
>
发布
</Button>
</HStack>
</VStack>
</Box>
)}
{/* 帖子列表 */}
<Box w="100%">
{postsLoading ? (
<VStack spacing={4}>
{[1, 2, 3].map((i) => (
<Skeleton key={i} height="120px" w="100%" borderRadius="lg" />
))}
</VStack>
) : posts.length > 0 ? (
posts.map((post) => (
<PostItem
key={post.id}
post={post}
onRefresh={loadPosts}
eventEvents={eventEvents}
/>
))
) : (
<Box
bg={useColorModeValue('white', 'gray.800')}
borderRadius="lg"
borderWidth="1px"
borderColor={useColorModeValue('gray.200', 'gray.700')}
p={8}
textAlign="center"
>
<Text color="gray.500">还没有讨论来发布第一个帖子吧</Text>
</Box>
)}
</Box>
</VStack>
</TabPanel>
</TabPanels>
</Tabs>
</VStack>
</Container>
{/* 升级弹窗 */}
<SubscriptionUpgradeModal
isOpen={upgradeModal.isOpen}
onClose={() => setUpgradeModal({ isOpen: false, feature: '功能', required: 'pro' })}
requiredLevel={upgradeModal.required}
featureName={upgradeModal.feature}
currentLevel={user?.subscription_type || 'free'}
/>
</Box>
<Box minH="100vh" w="100%">
<Center py={20}>
<Spinner size="xl" color="blue.500" />
</Center>
</Box>
);
}
// 错误状态
if (!error) {
return (
<ErrorPage
title="页面找不到了"
description={error}
detail={eventId}
detailLabel="事件ID"
showRetry
onRetry={() => window.location.reload()}
/>
);
}
// 主内容
return (
<Box maxW="7xl" mx="auto"><DynamicNewsDetailPanel event={eventData} showHeader={true} /></Box>
);
};
export default EventDetail;
export default EventDetail;

View File

@@ -91,7 +91,7 @@ const HomePage: React.FC = () => {
<Container maxW="7xl" position="relative" zIndex={30} px={containerPx}>
<VStack
spacing={{ base: 8, md: 12, lg: 16 }}
spacing={{ base: 5, md: 8, lg: 10 }}
align="stretch"
minH={heroHeight}
justify="center"
@@ -104,8 +104,8 @@ const HomePage: React.FC = () => {
/>
{/* 核心功能面板 */}
<Box pb={{ base: 8, md: 12 }}>
<VStack spacing={{ base: 6, md: 8 }}>
<Box pb={{ base: 5, md: 8 }}>
<VStack spacing={{ base: 4, md: 5 }}>
{/* 特色功能卡片 - 新闻中心 */}
<FeaturedFeatureCard
feature={featuredFeature}
@@ -115,7 +115,7 @@ const HomePage: React.FC = () => {
{/* 其他功能卡片 */}
<SimpleGrid
columns={{ base: 1, md: 2, lg: 3 }}
spacing={{ base: 4, md: 5, lg: 6 }}
spacing={{ base: 2, md: 3, lg: 4 }}
w="100%"
>
{regularFeatures.map((feature) => (

View File

@@ -34,51 +34,51 @@ export const FeatureCard: React.FC<FeatureCardProps> = ({
backdropFilter="blur(10px)"
border="1px solid"
borderColor="whiteAlpha.200"
borderRadius={{ base: 'xl', md: '2xl' }}
borderRadius={{ base: 'lg', md: 'xl' }}
transition="all 0.3s ease"
_hover={{
bg: 'whiteAlpha.200',
borderColor: `${feature.color}.400`,
transform: 'translateY(-4px)',
shadow: '2xl'
transform: 'translateY(-3px)',
shadow: 'xl'
}}
_active={{
bg: 'whiteAlpha.200',
borderColor: `${feature.color}.400`,
transform: 'translateY(-2px)'
transform: 'translateY(-1px)'
}}
onClick={() => onClick(feature)}
minH={{ base: 'auto', md: '180px' }}
minH={{ base: 'auto', md: '120px' }}
cursor="pointer"
>
<CardBody p={{ base: 5, md: 6 }}>
<VStack spacing={{ base: 3, md: 4 }} align="start" h="100%">
<CardBody p={{ base: 3, md: 4 }}>
<VStack spacing={{ base: 2, md: 2.5 }} align="start" h="100%">
<HStack>
<Box
p={{ base: 2, md: 3 }}
borderRadius="lg"
p={{ base: 1.5, md: 2 }}
borderRadius="md"
bg={`${feature.color}.50`}
border="1px solid"
borderColor={`${feature.color}.200`}
>
<Text fontSize={{ base: 'xl', md: '2xl' }}>{feature.icon}</Text>
<Text fontSize={{ base: 'md', md: 'lg' }}>{feature.icon}</Text>
</Box>
<Badge
colorScheme={feature.color}
variant="solid"
fontSize={{ base: 'xs', md: 'sm' }}
fontSize={{ base: '2xs', md: 'xs' }}
>
{feature.badge}
</Badge>
</HStack>
<VStack align="start" spacing={{ base: 1, md: 2 }} flex={1}>
<Heading size={{ base: 'md', md: 'lg' }} color="white">
<VStack align="start" spacing={{ base: 0.5, md: 1 }} flex={1}>
<Heading size={{ base: 'sm', md: 'md' }} color="white">
{feature.title}
</Heading>
<Text
color="whiteAlpha.800"
fontSize={{ base: 'xs', md: 'sm' }}
fontSize={{ base: '2xs', md: 'xs' }}
lineHeight="tall"
>
{feature.description}
@@ -87,11 +87,11 @@ export const FeatureCard: React.FC<FeatureCardProps> = ({
<Button
colorScheme={feature.color}
size={{ base: 'md', md: 'sm' }}
size={{ base: 'sm', md: 'xs' }}
variant="outline"
alignSelf="flex-end"
w={{ base: '100%', md: 'auto' }}
minH="44px"
minH="32px"
onClick={(e) => {
e.stopPropagation();
onClick(feature);

View File

@@ -34,7 +34,7 @@ export const FeaturedFeatureCard: React.FC<FeaturedFeatureCardProps> = ({
bg="transparent"
border="2px solid"
borderColor="yellow.400"
borderRadius={{ base: '2xl', md: '3xl' }}
borderRadius={{ base: 'xl', md: '2xl' }}
overflow="hidden"
position="relative"
shadow="2xl"
@@ -50,35 +50,35 @@ export const FeaturedFeatureCard: React.FC<FeaturedFeatureCardProps> = ({
zIndex: 0
}}
>
<CardBody p={{ base: 6, md: 8 }} position="relative" zIndex={1}>
<CardBody p={{ base: 4, md: 5 }} position="relative" zIndex={1}>
<Flex
direction={{ base: 'column', md: 'row' }}
align={{ base: 'stretch', md: 'center' }}
justify={{ base: 'flex-start', md: 'space-between' }}
gap={{ base: 4, md: 6 }}
gap={{ base: 3, md: 4 }}
>
<Flex align="center" gap={{ base: 4, md: 6 }} flex={1}>
<Flex align="center" gap={{ base: 3, md: 4 }} flex={1}>
<Box
p={{ base: 3, md: 4 }}
borderRadius={{ base: 'lg', md: 'xl' }}
p={{ base: 2, md: 2.5 }}
borderRadius={{ base: 'md', md: 'lg' }}
bg="yellow.400"
color="black"
>
<Text fontSize={{ base: '2xl', md: '3xl' }}>{feature.icon}</Text>
<Text fontSize={{ base: 'xl', md: '2xl' }}>{feature.icon}</Text>
</Box>
<VStack align="start" spacing={{ base: 1, md: 2 }} flex={1}>
<VStack align="start" spacing={{ base: 0.5, md: 1 }} flex={1}>
<HStack>
<Heading size={{ base: 'lg', md: 'xl' }} color="white">
<Heading size={{ base: 'md', md: 'lg' }} color="white">
{feature.title}
</Heading>
<Badge colorScheme="yellow" variant="solid" fontSize={{ base: 'xs', md: 'sm' }}>
<Badge colorScheme="yellow" variant="solid" fontSize={{ base: '2xs', md: 'xs' }}>
{feature.badge}
</Badge>
</HStack>
<Text
color="whiteAlpha.800"
fontSize={{ base: 'md', md: 'lg' }}
maxW={{ md: 'md' }}
fontSize={{ base: 'xs', md: 'sm' }}
maxW={{ md: 'sm' }}
lineHeight="tall"
>
{feature.description}
@@ -87,12 +87,12 @@ export const FeaturedFeatureCard: React.FC<FeaturedFeatureCardProps> = ({
</Flex>
<Button
colorScheme="yellow"
size={{ base: 'md', md: 'lg' }}
size={{ base: 'sm', md: 'md' }}
borderRadius="full"
fontWeight="bold"
w={{ base: '100%', md: 'auto' }}
onClick={() => onClick(feature)}
minH="44px"
minH="36px"
flexShrink={0}
>

View File

@@ -21,9 +21,9 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
}) => {
return (
<VStack
spacing={{ base: 4, md: 5, lg: 6 }}
spacing={{ base: 2, md: 3, lg: 4 }}
textAlign="center"
pt={{ base: 4, md: 6, lg: 8 }}
pt={{ base: 2, md: 4, lg: 5 }}
>
<Heading
size={headingSize}
@@ -37,9 +37,9 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
<Text
fontSize={heroTextSize}
color="whiteAlpha.800"
maxW={{ base: '100%', md: '2xl', lg: '3xl' }}
maxW={{ base: '100%', md: 'xl', lg: '2xl' }}
lineHeight="tall"
px={{ base: 4, md: 0 }}
px={{ base: 2, md: 0 }}
>
,
</Text>

View File

@@ -27,7 +27,6 @@ import {
Spacer,
Icon,
useColorModeValue,
useColorMode,
useToast,
Spinner,
Center,
@@ -49,24 +48,26 @@ import {
TagLabel,
Skeleton,
SkeletonText,
Popover,
PopoverTrigger,
PopoverContent,
PopoverBody,
} from '@chakra-ui/react';
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
import ConceptStocksModal from '@components/ConceptStocksModal';
import TradeDatePicker from '@components/TradeDatePicker';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import * as echarts from 'echarts';
import { logger } from '../../utils/logger';
import tradingDays from '../../data/tradingDays.json';
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
// 交易日 Set用于快速查找
const tradingDaysSet = new Set(tradingDays);
// Navigation bar now provided by MainLayout
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
const StockOverview = () => {
const navigate = useNavigate();
const toast = useToast();
const { colorMode, toggleColorMode } = useColorMode();
const colorMode = 'light'; // 固定为 light 模式
const heatmapRef = useRef(null);
const heatmapChart = useRef(null);
@@ -96,7 +97,10 @@ const StockOverview = () => {
const [selectedDate, setSelectedDate] = useState(null);
const [marketStats, setMarketStats] = useState(null);
const [availableDates, setAvailableDates] = useState([]);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
// 个股列表弹窗状态
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
const [selectedConcept, setSelectedConcept] = useState(null);
// 专业的颜色主题
const bgColor = useColorModeValue('white', '#0a0a0a');
@@ -110,6 +114,13 @@ const StockOverview = () => {
const accentColor = useColorModeValue('purple.600', goldColor);
const heroBg = useColorModeValue('linear(to-br, purple.600, pink.500)', 'linear(to-br, #0a0a0a, #1a1a1a)');
// 打开个股列表弹窗
const handleViewStocks = useCallback((e, concept) => {
e.stopPropagation();
setSelectedConcept(concept);
setIsStockModalOpen(true);
}, []);
// 防抖搜索
const debounceSearch = useCallback(
(() => {
@@ -173,7 +184,27 @@ const StockOverview = () => {
if (data.success) {
setTopConcepts(data.data);
if (!selectedDate) setSelectedDate(data.trade_date);
// 使用概念接口的日期作为统一数据源(数据最新)
setSelectedDate(new Date(data.trade_date));
// 基于交易日历生成可选日期列表
if (data.trade_date && tradingDays.length > 0) {
// 找到当前日期或最近的交易日
let targetDate = data.trade_date;
if (!tradingDaysSet.has(data.trade_date)) {
for (let i = tradingDays.length - 1; i >= 0; i--) {
if (tradingDays[i] <= data.trade_date) {
targetDate = tradingDays[i];
break;
}
}
}
const idx = tradingDays.indexOf(targetDate);
if (idx !== -1) {
const startIdx = Math.max(0, idx - 19);
const dates = tradingDays.slice(startIdx, idx + 1).reverse();
setAvailableDates(dates);
}
}
logger.debug('StockOverview', '热门概念加载成功', {
count: data.data?.length || 0,
date: data.trade_date
@@ -204,7 +235,7 @@ const StockOverview = () => {
falling_count: data.statistics.falling_count
}));
}
if (!selectedDate) setSelectedDate(data.trade_date);
// 日期由 fetchTopConcepts 统一设置,这里不再设置
logger.debug('StockOverview', '热力图数据加载成功', {
count: data.data?.length || 0,
date: data.trade_date
@@ -235,11 +266,9 @@ const StockOverview = () => {
date: data.trade_date
};
setMarketStats(newStats);
setAvailableDates(data.available_dates || []);
if (!selectedDate) setSelectedDate(data.trade_date);
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
logger.debug('StockOverview', '市场统计数据加载成功', {
date: data.trade_date,
availableDatesCount: data.available_dates?.length || 0
date: data.trade_date
});
// 🎯 追踪市场统计数据查看
@@ -484,20 +513,6 @@ const StockOverview = () => {
window.open(htmlPath, '_blank');
};
// 处理日期选择
const handleDateChange = (date) => {
const previousDate = selectedDate;
// 🎯 追踪日期变化
trackDateChanged(date, previousDate);
setSelectedDate(date);
setIsCalendarOpen(false);
// 重新获取数据
fetchHeatmapData(date);
fetchMarketStats(date);
fetchTopConcepts(date);
};
// 格式化涨跌幅
const formatChangePercent = (value) => {
@@ -586,25 +601,6 @@ const StockOverview = () => {
filter="blur(40px)"
/>
{/* 日夜模式切换按钮 */}
<Box position="absolute" top={4} right={4}>
<IconButton
aria-label="Toggle color mode"
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
onClick={toggleColorMode}
size="lg"
bg={colorMode === 'dark' ? '#1a1a1a' : 'white'}
color={colorMode === 'dark' ? goldColor : 'purple.600'}
border="2px solid"
borderColor={colorMode === 'dark' ? goldColor : 'purple.200'}
_hover={{
bg: colorMode === 'dark' ? '#2a2a2a' : 'purple.50',
transform: 'scale(1.1)'
}}
transition="all 0.3s"
/>
</Box>
<Container maxW="container.xl" position="relative">
<VStack spacing={8} align="center">
<VStack spacing={4} textAlign="center" maxW="3xl">
@@ -819,60 +815,27 @@ const StockOverview = () => {
<Container maxW="container.xl" py={10}>
{/* 日期选择器 */}
<Box mb={6}>
<Popover isOpen={isCalendarOpen} onClose={() => setIsCalendarOpen(false)}>
<PopoverTrigger>
<Button
leftIcon={<CalendarIcon />}
onClick={() => setIsCalendarOpen(!isCalendarOpen)}
variant="outline"
size="md"
colorScheme={colorMode === 'dark' ? 'yellow' : 'purple'}
bg={colorMode === 'dark' ? '#1a1a1a' : 'white'}
border="2px solid"
borderColor={colorMode === 'dark' ? goldColor : 'purple.300'}
_hover={{
bg: colorMode === 'dark' ? '#2a2a2a' : 'purple.50'
}}
>
{selectedDate ?
`交易日期: ${selectedDate}` :
'选择交易日期'
}
</Button>
</PopoverTrigger>
<PopoverContent bg={cardBg} borderColor={borderColor} boxShadow="xl">
<PopoverBody p={4}>
<VStack align="start" spacing={3}>
<Text fontWeight="bold" color={textColor}>选择交易日期</Text>
<Divider />
{availableDates.length > 0 ? (
<VStack align="stretch" maxH="300px" overflowY="auto" spacing={1} w="100%">
{availableDates.map((date) => (
<Button
key={date}
size="sm"
variant={selectedDate === date ? 'solid' : 'ghost'}
colorScheme={selectedDate === date ? (colorMode === 'dark' ? 'yellow' : 'purple') : 'gray'}
onClick={() => handleDateChange(date)}
justifyContent="start"
w="100%"
>
{date}
</Button>
))}
</VStack>
) : (
<Text fontSize="sm" color={subTextColor}>
暂无可用日期
</Text>
)}
</VStack>
</PopoverBody>
</PopoverContent>
</Popover>
<Flex align="center" gap={4} flexWrap="wrap">
<TradeDatePicker
value={selectedDate}
onChange={(date) => {
const dateStr = date.toISOString().split('T')[0];
const previousDateStr = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
trackDateChanged(dateStr, previousDateStr);
setSelectedDate(date);
fetchHeatmapData(dateStr);
fetchMarketStats(dateStr);
fetchTopConcepts(dateStr);
}}
latestTradeDate={null}
minDate={tradingDays.length > 0 ? new Date(tradingDays[0]) : undefined}
maxDate={tradingDays.length > 0 ? new Date(tradingDays[tradingDays.length - 1]) : undefined}
label="交易日期"
/>
</Flex>
{selectedDate && (
<Text fontSize="sm" color={subTextColor} mt={2}>
当前显示 {selectedDate} 的市场数据
当前显示 {selectedDate.toISOString().split('T')[0]} 的市场数据
</Text>
)}
</Box>
@@ -974,31 +937,33 @@ const StockOverview = () => {
<Divider />
<Box w="100%">
<Box
w="100%"
cursor="pointer"
onClick={(e) => handleViewStocks(e, concept)}
_hover={{ bg: hoverBg }}
p={2}
borderRadius="md"
transition="background 0.2s"
>
<Text fontSize="xs" color="gray.500" mb={2}>
包含 {concept.stock_count} 只个股
</Text>
{concept.stocks && concept.stocks.length > 0 && (
<Flex flexWrap="wrap" gap={2}>
<Flex
flexWrap="nowrap"
gap={2}
overflow="hidden"
maxH="24px"
>
{concept.stocks.map((stock, idx) => (
<Tag
key={idx}
size="sm"
colorScheme="purple"
variant="subtle"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
// 🎯 追踪概念下的股票标签点击
trackConceptStockClicked({
code: stock.stock_code,
name: stock.stock_name
}, concept.concept_name);
navigate(`/company?scode=${stock.stock_code}`);
}}
flexShrink={0}
>
<TagLabel>{stock.stock_name}</TagLabel>
</Tag>
@@ -1098,7 +1063,14 @@ const StockOverview = () => {
</Card>
</Box>
</Container>
{/* 个股列表弹窗 */}
<ConceptStocksModal
isOpen={isStockModalOpen}
onClose={() => setIsStockModalOpen(false)}
concept={selectedConcept}
/>
</Box>
);
};