Compare commits
26 Commits
784202025c
...
feature
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce46820105 | ||
|
|
012c13c49a | ||
|
|
0e9a0d9123 | ||
| 4f163af846 | |||
|
|
ce495ed6fa | ||
|
|
0e66bb471f | ||
|
|
82cb0b4034 | ||
|
|
78e7001372 | ||
|
|
26ad017d32 | ||
|
|
fea0bc3bbe | ||
|
|
f17a8fbd87 | ||
|
|
6a0a8e8e2b | ||
|
|
8ebfad9992 | ||
|
|
c208ba36b7 | ||
|
|
b14eb175f5 | ||
| 0d84ffe87f | |||
|
|
b95607e9b4 | ||
|
|
462933f4af | ||
|
|
26dcfd061c | ||
|
|
7e32dda2df | ||
|
|
9274323151 | ||
|
|
cedfd3978d | ||
|
|
89fe0cd10b | ||
|
|
d027071e98 | ||
|
|
e31e4118a0 | ||
|
|
5611c06991 |
42
.env.production
Normal file
42
.env.production
Normal 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
133
CLAUDE.md
@@ -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
63
app.py
@@ -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,
|
||||
|
||||
@@ -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': '' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -501,7 +501,7 @@ export default function WechatRegister() {
|
||||
bg="gray.50"
|
||||
boxShadow="sm" // ✅ 添加轻微阴影
|
||||
>
|
||||
{wechatStatus !== WECHAT_STATUS.NONE ? (
|
||||
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||
/* 已获取二维码:显示iframe */
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
@@ -509,6 +509,7 @@ export default function WechatRegister() {
|
||||
width="300"
|
||||
height="350"
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||
@@ -520,7 +521,6 @@ export default function WechatRegister() {
|
||||
// 使用 onWheel 事件阻止滚动 │ │
|
||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -47,29 +47,29 @@ const StockChangeIndicators = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 根据涨跌幅获取背景色(跟随数字颜色)
|
||||
// 根据涨跌幅获取背景色(永远比文字色浅)
|
||||
const getBgColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.100', 'gray.700');
|
||||
return useColorModeValue('gray.50', 'gray.800');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色背景
|
||||
if (value === 0) {
|
||||
return useColorModeValue('gray.100', 'gray.700');
|
||||
return useColorModeValue('gray.50', 'gray.800');
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨背景:红色系 → 橙色系
|
||||
// 上涨背景:红色系 → 橙色系(统一使用 50 最浅色)
|
||||
if (absValue >= 10) return useColorModeValue('red.50', 'red.900');
|
||||
if (absValue >= 5) return useColorModeValue('red.50', 'red.900');
|
||||
if (absValue >= 3) return useColorModeValue('red.50', 'red.900');
|
||||
if (absValue >= 1) return useColorModeValue('orange.50', 'orange.900');
|
||||
return useColorModeValue('orange.50', 'orange.900');
|
||||
} else {
|
||||
// 下跌背景:绿色系 → 青色系
|
||||
// 下跌背景:绿色系 → 青色系(统一使用 50 最浅色)
|
||||
if (absValue >= 10) return useColorModeValue('green.50', 'green.900');
|
||||
if (absValue >= 5) return useColorModeValue('green.50', 'green.900');
|
||||
if (absValue >= 3) return useColorModeValue('green.50', 'green.900');
|
||||
@@ -78,34 +78,34 @@ const StockChangeIndicators = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 根据涨跌幅获取边框色(跟随数字颜色)
|
||||
// 根据涨跌幅获取边框色(比背景深,比文字浅)
|
||||
const getBorderColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.300', 'gray.600');
|
||||
return useColorModeValue('gray.200', 'gray.700');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色边框
|
||||
if (value === 0) {
|
||||
return useColorModeValue('gray.300', 'gray.600');
|
||||
return useColorModeValue('gray.200', 'gray.700');
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨边框:红色系 → 橙色系
|
||||
if (absValue >= 10) return useColorModeValue('red.200', 'red.700');
|
||||
if (absValue >= 5) return useColorModeValue('red.200', 'red.700');
|
||||
if (absValue >= 3) return useColorModeValue('red.200', 'red.700');
|
||||
if (absValue >= 1) return useColorModeValue('orange.200', 'orange.700');
|
||||
return useColorModeValue('orange.200', 'orange.700');
|
||||
// 上涨边框:红色系 → 橙色系(跟随文字深浅)
|
||||
if (absValue >= 10) return useColorModeValue('red.200', 'red.800'); // 文字 red.900
|
||||
if (absValue >= 5) return useColorModeValue('red.200', 'red.700'); // 文字 red.700
|
||||
if (absValue >= 3) return useColorModeValue('red.100', 'red.600'); // 文字 red.500
|
||||
if (absValue >= 1) return useColorModeValue('orange.200', 'orange.700'); // 文字 orange.600
|
||||
return useColorModeValue('orange.100', 'orange.600'); // 文字 orange.400
|
||||
} else {
|
||||
// 下跌边框:绿色系 → 青色系
|
||||
if (absValue >= 10) return useColorModeValue('green.200', 'green.700');
|
||||
if (absValue >= 5) return useColorModeValue('green.200', 'green.700');
|
||||
if (absValue >= 3) return useColorModeValue('green.200', 'green.700');
|
||||
if (absValue >= 1) return useColorModeValue('teal.200', 'teal.700');
|
||||
return useColorModeValue('teal.200', 'teal.700');
|
||||
// 下跌边框:绿色系 → 青色系(跟随文字深浅)
|
||||
if (absValue >= 10) return useColorModeValue('green.200', 'green.800'); // 文字 green.900
|
||||
if (absValue >= 5) return useColorModeValue('green.200', 'green.700'); // 文字 green.700
|
||||
if (absValue >= 3) return useColorModeValue('green.100', 'green.600'); // 文字 green.500
|
||||
if (absValue >= 1) return useColorModeValue('teal.200', 'teal.700'); // 文字 teal.600
|
||||
return useColorModeValue('teal.100', 'teal.600'); // 文字 teal.400
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,8 +127,8 @@ const StockChangeIndicators = ({
|
||||
borderWidth="2px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
px={2}
|
||||
py={1}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
|
||||
@@ -15,47 +15,55 @@ import {
|
||||
export const IMPORTANCE_LEVELS = {
|
||||
'S': {
|
||||
level: 'S',
|
||||
color: 'purple.600',
|
||||
bgColor: 'purple.50',
|
||||
borderColor: 'purple.200',
|
||||
color: 'red.800',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
colorScheme: 'red',
|
||||
badgeBg: 'red.800', // 角标边框和文字颜色 - 极深红色
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
dotBg: 'purple.500',
|
||||
dotBg: 'red.800',
|
||||
description: '重大事件,市场影响深远',
|
||||
antdColor: '#722ed1', // 对应 Ant Design 的紫色
|
||||
antdColor: '#cf1322',
|
||||
},
|
||||
'A': {
|
||||
level: 'A',
|
||||
color: 'red.600',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
colorScheme: 'red',
|
||||
badgeBg: 'red.600', // 角标边框和文字颜色 - 深红色
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
dotBg: 'red.500',
|
||||
dotBg: 'red.600',
|
||||
description: '重要事件,影响较大',
|
||||
antdColor: '#ff4d4f', // 对应 Ant Design 的红色
|
||||
antdColor: '#ff4d4f',
|
||||
},
|
||||
'B': {
|
||||
level: 'B',
|
||||
color: 'orange.600',
|
||||
bgColor: 'orange.50',
|
||||
borderColor: 'orange.200',
|
||||
color: 'red.500',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.100',
|
||||
colorScheme: 'red',
|
||||
badgeBg: 'red.500', // 角标边框和文字颜色 - 中红色
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
dotBg: 'orange.500',
|
||||
dotBg: 'red.500',
|
||||
description: '普通事件,有一定影响',
|
||||
antdColor: '#faad14', // 对应 Ant Design 的橙色
|
||||
antdColor: '#ff7875',
|
||||
},
|
||||
'C': {
|
||||
level: 'C',
|
||||
color: 'green.600',
|
||||
bgColor: 'green.50',
|
||||
borderColor: 'green.200',
|
||||
color: 'red.400',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.100',
|
||||
colorScheme: 'red',
|
||||
badgeBg: 'red.400', // 角标边框和文字颜色 - 浅红色
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
dotBg: 'green.500',
|
||||
dotBg: 'red.400',
|
||||
description: '参考事件,影响有限',
|
||||
antdColor: '#52c41a', // 对应 Ant Design 的绿色
|
||||
antdColor: '#ffa39e',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
142
src/hooks/useDelayedMenu.js
Normal file
142
src/hooks/useDelayedMenu.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -696,17 +696,24 @@ function generateKeywords(industry, seed) {
|
||||
return selectedStocks;
|
||||
};
|
||||
|
||||
// 将字符串数组转换为对象数组,包含完整字段
|
||||
return keywordNames.map((name, index) => ({
|
||||
name: name,
|
||||
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
|
||||
relevance: 70 + Math.floor((seed * 7 + index * 11) % 30), // 70-99的相关度
|
||||
description: descriptionTemplates[name] || `${name}相关概念,市场关注度较高,具有一定的投资价值。`,
|
||||
avg_change_pct: (Math.random() * 15 - 5).toFixed(2), // -5% ~ +10% 的涨跌幅
|
||||
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
|
||||
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
|
||||
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
|
||||
}));
|
||||
// 将字符串数组转换为对象数组,匹配真实API数据结构
|
||||
return keywordNames.map((name, index) => {
|
||||
const score = (70 + Math.floor((seed * 7 + index * 11) % 30)) / 100; // 0.70-0.99的分数
|
||||
const avgChangePct = (Math.random() * 15 - 5).toFixed(2); // -5% ~ +10% 的涨跌幅
|
||||
|
||||
return {
|
||||
concept: name, // 使用 concept 字段而不是 name
|
||||
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
|
||||
score: parseFloat(score.toFixed(2)), // 0-1之间的分数,而不是0-100
|
||||
description: descriptionTemplates[name] || `${name}相关概念,市场关注度较高,具有一定的投资价值。`,
|
||||
price_info: { // 将 avg_change_pct 嵌套在 price_info 对象中
|
||||
avg_change_pct: parseFloat(avgChangePct)
|
||||
},
|
||||
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
|
||||
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
|
||||
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -746,6 +753,7 @@ export function generateMockEvents(params = {}) {
|
||||
const hotScore = Math.max(50, 100 - i);
|
||||
const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15%
|
||||
const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30%
|
||||
const relatedWeekChg = (Math.random() * 30 - 10).toFixed(2); // -10% 到 20%
|
||||
|
||||
// 生成价格走势数据(前一天、当天、后一天)
|
||||
const generatePriceTrend = (seed) => {
|
||||
@@ -841,6 +849,7 @@ export function generateMockEvents(params = {}) {
|
||||
view_count: Math.floor(Math.random() * 10000),
|
||||
related_avg_chg: parseFloat(relatedAvgChg),
|
||||
related_max_chg: parseFloat(relatedMaxChg),
|
||||
related_week_chg: parseFloat(relatedWeekChg),
|
||||
keywords: generateKeywords(industry, i),
|
||||
is_ai_generated: i % 4 === 0, // 25% 的事件是AI生成
|
||||
industry: industry,
|
||||
|
||||
@@ -6,8 +6,50 @@ import { http, HttpResponse } from 'msw';
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 生成历史触发时间(3-5个历史日期)
|
||||
const generateHappenedTimes = (seed) => {
|
||||
const times = [];
|
||||
const count = 3 + (seed % 3); // 3-5个时间点
|
||||
for (let i = 0; i < count; i++) {
|
||||
const daysAgo = 30 + (seed * 7 + i * 11) % 330; // 30-360天前
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
times.push(date.toISOString().split('T')[0]);
|
||||
}
|
||||
return times.sort().reverse(); // 降序排列
|
||||
};
|
||||
|
||||
// 生成核心相关股票
|
||||
const generateStocksForConcept = (seed, count = 4) => {
|
||||
const stockPool = [
|
||||
{ name: '贵州茅台', code: '600519' },
|
||||
{ name: '宁德时代', code: '300750' },
|
||||
{ name: '中国平安', code: '601318' },
|
||||
{ name: '比亚迪', code: '002594' },
|
||||
{ name: '隆基绿能', code: '601012' },
|
||||
{ name: '阳光电源', code: '300274' },
|
||||
{ name: '三一重工', code: '600031' },
|
||||
{ name: '中芯国际', code: '688981' },
|
||||
{ name: '京东方A', code: '000725' },
|
||||
{ name: '立讯精密', code: '002475' }
|
||||
];
|
||||
|
||||
const stocks = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const stockIndex = (seed + i * 7) % stockPool.length;
|
||||
const stock = stockPool[stockIndex];
|
||||
stocks.push({
|
||||
stock_name: stock.name,
|
||||
stock_code: stock.code,
|
||||
reason: `作为行业龙头企业,${stock.name}在该领域具有核心竞争优势,市场份额领先,技术实力雄厚。`,
|
||||
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
|
||||
});
|
||||
}
|
||||
return stocks;
|
||||
};
|
||||
|
||||
// 生成热门概念数据
|
||||
const generatePopularConcepts = (size = 20) => {
|
||||
export const generatePopularConcepts = (size = 20) => {
|
||||
const concepts = [
|
||||
'人工智能', '新能源汽车', '半导体', '光伏', '锂电池',
|
||||
'储能', '氢能源', '风电', '特高压', '工业母机',
|
||||
@@ -22,21 +64,38 @@ const generatePopularConcepts = (size = 20) => {
|
||||
'疫苗', '中药', '医疗信息化', '智慧医疗', '基因测序'
|
||||
];
|
||||
|
||||
const conceptDescriptions = {
|
||||
'人工智能': '人工智能是"技术突破+政策扶持"双轮驱动的硬科技主题。随着大模型技术的突破,AI应用场景不断拓展,预计将催化算力、数据、应用三大产业链。',
|
||||
'新能源汽车': '新能源汽车行业景气度持续向好,渗透率不断提升。政策支持力度大,产业链上下游企业均受益明显。',
|
||||
'半导体': '国产半导体替代加速,自主可控需求强烈。政策和资金支持力度大,行业迎来黄金发展期。',
|
||||
'光伏': '光伏装机量快速增长,成本持续下降,行业景气度维持高位。双碳目标下,光伏行业前景广阔。',
|
||||
'锂电池': '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。新能源汽车和储能需求旺盛。',
|
||||
'储能': '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。未来市场空间巨大。',
|
||||
'默认': '该概念市场关注度较高,具有一定的投资价值。相关企业技术实力雄厚,市场前景广阔。'
|
||||
};
|
||||
|
||||
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < Math.min(size, concepts.length); i++) {
|
||||
const changePct = (Math.random() * 12 - 2).toFixed(2); // -2% 到 +10%
|
||||
const stockCount = Math.floor(Math.random() * 50) + 10; // 10-60 只股票
|
||||
const score = parseFloat((Math.random() * 5 + 3).toFixed(2)); // 3-8 分数范围
|
||||
|
||||
results.push({
|
||||
concept: concepts[i],
|
||||
concept_id: `CONCEPT_${1000 + i}`,
|
||||
stock_count: stockCount,
|
||||
score: score, // 相关度分数
|
||||
match_type: matchTypes[i % 3], // 匹配类型
|
||||
description: conceptDescriptions[concepts[i]] || conceptDescriptions['默认'],
|
||||
price_info: {
|
||||
avg_change_pct: parseFloat(changePct),
|
||||
avg_price: (Math.random() * 100 + 10).toFixed(2),
|
||||
total_market_cap: (Math.random() * 1000 + 100).toFixed(2)
|
||||
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
|
||||
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
|
||||
},
|
||||
description: `${concepts[i]}相关概念股`,
|
||||
happened_times: generateHappenedTimes(i), // 历史触发时间
|
||||
stocks: generateStocksForConcept(i, 4), // 核心相关股票
|
||||
hot_score: Math.floor(Math.random() * 100)
|
||||
});
|
||||
}
|
||||
@@ -115,15 +174,12 @@ export const conceptHandlers = [
|
||||
|
||||
console.log('[Mock Concept] 搜索概念:', { query, size, page, sort_by });
|
||||
|
||||
// 生成数据
|
||||
// 生成数据(不过滤,模拟真实 API 的语义搜索返回热门概念)
|
||||
let results = generatePopularConcepts(size);
|
||||
console.log('[Mock Concept] 生成概念数量:', results.length);
|
||||
|
||||
// 如果有查询关键词,过滤结果
|
||||
if (query) {
|
||||
results = results.filter(item =>
|
||||
item.concept.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
// Mock 环境下不做过滤,直接返回热门概念
|
||||
// 真实环境会根据 query 进行语义搜索
|
||||
|
||||
// 根据排序字段排序
|
||||
if (sort_by === 'change_pct') {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events';
|
||||
import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account';
|
||||
import { generatePopularConcepts } from './concept';
|
||||
|
||||
// 模拟网络延迟
|
||||
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -183,6 +184,71 @@ export const eventHandlers = [
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取事件相关概念
|
||||
http.get('/api/events/:eventId/concepts', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 获取事件相关概念, eventId:', eventId);
|
||||
|
||||
try {
|
||||
// 返回热门概念列表(模拟真实场景下根据事件标题搜索的结果)
|
||||
const concepts = generatePopularConcepts(5);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: concepts,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取事件相关概念失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件相关概念失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 切换事件关注状态
|
||||
http.post('/api/events/:eventId/follow', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 切换事件关注状态, eventId:', eventId);
|
||||
|
||||
try {
|
||||
// 模拟切换逻辑:随机生成关注状态
|
||||
// 实际应用中,这里应该从某个状态存储中读取和更新
|
||||
const isFollowing = Math.random() > 0.5;
|
||||
const followerCount = Math.floor(Math.random() * 1000) + 100;
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
is_following: isFollowing,
|
||||
follower_count: followerCount
|
||||
},
|
||||
message: isFollowing ? '关注成功' : '取消关注成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 切换事件关注状态失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '切换关注状态失败',
|
||||
data: null
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取事件传导链分析数据
|
||||
http.get('/api/events/:eventId/transmission', async ({ params }) => {
|
||||
await delay(500);
|
||||
|
||||
@@ -157,15 +157,31 @@ export const fetchHotEvents = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取动态新闻(无缓存,每次都发起请求)
|
||||
* 用于 DynamicNewsCard 组件,需要保持实时性
|
||||
* @param {Object} params - 分页参数 { page, per_page }
|
||||
* 获取动态新闻(客户端缓存 + 智能请求)
|
||||
* 用于 DynamicNewsCard 组件
|
||||
* @param {Object} params - 请求参数
|
||||
* @param {number} params.page - 页码
|
||||
* @param {number} params.per_page - 每页数量
|
||||
* @param {boolean} params.clearCache - 是否清空缓存(默认 false)
|
||||
* @param {boolean} params.prependMode - 是否追加到头部(用于定时刷新,默认 false)
|
||||
*/
|
||||
export const fetchDynamicNews = createAsyncThunk(
|
||||
'communityData/fetchDynamicNews',
|
||||
async ({ page = 1, per_page = 5 } = {}, { rejectWithValue }) => {
|
||||
async ({
|
||||
page = 1,
|
||||
per_page = 5,
|
||||
pageSize = 5, // 每页实际显示的数据量(用于计算索引)
|
||||
clearCache = false,
|
||||
prependMode = false
|
||||
} = {}, { rejectWithValue }) => {
|
||||
try {
|
||||
logger.debug('CommunityData', '开始获取动态新闻', { page, per_page });
|
||||
logger.debug('CommunityData', '开始获取动态新闻', {
|
||||
page,
|
||||
per_page,
|
||||
clearCache,
|
||||
prependMode
|
||||
});
|
||||
|
||||
const response = await eventService.getEvents({
|
||||
page,
|
||||
per_page,
|
||||
@@ -180,12 +196,25 @@ export const fetchDynamicNews = createAsyncThunk(
|
||||
});
|
||||
return {
|
||||
events: response.data.events,
|
||||
pagination: response.data.pagination || {}
|
||||
total: response.data.pagination?.total || 0,
|
||||
page,
|
||||
per_page,
|
||||
pageSize, // 返回 pageSize 用于索引计算
|
||||
clearCache,
|
||||
prependMode
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn('CommunityData', '动态新闻返回数据为空', response);
|
||||
return { events: [], pagination: {} };
|
||||
return {
|
||||
events: [],
|
||||
total: 0,
|
||||
page,
|
||||
per_page,
|
||||
pageSize, // 返回 pageSize 用于索引计算
|
||||
clearCache,
|
||||
prependMode
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('CommunityData', '获取动态新闻失败', error);
|
||||
return rejectWithValue(error.message || '获取动态新闻失败');
|
||||
@@ -193,6 +222,51 @@ export const fetchDynamicNews = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 切换事件关注状态
|
||||
* 复用 EventList.js 中的关注逻辑
|
||||
* @param {number} eventId - 事件ID
|
||||
*/
|
||||
export const toggleEventFollow = createAsyncThunk(
|
||||
'communityData/toggleEventFollow',
|
||||
async (eventId, { rejectWithValue }) => {
|
||||
try {
|
||||
logger.debug('CommunityData', '切换事件关注状态', { eventId });
|
||||
|
||||
// 调用 API(自动切换关注状态,后端根据当前状态决定关注/取消关注)
|
||||
const response = await fetch(`/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '操作失败');
|
||||
}
|
||||
|
||||
const isFollowing = data.data?.is_following;
|
||||
const followerCount = data.data?.follower_count ?? 0;
|
||||
|
||||
logger.info('CommunityData', '关注状态切换成功', {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount
|
||||
});
|
||||
|
||||
return {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('CommunityData', '切换关注状态失败', error);
|
||||
return rejectWithValue(error.message || '切换关注状态失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== Slice 定义 ====================
|
||||
|
||||
const communityDataSlice = createSlice({
|
||||
@@ -201,8 +275,9 @@ const communityDataSlice = createSlice({
|
||||
// 数据
|
||||
popularKeywords: [],
|
||||
hotEvents: [],
|
||||
dynamicNews: [], // 动态新闻(无缓存)
|
||||
dynamicNewsPagination: {}, // 动态新闻分页信息
|
||||
dynamicNews: [], // 动态新闻完整缓存列表
|
||||
dynamicNewsTotal: 0, // 服务端总数量
|
||||
eventFollowStatus: {}, // 事件关注状态 { [eventId]: { isFollowing: boolean, followerCount: number } }
|
||||
|
||||
// 加载状态
|
||||
loading: {
|
||||
@@ -278,6 +353,16 @@ const communityDataSlice = createSlice({
|
||||
preloadData: (state) => {
|
||||
logger.info('CommunityData', '准备预加载数据');
|
||||
// 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置单个事件的关注状态(同步)
|
||||
* @param {Object} action.payload - { eventId, isFollowing, followerCount }
|
||||
*/
|
||||
setEventFollowStatus: (state, action) => {
|
||||
const { eventId, isFollowing, followerCount } = action.payload;
|
||||
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
|
||||
logger.debug('CommunityData', '设置事件关注状态', { eventId, isFollowing, followerCount });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -286,34 +371,124 @@ const communityDataSlice = createSlice({
|
||||
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
|
||||
createDataReducers(builder, fetchHotEvents, 'hotEvents');
|
||||
|
||||
// dynamicNews 需要特殊处理(包含 pagination)
|
||||
// dynamicNews 需要特殊处理(缓存 + 追加模式)
|
||||
builder
|
||||
.addCase(fetchDynamicNews.pending, (state) => {
|
||||
state.loading.dynamicNews = true;
|
||||
state.error.dynamicNews = null;
|
||||
})
|
||||
.addCase(fetchDynamicNews.fulfilled, (state, action) => {
|
||||
const { events, total, page, per_page, pageSize, clearCache, prependMode } = action.payload;
|
||||
|
||||
if (clearCache) {
|
||||
// 清空缓存模式:直接替换
|
||||
state.dynamicNews = events;
|
||||
logger.debug('CommunityData', '清空缓存并加载新数据', {
|
||||
count: events.length
|
||||
});
|
||||
} else if (prependMode) {
|
||||
// 追加到头部模式(用于定时刷新):去重后插入头部
|
||||
const existingIds = new Set(state.dynamicNews.map(e => e.id));
|
||||
const newEvents = events.filter(e => !existingIds.has(e.id));
|
||||
state.dynamicNews = [...newEvents, ...state.dynamicNews];
|
||||
logger.debug('CommunityData', '追加新数据到头部', {
|
||||
newCount: newEvents.length,
|
||||
totalCount: state.dynamicNews.length
|
||||
});
|
||||
} else {
|
||||
// 智能插入模式:根据页码计算正确的插入位置
|
||||
// 使用 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;
|
||||
state.loading.dynamicNews = false;
|
||||
state.dynamicNews = action.payload.events;
|
||||
state.dynamicNewsPagination = action.payload.pagination;
|
||||
state.lastUpdated.dynamicNews = new Date().toISOString();
|
||||
})
|
||||
.addCase(fetchDynamicNews.rejected, (state, action) => {
|
||||
state.loading.dynamicNews = false;
|
||||
state.error.dynamicNews = action.payload;
|
||||
logger.error('CommunityData', 'dynamicNews 加载失败', new Error(action.payload));
|
||||
})
|
||||
// toggleEventFollow
|
||||
.addCase(toggleEventFollow.fulfilled, (state, action) => {
|
||||
const { eventId, isFollowing, followerCount } = action.payload;
|
||||
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
|
||||
logger.debug('CommunityData', 'toggleEventFollow fulfilled', { eventId, isFollowing, followerCount });
|
||||
})
|
||||
.addCase(toggleEventFollow.rejected, (state, action) => {
|
||||
logger.error('CommunityData', 'toggleEventFollow rejected', action.payload);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
export const { clearCache, clearSpecificCache, preloadData } = communityDataSlice.actions;
|
||||
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus } = communityDataSlice.actions;
|
||||
|
||||
// 基础选择器(Selectors)
|
||||
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
|
||||
export const selectHotEvents = (state) => state.communityData.hotEvents;
|
||||
export const selectDynamicNews = (state) => state.communityData.dynamicNews;
|
||||
export const selectEventFollowStatus = (state) => state.communityData.eventFollowStatus;
|
||||
export const selectLoading = (state) => state.communityData.loading;
|
||||
export const selectError = (state) => state.communityData.error;
|
||||
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
|
||||
@@ -334,10 +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,
|
||||
pagination: state.communityData.dynamicNewsPagination,
|
||||
total: state.communityData.dynamicNewsTotal, // 服务端总数量
|
||||
cachedCount: state.communityData.dynamicNews.filter(e => e !== null).length, // 已缓存有效数量(排除 null)
|
||||
lastUpdated: state.communityData.lastUpdated.dynamicNews
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// src/views/Community/components/DynamicNewsCard.js
|
||||
// 横向滚动事件卡片组件(实时要闻·动态追踪)
|
||||
|
||||
import React, { forwardRef, useState, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import React, { forwardRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -16,19 +16,21 @@ import {
|
||||
Badge,
|
||||
Center,
|
||||
Spinner,
|
||||
useColorModeValue
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon } from '@chakra-ui/icons';
|
||||
import EventScrollList from './DynamicNewsCard/EventScrollList';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
import UnifiedSearchBox from './UnifiedSearchBox';
|
||||
import { fetchDynamicNews } from '../../../store/slices/communityDataSlice';
|
||||
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
|
||||
|
||||
/**
|
||||
* 实时要闻·动态追踪 - 横向滚动卡片组件
|
||||
* @param {Array} events - 事件列表
|
||||
* 实时要闻·动态追踪 - 事件展示卡片组件
|
||||
* @param {Array} allCachedEvents - 完整缓存事件列表(从 Redux 传入)
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Object} pagination - 分页信息 { page, per_page, total, total_pages }
|
||||
* @param {number} total - 服务端总数量
|
||||
* @param {number} cachedCount - 已缓存数量
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @param {Array} popularKeywords - 热门关键词
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
@@ -39,9 +41,10 @@ import { fetchDynamicNews } from '../../../store/slices/communityDataSlice';
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const DynamicNewsCard = forwardRef(({
|
||||
events,
|
||||
allCachedEvents = [],
|
||||
loading,
|
||||
pagination = {},
|
||||
total = 0,
|
||||
cachedCount = 0,
|
||||
filters = {},
|
||||
popularKeywords = [],
|
||||
lastUpdateTime,
|
||||
@@ -52,29 +55,304 @@ const DynamicNewsCard = forwardRef(({
|
||||
...rest
|
||||
}, ref) => {
|
||||
const dispatch = useDispatch();
|
||||
const toast = useToast();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
const pageSize = 5; // 每页显示5个事件
|
||||
const currentPage = pagination.page || 1;
|
||||
const totalPages = pagination.total_pages || 1;
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
|
||||
// 关注按钮点击处理
|
||||
const handleToggleFollow = useCallback((eventId) => {
|
||||
dispatch(toggleEventFollow(eventId));
|
||||
}, [dispatch]);
|
||||
|
||||
// 本地状态
|
||||
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(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).filter(event => event !== null);
|
||||
}, [allCachedEvents, currentPage, pageSize]);
|
||||
|
||||
// 翻页处理(智能预加载)
|
||||
const handlePageChange = useCallback(async (newPage) => {
|
||||
// 🔍 诊断日志 - 记录翻页开始状态
|
||||
console.log('[handlePageChange] 开始翻页', {
|
||||
currentPage,
|
||||
newPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
hasMore,
|
||||
total,
|
||||
allCachedEventsLength: allCachedEvents.length,
|
||||
cachedCount
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
// 在后台静默预加载其他缺失页面(拆分为单页请求)
|
||||
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; // 提前返回,不执行下面的加载逻辑
|
||||
}
|
||||
|
||||
// 5. 如果目标页未缓存,显示 loading 并等待加载完成
|
||||
if (missingPages.length > 0 && hasMore) {
|
||||
console.log('[DynamicNewsCard] 目标页未缓存,显示loading', {
|
||||
currentPage,
|
||||
newPage,
|
||||
翻页类型: isSequentialNavigation ? '连续翻页' : '跳转翻页',
|
||||
预加载范围: preloadRange,
|
||||
缺失页面: missingPages,
|
||||
每页数量: pageSize,
|
||||
目标页已缓存: 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'
|
||||
});
|
||||
}
|
||||
}, [currentPage, allCachedEvents, pageSize, totalPages, hasMore, dispatch, total, toast, cachedCount]);
|
||||
|
||||
// 模式切换处理
|
||||
const handleModeToggle = useCallback((newMode) => {
|
||||
if (newMode === mode) return;
|
||||
|
||||
setMode(newMode);
|
||||
setCurrentPage(1);
|
||||
|
||||
const newPageSize = newMode === 'carousel' ? 5 : 10;
|
||||
|
||||
// 检查第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
|
||||
}));
|
||||
}
|
||||
// 如果第1页数据完整,不发起请求,直接切换
|
||||
}, [mode, allCachedEvents, total, dispatch]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
if (allCachedEvents.length === 0) {
|
||||
dispatch(fetchDynamicNews({
|
||||
page: 1,
|
||||
per_page: 5,
|
||||
pageSize: 5, // 传递 pageSize 确保索引计算一致
|
||||
clearCache: true
|
||||
}));
|
||||
}
|
||||
}, [dispatch, allCachedEvents.length]);
|
||||
|
||||
// 默认选中第一个事件
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && !selectedEvent) {
|
||||
setSelectedEvent(events[0]);
|
||||
if (currentPageEvents.length > 0 && !selectedEvent) {
|
||||
setSelectedEvent(currentPageEvents[0]);
|
||||
}
|
||||
}, [events, selectedEvent]);
|
||||
|
||||
// 页码改变时,触发服务端分页请求
|
||||
const handlePageChange = (newPage) => {
|
||||
// 发起 Redux action 获取新页面数据
|
||||
dispatch(fetchDynamicNews({ page: newPage, per_page: pageSize }));
|
||||
|
||||
// 重置选中事件(等新数据加载后自动选中第一个)
|
||||
setSelectedEvent(null);
|
||||
};
|
||||
}, [currentPageEvents, selectedEvent]);
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
@@ -111,9 +389,34 @@ const DynamicNewsCard = forwardRef(({
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody position="relative">
|
||||
{/* Loading 状态 */}
|
||||
{loading && (
|
||||
<CardBody position="relative" pt={0}>
|
||||
{/* 横向滚动事件列表 - 始终渲染(除非为空) */}
|
||||
{currentPageEvents && currentPageEvents.length > 0 ? (
|
||||
<EventScrollList
|
||||
events={currentPageEvents}
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={setSelectedEvent}
|
||||
borderColor={borderColor}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
loading={loadingPage !== null}
|
||||
loadingPage={loadingPage}
|
||||
mode={mode}
|
||||
onModeChange={handleModeToggle}
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
) : !loading ? (
|
||||
/* Empty 状态 - 只在非加载且无数据时显示 */
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
/* 首次加载状态 */
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
@@ -122,30 +425,8 @@ const DynamicNewsCard = forwardRef(({
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Empty 状态 */}
|
||||
{!loading && (!events || events.length === 0) && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 横向滚动事件列表 */}
|
||||
{!loading && events && events.length > 0 && (
|
||||
<EventScrollList
|
||||
events={events}
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={setSelectedEvent}
|
||||
borderColor={borderColor}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 详情面板 */}
|
||||
{!loading && events && events.length > 0 && (
|
||||
{/* 详情面板 - 始终显示(如果有选中事件) */}
|
||||
{currentPageEvents && currentPageEvents.length > 0 && selectedEvent && (
|
||||
<Box mt={6}>
|
||||
<DynamicNewsDetailPanel event={selectedEvent} />
|
||||
</Box>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
// src/views/Community/components/DynamicNewsCard/EventScrollList.js
|
||||
// 横向滚动事件列表组件
|
||||
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Grid,
|
||||
IconButton,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Center,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Text,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
@@ -13,7 +21,7 @@ import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||||
import PaginationControl from './PaginationControl';
|
||||
|
||||
/**
|
||||
* 横向滚动事件列表组件
|
||||
* 事件列表组件 - 支持两种展示模式
|
||||
* @param {Array} events - 当前页的事件列表(服务端已分页)
|
||||
* @param {Object} selectedEvent - 当前选中的事件
|
||||
* @param {Function} onEventSelect - 事件选择回调
|
||||
@@ -21,6 +29,13 @@ import PaginationControl from './PaginationControl';
|
||||
* @param {number} currentPage - 当前页码
|
||||
* @param {number} totalPages - 总页数(由服务端返回)
|
||||
* @param {Function} onPageChange - 页码改变回调
|
||||
* @param {boolean} loading - 全局加载状态
|
||||
* @param {number|null} loadingPage - 正在加载的目标页码(用于显示"正在加载第X页...")
|
||||
* @param {string} mode - 展示模式:'carousel'(单排轮播)| 'grid'(双排网格)
|
||||
* @param {Function} onModeChange - 模式切换回调
|
||||
* @param {boolean} hasMore - 是否还有更多数据
|
||||
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
|
||||
* @param {Function} onToggleFollow - 关注按钮回调
|
||||
*/
|
||||
const EventScrollList = ({
|
||||
events,
|
||||
@@ -29,175 +44,252 @@ const EventScrollList = ({
|
||||
borderColor,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
onPageChange,
|
||||
loading = false,
|
||||
mode = 'carousel',
|
||||
onModeChange,
|
||||
hasMore = true,
|
||||
eventFollowStatus = {},
|
||||
onToggleFollow
|
||||
}) => {
|
||||
const scrollContainerRef = useRef(null);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(true);
|
||||
|
||||
// 页码变化时,滚动到左侧起始位置
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
left: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}, [currentPage]);
|
||||
// 所有 useColorModeValue 必须在组件顶层调用(不能在条件渲染中)
|
||||
const timelineBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const timelineBorderColor = useColorModeValue('gray.400', 'gray.500');
|
||||
const timelineTextColor = useColorModeValue('blue.600', 'blue.400');
|
||||
|
||||
// 滚动到左侧
|
||||
const scrollLeft = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: -400,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
// 翻页按钮颜色
|
||||
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 scrollRight = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: 400,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
// 滚动条颜色
|
||||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||||
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||||
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||||
|
||||
// 监听滚动位置,更新箭头显示状态
|
||||
const handleScroll = (e) => {
|
||||
const container = e.target;
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const scrollWidth = container.scrollWidth;
|
||||
const clientWidth = container.clientWidth;
|
||||
// 加载遮罩颜色
|
||||
const loadingOverlayBg = useColorModeValue('whiteAlpha.800', 'blackAlpha.700');
|
||||
const loadingTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
setShowLeftArrow(scrollLeft > 0);
|
||||
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 10);
|
||||
};
|
||||
|
||||
// 时间轴样式配置
|
||||
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>
|
||||
{/* 分页控制器 - 右上角 */}
|
||||
{totalPages > 1 && (
|
||||
<Flex justify="flex-end" mb={3}>
|
||||
<PaginationControl
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器 + 加载提示(右) */}
|
||||
<Flex justify="space-between" align="center" mb={2}>
|
||||
{/* 模式切换按钮 */}
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
<Button
|
||||
onClick={() => onModeChange('carousel')}
|
||||
colorScheme="blue"
|
||||
variant={mode === 'carousel' ? 'solid' : 'outline'}
|
||||
>
|
||||
单排
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onModeChange('grid')}
|
||||
colorScheme="blue"
|
||||
variant={mode === 'grid' ? 'solid' : 'outline'}
|
||||
>
|
||||
双排
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
{/* 分页控制器 */}
|
||||
{totalPages > 1 && (
|
||||
<PaginationControl
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* 横向滚动区域 */}
|
||||
<Box position="relative">
|
||||
{/* 左侧滚动按钮 */}
|
||||
{showLeftArrow && (
|
||||
|
||||
{/* 左侧翻页按钮 - 上一页 */}
|
||||
{currentPage > 1 && (
|
||||
<IconButton
|
||||
icon={<ChevronLeftIcon boxSize={6} />}
|
||||
icon={<ChevronLeftIcon boxSize={6} color="blue.500" />}
|
||||
position="absolute"
|
||||
left="-4"
|
||||
left="0"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={scrollLeft}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
w="40px"
|
||||
h="40px"
|
||||
minW="40px"
|
||||
borderRadius="full"
|
||||
shadow="md"
|
||||
aria-label="向左滚动"
|
||||
bg={arrowBtnBg}
|
||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||||
_hover={{
|
||||
bg: arrowBtnHoverBg,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transform: 'translateY(-50%) scale(1.05)'
|
||||
}}
|
||||
aria-label="上一页"
|
||||
title="上一页"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧滚动按钮 */}
|
||||
{showRightArrow && (
|
||||
{/* 右侧翻页按钮 - 下一页 */}
|
||||
{currentPage < totalPages && hasMore && (
|
||||
<IconButton
|
||||
icon={<ChevronRightIcon boxSize={6} />}
|
||||
icon={<ChevronRightIcon boxSize={6} color="blue.500" />}
|
||||
position="absolute"
|
||||
right="-4"
|
||||
right="0"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={scrollRight}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
w="40px"
|
||||
h="40px"
|
||||
minW="40px"
|
||||
borderRadius="full"
|
||||
shadow="md"
|
||||
aria-label="向右滚动"
|
||||
bg={arrowBtnBg}
|
||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||||
_hover={{
|
||||
bg: arrowBtnHoverBg,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transform: 'translateY(-50%) scale(1.05)'
|
||||
}}
|
||||
isDisabled={currentPage >= totalPages && !hasMore}
|
||||
aria-label="下一页"
|
||||
title="下一页"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 横向滚动容器 */}
|
||||
<Flex
|
||||
{/* 事件卡片容器 */}
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
overflowX="auto"
|
||||
overflowX={mode === 'carousel' ? 'auto' : 'hidden'}
|
||||
overflowY="hidden"
|
||||
gap={4}
|
||||
py={4}
|
||||
pt={0}
|
||||
pb={4}
|
||||
px={2}
|
||||
onScroll={handleScroll}
|
||||
css={{
|
||||
position="relative"
|
||||
css={mode === 'carousel' ? {
|
||||
'&::-webkit-scrollbar': {
|
||||
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',
|
||||
}}
|
||||
} : {}}
|
||||
>
|
||||
{events.map((event, index) => (
|
||||
<Box
|
||||
key={event.id}
|
||||
minW="calc((100% - 64px) / 5)"
|
||||
maxW="calc((100% - 64px) / 5)"
|
||||
flexShrink={0}
|
||||
{/* 加载遮罩 */}
|
||||
{loading && (
|
||||
<Center
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg={loadingOverlayBg}
|
||||
backdropFilter="blur(2px)"
|
||||
zIndex={10}
|
||||
borderRadius="md"
|
||||
>
|
||||
<DynamicNewsEventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isFollowing={false}
|
||||
followerCount={event.follower_count || 0}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={(clickedEvent) => {
|
||||
onEventSelect(clickedEvent);
|
||||
}}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventSelect(event);
|
||||
}}
|
||||
onToggleFollow={() => {}}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
<VStack>
|
||||
<Spinner size="lg" color="blue.500" thickness="3px" />
|
||||
<Text fontSize="sm" color={loadingTextColor}>
|
||||
加载中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 模式1: 单排轮播模式 */}
|
||||
{mode === 'carousel' && (
|
||||
<Flex gap={4}>
|
||||
{events.map((event, index) => (
|
||||
<Box
|
||||
key={event.id}
|
||||
minW="calc((100% - 64px) / 5)"
|
||||
maxW="calc((100% - 64px) / 5)"
|
||||
flexShrink={0}
|
||||
>
|
||||
<DynamicNewsEventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
||||
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={(clickedEvent) => {
|
||||
onEventSelect(clickedEvent);
|
||||
}}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventSelect(event);
|
||||
}}
|
||||
onToggleFollow={() => onToggleFollow?.(event.id)}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* 模式2: 双排网格模式 */}
|
||||
{mode === 'grid' && (
|
||||
<Grid
|
||||
templateRows="repeat(2, 1fr)"
|
||||
templateColumns="repeat(5, 1fr)"
|
||||
gap={4}
|
||||
autoFlow="column"
|
||||
>
|
||||
{events.map((event, index) => (
|
||||
<Box key={event.id}>
|
||||
<DynamicNewsEventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
||||
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={(clickedEvent) => {
|
||||
onEventSelect(clickedEvent);
|
||||
}}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventSelect(event);
|
||||
}}
|
||||
onToggleFollow={() => onToggleFollow?.(event.id)}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 动态新闻详情面板主组件(组装所有子组件)
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { eventService } from '../../../../services/eventService';
|
||||
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
|
||||
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
|
||||
import EventHeaderInfo from './EventHeaderInfo';
|
||||
import EventDescriptionSection from './EventDescriptionSection';
|
||||
import RelatedConceptsSection from './RelatedConceptsSection';
|
||||
@@ -29,11 +31,17 @@ import TransmissionChainAnalysis from '../../../EventDetail/components/Transmiss
|
||||
* @param {Object} props.event - 事件对象(包含详情数据)
|
||||
*/
|
||||
const DynamicNewsDetailPanel = ({ event }) => {
|
||||
const dispatch = useDispatch();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const toast = useToast();
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
|
||||
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
|
||||
|
||||
// 使用 Hook 获取实时数据
|
||||
const {
|
||||
stocks,
|
||||
@@ -49,10 +57,6 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(true);
|
||||
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
|
||||
|
||||
// 关注状态管理
|
||||
const [isFollowing, setIsFollowing] = useState(false);
|
||||
const [followerCount, setFollowerCount] = useState(0);
|
||||
|
||||
// 自选股管理(使用 localStorage)
|
||||
const [watchlistSet, setWatchlistSet] = useState(() => {
|
||||
try {
|
||||
@@ -64,23 +68,10 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
});
|
||||
|
||||
// 切换关注状态
|
||||
const handleToggleFollow = async () => {
|
||||
try {
|
||||
if (isFollowing) {
|
||||
// 取消关注
|
||||
await eventService.unfollowEvent(event.id);
|
||||
setIsFollowing(false);
|
||||
setFollowerCount(prev => Math.max(0, prev - 1));
|
||||
} else {
|
||||
// 添加关注
|
||||
await eventService.followEvent(event.id);
|
||||
setIsFollowing(true);
|
||||
setFollowerCount(prev => prev + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换关注状态失败:', error);
|
||||
}
|
||||
};
|
||||
const handleToggleFollow = useCallback(async () => {
|
||||
if (!event?.id) return;
|
||||
dispatch(toggleEventFollow(event.id));
|
||||
}, [dispatch, event?.id]);
|
||||
|
||||
// 切换自选股
|
||||
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
|
||||
@@ -152,8 +143,8 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
|
||||
{/* 相关概念 */}
|
||||
<RelatedConceptsSection
|
||||
keywords={event.keywords}
|
||||
effectiveTradingDate={event.trading_date}
|
||||
eventTitle={event.title}
|
||||
effectiveTradingDate={event.trading_date || event.created_at}
|
||||
eventTime={event.created_at}
|
||||
/>
|
||||
|
||||
|
||||
@@ -112,12 +112,14 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
|
||||
|
||||
{/* 重要性文本 */}
|
||||
<Box
|
||||
bg="orange.50"
|
||||
bg={importance.bgColor}
|
||||
borderWidth="2px"
|
||||
borderColor={importance.badgeBg}
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color="orange.800" whiteSpace="nowrap" fontWeight="medium">
|
||||
<Text fontSize="sm" color={importance.badgeBg} whiteSpace="nowrap" fontWeight="medium">
|
||||
重要性:{getImportanceText()}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -35,8 +35,11 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 计算相关度百分比
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
|
||||
// 计算涨跌幅颜色
|
||||
const changePct = parseFloat(concept.avg_change_pct);
|
||||
const changePct = parseFloat(concept.price_info?.avg_change_pct);
|
||||
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
|
||||
const changeSymbol = changePct > 0 ? '+' : '';
|
||||
|
||||
@@ -61,11 +64,11 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
{/* 左侧:概念名称 + Badge */}
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<Text fontSize="md" fontWeight="bold" color="blue.600">
|
||||
{concept.name}
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
相关度: {concept.relevance}%
|
||||
相关度: {relevanceScore}%
|
||||
</Badge>
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
{concept.stock_count} 只股票
|
||||
@@ -74,7 +77,7 @@ const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅 */}
|
||||
{concept.avg_change_pct && (
|
||||
{concept.price_info?.avg_change_pct && (
|
||||
<Box textAlign="right">
|
||||
<Text fontSize="xs" color={stockCountColor} mb={1}>
|
||||
平均涨跌幅
|
||||
|
||||
@@ -24,7 +24,8 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.600');
|
||||
|
||||
const relevanceColors = getRelevanceColor(concept.relevance);
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
const relevanceColors = getRelevanceColor(relevanceScore);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -47,7 +48,7 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
>
|
||||
{/* 左侧:概念名 + 数量 */}
|
||||
<Text fontSize="sm" fontWeight="normal" color={conceptNameColor} mr={3}>
|
||||
{concept.name}{' '}
|
||||
{concept.concept}{' '}
|
||||
<Text as="span" color="gray.500">
|
||||
({concept.stock_count})
|
||||
</Text>
|
||||
@@ -63,7 +64,7 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap">
|
||||
相关度: {concept.relevance}%
|
||||
相关度: {relevanceScore}%
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/index.js
|
||||
// 相关概念区组件(主组件)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
@@ -9,34 +9,168 @@ import {
|
||||
Button,
|
||||
Collapse,
|
||||
Heading,
|
||||
Center,
|
||||
Spinner,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import SimpleConceptCard from './SimpleConceptCard';
|
||||
import DetailedConceptCard from './DetailedConceptCard';
|
||||
import TradingDateInfo from './TradingDateInfo';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 相关概念区组件
|
||||
* @param {Object} props
|
||||
* @param {Array<Object>} props.keywords - 相关概念数组
|
||||
* - name: 概念名称
|
||||
* - stock_count: 相关股票数量
|
||||
* - relevance: 相关度(0-100)
|
||||
* @param {string} props.eventTitle - 事件标题(用于搜索概念)
|
||||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||||
* @param {string|Object} props.eventTime - 事件发生时间
|
||||
*/
|
||||
const RelatedConceptsSection = ({ keywords, effectiveTradingDate, eventTime }) => {
|
||||
const RelatedConceptsSection = ({ eventTitle, effectiveTradingDate, eventTime }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [concepts, setConcepts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 颜色配置
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
// 如果没有关键词,不渲染
|
||||
if (!keywords || keywords.length === 0) {
|
||||
console.log('[RelatedConceptsSection] 组件渲染', {
|
||||
eventTitle,
|
||||
effectiveTradingDate,
|
||||
eventTime,
|
||||
loading,
|
||||
conceptsCount: concepts?.length || 0,
|
||||
error
|
||||
});
|
||||
|
||||
// 搜索相关概念
|
||||
useEffect(() => {
|
||||
const searchConcepts = async () => {
|
||||
console.log('[RelatedConceptsSection] useEffect 触发', {
|
||||
eventTitle,
|
||||
effectiveTradingDate
|
||||
});
|
||||
|
||||
if (!eventTitle || !effectiveTradingDate) {
|
||||
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
|
||||
hasEventTitle: !!eventTitle,
|
||||
hasEffectiveTradingDate: !!effectiveTradingDate
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 格式化交易日期 - 统一使用 moment 处理
|
||||
let formattedTradeDate;
|
||||
try {
|
||||
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
|
||||
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
|
||||
// 验证日期是否有效
|
||||
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');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
query: eventTitle,
|
||||
size: 5,
|
||||
page: 1,
|
||||
sort_by: "_score",
|
||||
trade_date: formattedTradeDate
|
||||
};
|
||||
|
||||
console.log('[RelatedConceptsSection] 发送请求', {
|
||||
url: '/concept-api/search',
|
||||
requestBody
|
||||
});
|
||||
logger.debug('RelatedConceptsSection', '搜索概念', requestBody);
|
||||
|
||||
const response = await fetch('/concept-api/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
console.log('[RelatedConceptsSection] 响应状态', {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[RelatedConceptsSection] 响应数据', {
|
||||
hasResults: !!data.results,
|
||||
resultsCount: data.results?.length || 0,
|
||||
hasDataConcepts: !!(data.data && data.data.concepts),
|
||||
data: data
|
||||
});
|
||||
logger.debug('RelatedConceptsSection', '概念搜索响应', {
|
||||
hasResults: !!data.results,
|
||||
resultsCount: data.results?.length || 0
|
||||
});
|
||||
|
||||
// 设置概念数据
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
console.log('[RelatedConceptsSection] 设置概念数据 (results)', data.results);
|
||||
setConcepts(data.results);
|
||||
} else if (data.data && data.data.concepts) {
|
||||
// 向后兼容
|
||||
console.log('[RelatedConceptsSection] 设置概念数据 (data.concepts)', data.data.concepts);
|
||||
setConcepts(data.data.concepts);
|
||||
} else {
|
||||
console.log('[RelatedConceptsSection] 没有找到概念数据,设置为空数组');
|
||||
setConcepts([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RelatedConceptsSection] 搜索概念失败', err);
|
||||
logger.error('RelatedConceptsSection', 'searchConcepts', err);
|
||||
setError('加载概念数据失败');
|
||||
setConcepts([]);
|
||||
} finally {
|
||||
console.log('[RelatedConceptsSection] 加载完成');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
searchConcepts();
|
||||
}, [eventTitle, effectiveTradingDate]);
|
||||
|
||||
// 加载中状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" mr={2} />
|
||||
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有概念,不渲染
|
||||
if (!concepts || concepts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -63,7 +197,7 @@ const RelatedConceptsSection = ({ keywords, effectiveTradingDate, eventTime }) =
|
||||
*/
|
||||
const handleConceptClick = (concept) => {
|
||||
// 跳转到概念中心,并搜索该概念
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.name)}`);
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -86,7 +220,7 @@ const RelatedConceptsSection = ({ keywords, effectiveTradingDate, eventTime }) =
|
||||
|
||||
{/* 简单模式:横向卡片列表(总是显示) */}
|
||||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||||
{keywords.map((concept, index) => (
|
||||
{concepts.map((concept, index) => (
|
||||
<SimpleConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
@@ -106,7 +240,7 @@ const RelatedConceptsSection = ({ keywords, effectiveTradingDate, eventTime }) =
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
{/* 详细概念卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{keywords.map((concept, index) => (
|
||||
{concepts.map((concept, index) => (
|
||||
<DetailedConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
|
||||
@@ -8,10 +8,17 @@ import {
|
||||
CardBody,
|
||||
Box,
|
||||
Text,
|
||||
HStack,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
PopoverArrow,
|
||||
Portal,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { getImportanceConfig, getAllImportanceLevels } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
import EventFollowButton from './EventFollowButton';
|
||||
@@ -85,6 +92,7 @@ const DynamicNewsEventCard = ({
|
||||
}
|
||||
borderRadius="md"
|
||||
boxShadow={isSelected ? "lg" : "sm"}
|
||||
overflow="hidden"
|
||||
_hover={{
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
@@ -95,7 +103,74 @@ const DynamicNewsEventCard = ({
|
||||
onClick={() => onEventClick?.(event)}
|
||||
>
|
||||
<CardBody p={3}>
|
||||
{/* 关注按钮 - 绝对定位在右上角 */}
|
||||
{/* 左上角:重要性矩形角标(镂空边框样式) */}
|
||||
<Popover trigger="hover" placement="right" isLazy>
|
||||
<PopoverTrigger>
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
zIndex={1}
|
||||
bg="transparent"
|
||||
color={importance.badgeBg}
|
||||
borderWidth="2px"
|
||||
borderColor={importance.badgeBg}
|
||||
fontSize="11px"
|
||||
fontWeight="bold"
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
minW="auto"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
lineHeight="1"
|
||||
borderBottomRightRadius="md"
|
||||
cursor="help"
|
||||
>
|
||||
{importance.label}
|
||||
</Box>
|
||||
</PopoverTrigger>
|
||||
<Portal>
|
||||
<PopoverContent width="auto" maxW="350px">
|
||||
<PopoverArrow />
|
||||
<PopoverBody p={3}>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<Text fontSize="sm" fontWeight="bold" mb={1}>
|
||||
重要性等级说明
|
||||
</Text>
|
||||
{getAllImportanceLevels().map(item => (
|
||||
<HStack key={item.level} spacing={2} align="flex-start">
|
||||
<Box
|
||||
w="20px"
|
||||
h="20px"
|
||||
borderWidth="2px"
|
||||
borderColor={item.badgeBg}
|
||||
color={item.badgeBg}
|
||||
fontSize="9px"
|
||||
fontWeight="bold"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
borderRadius="sm"
|
||||
flexShrink={0}
|
||||
>
|
||||
{item.level}
|
||||
</Box>
|
||||
<Text fontSize="xs" flex={1}>
|
||||
<Text as="span" fontWeight="bold">
|
||||
{item.label}:
|
||||
</Text>
|
||||
{item.description}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Portal>
|
||||
</Popover>
|
||||
|
||||
{/* 右上角:关注按钮 */}
|
||||
<Box position="absolute" top={2} right={2} zIndex={1}>
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
@@ -106,11 +181,12 @@ const DynamicNewsEventCard = ({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<VStack align="stretch" spacing={2.5}>
|
||||
{/* 第一行:标题 + 重要性(行内文字) */}
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* 标题 - 最多两行,添加上边距避免与角标重叠 */}
|
||||
<Box
|
||||
cursor="pointer"
|
||||
onClick={(e) => onTitleClick?.(e, event)}
|
||||
mt={1}
|
||||
paddingRight="10px"
|
||||
>
|
||||
<Text
|
||||
@@ -118,18 +194,10 @@ const DynamicNewsEventCard = ({
|
||||
fontWeight="semibold"
|
||||
color={linkColor}
|
||||
lineHeight="1.4"
|
||||
noOfLines={2}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{event.title}
|
||||
<Text
|
||||
as="span"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={importance.color}
|
||||
ml={2}
|
||||
>
|
||||
[{importance.level}]
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -102,17 +102,7 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
|
||||
// 保持现有筛选条件,只更新页码
|
||||
updateFilters({ ...filters, page });
|
||||
|
||||
// 滚动到实时事件时间轴(平滑滚动)
|
||||
if (eventTimelineRef && eventTimelineRef.current) {
|
||||
setTimeout(() => {
|
||||
eventTimelineRef.current.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动
|
||||
block: 'start' // 滚动到元素顶部
|
||||
});
|
||||
}, 100); // 延迟100ms,确保DOM更新
|
||||
}
|
||||
}, [filters, updateFilters, eventTimelineRef, track]);
|
||||
}, [filters, updateFilters, track]);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((event) => {
|
||||
|
||||
@@ -48,10 +48,11 @@ const Community = () => {
|
||||
// Redux状态
|
||||
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
|
||||
const {
|
||||
data: dynamicNewsEvents,
|
||||
data: allCachedEvents,
|
||||
loading: dynamicNewsLoading,
|
||||
error: dynamicNewsError,
|
||||
pagination: dynamicNewsPagination
|
||||
total: dynamicNewsTotal,
|
||||
cachedCount: dynamicNewsCachedCount
|
||||
} = useSelector(selectDynamicNewsWithLoading);
|
||||
|
||||
// Chakra UI hooks
|
||||
@@ -60,6 +61,7 @@ const Community = () => {
|
||||
// Ref:用于滚动到实时事件时间轴
|
||||
const eventTimelineRef = useRef(null);
|
||||
const hasScrolledRef = useRef(false); // 标记是否已滚动
|
||||
const containerRef = useRef(null); // 用于首次滚动到内容区域
|
||||
|
||||
// ⚡ 通知权限引导
|
||||
const { showCommunityGuide } = useNotification();
|
||||
@@ -95,17 +97,20 @@ const Community = () => {
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
// 加载热门关键词、热点事件和动态新闻(使用Redux)
|
||||
// 加载热门关键词和热点事件(动态新闻由 DynamicNewsCard 内部管理)
|
||||
useEffect(() => {
|
||||
dispatch(fetchPopularKeywords());
|
||||
dispatch(fetchHotEvents());
|
||||
dispatch(fetchDynamicNews());
|
||||
}, [dispatch]);
|
||||
|
||||
// 每5分钟刷新一次动态新闻
|
||||
// 每5分钟刷新一次动态新闻(使用 prependMode 追加到头部)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
dispatch(fetchDynamicNews());
|
||||
dispatch(fetchDynamicNews({
|
||||
page: 1,
|
||||
per_page: 10, // 获取最新的10条
|
||||
prependMode: true // 追加到头部,不清空缓存
|
||||
}));
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
@@ -145,6 +150,23 @@ const Community = () => {
|
||||
}
|
||||
}, [showCommunityGuide]); // 只在组件挂载时执行一次
|
||||
|
||||
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
|
||||
useEffect(() => {
|
||||
// 延迟执行,确保DOM已完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
// 滚动到容器顶部,自动考虑导航栏的高度
|
||||
containerRef.current.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // 空依赖数组,只在组件挂载时执行一次
|
||||
|
||||
// ⚡ 滚动到实时事件区域(由搜索框聚焦触发)
|
||||
const scrollToTimeline = useCallback(() => {
|
||||
if (!hasScrolledRef.current && eventTimelineRef.current) {
|
||||
@@ -161,16 +183,17 @@ const Community = () => {
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 主内容区域 */}
|
||||
<Container maxW="container.xl" pt={6} pb={8}>
|
||||
<Container ref={containerRef} maxW="container.xl" pt={6} pb={8}>
|
||||
{/* 热点事件区域 */}
|
||||
<HotEventsSection events={hotEvents} />
|
||||
|
||||
{/* 实时要闻·动态追踪 - 横向滚动 */}
|
||||
<DynamicNewsCard
|
||||
mt={6}
|
||||
events={dynamicNewsEvents}
|
||||
allCachedEvents={allCachedEvents}
|
||||
loading={dynamicNewsLoading}
|
||||
pagination={dynamicNewsPagination}
|
||||
total={dynamicNewsTotal}
|
||||
cachedCount={dynamicNewsCachedCount}
|
||||
filters={filters}
|
||||
popularKeywords={popularKeywords}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
|
||||
@@ -34,9 +34,6 @@ import moment from 'moment';
|
||||
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
// API配置
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production' ? '/concept-api' : 'https://valuefrontier.cn/concept-api';
|
||||
|
||||
// 增强版 ConceptCard 组件 - 展示更多数据细节
|
||||
const ConceptCard = ({ concept, tradingDate, onViewDetails }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -331,7 +328,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
|
||||
logger.debug('RelatedConcepts', '搜索概念', requestBody);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/search`, {
|
||||
const response = await fetch('/concept-api/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
@@ -358,6 +358,9 @@ const EventDetail = () => {
|
||||
const { user } = useAuth();
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
|
||||
// 滚动位置管理
|
||||
const scrollPositionRef = useRef(0);
|
||||
|
||||
// State hooks
|
||||
const [eventData, setEventData] = useState(null);
|
||||
const [relatedStocks, setRelatedStocks] = useState([]);
|
||||
@@ -399,6 +402,16 @@ const EventDetail = () => {
|
||||
|
||||
const actualEventId = getEventIdFromPath();
|
||||
|
||||
// 保存当前滚动位置
|
||||
const saveScrollPosition = () => {
|
||||
scrollPositionRef.current = window.scrollY || window.pageYOffset;
|
||||
};
|
||||
|
||||
// 恢复滚动位置
|
||||
const restoreScrollPosition = () => {
|
||||
window.scrollTo(0, scrollPositionRef.current);
|
||||
};
|
||||
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -540,8 +553,19 @@ const EventDetail = () => {
|
||||
// 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);
|
||||
|
||||
@@ -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
147
test_events_api.py
Normal 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}")
|
||||
Reference in New Issue
Block a user