Compare commits

...

11 Commits

Author SHA1 Message Date
zdl
ce46820105 feat: 优化社区动态新闻分页和预加载策略
## 主要改动

### 1. 修复分页显示问题
- 修复总页数计算错误(使用服务端 total 而非缓存 cachedCount)
- 修复目标页数据检查逻辑(排除 null 占位符)

### 2. 实现请求拆分策略 (Critical Fix)
- 将合并请求(per_page: 15)拆分为单页循环请求(per_page: 5)
- 解决后端无法处理动态 per_page 导致返回空数据的问题
- 后台预加载和显示 loading 两个场景均已拆分

### 3. 优化智能预加载逻辑
- 连续翻页(上/下页):预加载前后各 2 页
- 跳转翻页(点页码):只加载当前页
- 目标页已缓存时立即切换,后台静默预加载其他页

### 4. Redux 状态管理优化
- 添加 pageSize 参数用于正确计算索引
- 重写 reducer 插入逻辑(append/replace/jump 三种模式)
- 只在 append 模式去重,避免替换和跳页时数据丢失
- 修复 selector 计算有效数量(排除 null)

### 5. 修复 React Hook 规则违规
- 将所有 useColorModeValue 移至组件顶层
- 添加缺失的 HStack 导入

## 影响范围
- 仅影响社区页面动态新闻分页功能
- 无后端变更,向后兼容

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 11:43:54 +08:00
zdl
012c13c49a fix: 修复微信扫码登录后页面跳转问题
修改 iframe 显示条件,仅在 WAITING 状态时显示 iframe,
当状态变更为 SCANNED/AUTHORIZED 时立即移除 iframe,
防止微信页面执行父页面跳转操作。

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 11:43:54 +08:00
zdl
0e9a0d9123 feat: 恢复bugfix 2025-11-04 11:43:54 +08:00
4f163af846 fix 2025-11-04 09:45:12 +08:00
zdl
ce495ed6fa feat: bugfix 2025-11-03 19:45:32 +08:00
zdl
0e66bb471f fix: 修复 PostHog 生产环境配置问题
## 问题描述
生产环境部署后,PostHog 只收到 localhost:3000 的错误报告,而不是生产环境的真实 URL。

## 根本原因
构建脚本未显式加载生产环境配置文件,导致 PostHog API Key 和 Host 配置未正确嵌入到打包文件中。

## 解决方案
1. 新增 `.env.production` 生产环境专用配置文件
   - 包含正确的 PostHog API Key 和 Host
   - 设置 REACT_APP_ENV=production
   - 禁用 Mock 数据 (REACT_APP_ENABLE_MOCK=false)
   - 配置生产 API 地址

2. 修改 package.json 构建脚本
   - 使用 env-cmd 显式加载 .env.production
   - 确保构建时环境变量正确嵌入

## 影响范围
-  生产环境构建: 现在会正确加载配置
-  PostHog 功能: 将使用正确的配置初始化
-  开发环境: 无影响,仍使用各自的环境文件
-  部署流程: 服务器构建时自动使用新配置

## 测试计划
1. 本地执行 npm run build 验证构建成功
2. 部署到生产环境
3. 验证 PostHog 后台收到正确的生产 URL

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 18:42:58 +08:00
zdl
82cb0b4034 feat: bugfix 2025-11-03 18:26:59 +08:00
zdl
78e7001372 feat: bugfix 2025-11-03 18:20:57 +08:00
zdl
26ad017d32 feat: bugfix 2025-11-03 18:11:21 +08:00
zdl
fea0bc3bbe Merge branch 'feature_2025/1028_event' into feature
* feature_2025/1028_event: (107 commits)
  feat: 实现 Redux 全局状态管理事件关注功能
  feat: 添加mock接口
  feat: 单排/双排列表模式切换
  feat: bug修复
  fix: 修复 Mock 环境相关概念返回空结果问题
  refactor: 优化 StockChangeIndicators 颜色层次和视觉对比度
  feat: 统一事件详情和滚动列表的重要性颜色样式
  feat: 优化 EventScrollList 分页控制器位置和样式
  feat本次提交包含的优化
  fix: 完全移除 EventScrollList 顶部间距
  fix: 减少 EventScrollList 顶部间距
  fix: 修改 EventScrollList 左右箭头为翻页功能
  feat: 优化社区页面滚动和分页交互体验…)   ⎿  [feature_2025/1028_event 5dedbb3] feat: 优化社区页面滚动和分页交互体验       6 files changed, 1355 insertions(+), 49 deletions(-)       create mode 100644 docs/test-cases/Community351241265351235242346265213350257225347224250344276213.md
  fix: 修改相关概念组件以匹配真实API数据结构
  refactor: 移除 RelatedConcepts 组件中的 API_BASE_URL 配置
  feat: 增强历史事件对比卡片交互,支持点击跳转事件详情
  feat: 修复相关概念卡片跳转逻辑,支持跳转至概念中心
  feat: 优化股票卡片交互体验
  feat: 在 DynamicNewsCard 头部集成搜索和筛选功能
  feat(HistoricalEvents): 优化历史事件列表 UI 和相关股票弹窗
  ...
2025-11-03 17:41:28 +08:00
0d84ffe87f 修改总结 2025-11-03 16:10:35 +08:00
17 changed files with 868 additions and 328 deletions

42
.env.production Normal file
View File

@@ -0,0 +1,42 @@
# ========================================
# 生产环境配置
# ========================================
# 使用方式: npm run build
#
# 工作原理:
# 1. 此文件专门用于生产环境构建
# 2. 构建时会将环境变量嵌入到打包文件中
# 3. 确保 PostHog 等服务使用正确的生产配置
# ========================================
# 环境标识
REACT_APP_ENV=production
NODE_ENV=production
# Mock 配置(生产环境禁用 Mock
REACT_APP_ENABLE_MOCK=false
# 后端 API 地址(生产环境)
REACT_APP_API_URL=http://49.232.185.254:5001
# PostHog 分析配置(生产环境)
# PostHog API Key从 PostHog 项目设置中获取)
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
# PostHog API Host使用 PostHog Cloud
REACT_APP_POSTHOG_HOST=https://app.posthog.com
# 启用会话录制Session Recording用于回放用户操作、排查问题
REACT_APP_ENABLE_SESSION_RECORDING=true
# React 构建优化配置
# 禁用 source map 生成(生产环境不需要,提升打包速度和安全性)
GENERATE_SOURCEMAP=false
# 跳过预检查(加快启动速度)
SKIP_PREFLIGHT_CHECK=true
# 禁用 ESLint 检查(生产构建时不需要)
DISABLE_ESLINT_PLUGIN=true
# TypeScript 编译错误时继续
TSC_COMPILE_ON_ERROR=true
# 图片内联大小限制
IMAGE_INLINE_SIZE_LIMIT=10000
# Node.js 内存限制(适用于大型项目)
NODE_OPTIONS=--max_old_space_size=4096

133
CLAUDE.md
View File

@@ -4,40 +4,61 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
This is a hybrid React dashboard application with a Flask/Python backend. The project is built on the Argon Dashboard Chakra PRO template and includes financial/trading analysis features.
This is a hybrid React dashboard application with a Flask/Python backend for financial/trading analysis. Built on the Argon Dashboard Chakra PRO template with extensive customization.
### Frontend (React + Chakra UI)
- **Framework**: React 18.3.1 with Chakra UI 2.8.2
- **State Management**: Redux Toolkit (@reduxjs/toolkit)
- **Routing**: React Router DOM v6 with lazy loading for code splitting
- **Styling**: Tailwind CSS + custom Chakra theme
- **Build Tool**: React Scripts with custom Gulp tasks
- **Charts**: ApexCharts, ECharts, and custom visualization components
- **Build Tool**: CRACO (Create React App Configuration Override) with custom webpack optimizations
- **Charts**: ApexCharts, ECharts, Recharts, D3
- **UI Components**: Ant Design (antd) alongside Chakra UI
- **Other Libraries**: Three.js (@react-three), FullCalendar, Leaflet maps
### Backend (Flask/Python)
- **Framework**: Flask with SQLAlchemy ORM
- **Database**: ClickHouse for analytics + MySQL/PostgreSQL
- **Features**: Real-time data processing, trading analysis, user authentication
- **Task Queue**: Celery for background processing
- **Database**: ClickHouse for analytics queries + MySQL/PostgreSQL
- **Real-time**: Flask-SocketIO for WebSocket connections
- **Task Queue**: Celery with Redis for background processing
- **External APIs**: Tencent Cloud SMS, WeChat Pay integration
## Development Commands
### Frontend Development
```bash
npm start # Start development server (port 3000, proxies to localhost:5001)
npm run build # Production build with license headers
npm test # Run React test suite
npm run lint:check # Check ESLint rules
npm run lint:fix # Auto-fix ESLint issues
npm run install:clean # Clean install (removes node_modules and package-lock)
npm start # Start with mock data (.env.mock), proxies to localhost:5001
npm run start:real # Start with real backend (.env.local)
npm run start:dev # Start with development config (.env.development)
npm run start:test # Starts both backend (app_2.py) and frontend (.env.test) concurrently
npm run dev # Alias for 'npm start'
npm run backend # Start Flask server only (python app_2.py)
npm run build # Production build with Gulp license headers
npm run build:analyze # Build with webpack bundle analyzer
npm test # Run React test suite with CRACO
npm run lint:check # Check ESLint rules (exits 0)
npm run lint:fix # Auto-fix ESLint issues
npm run clean # Remove node_modules and package-lock.json
npm run reinstall # Clean install (runs clean + install)
```
### Backend Development
```bash
python app_2.py # Start Flask server (main backend)
python simulation_background_processor.py # Background data processor
python app.py # Main Flask server (newer version)
python app_2.py # Flask server (appears to be current main)
python simulation_background_processor.py # Background data processor for simulations
```
### Deployment
```bash
npm run deploy # Executes scripts/deploy-from-local.sh
npm run deploy:setup # Setup deployment (scripts/setup-deployment.sh)
npm run rollback # Rollback deployment (scripts/rollback-from-local.sh)
```
### Python Dependencies
Install from requirements.txt:
```bash
pip install -r requirements.txt
```
@@ -45,47 +66,69 @@ pip install -r requirements.txt
## Architecture
### Frontend Structure
- `src/layouts/` - Main layout components (Admin, Auth, Home)
- `src/views/` - Page components organized by feature (Dashboard, Company, Community, etc.)
- `src/components/` - Reusable UI components (Charts, Cards, Buttons, etc.)
- `src/theme/` - Chakra UI theme customization
- `src/routes.js` - Application routing configuration
- `src/contexts/` - React context providers
- `src/services/` - API service layer
- **src/App.js** - Main application entry with route definitions (routing moved from src/routes.js)
- **src/layouts/** - Layout wrappers (Auth, Home, MainLayout)
- **src/views/** - Page components (Community, Company, TradingSimulation, etc.)
- **src/components/** - Reusable UI components
- **src/contexts/** - React contexts (AuthContext, NotificationContext, IndustryContext)
- **src/store/** - Redux store with slices (posthogSlice, etc.)
- **src/services/** - API service layer
- **src/theme/** - Chakra UI theme customization
- **src/mocks/** - MSW (Mock Service Worker) handlers for development
- src/mocks/handlers/ - Request handlers by domain
- src/mocks/data/ - Mock data files
- src/mocks/browser.js - MSW browser setup
### Backend Structure
- `app_2.py` - Main Flask application with routes and business logic
- `simulation_background_processor.py` - Background data processing service
- `wechat_pay.py` / `wechat_pay_config.py` - Payment integration
- `tdays.csv` - Trading days data
- **app.py / app_2.py** - Main Flask application with routes, authentication, and business logic
- **simulation_background_processor.py** - Background processor for trading simulations
- **wechat_pay.py / wechat_pay_config.py** - WeChat payment integration
- **concept_api.py** - API for concept/industry analysis
- **tdays.csv** - Trading days calendar data (loaded into memory at startup)
### Key Integrations
- ClickHouse for high-performance analytics queries
- Celery + Redis for background task processing
- Flask-SocketIO for real-time data updates
- Tencent Cloud services (SMS, etc.)
- WeChat Pay integration
- **ClickHouse** - High-performance analytics queries
- **Celery + Redis** - Background task processing
- **Flask-SocketIO** - Real-time data updates via WebSocket
- **Tencent Cloud** - SMS services
- **WeChat Pay** - Payment processing
- **PostHog** - Analytics (initialized in Redux)
- **MSW** - API mocking for development/testing
### Routing & Code Splitting
- Routing is defined in **src/App.js** (not src/routes.js - that file is deprecated)
- Heavy components use React.lazy() for code splitting (Community, TradingSimulation, etc.)
- Protected routes use ProtectedRoute and ProtectedRouteRedirect components
## Configuration
### Proxy Setup
The React dev server proxies API calls to `http://localhost:5001` (see package.json).
### Environment Files
- `.env` - Environment variables for both frontend and backend
Multiple environment configurations available:
- **.env.mock** - Mock data mode (default for `npm start`)
- **.env.local** - Real backend connection
- **.env.development** - Development environment
- **.env.test** - Test environment
### Build Process
The build process includes custom Gulp tasks that add Creative Tim license headers to JS, CSS, and HTML files.
### Build Configuration (craco.config.js)
- **Webpack caching**: Filesystem cache for faster rebuilds (50-80% improvement)
- **Code splitting**: Aggressive chunk splitting by library (react-vendor, charts-lib, chakra-ui, antd-lib, three-lib, etc.)
- **Path aliases**: `@` → src/, `@components` → src/components/, `@views` → src/views/, `@assets` → src/assets/, `@contexts` → src/contexts/
- **Optimizations**: ESLint plugin removed from build for speed, Babel caching enabled, moment locale stripping
- **Source maps**: Disabled in production, eval-cheap-module-source-map in development
- **Dev server proxy**: `/api` requests proxy to http://49.232.185.254:5001
### Styling Architecture
- Tailwind CSS for utility classes
- Custom Chakra UI theme with extended color palette
- Component-specific SCSS files in `src/assets/scss/`
### Important Build Notes
- Uses NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' for Node compatibility
- Gulp task adds Creative Tim license headers post-build
- Bundle analyzer available via `ANALYZE=true npm run build:analyze`
- Pre-build: kills any process on port 3000
## Testing
- React Testing Library setup for frontend components
- Test command: `npm test`
- **React Testing Library** for component tests
- **MSW** (Mock Service Worker) for API mocking during tests
- Run tests: `npm test`
## Deployment
- Build: `npm run build`
- Deploy: `npm run deploy` (builds the project)
- Deployment scripts in **scripts/** directory
- Build output processed by Gulp for licensing
- Supports rollback via scripts/rollback-from-local.sh

63
app.py
View File

@@ -2602,13 +2602,9 @@ def get_wechat_qrcode():
# 生成唯一state参数
state = uuid.uuid4().hex
print(f"🆕 [QRCODE] 生成新的微信二维码, state={state[:8]}...")
# URL编码回调地址
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
print(f"🔗 [QRCODE] 回调地址: {WECHAT_REDIRECT_URI}")
# 构建微信授权URL
wechat_auth_url = (
f"https://open.weixin.qq.com/connect/qrconnect?"
@@ -2626,8 +2622,6 @@ def get_wechat_qrcode():
'wechat_unionid': None
}
print(f"✅ [QRCODE] session 已存储, 当前总数: {len(wechat_qr_sessions)}")
return jsonify({"code":0,
"data":
{
@@ -2691,8 +2685,6 @@ def check_wechat_scan():
del wechat_qr_sessions[session_id]
return jsonify({'status': 'expired'}), 200
print(f"📡 [CHECK] session_id: {session_id[:8]}..., status: {session['status']}, user_info: {session.get('user_info')}")
return jsonify({
'status': session['status'],
'user_info': session.get('user_info'),
@@ -2751,17 +2743,12 @@ def wechat_callback():
# 验证state
if state not in wechat_qr_sessions:
print(f"❌ [CALLBACK] state 不在 wechat_qr_sessions 中: {state[:8]}...")
print(f" 当前 sessions: {list(wechat_qr_sessions.keys())}")
return redirect('/auth/signin?error=session_expired')
session_data = wechat_qr_sessions[state]
print(f"✅ [CALLBACK] 找到 session_data, mode={session_data.get('mode')}")
# 检查过期
if time.time() > session_data['expires']:
print(f"❌ [CALLBACK] session 已过期")
del wechat_qr_sessions[state]
return redirect('/auth/signin?error=session_expired')
@@ -2790,8 +2777,6 @@ def wechat_callback():
print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}")
return redirect('/auth/signin?error=userinfo_failed')
print(f"✅ [CALLBACK] 获取用户信息成功, nickname={user_info.get('nickname', 'N/A')}")
# 查找或创建用户 / 或处理绑定
openid = token_data['openid']
unionid = user_info.get('unionid') or token_data.get('unionid')
@@ -2842,8 +2827,7 @@ def wechat_callback():
user = User.query.filter_by(wechat_open_id=openid).first()
if not user:
# 创建新用户(自动注册)
is_new_user = True
# 创建新用户
# 先清理微信昵称
raw_nickname = user_info.get('nickname', '微信用户')
# 创建临时用户实例以使用清理方法
@@ -2893,22 +2877,8 @@ def wechat_callback():
session_item['user_info'] = {'user_id': user.id}
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
# 返回一个简单的成功页面(前端轮询会检测到状态变化)
return '''
<html>
<head><title>授权成功</title></head>
<body>
<h2>微信授权成功</h2>
<p>请返回原页面继续操作...</p>
<script>
// 尝试关闭窗口(如果是弹窗的话)
setTimeout(function() {
window.close();
}, 1000);
</script>
</body>
</html>
''', 200
# 直接跳转到首页
return redirect('/home')
except Exception as e:
print(f"❌ 微信登录失败: {e}")
@@ -2934,16 +2904,16 @@ def login_with_wechat():
return jsonify({'success': False, 'error': 'session_id不能为空'}), 400
# 验证session
wechat_session = wechat_qr_sessions.get(session_id)
if not wechat_session:
session = wechat_qr_sessions.get(session_id)
if not session:
return jsonify({'success': False, 'error': '会话不存在或已过期'}), 400
# 检查session状态
if wechat_session['status'] not in ['login_success', 'register_success']:
if session['status'] not in ['login_ready', 'register_ready']:
return jsonify({'success': False, 'error': '会话状态无效'}), 400
# 检查是否有用户信息
user_info = wechat_session.get('user_info')
user_info = session.get('user_info')
if not user_info or not user_info.get('user_id'):
return jsonify({'success': False, 'error': '用户信息不完整'}), 400
@@ -2955,33 +2925,18 @@ def login_with_wechat():
# 更新最后登录时间
user.update_last_seen()
# 设置 Flask session
session.permanent = True
session['user_id'] = user.id
session['username'] = user.username
session['logged_in'] = True
session['wechat_login'] = True # 标记是微信登录
# Flask-Login 登录
login_user(user, remember=True)
# 判断是否为新用户
is_new_user = user_info.get('is_new_user', False)
# 清除 wechat_qr_sessions
# 清除session
del wechat_qr_sessions[session_id]
# 生成登录响应
response_data = {
'success': True,
'message': '注册成功' if is_new_user else '登录成功',
'isNewUser': is_new_user,
'message': '登录成功' if session['status'] == 'login_ready' else '注册并登录成功',
'user': {
'id': user.id,
'username': user.username,
'nickname': user.nickname or user.username,
'email': user.email,
'phone': user.phone,
'avatar_url': user.avatar_url,
'has_wechat': True,
'wechat_open_id': user.wechat_open_id,

View File

@@ -244,6 +244,13 @@ module.exports = {
secure: false,
logLevel: 'debug',
},
'/concept-api': {
target: 'http://49.232.185.254:6801',
changeOrigin: true,
secure: false,
logLevel: 'debug',
pathRewrite: { '^/concept-api': '' },
},
},
},
};

View File

@@ -101,7 +101,7 @@
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
"dev": "npm start",
"backend": "python app_2.py",
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco build && gulp licenses",
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
"test": "craco test --env=jsdom",
"eject": "react-scripts eject",

View File

@@ -501,26 +501,26 @@ export default function WechatRegister() {
bg="gray.50"
boxShadow="sm" // ✅ 添加轻微阴影
>
{wechatStatus !== WECHAT_STATUS.NONE ? (
{wechatStatus === WECHAT_STATUS.WAITING ? (
/* 已获取二维码显示iframe */
<iframe
src={wechatAuthUrl}
title="微信扫码登录"
width="300"
height="350"
scrolling="no" // ✅ 新增:禁止滚动
scrolling="no" // ✅ 新增:禁止滚动
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
style={{
border: 'none',
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
transformOrigin: 'top left',
marginLeft: '-5px',
pointerEvents: 'auto', // 允许点击 │ │
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
}}
// 使用 onWheel 事件阻止滚动 │ │
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
/>
) : (
/* 未获取:显示占位符 */

View File

@@ -12,12 +12,12 @@ import {
Text,
Flex,
Badge,
useColorModeValue,
useDisclosure
useColorModeValue
} from '@chakra-ui/react';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigationEvents } from '../../../../hooks/useNavigationEvents';
import { useDelayedMenu } from '../../../../hooks/useDelayedMenu';
/**
* 桌面版主导航菜单组件
@@ -37,11 +37,11 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
// 🎯 初始化导航埋点Hook
const navEvents = useNavigationEvents({ component: 'top_nav' });
// 🎯 为每个菜单创建独立的 useDisclosure Hook
const { isOpen: isHighFreqOpen, onOpen: onHighFreqOpen, onClose: onHighFreqClose } = useDisclosure();
const { isOpen: isMarketReviewOpen, onOpen: onMarketReviewOpen, onClose: onMarketReviewClose } = useDisclosure();
const { isOpen: isAgentCommunityOpen, onOpen: onAgentCommunityOpen, onClose: onAgentCommunityClose } = useDisclosure();
const { isOpen: isContactUsOpen, onOpen: onContactUsOpen, onClose: onContactUsClose } = useDisclosure();
// 🎯 为每个菜单创建延迟关闭控制200ms 延迟)
const highFreqMenu = useDelayedMenu({ closeDelay: 200 });
const marketReviewMenu = useDelayedMenu({ closeDelay: 200 });
const agentCommunityMenu = useDelayedMenu({ closeDelay: 200 });
const contactUsMenu = useDelayedMenu({ closeDelay: 200 });
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
@@ -53,7 +53,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
return (
<HStack spacing={8}>
{/* 高频跟踪 */}
<Menu isOpen={isHighFreqOpen} onClose={onHighFreqClose}>
<Menu isOpen={highFreqMenu.isOpen} onClose={highFreqMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
@@ -64,18 +64,24 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
onMouseEnter={onHighFreqOpen}
onMouseLeave={onHighFreqClose}
onMouseEnter={highFreqMenu.handleMouseEnter}
onMouseLeave={highFreqMenu.handleMouseLeave}
onClick={highFreqMenu.handleClick}
>
高频跟踪
</MenuButton>
<MenuList minW="260px" p={2} onMouseEnter={onHighFreqOpen}>
<MenuList
minW="260px"
p={2}
onMouseEnter={highFreqMenu.handleMouseEnter}
onMouseLeave={highFreqMenu.handleMouseLeave}
>
<MenuItem
onClick={() => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
navigate('/community');
onHighFreqClose(); // 跳转后关闭菜单
highFreqMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
@@ -96,7 +102,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
// 🎯 追踪菜单项点击
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
navigate('/concepts');
onHighFreqClose(); // 跳转后关闭菜单
highFreqMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
@@ -113,7 +119,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Menu>
{/* 行情复盘 */}
<Menu isOpen={isMarketReviewOpen} onClose={onMarketReviewClose}>
<Menu isOpen={marketReviewMenu.isOpen} onClose={marketReviewMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
@@ -124,16 +130,22 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
borderBottom={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.100' : 'gray.50' }}
onMouseEnter={onMarketReviewOpen}
onMouseLeave={onMarketReviewClose}
onMouseEnter={marketReviewMenu.handleMouseEnter}
onMouseLeave={marketReviewMenu.handleMouseLeave}
onClick={marketReviewMenu.handleClick}
>
行情复盘
</MenuButton>
<MenuList minW="260px" p={2} onMouseEnter={onMarketReviewOpen}>
<MenuList
minW="260px"
p={2}
onMouseEnter={marketReviewMenu.handleMouseEnter}
onMouseLeave={marketReviewMenu.handleMouseLeave}
>
<MenuItem
onClick={() => {
navigate('/limit-analyse');
onMarketReviewClose(); // 跳转后关闭菜单
marketReviewMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
@@ -149,7 +161,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
<MenuItem
onClick={() => {
navigate('/stocks');
onMarketReviewClose(); // 跳转后关闭菜单
marketReviewMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
@@ -165,7 +177,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
<MenuItem
onClick={() => {
navigate('/trading-simulation');
onMarketReviewClose(); // 跳转后关闭菜单
marketReviewMenu.onClose(); // 跳转后关闭菜单
}}
borderRadius="md"
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
@@ -182,17 +194,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Menu>
{/* AGENT社群 */}
<Menu isOpen={isAgentCommunityOpen} onClose={onAgentCommunityClose}>
<Menu isOpen={agentCommunityMenu.isOpen} onClose={agentCommunityMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
onMouseEnter={onAgentCommunityOpen}
onMouseLeave={onAgentCommunityClose}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
onClick={agentCommunityMenu.handleClick}
>
AGENT社群
</MenuButton>
<MenuList minW="300px" p={4} onMouseEnter={onAgentCommunityOpen}>
<MenuList
minW="300px"
p={4}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
>
<MenuItem
isDisabled
cursor="not-allowed"
@@ -211,17 +229,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
</Menu>
{/* 联系我们 */}
<Menu isOpen={isContactUsOpen} onClose={onContactUsClose}>
<Menu isOpen={contactUsMenu.isOpen} onClose={contactUsMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
onMouseEnter={onContactUsOpen}
onMouseLeave={onContactUsClose}
onMouseEnter={contactUsMenu.handleMouseEnter}
onMouseLeave={contactUsMenu.handleMouseLeave}
onClick={contactUsMenu.handleClick}
>
联系我们
</MenuButton>
<MenuList minW="260px" p={4} onMouseEnter={onContactUsOpen}>
<MenuList
minW="260px"
p={4}
onMouseEnter={contactUsMenu.handleMouseEnter}
onMouseLeave={contactUsMenu.handleMouseLeave}
>
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
</MenuList>
</Menu>

View File

@@ -12,11 +12,11 @@ import {
Text,
Flex,
HStack,
Badge,
useDisclosure
Badge
} from '@chakra-ui/react';
import { ChevronDownIcon } from '@chakra-ui/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useDelayedMenu } from '../../../../hooks/useDelayedMenu';
/**
* 平板版"更多"下拉菜单组件
@@ -30,8 +30,8 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
const navigate = useNavigate();
const location = useLocation();
// 🎯 为"更多"菜单创建 useDisclosure Hook
const { isOpen, onOpen, onClose } = useDisclosure();
// 🎯 使用延迟关闭菜单控制
const moreMenu = useDelayedMenu({ closeDelay: 200 });
// 辅助函数:判断导航项是否激活
const isActive = useCallback((paths) => {
@@ -41,23 +41,29 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
if (!isAuthenticated || !user) return null;
return (
<Menu isOpen={isOpen} onClose={onClose}>
<Menu isOpen={moreMenu.isOpen} onClose={moreMenu.onClose}>
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
fontWeight="medium"
onMouseEnter={onOpen}
onMouseLeave={onClose}
onMouseEnter={moreMenu.handleMouseEnter}
onMouseLeave={moreMenu.handleMouseLeave}
onClick={moreMenu.handleClick}
>
更多
</MenuButton>
<MenuList minW="300px" p={2} onMouseEnter={onOpen}>
<MenuList
minW="300px"
p={2}
onMouseEnter={moreMenu.handleMouseEnter}
onMouseLeave={moreMenu.handleMouseLeave}
>
{/* 高频跟踪组 */}
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">高频跟踪</Text>
<MenuItem
onClick={() => {
onClose(); // 先关闭菜单
moreMenu.onClose(); // 先关闭菜单
navigate('/community');
}}
borderRadius="md"
@@ -73,7 +79,7 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</MenuItem>
<MenuItem
onClick={() => {
onClose(); // 先关闭菜单
moreMenu.onClose(); // 先关闭菜单
navigate('/concepts');
}}
borderRadius="md"
@@ -91,7 +97,7 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">行情复盘</Text>
<MenuItem
onClick={() => {
onClose(); // 先关闭菜单
moreMenu.onClose(); // 先关闭菜单
navigate('/limit-analyse');
}}
borderRadius="md"
@@ -104,7 +110,7 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</MenuItem>
<MenuItem
onClick={() => {
onClose(); // 先关闭菜单
moreMenu.onClose(); // 先关闭菜单
navigate('/stocks');
}}
borderRadius="md"
@@ -117,7 +123,7 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
</MenuItem>
<MenuItem
onClick={() => {
onClose(); // 先关闭菜单
moreMenu.onClose(); // 先关闭菜单
navigate('/trading-simulation');
}}
borderRadius="md"

142
src/hooks/useDelayedMenu.js Normal file
View File

@@ -0,0 +1,142 @@
// src/hooks/useDelayedMenu.js
// 导航菜单延迟关闭 Hook - 优化 hover 和 click 交互体验
import { useState, useRef, useCallback } from 'react';
/**
* 自定义 Hook提供带延迟关闭功能的菜单控制
*
* 解决问题:
* 1. 用户快速移动鼠标导致菜单意外关闭
* 2. Hover 和 Click 状态冲突
* 3. 从 MenuButton 移动到 MenuList 时菜单闪烁
*
* 功能特性:
* - ✅ Hover 进入:立即打开菜单
* - ✅ Hover 离开:延迟关闭(默认 200ms
* - ✅ Click 切换:支持点击切换打开/关闭状态
* - ✅ 智能取消:再次 hover 进入时取消关闭定时器
*
* @param {Object} options - 配置选项
* @param {number} options.closeDelay - 延迟关闭时间(毫秒),默认 200ms
* @returns {Object} 菜单控制对象
*/
export function useDelayedMenu({ closeDelay = 200 } = {}) {
const [isOpen, setIsOpen] = useState(false);
const closeTimerRef = useRef(null);
const isClickedRef = useRef(false); // 追踪是否通过点击打开
/**
* 打开菜单
* - 立即打开,无延迟
* - 清除任何待执行的关闭定时器
*/
const onOpen = useCallback(() => {
// 清除待执行的关闭定时器
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsOpen(true);
}, []);
/**
* 延迟关闭菜单
* - 设置定时器,延迟后关闭
* - 如果在延迟期间再次 hover 进入,会被 onOpen 取消
*/
const onDelayedClose = useCallback(() => {
// 如果是点击打开的hover 离开时不自动关闭
if (isClickedRef.current) {
return;
}
// 清除之前的定时器(防止重复设置)
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
}
// 设置延迟关闭定时器
closeTimerRef.current = setTimeout(() => {
setIsOpen(false);
closeTimerRef.current = null;
}, closeDelay);
}, [closeDelay]);
/**
* 立即关闭菜单
* - 无延迟,立即关闭
* - 清除所有定时器和状态标记
*/
const onClose = useCallback(() => {
// 清除定时器
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsOpen(false);
isClickedRef.current = false;
}, []);
/**
* 切换菜单状态(用于点击)
* - 如果关闭 → 打开,并标记为点击打开
* - 如果打开 → 关闭,并清除点击标记
*/
const onToggle = useCallback(() => {
if (isOpen) {
// 当前已打开 → 关闭
onClose();
} else {
// 当前已关闭 → 打开
onOpen();
isClickedRef.current = true; // 标记为点击打开
}
}, [isOpen, onOpen, onClose]);
/**
* Hover 进入处理
* - 打开菜单
* - 清除点击标记(允许 hover 离开时自动关闭)
*/
const handleMouseEnter = useCallback(() => {
onOpen();
isClickedRef.current = false; // 清除点击标记,允许 hover 控制
}, [onOpen]);
/**
* Hover 离开处理
* - 延迟关闭菜单
*/
const handleMouseLeave = useCallback(() => {
onDelayedClose();
}, [onDelayedClose]);
/**
* 点击处理
* - 切换菜单状态
*/
const handleClick = useCallback(() => {
onToggle();
}, [onToggle]);
// 组件卸载时清理定时器
const cleanup = useCallback(() => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
}, []);
return {
isOpen,
onOpen,
onClose,
onDelayedClose,
onToggle,
handleMouseEnter,
handleMouseLeave,
handleClick,
cleanup
};
}

View File

@@ -170,6 +170,7 @@ export const fetchDynamicNews = createAsyncThunk(
async ({
page = 1,
per_page = 5,
pageSize = 5, // 每页实际显示的数据量(用于计算索引)
clearCache = false,
prependMode = false
} = {}, { rejectWithValue }) => {
@@ -196,6 +197,9 @@ export const fetchDynamicNews = createAsyncThunk(
return {
events: response.data.events,
total: response.data.pagination?.total || 0,
page,
per_page,
pageSize, // 返回 pageSize 用于索引计算
clearCache,
prependMode
};
@@ -205,6 +209,9 @@ export const fetchDynamicNews = createAsyncThunk(
return {
events: [],
total: 0,
page,
per_page,
pageSize, // 返回 pageSize 用于索引计算
clearCache,
prependMode
};
@@ -371,7 +378,7 @@ const communityDataSlice = createSlice({
state.error.dynamicNews = null;
})
.addCase(fetchDynamicNews.fulfilled, (state, action) => {
const { events, total, clearCache, prependMode } = action.payload;
const { events, total, page, per_page, pageSize, clearCache, prependMode } = action.payload;
if (clearCache) {
// 清空缓存模式:直接替换
@@ -389,14 +396,67 @@ const communityDataSlice = createSlice({
totalCount: state.dynamicNews.length
});
} else {
// 追加到尾部模式(默认):去重后追加
const existingIds = new Set(state.dynamicNews.map(e => e.id));
const newEvents = events.filter(e => !existingIds.has(e.id));
state.dynamicNews = [...state.dynamicNews, ...newEvents];
logger.debug('CommunityData', '追加新数据到尾部', {
newCount: newEvents.length,
totalCount: state.dynamicNews.length
});
// 智能插入模式:根据页码计算正确的插入位置
// 使用 pageSize每页显示量而不是 per_page请求数量
const startIndex = (page - 1) * (pageSize || per_page);
// 判断插入模式
const isAppend = startIndex === state.dynamicNews.length;
const isReplace = startIndex < state.dynamicNews.length;
const isJump = startIndex > state.dynamicNews.length;
// 只在 append 模式下去重(避免定时刷新重复)
// 替换和跳页模式直接使用原始数据(避免因去重导致数据丢失)
if (isAppend) {
// Append 模式:连续加载,需要去重
const existingIds = new Set(
state.dynamicNews
.filter(e => e !== null)
.map(e => e.id)
);
const newEvents = events.filter(e => !existingIds.has(e.id));
state.dynamicNews = [...state.dynamicNews, ...newEvents];
logger.debug('CommunityData', '连续追加数据(去重)', {
page,
startIndex,
endIndex: startIndex + newEvents.length,
originalEventsCount: events.length,
newEventsCount: newEvents.length,
filteredCount: events.length - newEvents.length,
totalCount: state.dynamicNews.length
});
} else if (isReplace) {
// 替换模式:直接覆盖,不去重
const endIndex = startIndex + events.length;
const before = state.dynamicNews.slice(0, startIndex);
const after = state.dynamicNews.slice(endIndex);
state.dynamicNews = [...before, ...events, ...after];
logger.debug('CommunityData', '替换重叠数据(不去重)', {
page,
startIndex,
endIndex,
eventsCount: events.length,
beforeLength: before.length,
afterLength: after.length,
totalCount: state.dynamicNews.length
});
} else {
// 跳页模式:填充间隔,不去重
const gap = startIndex - state.dynamicNews.length;
const fillers = Array(gap).fill(null);
state.dynamicNews = [...state.dynamicNews, ...fillers, ...events];
logger.debug('CommunityData', '跳页加载,填充间隔(不去重)', {
page,
startIndex,
endIndex: startIndex + events.length,
gap,
eventsCount: events.length,
totalCount: state.dynamicNews.length
});
}
}
state.dynamicNewsTotal = total;
@@ -449,11 +509,11 @@ export const selectHotEventsWithLoading = (state) => ({
});
export const selectDynamicNewsWithLoading = (state) => ({
data: state.communityData.dynamicNews, // 完整缓存列表
data: state.communityData.dynamicNews, // 完整缓存列表(可能包含 null 占位符)
loading: state.communityData.loading.dynamicNews,
error: state.communityData.error.dynamicNews,
total: state.communityData.dynamicNewsTotal, // 服务端总数量
cachedCount: state.communityData.dynamicNews.length, // 已缓存数量
cachedCount: state.communityData.dynamicNews.filter(e => e !== null).length, // 已缓存有效数量(排除 null
lastUpdated: state.communityData.lastUpdated.dynamicNews
});

View File

@@ -16,7 +16,8 @@ import {
Badge,
Center,
Spinner,
useColorModeValue
useColorModeValue,
useToast
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
import EventScrollList from './DynamicNewsCard/EventScrollList';
@@ -54,6 +55,7 @@ const DynamicNewsCard = forwardRef(({
...rest
}, ref) => {
const dispatch = useDispatch();
const toast = useToast();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
@@ -69,52 +71,243 @@ const DynamicNewsCard = forwardRef(({
const [selectedEvent, setSelectedEvent] = useState(null);
const [mode, setMode] = useState('carousel'); // 'carousel' 或 'grid',默认单排
const [currentPage, setCurrentPage] = useState(1); // 当前页码
const [loadingPage, setLoadingPage] = useState(null); // 正在加载的目标页码(用于 UX 提示)
// 根据模式决定每页显示数量
const pageSize = mode === 'carousel' ? 5 : 10;
// 计算总页数(基于缓存数量)
const totalPages = Math.ceil(cachedCount / pageSize) || 1;
// 计算总页数(基于服务端总数据量)
const totalPages = Math.ceil(total / pageSize) || 1;
// 检查是否还有更多数据
const hasMore = cachedCount < total;
// 从缓存中切片获取当前页数据
// 从缓存中切片获取当前页数据(过滤 null 占位符)
const currentPageEvents = useMemo(() => {
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return allCachedEvents.slice(startIndex, endIndex);
return allCachedEvents.slice(startIndex, endIndex).filter(event => event !== null);
}, [allCachedEvents, currentPage, pageSize]);
// 检查是否需要请求更多数据
const shouldFetchMore = useCallback((targetPage) => {
const requiredCount = targetPage * pageSize;
// 如果缓存不足,且服务端还有更多数据
return cachedCount < requiredCount && hasMore;
}, [cachedCount, total, pageSize, hasMore]);
// 翻页处理(智能预加载)
const handlePageChange = useCallback(async (newPage) => {
// 🔍 诊断日志 - 记录翻页开始状态
console.log('[handlePageChange] 开始翻页', {
currentPage,
newPage,
pageSize,
totalPages,
hasMore,
total,
allCachedEventsLength: allCachedEvents.length,
cachedCount
});
// 翻页处理
const handlePageChange = useCallback((newPage) => {
// 向后翻页(上一页):不请求,直接切换
if (newPage < currentPage) {
// 0. 首先检查目标页数据是否已完整缓存
const targetPageStartIndex = (newPage - 1) * pageSize;
const targetPageEndIndex = targetPageStartIndex + pageSize;
const targetPageData = allCachedEvents.slice(targetPageStartIndex, targetPageEndIndex);
const validTargetData = targetPageData.filter(e => e !== null);
const expectedCount = Math.min(pageSize, total - targetPageStartIndex);
const isTargetPageCached = validTargetData.length >= expectedCount;
console.log('[handlePageChange] 目标页缓存检查', {
newPage,
targetPageStartIndex,
targetPageEndIndex,
targetPageDataLength: targetPageData.length,
validTargetDataLength: validTargetData.length,
expectedCount,
isTargetPageCached
});
// 1. 判断翻页类型:连续翻页(上一页/下一页)还是跳转翻页(点击页码/输入跳转)
const isSequentialNavigation = Math.abs(newPage - currentPage) === 1;
// 2. 计算预加载范围
let preloadRange;
if (isSequentialNavigation) {
// 连续翻页前后各2页共5页
const start = Math.max(1, newPage - 2);
const end = Math.min(totalPages, newPage + 2);
preloadRange = Array.from(
{ length: end - start + 1 },
(_, i) => start + i
);
} else {
// 跳转翻页:只加载当前页
preloadRange = [newPage];
}
// 3. 检查哪些页面的数据还未缓存(检查是否包含 null 或超出数组长度)
const missingPages = preloadRange.filter(page => {
const pageStartIndex = (page - 1) * pageSize;
const pageEndIndex = pageStartIndex + pageSize;
// 如果该页超出数组范围,说明未缓存
if (pageEndIndex > allCachedEvents.length) {
console.log(`[missingPages] 页面${page}超出数组范围`, {
pageStartIndex,
pageEndIndex,
allCachedEventsLength: allCachedEvents.length
});
return true;
}
// 检查该页的数据是否包含 null 占位符或数据不足
const pageData = allCachedEvents.slice(pageStartIndex, pageEndIndex);
const validData = pageData.filter(e => e !== null);
const expectedCount = Math.min(pageSize, total - pageStartIndex);
const hasNullOrIncomplete = validData.length < expectedCount;
console.log(`[missingPages] 页面${page}检查`, {
pageStartIndex,
pageEndIndex,
pageDataLength: pageData.length,
validDataLength: validData.length,
expectedCount,
hasNullOrIncomplete
});
return hasNullOrIncomplete;
});
console.log('[handlePageChange] 缺失页面检测完成', {
preloadRange,
missingPages,
missingPagesCount: missingPages.length
});
// 4. 如果目标页已缓存,立即切换页码,然后在后台静默预加载其他页
if (isTargetPageCached && missingPages.length > 0 && hasMore) {
console.log('[DynamicNewsCard] 目标页已缓存,立即切换', {
currentPage,
newPage,
缺失页面: missingPages,
目标页已缓存: true
});
// 立即切换页码(用户无感知延迟)
setCurrentPage(newPage);
return;
// 在后台静默预加载其他缺失页面(拆分为单页请求)
try {
console.log('[DynamicNewsCard] 开始后台预加载', {
缺失页面: missingPages,
每页数量: pageSize
});
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
for (const page of missingPages) {
await dispatch(fetchDynamicNews({
page: page,
per_page: pageSize, // 固定值5或10不使用动态计算
pageSize: pageSize,
clearCache: false
})).unwrap();
console.log(`[DynamicNewsCard] 后台预加载第 ${page} 页完成`);
}
console.log('[DynamicNewsCard] 后台预加载全部完成', {
预加载页面: missingPages
});
} catch (error) {
console.error('[DynamicNewsCard] 后台预加载失败', error);
// 静默失败,不影响用户体验
}
return; // 提前返回,不执行下面的加载逻辑
}
// 向前翻页(下一页):检查是否需要请求
if (shouldFetchMore(newPage)) {
// 计算需要请求的页码(从缓存末尾继续)
const nextFetchPage = Math.ceil(cachedCount / pageSize) + 1;
// 5. 如果目标页未缓存,显示 loading 并等待加载完成
if (missingPages.length > 0 && hasMore) {
console.log('[DynamicNewsCard] 目标页未缓存显示loading', {
currentPage,
newPage,
翻页类型: isSequentialNavigation ? '连续翻页' : '跳转翻页',
预加载范围: preloadRange,
缺失页面: missingPages,
每页数量: pageSize,
目标页已缓存: false
});
dispatch(fetchDynamicNews({
page: nextFetchPage,
per_page: pageSize,
clearCache: false
}));
try {
// 设置加载状态(显示"正在加载第X页..."
setLoadingPage(newPage);
// 拆分为单页请求,避免 per_page 动态值导致后端返回空数据
for (const page of missingPages) {
console.log(`[DynamicNewsCard] 开始加载第 ${page}`);
await dispatch(fetchDynamicNews({
page: page,
per_page: pageSize, // 固定值5或10不使用动态计算
pageSize: pageSize, // 传递原始 pageSize用于正确计算索引
clearCache: false
})).unwrap();
console.log(`[DynamicNewsCard] 第 ${page} 页加载完成`);
}
console.log('[DynamicNewsCard] 所有缺失页面加载完成', {
缺失页面: missingPages
});
// 数据加载成功后才更新当前页码
setCurrentPage(newPage);
} catch (error) {
console.error('[DynamicNewsCard] 翻页加载失败', error);
// 显示错误提示
toast({
title: '加载失败',
description: `无法加载第 ${newPage} 页数据,请稍后重试`,
status: 'error',
duration: 3000,
isClosable: true,
position: 'top'
});
// 加载失败时不更新页码,保持在当前页
} finally {
// 清除加载状态
setLoadingPage(null);
}
} else if (missingPages.length === 0) {
// 只有在确实不需要加载时才直接切换
console.log('[handlePageChange] 无需加载,直接切换', {
currentPage,
newPage,
preloadRange,
missingPages,
reason: '所有页面均已缓存'
});
setCurrentPage(newPage);
} else {
// 理论上不应该到这里missingPages.length > 0 但 hasMore=false
console.warn('[handlePageChange] 意外分支:有缺失页面但无法加载', {
missingPages,
hasMore,
currentPage,
newPage,
total,
cachedCount
});
// 尝试切换页码,但可能会显示空数据
setCurrentPage(newPage);
toast({
title: '数据不完整',
description: `${newPage} 页数据可能不完整`,
status: 'warning',
duration: 2000,
isClosable: true,
position: 'top'
});
}
setCurrentPage(newPage);
}, [currentPage, cachedCount, pageSize, shouldFetchMore, dispatch]);
}, [currentPage, allCachedEvents, pageSize, totalPages, hasMore, dispatch, total, toast, cachedCount]);
// 模式切换处理
const handleModeToggle = useCallback((newMode) => {
@@ -125,17 +318,22 @@ const DynamicNewsCard = forwardRef(({
const newPageSize = newMode === 'carousel' ? 5 : 10;
// 检查缓存是否足够显示第1页
if (cachedCount < newPageSize) {
// 清空缓存,重新请求
// 检查第1页的数据是否完整(排除 null
const firstPageData = allCachedEvents.slice(0, newPageSize);
const validFirstPageCount = firstPageData.filter(e => e !== null).length;
const needsRefetch = validFirstPageCount < Math.min(newPageSize, total);
if (needsRefetch) {
// 第1页数据不完整清空缓存重新请求
dispatch(fetchDynamicNews({
page: 1,
per_page: newPageSize,
pageSize: newPageSize, // 传递 pageSize 确保索引计算一致
clearCache: true
}));
}
// 如果缓存足够,不发起请求,直接切换
}, [mode, cachedCount, dispatch]);
// 如果第1页数据完整,不发起请求,直接切换
}, [mode, allCachedEvents, total, dispatch]);
// 初始加载
useEffect(() => {
@@ -143,6 +341,7 @@ const DynamicNewsCard = forwardRef(({
dispatch(fetchDynamicNews({
page: 1,
per_page: 5,
pageSize: 5, // 传递 pageSize 确保索引计算一致
clearCache: true
}));
}
@@ -201,7 +400,8 @@ const DynamicNewsCard = forwardRef(({
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
loading={loading}
loading={loadingPage !== null}
loadingPage={loadingPage}
mode={mode}
onModeChange={handleModeToggle}
eventFollowStatus={eventFollowStatus}

View File

@@ -11,6 +11,7 @@ import {
ButtonGroup,
Center,
VStack,
HStack,
Spinner,
Text,
useColorModeValue
@@ -28,7 +29,8 @@ import PaginationControl from './PaginationControl';
* @param {number} currentPage - 当前页码
* @param {number} totalPages - 总页数(由服务端返回)
* @param {Function} onPageChange - 页码改变回调
* @param {boolean} loading - 加载状态
* @param {boolean} loading - 全局加载状态
* @param {number|null} loadingPage - 正在加载的目标页码(用于显示"正在加载第X页..."
* @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格)
* @param {Function} onModeChange - 模式切换回调
* @param {boolean} hasMore - 是否还有更多数据
@@ -52,20 +54,37 @@ const EventScrollList = ({
}) => {
const scrollContainerRef = useRef(null);
// 时间轴样式配置
// 所有 useColorModeValue 必须在组件顶层调用(不能在条件渲染中)
const timelineBg = useColorModeValue('gray.50', 'gray.700');
const timelineBorderColor = useColorModeValue('gray.400', 'gray.500');
const timelineTextColor = useColorModeValue('blue.600', 'blue.400');
// 翻页按钮颜色
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
// 滚动条颜色
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
// 加载遮罩颜色
const loadingOverlayBg = useColorModeValue('whiteAlpha.800', 'blackAlpha.700');
const loadingTextColor = useColorModeValue('gray.600', 'gray.300');
const getTimelineBoxStyle = () => {
return {
bg: useColorModeValue('gray.50', 'gray.700'),
borderColor: useColorModeValue('gray.400', 'gray.500'),
bg: timelineBg,
borderColor: timelineBorderColor,
borderWidth: '2px',
textColor: useColorModeValue('blue.600', 'blue.400'),
textColor: timelineTextColor,
boxShadow: 'sm',
};
};
return (
<Box>
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器(右) */}
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器 + 加载提示(右) */}
<Flex justify="space-between" align="center" mb={2}>
{/* 模式切换按钮 */}
<ButtonGroup size="sm" isAttached>
@@ -87,11 +106,11 @@ const EventScrollList = ({
{/* 分页控制器 */}
{totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
)}
</Flex>
@@ -114,10 +133,10 @@ const EventScrollList = ({
h="40px"
minW="40px"
borderRadius="full"
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)')}
bg={arrowBtnBg}
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
_hover={{
bg: useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'),
bg: arrowBtnHoverBg,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
transform: 'translateY(-50%) scale(1.05)'
}}
@@ -142,10 +161,10 @@ const EventScrollList = ({
h="40px"
minW="40px"
borderRadius="full"
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)')}
bg={arrowBtnBg}
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
_hover={{
bg: useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)'),
bg: arrowBtnHoverBg,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
transform: 'translateY(-50%) scale(1.05)'
}}
@@ -169,15 +188,15 @@ const EventScrollList = ({
height: '8px',
},
'&::-webkit-scrollbar-track': {
background: useColorModeValue('#f1f1f1', '#2D3748'),
background: scrollbarTrackBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb': {
background: useColorModeValue('#888', '#4A5568'),
background: scrollbarThumbBg,
borderRadius: '10px',
},
'&::-webkit-scrollbar-thumb:hover': {
background: useColorModeValue('#555', '#718096'),
background: scrollbarThumbHoverBg,
},
scrollBehavior: 'smooth',
WebkitOverflowScrolling: 'touch',
@@ -191,14 +210,14 @@ const EventScrollList = ({
left={0}
right={0}
bottom={0}
bg={useColorModeValue('whiteAlpha.800', 'blackAlpha.700')}
bg={loadingOverlayBg}
backdropFilter="blur(2px)"
zIndex={10}
borderRadius="md"
>
<VStack>
<Spinner size="lg" color="blue.500" thickness="3px" />
<Text fontSize="sm" color={useColorModeValue('gray.600', 'gray.300')}>
<Text fontSize="sm" color={loadingTextColor}>
加载中...
</Text>
</VStack>

View File

@@ -144,7 +144,7 @@ const DynamicNewsDetailPanel = ({ event }) => {
{/* 相关概念 */}
<RelatedConceptsSection
eventTitle={event.title}
effectiveTradingDate={event.trading_date}
effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at}
/>

View File

@@ -71,15 +71,19 @@ const RelatedConceptsSection = ({ eventTitle, effectiveTradingDate, eventTime })
setLoading(true);
setError(null);
// 格式化交易日期
// 格式化交易日期 - 统一使用 moment 处理
let formattedTradeDate;
if (typeof effectiveTradingDate === 'string') {
formattedTradeDate = effectiveTradingDate;
} else if (effectiveTradingDate instanceof Date) {
try {
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD');
} else if (moment.isMoment(effectiveTradingDate)) {
formattedTradeDate = effectiveTradingDate.format('YYYY-MM-DD');
} else {
// 验证日期是否有效
if (!moment(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
formattedTradeDate = moment().format('YYYY-MM-DD');
}
} catch (error) {
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
formattedTradeDate = moment().format('YYYY-MM-DD');
}

View File

@@ -268,7 +268,7 @@ const RelatedStocks = ({
return (
<VStack spacing={4}>
{[1, 2, 3].map((i) => (
<HStack key={i} w="100%" spacing={4}>
<HStack key={`skeleton-${i}`} w="100%" spacing={4}>
<Skeleton height="20px" width="100px" />
<Skeleton height="20px" width="150px" />
<Skeleton height="20px" width="80px" />

View File

@@ -1,109 +0,0 @@
"""
测试脚本:手动创建事件到数据库
用于测试 WebSocket 实时推送功能
"""
import sys
from datetime import datetime
from sqlalchemy import create_engine, Column, Integer, String, Text, Float, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# 数据库连接(从 app.py 复制)
DATABASE_URI = 'mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/stock?charset=utf8mb4'
engine = create_engine(DATABASE_URI, echo=False)
Session = sessionmaker(bind=engine)
session = Session()
Base = declarative_base()
# Event 模型(简化版,只包含必要字段)
class Event(Base):
__tablename__ = 'events'
id = Column(Integer, primary_key=True)
title = Column(String(500), nullable=False)
description = Column(Text)
event_type = Column(String(100))
importance = Column(String(10))
status = Column(String(50), default='active')
hot_score = Column(Float, default=0)
view_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
def create_test_event():
"""创建一个测试事件"""
import random
event_types = ['policy', 'market', 'tech', 'industry', 'finance']
importances = ['S', 'A', 'B', 'C']
test_event = Event(
title=f'测试事件 - {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}',
description=f'这是一个用于测试 WebSocket 实时推送的事件,创建于 {datetime.now()}',
event_type=random.choice(event_types),
importance=random.choice(importances),
status='active',
hot_score=round(random.uniform(50, 100), 2),
view_count=random.randint(100, 1000)
)
try:
session.add(test_event)
session.commit()
print("✅ 测试事件创建成功!")
print(f" ID: {test_event.id}")
print(f" 标题: {test_event.title}")
print(f" 类型: {test_event.event_type}")
print(f" 重要性: {test_event.importance}")
print(f" 热度: {test_event.hot_score}")
print(f"\n💡 提示: 轮询将在 2 分钟内检测到此事件并推送到前端")
print(f" (如果需要立即推送,请将轮询间隔改为更短)")
return test_event.id
except Exception as e:
session.rollback()
print(f"❌ 创建事件失败: {e}")
return None
finally:
session.close()
def create_multiple_events(count=3):
"""创建多个测试事件"""
print(f"正在创建 {count} 个测试事件...\n")
for i in range(count):
event_id = create_test_event()
if event_id:
print(f"[{i+1}/{count}] 事件 #{event_id} 创建成功\n")
else:
print(f"[{i+1}/{count}] 创建失败\n")
print(f"\n✅ 完成!共创建 {count} 个事件")
if __name__ == '__main__':
print("=" * 60)
print("WebSocket 事件推送测试 - 手动创建事件")
print("=" * 60)
print()
if len(sys.argv) > 1:
try:
count = int(sys.argv[1])
create_multiple_events(count)
except ValueError:
print("❌ 参数必须是数字")
print("用法: python test_create_event.py [数量]")
else:
# 默认创建 1 个事件
create_test_event()
print("\n" + "=" * 60)

147
test_events_api.py Normal file
View File

@@ -0,0 +1,147 @@
"""
测试 /api/events 接口的分页和交易日筛选功能
"""
import requests
from datetime import datetime, timedelta
# 接口地址
BASE_URL = "http://localhost:5001"
EVENTS_API = f"{BASE_URL}/api/events"
def test_pagination():
"""测试分页功能"""
print("\n=== 测试分页功能 ===")
# 测试第一页
params = {
'page': 1,
'per_page': 5
}
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
pagination = data['data']['pagination']
print(f"✓ 第一页请求成功")
print(f" - 当前页: {pagination['page']}")
print(f" - 每页数量: {pagination['per_page']}")
print(f" - 总记录数: {pagination['total']}")
print(f" - 总页数: {pagination['pages']}")
print(f" - 是否有下一页: {pagination['has_next']}")
print(f" - 本页事件数: {len(data['data']['events'])}")
# 测试第二页
if pagination['has_next']:
params['page'] = 2
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 第二页请求成功,返回 {len(data['data']['events'])} 个事件")
else:
print(f"✗ 请求失败: {data.get('error')}")
def test_trading_day_filter():
"""测试交易日筛选功能"""
print("\n=== 测试交易日筛选功能 ===")
# 测试使用 YYYY-MM-DD 格式
tday = "2024-11-01" # 使用一个交易日
params = {
'tday': tday,
'per_page': 10
}
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 交易日筛选成功 (格式: YYYY-MM-DD)")
print(f" - 交易日: {tday}")
print(f" - 筛选到的事件数: {len(data['data']['events'])}")
print(f" - 总记录数: {data['data']['pagination']['total']}")
if data['data']['events']:
print(f" - 第一个事件创建时间: {data['data']['events'][0]['created_at']}")
# 检查 applied_filters
filters = data['data']['filters']['applied_filters']
if 'tday' in filters:
print(f" - 应用的交易日筛选: {filters['tday']}")
else:
print(f"✗ 请求失败: {data.get('error')}")
# 测试使用 YYYY/M/D 格式
tday2 = "2024/11/1"
params['tday'] = tday2
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 交易日筛选成功 (格式: YYYY/M/D)")
print(f" - 交易日: {tday2}")
print(f" - 筛选到的事件数: {len(data['data']['events'])}")
else:
print(f"✗ 请求失败: {data.get('error')}")
def test_combined_filters():
"""测试组合筛选功能"""
print("\n=== 测试组合筛选 (分页 + 交易日) ===")
params = {
'tday': '2024-10-31',
'page': 1,
'per_page': 3,
'status': 'active'
}
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 组合筛选成功")
print(f" - 筛选到的事件数: {len(data['data']['events'])}")
print(f" - 应用的筛选条件: {data['data']['filters']['applied_filters']}")
if data['data']['events']:
for i, event in enumerate(data['data']['events'], 1):
print(f" - 事件{i}: {event['title'][:30]}... (创建时间: {event['created_at']})")
else:
print(f"✗ 请求失败: {data.get('error')}")
def test_latest_trading_day():
"""测试获取最新数据(不传 tday 参数)"""
print("\n=== 测试获取最新数据 ===")
params = {
'page': 1,
'per_page': 5,
'sort': 'new'
}
response = requests.get(EVENTS_API, params=params)
data = response.json()
if data.get('success'):
print(f"✓ 获取最新数据成功")
print(f" - 返回事件数: {len(data['data']['events'])}")
if data['data']['events']:
print(f" - 最新事件: {data['data']['events'][0]['title'][:50]}")
print(f" - 创建时间: {data['data']['events'][0]['created_at']}")
else:
print(f"✗ 请求失败: {data.get('error')}")
if __name__ == "__main__":
print("开始测试 /api/events 接口")
print("=" * 50)
try:
# 测试各项功能
test_pagination()
test_trading_day_filter()
test_combined_filters()
test_latest_trading_day()
print("\n" + "=" * 50)
print("测试完成!")
except requests.exceptions.ConnectionError:
print("\n✗ 连接失败:请确保后端服务正在运行 (python app.py)")
except Exception as e:
print(f"\n✗ 测试出错: {e}")