Compare commits
38 Commits
7ae4bc418f
...
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 | ||
|
|
784202025c | ||
|
|
daf7372bab | ||
|
|
7291777488 | ||
|
|
92d6751529 | ||
|
|
95134d526d | ||
|
|
cc2777ae20 | ||
|
|
39a2ccd53b | ||
|
|
6160edf060 | ||
|
|
bdea4209b2 | ||
|
|
6cde2175db | ||
|
|
f432d72151 | ||
|
|
befa68cc51 |
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,26 +501,26 @@ export default function WechatRegister() {
|
||||
bg="gray.50"
|
||||
boxShadow="sm" // ✅ 添加轻微阴影
|
||||
>
|
||||
{wechatStatus !== WECHAT_STATUS.NONE ? (
|
||||
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||
/* 已获取二维码:显示iframe */
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||
transformOrigin: 'top left',
|
||||
marginLeft: '-5px',
|
||||
pointerEvents: 'auto', // 允许点击 │ │
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
}}
|
||||
// 使用 onWheel 事件阻止滚动 │ │
|
||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -23,6 +23,25 @@ const StockChartModal = ({
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [preloadedData, setPreloadedData] = useState({});
|
||||
|
||||
// 处理关联描述(兼容对象和字符串格式)
|
||||
const getRelationDesc = () => {
|
||||
const relationDesc = stock?.relation_desc;
|
||||
|
||||
if (!relationDesc) return null;
|
||||
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
|
||||
return relationDesc.data
|
||||
.map(item => item.query_part || item.sentences || '')
|
||||
.filter(s => s)
|
||||
.join(';') || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 预加载数据
|
||||
const preloadData = async (type) => {
|
||||
if (!stock || preloadedData[type]) return;
|
||||
@@ -539,10 +558,10 @@ const StockChartModal = ({
|
||||
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} />
|
||||
</Box>
|
||||
|
||||
{stock?.relation_desc && (
|
||||
{getRelationDesc() && (
|
||||
<Box p={4} borderTop="1px solid" borderTopColor="gray.200">
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>关联描述:</Text>
|
||||
<Text fontSize="sm" color="gray.600">{stock.relation_desc}</Text>
|
||||
<Text fontSize="sm" color="gray.600">{getRelationDesc()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) // 核心相关股票
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -726,8 +733,8 @@ export function generateMockEvents(params = {}) {
|
||||
stock_code = '',
|
||||
} = params;
|
||||
|
||||
// 生成100个事件用于测试
|
||||
const totalEvents = 100;
|
||||
// 生成200个事件用于测试(足够测试分页功能)
|
||||
const totalEvents = 200;
|
||||
const allEvents = [];
|
||||
|
||||
const importanceLevels = ['S', 'A', 'B', 'C'];
|
||||
@@ -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);
|
||||
|
||||
@@ -156,6 +156,117 @@ export const fetchHotEvents = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取动态新闻(客户端缓存 + 智能请求)
|
||||
* 用于 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,
|
||||
pageSize = 5, // 每页实际显示的数据量(用于计算索引)
|
||||
clearCache = false,
|
||||
prependMode = false
|
||||
} = {}, { rejectWithValue }) => {
|
||||
try {
|
||||
logger.debug('CommunityData', '开始获取动态新闻', {
|
||||
page,
|
||||
per_page,
|
||||
clearCache,
|
||||
prependMode
|
||||
});
|
||||
|
||||
const response = await eventService.getEvents({
|
||||
page,
|
||||
per_page,
|
||||
sort: 'new'
|
||||
});
|
||||
|
||||
if (response.success && response.data?.events) {
|
||||
logger.info('CommunityData', '动态新闻加载成功', {
|
||||
count: response.data.events.length,
|
||||
page: response.data.pagination?.page || page,
|
||||
total: response.data.pagination?.total || 0
|
||||
});
|
||||
return {
|
||||
events: response.data.events,
|
||||
total: response.data.pagination?.total || 0,
|
||||
page,
|
||||
per_page,
|
||||
pageSize, // 返回 pageSize 用于索引计算
|
||||
clearCache,
|
||||
prependMode
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn('CommunityData', '动态新闻返回数据为空', response);
|
||||
return {
|
||||
events: [],
|
||||
total: 0,
|
||||
page,
|
||||
per_page,
|
||||
pageSize, // 返回 pageSize 用于索引计算
|
||||
clearCache,
|
||||
prependMode
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('CommunityData', '获取动态新闻失败', error);
|
||||
return rejectWithValue(error.message || '获取动态新闻失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 切换事件关注状态
|
||||
* 复用 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({
|
||||
@@ -164,29 +275,36 @@ const communityDataSlice = createSlice({
|
||||
// 数据
|
||||
popularKeywords: [],
|
||||
hotEvents: [],
|
||||
dynamicNews: [], // 动态新闻完整缓存列表
|
||||
dynamicNewsTotal: 0, // 服务端总数量
|
||||
eventFollowStatus: {}, // 事件关注状态 { [eventId]: { isFollowing: boolean, followerCount: number } }
|
||||
|
||||
// 加载状态
|
||||
loading: {
|
||||
popularKeywords: false,
|
||||
hotEvents: false
|
||||
hotEvents: false,
|
||||
dynamicNews: false
|
||||
},
|
||||
|
||||
// 错误信息
|
||||
error: {
|
||||
popularKeywords: null,
|
||||
hotEvents: null
|
||||
hotEvents: null,
|
||||
dynamicNews: null
|
||||
},
|
||||
|
||||
// 最后更新时间
|
||||
lastUpdated: {
|
||||
popularKeywords: null,
|
||||
hotEvents: null
|
||||
hotEvents: null,
|
||||
dynamicNews: null
|
||||
}
|
||||
},
|
||||
|
||||
reducers: {
|
||||
/**
|
||||
* 清除所有缓存(Redux + localStorage)
|
||||
* 注意:dynamicNews 不使用 localStorage 缓存
|
||||
*/
|
||||
clearCache: (state) => {
|
||||
// 清除 localStorage
|
||||
@@ -195,15 +313,17 @@ const communityDataSlice = createSlice({
|
||||
// 清除 Redux 状态
|
||||
state.popularKeywords = [];
|
||||
state.hotEvents = [];
|
||||
state.dynamicNews = []; // 动态新闻也清除
|
||||
state.lastUpdated.popularKeywords = null;
|
||||
state.lastUpdated.hotEvents = null;
|
||||
state.lastUpdated.dynamicNews = null;
|
||||
|
||||
logger.info('CommunityData', '所有缓存已清除');
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除指定类型的缓存
|
||||
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents')
|
||||
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents' | 'dynamicNews')
|
||||
*/
|
||||
clearSpecificCache: (state, action) => {
|
||||
const type = action.payload;
|
||||
@@ -218,6 +338,11 @@ const communityDataSlice = createSlice({
|
||||
state.hotEvents = [];
|
||||
state.lastUpdated.hotEvents = null;
|
||||
logger.info('CommunityData', '热点事件缓存已清除');
|
||||
} else if (type === 'dynamicNews') {
|
||||
// dynamicNews 不使用 localStorage,只清除 Redux state
|
||||
state.dynamicNews = [];
|
||||
state.lastUpdated.dynamicNews = null;
|
||||
logger.info('CommunityData', '动态新闻数据已清除');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -228,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 });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -235,16 +370,125 @@ const communityDataSlice = createSlice({
|
||||
// 使用工厂函数创建 reducers,消除重复代码
|
||||
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
|
||||
createDataReducers(builder, fetchHotEvents, 'hotEvents');
|
||||
|
||||
// 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.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;
|
||||
@@ -264,6 +508,15 @@ export const selectHotEventsWithLoading = (state) => ({
|
||||
lastUpdated: state.communityData.lastUpdated.hotEvents
|
||||
});
|
||||
|
||||
export const selectDynamicNewsWithLoading = (state) => ({
|
||||
data: state.communityData.dynamicNews, // 完整缓存列表(可能包含 null 占位符)
|
||||
loading: state.communityData.loading.dynamicNews,
|
||||
error: state.communityData.error.dynamicNews,
|
||||
total: state.communityData.dynamicNewsTotal, // 服务端总数量
|
||||
cachedCount: state.communityData.dynamicNews.filter(e => e !== null).length, // 已缓存有效数量(排除 null)
|
||||
lastUpdated: state.communityData.lastUpdated.dynamicNews
|
||||
});
|
||||
|
||||
// 工具函数:检查数据是否需要刷新(超过指定时间)
|
||||
export const shouldRefresh = (lastUpdated, thresholdMinutes = 30) => {
|
||||
if (!lastUpdated) return true;
|
||||
|
||||
@@ -2,35 +2,19 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { eventService, stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
|
||||
// ==================== 常量定义 ====================
|
||||
|
||||
// 缓存键名
|
||||
const CACHE_KEYS = {
|
||||
EVENT_STOCKS: 'event_stocks_',
|
||||
EVENT_DETAIL: 'event_detail_',
|
||||
HISTORICAL_EVENTS: 'historical_events_',
|
||||
CHAIN_ANALYSIS: 'chain_analysis_',
|
||||
EXPECTATION_SCORE: 'expectation_score_',
|
||||
WATCHLIST: 'user_watchlist'
|
||||
};
|
||||
|
||||
// 请求去重:缓存正在进行的请求
|
||||
const pendingRequests = new Map();
|
||||
|
||||
// ==================== Async Thunks ====================
|
||||
|
||||
/**
|
||||
* 获取事件相关股票(三级缓存)
|
||||
* 获取事件相关股票(Redux 缓存)
|
||||
*/
|
||||
export const fetchEventStocks = createAsyncThunk(
|
||||
'stock/fetchEventStocks',
|
||||
async ({ eventId, forceRefresh = false }, { getState }) => {
|
||||
logger.debug('stockSlice', 'fetchEventStocks', { eventId, forceRefresh });
|
||||
|
||||
// 1. Redux 状态缓存
|
||||
// Redux 状态缓存
|
||||
if (!forceRefresh) {
|
||||
const cached = getState().stock.eventStocksCache[eventId];
|
||||
if (cached && cached.length > 0) {
|
||||
@@ -39,27 +23,13 @@ export const fetchEventStocks = createAsyncThunk(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. LocalStorage 缓存
|
||||
if (!forceRefresh) {
|
||||
const localCached = localCacheManager.get(CACHE_KEYS.EVENT_STOCKS + eventId);
|
||||
if (localCached) {
|
||||
logger.debug('stockSlice', 'LocalStorage 缓存命中', { eventId });
|
||||
return { eventId, stocks: localCached };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. API 请求
|
||||
// API 请求
|
||||
const res = await eventService.getRelatedStocks(eventId);
|
||||
if (res.success && res.data) {
|
||||
logger.debug('stockSlice', 'API 请求成功', {
|
||||
eventId,
|
||||
stockCount: res.data.length
|
||||
});
|
||||
localCacheManager.set(
|
||||
CACHE_KEYS.EVENT_STOCKS + eventId,
|
||||
res.data,
|
||||
CACHE_EXPIRY_STRATEGY.LONG // 1小时
|
||||
);
|
||||
return { eventId, stocks: res.data };
|
||||
}
|
||||
|
||||
@@ -84,7 +54,7 @@ export const fetchStockQuotes = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取事件详情
|
||||
* 获取事件详情(Redux 缓存)
|
||||
*/
|
||||
export const fetchEventDetail = createAsyncThunk(
|
||||
'stock/fetchEventDetail',
|
||||
@@ -100,23 +70,9 @@ export const fetchEventDetail = createAsyncThunk(
|
||||
}
|
||||
}
|
||||
|
||||
// LocalStorage 缓存
|
||||
if (!forceRefresh) {
|
||||
const localCached = localCacheManager.get(CACHE_KEYS.EVENT_DETAIL + eventId);
|
||||
if (localCached) {
|
||||
logger.debug('stockSlice', 'LocalStorage 缓存命中 - eventDetail', { eventId });
|
||||
return { eventId, detail: localCached };
|
||||
}
|
||||
}
|
||||
|
||||
// API 请求
|
||||
const res = await eventService.getEventDetail(eventId);
|
||||
if (res.success && res.data) {
|
||||
localCacheManager.set(
|
||||
CACHE_KEYS.EVENT_DETAIL + eventId,
|
||||
res.data,
|
||||
CACHE_EXPIRY_STRATEGY.LONG
|
||||
);
|
||||
return { eventId, detail: res.data };
|
||||
}
|
||||
|
||||
@@ -125,7 +81,7 @@ export const fetchEventDetail = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取历史事件对比
|
||||
* 获取历史事件对比(Redux 缓存)
|
||||
*/
|
||||
export const fetchHistoricalEvents = createAsyncThunk(
|
||||
'stock/fetchHistoricalEvents',
|
||||
@@ -140,22 +96,9 @@ export const fetchHistoricalEvents = createAsyncThunk(
|
||||
}
|
||||
}
|
||||
|
||||
// LocalStorage 缓存
|
||||
if (!forceRefresh) {
|
||||
const localCached = localCacheManager.get(CACHE_KEYS.HISTORICAL_EVENTS + eventId);
|
||||
if (localCached) {
|
||||
return { eventId, events: localCached };
|
||||
}
|
||||
}
|
||||
|
||||
// API 请求
|
||||
const res = await eventService.getHistoricalEvents(eventId);
|
||||
if (res.success && res.data) {
|
||||
localCacheManager.set(
|
||||
CACHE_KEYS.HISTORICAL_EVENTS + eventId,
|
||||
res.data,
|
||||
CACHE_EXPIRY_STRATEGY.LONG
|
||||
);
|
||||
return { eventId, events: res.data };
|
||||
}
|
||||
|
||||
@@ -164,7 +107,7 @@ export const fetchHistoricalEvents = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取传导链分析
|
||||
* 获取传导链分析(Redux 缓存)
|
||||
*/
|
||||
export const fetchChainAnalysis = createAsyncThunk(
|
||||
'stock/fetchChainAnalysis',
|
||||
@@ -179,22 +122,9 @@ export const fetchChainAnalysis = createAsyncThunk(
|
||||
}
|
||||
}
|
||||
|
||||
// LocalStorage 缓存
|
||||
if (!forceRefresh) {
|
||||
const localCached = localCacheManager.get(CACHE_KEYS.CHAIN_ANALYSIS + eventId);
|
||||
if (localCached) {
|
||||
return { eventId, analysis: localCached };
|
||||
}
|
||||
}
|
||||
|
||||
// API 请求
|
||||
const res = await eventService.getTransmissionChainAnalysis(eventId);
|
||||
if (res.success && res.data) {
|
||||
localCacheManager.set(
|
||||
CACHE_KEYS.CHAIN_ANALYSIS + eventId,
|
||||
res.data,
|
||||
CACHE_EXPIRY_STRATEGY.LONG
|
||||
);
|
||||
return { eventId, analysis: res.data };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// src/views/Community/components/DynamicNewsCard.js
|
||||
// 横向滚动事件卡片组件(实时要闻·动态追踪)
|
||||
|
||||
import React, { forwardRef, useRef, useState, useEffect } from 'react';
|
||||
import React, { forwardRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -13,87 +14,345 @@ import {
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
IconButton,
|
||||
Center,
|
||||
Spinner,
|
||||
useColorModeValue
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, TimeIcon } from '@chakra-ui/icons';
|
||||
import DynamicNewsEventCard from './EventCard/DynamicNewsEventCard';
|
||||
import { TimeIcon } from '@chakra-ui/icons';
|
||||
import EventScrollList from './DynamicNewsCard/EventScrollList';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
import UnifiedSearchBox from './UnifiedSearchBox';
|
||||
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
|
||||
|
||||
/**
|
||||
* 实时要闻·动态追踪 - 横向滚动卡片组件
|
||||
* @param {Array} events - 事件列表
|
||||
* 实时要闻·动态追踪 - 事件展示卡片组件
|
||||
* @param {Array} allCachedEvents - 完整缓存事件列表(从 Redux 传入)
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {number} total - 服务端总数量
|
||||
* @param {number} cachedCount - 已缓存数量
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @param {Array} popularKeywords - 热门关键词
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onSearch - 搜索回调
|
||||
* @param {Function} onSearchFocus - 搜索框获得焦点回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const DynamicNewsCard = forwardRef(({
|
||||
events,
|
||||
allCachedEvents = [],
|
||||
loading,
|
||||
total = 0,
|
||||
cachedCount = 0,
|
||||
filters = {},
|
||||
popularKeywords = [],
|
||||
lastUpdateTime,
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const dispatch = useDispatch();
|
||||
const toast = useToast();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const scrollContainerRef = useRef(null);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
const [showRightArrow, setShowRightArrow] = useState(true);
|
||||
|
||||
// 从 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 scrollLeft = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: -400,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 滚动到右侧
|
||||
const scrollRight = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollBy({
|
||||
left: 400,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 监听滚动位置,更新箭头显示状态
|
||||
const handleScroll = (e) => {
|
||||
const container = e.target;
|
||||
const scrollLeft = container.scrollLeft;
|
||||
const scrollWidth = container.scrollWidth;
|
||||
const clientWidth = container.clientWidth;
|
||||
|
||||
setShowLeftArrow(scrollLeft > 0);
|
||||
setShowRightArrow(scrollLeft < scrollWidth - clientWidth - 10);
|
||||
};
|
||||
|
||||
// 时间轴样式配置
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
||||
borderWidth: '2px',
|
||||
textColor: useColorModeValue('blue.600', 'blue.400'),
|
||||
boxShadow: 'sm',
|
||||
};
|
||||
};
|
||||
}, [currentPageEvents, selectedEvent]);
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
@@ -117,12 +376,47 @@ const DynamicNewsCard = forwardRef(({
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索和筛选组件 */}
|
||||
<Box mt={4}>
|
||||
<UnifiedSearchBox
|
||||
onSearch={onSearch}
|
||||
onSearchFocus={onSearchFocus}
|
||||
popularKeywords={popularKeywords}
|
||||
filters={filters}
|
||||
/>
|
||||
</Box>
|
||||
</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" />
|
||||
@@ -131,120 +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 && (
|
||||
<Box position="relative">
|
||||
{/* 左侧滚动按钮 */}
|
||||
{showLeftArrow && (
|
||||
<IconButton
|
||||
icon={<ChevronLeftIcon boxSize={6} />}
|
||||
position="absolute"
|
||||
left="-4"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={scrollLeft}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
size="md"
|
||||
borderRadius="full"
|
||||
shadow="md"
|
||||
aria-label="向左滚动"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧滚动按钮 */}
|
||||
{showRightArrow && (
|
||||
<IconButton
|
||||
icon={<ChevronRightIcon boxSize={6} />}
|
||||
position="absolute"
|
||||
right="-4"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={scrollRight}
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
size="md"
|
||||
borderRadius="full"
|
||||
shadow="md"
|
||||
aria-label="向右滚动"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 横向滚动容器 */}
|
||||
<Flex
|
||||
ref={scrollContainerRef}
|
||||
overflowX="auto"
|
||||
overflowY="hidden"
|
||||
gap={4}
|
||||
py={4}
|
||||
px={2}
|
||||
onScroll={handleScroll}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: useColorModeValue('#f1f1f1', '#2D3748'),
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: useColorModeValue('#888', '#4A5568'),
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: useColorModeValue('#555', '#718096'),
|
||||
},
|
||||
// 平滑滚动
|
||||
scrollBehavior: 'smooth',
|
||||
// 触摸设备优化
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{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={false}
|
||||
followerCount={event.follower_count || 0}
|
||||
onEventClick={(clickedEvent) => {
|
||||
setSelectedEvent(clickedEvent);
|
||||
if (onEventClick) onEventClick(clickedEvent);
|
||||
}}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelectedEvent(event);
|
||||
if (onEventClick) onEventClick(event);
|
||||
}}
|
||||
onToggleFollow={() => {}}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 详情面板 */}
|
||||
{!loading && events && events.length > 0 && (
|
||||
{/* 详情面板 - 始终显示(如果有选中事件) */}
|
||||
{currentPageEvents && currentPageEvents.length > 0 && selectedEvent && (
|
||||
<Box mt={6}>
|
||||
<DynamicNewsDetailPanel event={selectedEvent} />
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
// src/views/Community/components/DynamicNewsCard/EventScrollList.js
|
||||
// 横向滚动事件列表组件
|
||||
|
||||
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';
|
||||
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||||
import PaginationControl from './PaginationControl';
|
||||
|
||||
/**
|
||||
* 事件列表组件 - 支持两种展示模式
|
||||
* @param {Array} events - 当前页的事件列表(服务端已分页)
|
||||
* @param {Object} selectedEvent - 当前选中的事件
|
||||
* @param {Function} onEventSelect - 事件选择回调
|
||||
* @param {string} borderColor - 边框颜色
|
||||
* @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,
|
||||
selectedEvent,
|
||||
onEventSelect,
|
||||
borderColor,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
loading = false,
|
||||
mode = 'carousel',
|
||||
onModeChange,
|
||||
hasMore = true,
|
||||
eventFollowStatus = {},
|
||||
onToggleFollow
|
||||
}) => {
|
||||
const scrollContainerRef = useRef(null);
|
||||
|
||||
// 所有 useColorModeValue 必须在组件顶层调用(不能在条件渲染中)
|
||||
const timelineBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const timelineBorderColor = useColorModeValue('gray.400', 'gray.500');
|
||||
const timelineTextColor = useColorModeValue('blue.600', 'blue.400');
|
||||
|
||||
// 翻页按钮颜色
|
||||
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
|
||||
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
|
||||
|
||||
// 滚动条颜色
|
||||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||||
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||||
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||||
|
||||
// 加载遮罩颜色
|
||||
const loadingOverlayBg = useColorModeValue('whiteAlpha.800', 'blackAlpha.700');
|
||||
const loadingTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: timelineBg,
|
||||
borderColor: timelineBorderColor,
|
||||
borderWidth: '2px',
|
||||
textColor: timelineTextColor,
|
||||
boxShadow: 'sm',
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 顶部控制栏:模式切换按钮(左)+ 分页控制器 + 加载提示(右) */}
|
||||
<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">
|
||||
|
||||
{/* 左侧翻页按钮 - 上一页 */}
|
||||
{currentPage > 1 && (
|
||||
<IconButton
|
||||
icon={<ChevronLeftIcon boxSize={6} color="blue.500" />}
|
||||
position="absolute"
|
||||
left="0"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
w="40px"
|
||||
h="40px"
|
||||
minW="40px"
|
||||
borderRadius="full"
|
||||
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="上一页"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 右侧翻页按钮 - 下一页 */}
|
||||
{currentPage < totalPages && hasMore && (
|
||||
<IconButton
|
||||
icon={<ChevronRightIcon boxSize={6} color="blue.500" />}
|
||||
position="absolute"
|
||||
right="0"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
w="40px"
|
||||
h="40px"
|
||||
minW="40px"
|
||||
borderRadius="full"
|
||||
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="下一页"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 事件卡片容器 */}
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
overflowX={mode === 'carousel' ? 'auto' : 'hidden'}
|
||||
overflowY="hidden"
|
||||
pt={0}
|
||||
pb={4}
|
||||
px={2}
|
||||
position="relative"
|
||||
css={mode === 'carousel' ? {
|
||||
'&::-webkit-scrollbar': {
|
||||
height: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
} : {}}
|
||||
>
|
||||
{/* 加载遮罩 */}
|
||||
{loading && (
|
||||
<Center
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg={loadingOverlayBg}
|
||||
backdropFilter="blur(2px)"
|
||||
zIndex={10}
|
||||
borderRadius="md"
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventScrollList;
|
||||
@@ -0,0 +1,211 @@
|
||||
// src/views/Community/components/DynamicNewsCard/PaginationControl.js
|
||||
// 分页控制器组件
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Button,
|
||||
Input,
|
||||
Text,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 分页控制器组件
|
||||
* @param {number} currentPage - 当前页码
|
||||
* @param {number} totalPages - 总页数
|
||||
* @param {Function} onPageChange - 页码改变回调
|
||||
*/
|
||||
const PaginationControl = ({ currentPage, totalPages, onPageChange }) => {
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
const toast = useToast();
|
||||
|
||||
const buttonBg = useColorModeValue('white', 'gray.700');
|
||||
const activeBg = useColorModeValue('blue.500', 'blue.400');
|
||||
const activeColor = useColorModeValue('white', 'white');
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.600');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.600');
|
||||
|
||||
// 生成页码数字列表(智能省略)
|
||||
const getPageNumbers = () => {
|
||||
const pageNumbers = [];
|
||||
const maxVisible = 5; // 最多显示5个页码(精简版)
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
// 总页数少,显示全部
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
} else {
|
||||
// 总页数多,使用省略号
|
||||
if (currentPage <= 3) {
|
||||
// 当前页在前面
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
pageNumbers.push('...');
|
||||
pageNumbers.push(totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
// 当前页在后面
|
||||
pageNumbers.push(1);
|
||||
pageNumbers.push('...');
|
||||
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
} else {
|
||||
// 当前页在中间
|
||||
pageNumbers.push(1);
|
||||
pageNumbers.push('...');
|
||||
pageNumbers.push(currentPage);
|
||||
pageNumbers.push('...');
|
||||
pageNumbers.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pageNumbers;
|
||||
};
|
||||
|
||||
// 处理页码跳转
|
||||
const handleJump = () => {
|
||||
const page = parseInt(jumpPage, 10);
|
||||
|
||||
if (isNaN(page)) {
|
||||
toast({
|
||||
title: '请输入有效的页码',
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (page < 1 || page > totalPages) {
|
||||
toast({
|
||||
title: `页码范围:1 - ${totalPages}`,
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onPageChange(page);
|
||||
setJumpPage('');
|
||||
};
|
||||
|
||||
// 处理回车键
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleJump();
|
||||
}
|
||||
};
|
||||
|
||||
const pageNumbers = getPageNumbers();
|
||||
|
||||
return (
|
||||
<Box mb={3}>
|
||||
<HStack spacing={1.5} justify="center" flexWrap="wrap">
|
||||
{/* 上一页按钮 */}
|
||||
<IconButton
|
||||
icon={<ChevronLeftIcon />}
|
||||
size="xs"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
isDisabled={currentPage === 1}
|
||||
bg={buttonBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ bg: hoverBg }}
|
||||
aria-label="上一页"
|
||||
title="上一页"
|
||||
/>
|
||||
|
||||
{/* 数字页码列表 */}
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<Text
|
||||
key={`ellipsis-${index}`}
|
||||
px={1}
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
>
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
size="xs"
|
||||
onClick={() => onPageChange(page)}
|
||||
bg={currentPage === page ? activeBg : buttonBg}
|
||||
color={currentPage === page ? activeColor : undefined}
|
||||
borderWidth="1px"
|
||||
borderColor={currentPage === page ? activeBg : borderColor}
|
||||
_hover={{
|
||||
bg: currentPage === page ? activeBg : hoverBg,
|
||||
}}
|
||||
minW="28px"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 下一页按钮 */}
|
||||
<IconButton
|
||||
icon={<ChevronRightIcon />}
|
||||
size="xs"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
isDisabled={currentPage === totalPages}
|
||||
bg={buttonBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ bg: hoverBg }}
|
||||
aria-label="下一页"
|
||||
title="下一页"
|
||||
/>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box w="1px" h="20px" bg={borderColor} mx={1.5} />
|
||||
|
||||
{/* 输入框跳转 */}
|
||||
<HStack spacing={1.5}>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
跳转到
|
||||
</Text>
|
||||
<Input
|
||||
size="xs"
|
||||
width="50px"
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="页"
|
||||
bg={buttonBg}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
onClick={handleJump}
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaginationControl;
|
||||
@@ -1,17 +1,22 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
|
||||
// 动态新闻详情面板主组件(组装所有子组件)
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
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';
|
||||
@@ -26,20 +31,32 @@ 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,
|
||||
quotes,
|
||||
eventDetail,
|
||||
historicalEvents,
|
||||
expectationScore,
|
||||
loading
|
||||
} = useEventStocks(event?.id, event?.created_at);
|
||||
|
||||
// 折叠状态管理
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(true);
|
||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
|
||||
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 {
|
||||
@@ -50,44 +67,11 @@ const DynamicNewsDetailPanel = ({ event }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 生成模拟行情数据
|
||||
const quotes = useMemo(() => {
|
||||
if (!event?.related_stocks) return {};
|
||||
|
||||
const quotesData = {};
|
||||
event.related_stocks.forEach(stock => {
|
||||
// 优先使用 stock.daily_change,否则生成随机涨跌幅
|
||||
const change = stock.daily_change
|
||||
? parseFloat(stock.daily_change)
|
||||
: (Math.random() * 10 - 3); // -3% ~ +7%
|
||||
|
||||
quotesData[stock.stock_code] = {
|
||||
change: change,
|
||||
price: 10 + Math.random() * 90 // 模拟价格 10-100
|
||||
};
|
||||
});
|
||||
|
||||
return quotesData;
|
||||
}, [event?.related_stocks]);
|
||||
|
||||
// 切换关注状态
|
||||
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) => {
|
||||
@@ -159,32 +143,46 @@ 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}
|
||||
/>
|
||||
|
||||
{/* 相关股票(可折叠) */}
|
||||
<RelatedStocksSection
|
||||
stocks={event.related_stocks}
|
||||
quotes={quotes}
|
||||
eventTime={event.created_at}
|
||||
watchlistSet={watchlistSet}
|
||||
isOpen={isStocksOpen}
|
||||
onToggle={() => setIsStocksOpen(!isStocksOpen)}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
{loading.stocks || loading.quotes ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" />
|
||||
<Text ml={2} color={textColor}>加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<RelatedStocksSection
|
||||
stocks={stocks}
|
||||
quotes={quotes}
|
||||
eventTime={event.created_at}
|
||||
watchlistSet={watchlistSet}
|
||||
isOpen={isStocksOpen}
|
||||
onToggle={() => setIsStocksOpen(!isStocksOpen)}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 历史事件对比(可折叠) */}
|
||||
<CollapsibleSection
|
||||
title="历史事件对比"
|
||||
isOpen={isHistoricalOpen}
|
||||
onToggle={() => setIsHistoricalOpen(!isHistoricalOpen)}
|
||||
count={event.historical_events?.length || 0}
|
||||
count={historicalEvents?.length || 0}
|
||||
>
|
||||
<HistoricalEvents
|
||||
events={event.historical_events || []}
|
||||
/>
|
||||
{loading.historicalEvents ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Text ml={2} color={textColor} fontSize="sm">加载历史事件...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<HistoricalEvents
|
||||
events={historicalEvents || []}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 传导链分析(可折叠) */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,12 +28,26 @@ const ConceptStockItem = ({ stock }) => {
|
||||
const stockChangeColor = stockChangePct > 0 ? 'red' : stockChangePct < 0 ? 'green' : 'gray';
|
||||
const stockChangeSymbol = stockChangePct > 0 ? '+' : '';
|
||||
|
||||
// 处理股票详情跳转
|
||||
const handleStockClick = (e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡到概念卡片
|
||||
const cleanCode = stock.stock_code.replace(/\.(SZ|SH)$/i, '');
|
||||
window.open(`https://valuefrontier.cn/company?scode=${cleanCode}`, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={sectionBg}
|
||||
fontSize="xs"
|
||||
cursor="pointer"
|
||||
onClick={handleStockClick}
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.700'),
|
||||
transform: 'translateX(4px)',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<HStack spacing={2}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -62,8 +196,8 @@ const RelatedConceptsSection = ({ keywords, effectiveTradingDate, eventTime }) =
|
||||
* @param {Object} concept - 概念对象
|
||||
*/
|
||||
const handleConceptClick = (concept) => {
|
||||
// 跳转到概念详情页
|
||||
navigate(`/concept/${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}
|
||||
|
||||
@@ -106,6 +106,8 @@ const StockListItem = ({
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
onClick={handleViewDetail}
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
borderColor: 'blue.300',
|
||||
@@ -155,7 +157,10 @@ const StockListItem = ({
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={handleViewDetail}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewDetail();
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
@@ -169,7 +174,7 @@ const StockListItem = ({
|
||||
<Box>
|
||||
<SimpleGrid columns={2} spacing={3}>
|
||||
{/* 左侧:分时图 */}
|
||||
<Box>
|
||||
<Box onClick={(e) => e.stopPropagation()}>
|
||||
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
|
||||
分时图
|
||||
</Text>
|
||||
@@ -181,7 +186,7 @@ const StockListItem = ({
|
||||
</Box>
|
||||
|
||||
{/* 右侧:K线图 */}
|
||||
<Box>
|
||||
<Box onClick={(e) => e.stopPropagation()}>
|
||||
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
|
||||
日K线
|
||||
</Text>
|
||||
@@ -213,7 +218,10 @@ const StockListItem = ({
|
||||
size="xs"
|
||||
variant="link"
|
||||
colorScheme="blue"
|
||||
onClick={() => setIsDescExpanded(!isDescExpanded)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescExpanded(!isDescExpanded);
|
||||
}}
|
||||
mt={1}
|
||||
>
|
||||
{isDescExpanded ? '收起' : '展开'}
|
||||
|
||||
@@ -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';
|
||||
@@ -24,6 +31,7 @@ import StockChangeIndicators from '../../../../components/StockChangeIndicators'
|
||||
* @param {number} props.index - 事件索引
|
||||
* @param {boolean} props.isFollowing - 是否已关注
|
||||
* @param {number} props.followerCount - 关注数
|
||||
* @param {boolean} props.isSelected - 是否被选中
|
||||
* @param {Function} props.onEventClick - 卡片点击事件
|
||||
* @param {Function} props.onTitleClick - 标题点击事件
|
||||
* @param {Function} props.onToggleFollow - 切换关注事件
|
||||
@@ -35,6 +43,7 @@ const DynamicNewsEventCard = ({
|
||||
index,
|
||||
isFollowing,
|
||||
followerCount,
|
||||
isSelected = false,
|
||||
onEventClick,
|
||||
onTitleClick,
|
||||
onToggleFollow,
|
||||
@@ -72,22 +81,96 @@ const DynamicNewsEventCard = ({
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
position="relative"
|
||||
bg={index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750')}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
bg={isSelected
|
||||
? useColorModeValue('blue.50', 'blue.900')
|
||||
: (index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750'))
|
||||
}
|
||||
borderWidth={isSelected ? "2px" : "1px"}
|
||||
borderColor={isSelected
|
||||
? useColorModeValue('blue.500', 'blue.400')
|
||||
: borderColor
|
||||
}
|
||||
borderRadius="md"
|
||||
boxShadow="sm"
|
||||
boxShadow={isSelected ? "lg" : "sm"}
|
||||
overflow="hidden"
|
||||
_hover={{
|
||||
boxShadow: 'lg',
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: importance.color,
|
||||
borderColor: isSelected ? 'blue.600' : importance.color,
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
cursor="pointer"
|
||||
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}
|
||||
@@ -98,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
|
||||
@@ -110,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) => {
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchPopularKeywords, fetchHotEvents } from '../../store/slices/communityDataSlice';
|
||||
import {
|
||||
fetchPopularKeywords,
|
||||
fetchHotEvents,
|
||||
fetchDynamicNews,
|
||||
selectDynamicNewsWithLoading
|
||||
} from '../../store/slices/communityDataSlice';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -42,6 +47,13 @@ const Community = () => {
|
||||
|
||||
// Redux状态
|
||||
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
|
||||
const {
|
||||
data: allCachedEvents,
|
||||
loading: dynamicNewsLoading,
|
||||
error: dynamicNewsError,
|
||||
total: dynamicNewsTotal,
|
||||
cachedCount: dynamicNewsCachedCount
|
||||
} = useSelector(selectDynamicNewsWithLoading);
|
||||
|
||||
// Chakra UI hooks
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
@@ -49,6 +61,7 @@ const Community = () => {
|
||||
// Ref:用于滚动到实时事件时间轴
|
||||
const eventTimelineRef = useRef(null);
|
||||
const hasScrolledRef = useRef(false); // 标记是否已滚动
|
||||
const containerRef = useRef(null); // 用于首次滚动到内容区域
|
||||
|
||||
// ⚡ 通知权限引导
|
||||
const { showCommunityGuide } = useNotification();
|
||||
@@ -57,10 +70,6 @@ const Community = () => {
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [selectedEventForStock, setSelectedEventForStock] = useState(null);
|
||||
|
||||
// 动态新闻数据状态
|
||||
const [dynamicNewsEvents, setDynamicNewsEvents] = useState([]);
|
||||
const [dynamicNewsLoading, setDynamicNewsLoading] = useState(true);
|
||||
|
||||
// 🎯 初始化Community埋点Hook
|
||||
const communityEvents = useCommunityEvents({ navigate });
|
||||
|
||||
@@ -88,75 +97,24 @@ const Community = () => {
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
// 加载热门关键词和热点事件(使用Redux,内部有缓存判断)
|
||||
// 加载热门关键词和热点事件(动态新闻由 DynamicNewsCard 内部管理)
|
||||
useEffect(() => {
|
||||
dispatch(fetchPopularKeywords());
|
||||
dispatch(fetchHotEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// 加载动态新闻数据
|
||||
// 每5分钟刷新一次动态新闻(使用 prependMode 追加到头部)
|
||||
useEffect(() => {
|
||||
const fetchDynamicNews = async () => {
|
||||
setDynamicNewsLoading(true);
|
||||
try {
|
||||
// 检查是否使用 mock 模式
|
||||
// 开发阶段默认使用 mock 数据
|
||||
const useMock = true; // TODO: 生产环境改为环境变量控制
|
||||
// const useMock = process.env.REACT_APP_USE_MOCK === 'true' ||
|
||||
// localStorage.getItem('use_mock_data') === 'true';
|
||||
const interval = setInterval(() => {
|
||||
dispatch(fetchDynamicNews({
|
||||
page: 1,
|
||||
per_page: 10, // 获取最新的10条
|
||||
prependMode: true // 追加到头部,不清空缓存
|
||||
}));
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
if (useMock) {
|
||||
// 使用 mock 数据
|
||||
const { generateMockEvents } = await import('../../mocks/data/events');
|
||||
const mockData = generateMockEvents({ page: 1, per_page: 30 });
|
||||
|
||||
// 调试:检查第一个事件的 related_stocks 和 historical_events 数据
|
||||
if (mockData.events[0]) {
|
||||
console.log('Mock 数据第一个事件的股票:', mockData.events[0].related_stocks);
|
||||
console.log('Mock 数据第一个事件的历史事件:', mockData.events[0].historical_events);
|
||||
}
|
||||
|
||||
setDynamicNewsEvents(mockData.events);
|
||||
logger.info('Community', '动态新闻(Mock)加载成功', {
|
||||
count: mockData.events.length,
|
||||
mode: 'mock',
|
||||
firstEventStocks: mockData.events[0]?.related_stocks?.length || 0
|
||||
});
|
||||
} else {
|
||||
// 使用真实 API
|
||||
const timeRange = getCurrentTradingTimeRange();
|
||||
const response = await fetch(
|
||||
`/api/events/dynamic-news?start_time=${timeRange.startTime.toISOString()}&end_time=${timeRange.endTime.toISOString()}&count=30`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
setDynamicNewsEvents(data.data);
|
||||
logger.info('Community', '动态新闻加载成功', {
|
||||
count: data.data.length,
|
||||
timeRange: timeRange.description,
|
||||
mode: 'api'
|
||||
});
|
||||
} else {
|
||||
logger.warn('Community', '动态新闻加载失败', data);
|
||||
setDynamicNewsEvents([]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Community', '动态新闻加载异常', error);
|
||||
setDynamicNewsEvents([]);
|
||||
} finally {
|
||||
setDynamicNewsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDynamicNews();
|
||||
|
||||
// 每5分钟刷新一次动态新闻
|
||||
const interval = setInterval(fetchDynamicNews, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [dispatch]);
|
||||
|
||||
// 🎯 PostHog 追踪:页面浏览
|
||||
// useEffect(() => {
|
||||
@@ -178,7 +136,7 @@ const Community = () => {
|
||||
industryFilter: filters.industry_code,
|
||||
});
|
||||
}
|
||||
}, [events, loading, pagination, filters, communityEvents]);
|
||||
}, [events, loading, pagination, filters]);
|
||||
|
||||
// ⚡ 首次访问社区时,延迟显示权限引导
|
||||
useEffect(() => {
|
||||
@@ -192,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) {
|
||||
@@ -208,16 +183,22 @@ 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}
|
||||
total={dynamicNewsTotal}
|
||||
cachedCount={dynamicNewsCachedCount}
|
||||
filters={filters}
|
||||
popularKeywords={popularKeywords}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
onSearch={updateFilters}
|
||||
onSearchFocus={scrollToTimeline}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/views/EventDetail/components/HistoricalEvents.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
@@ -7,121 +8,114 @@ import {
|
||||
Text,
|
||||
Badge,
|
||||
Button,
|
||||
Collapse,
|
||||
Skeleton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
Spinner,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Link
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
Link,
|
||||
Flex,
|
||||
Collapse
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaExclamationTriangle,
|
||||
FaClock,
|
||||
FaCalendarAlt,
|
||||
FaChartLine,
|
||||
FaEye,
|
||||
FaTimes,
|
||||
FaInfoCircle,
|
||||
FaChevronDown,
|
||||
FaChevronUp
|
||||
FaInfoCircle
|
||||
} from 'react-icons/fa';
|
||||
import { stockService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const HistoricalEvents = ({
|
||||
events = [],
|
||||
expectationScore = null,
|
||||
loading = false,
|
||||
error = null
|
||||
}) => {
|
||||
// 所有 useState/useEffect/useContext/useRef/useCallback/useMemo 必须在组件顶层、顺序一致
|
||||
// 不要在 if/循环/回调中调用 Hook
|
||||
const [expandedEvents, setExpandedEvents] = useState(new Set());
|
||||
const [expandedStocks, setExpandedStocks] = useState(new Set()); // 追踪哪些事件的股票列表被展开
|
||||
events = [],
|
||||
expectationScore = null,
|
||||
loading = false,
|
||||
error = null
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 状态管理
|
||||
const [selectedEventForStocks, setSelectedEventForStocks] = useState(null);
|
||||
const [stocksModalOpen, setStocksModalOpen] = useState(false);
|
||||
const [eventStocks, setEventStocks] = useState({});
|
||||
const [loadingStocks, setLoadingStocks] = useState({});
|
||||
|
||||
// 颜色主题
|
||||
const timelineBg = useColorModeValue('#D4AF37', '#B8860B');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||
|
||||
// 切换事件展开状态
|
||||
const toggleEventExpansion = (eventId) => {
|
||||
const newExpanded = new Set(expandedEvents);
|
||||
if (newExpanded.has(eventId)) {
|
||||
newExpanded.delete(eventId);
|
||||
} else {
|
||||
newExpanded.add(eventId);
|
||||
}
|
||||
setExpandedEvents(newExpanded);
|
||||
// 字段兼容函数
|
||||
const getEventDate = (event) => {
|
||||
return event?.event_date || event?.created_at || event?.date || event?.publish_time;
|
||||
};
|
||||
|
||||
// 切换股票列表展开状态
|
||||
const toggleStocksExpansion = async (event) => {
|
||||
const eventId = event.id;
|
||||
const newExpanded = new Set(expandedStocks);
|
||||
const getEventContent = (event) => {
|
||||
return event?.content || event?.description || event?.summary;
|
||||
};
|
||||
|
||||
// 如果正在收起,直接更新状态
|
||||
if (newExpanded.has(eventId)) {
|
||||
newExpanded.delete(eventId);
|
||||
setExpandedStocks(newExpanded);
|
||||
return;
|
||||
// Debug: 打印实际数据结构
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0) {
|
||||
console.log('===== Historical Events Debug =====');
|
||||
console.log('First Event Data:', events[0]);
|
||||
console.log('Available Fields:', Object.keys(events[0]));
|
||||
console.log('Date Field:', getEventDate(events[0]));
|
||||
console.log('Content Field:', getEventContent(events[0]));
|
||||
console.log('==================================');
|
||||
}
|
||||
}, [events]);
|
||||
|
||||
// 如果正在展开,先展开再加载数据
|
||||
newExpanded.add(eventId);
|
||||
setExpandedStocks(newExpanded);
|
||||
// 点击相关股票按钮
|
||||
const handleViewStocks = async (event) => {
|
||||
setSelectedEventForStocks(event);
|
||||
setStocksModalOpen(true);
|
||||
|
||||
// 如果已经加载过该事件的股票数据,不再重复加载
|
||||
if (eventStocks[eventId]) {
|
||||
if (eventStocks[event.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为加载中
|
||||
setLoadingStocks(prev => ({ ...prev, [eventId]: true }));
|
||||
setLoadingStocks(prev => ({ ...prev, [event.id]: true }));
|
||||
|
||||
try {
|
||||
// 调用API获取历史事件相关股票
|
||||
const response = await stockService.getHistoricalEventStocks(eventId);
|
||||
const response = await stockService.getHistoricalEventStocks(event.id);
|
||||
setEventStocks(prev => ({
|
||||
...prev,
|
||||
[eventId]: response.data || []
|
||||
[event.id]: response.data || []
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error('HistoricalEvents', 'toggleStocksExpansion', err, {
|
||||
eventId: eventId,
|
||||
logger.error('HistoricalEvents', 'handleViewStocks', err, {
|
||||
eventId: event.id,
|
||||
eventTitle: event.title
|
||||
});
|
||||
setEventStocks(prev => ({
|
||||
...prev,
|
||||
[eventId]: []
|
||||
[event.id]: []
|
||||
}));
|
||||
} finally {
|
||||
setLoadingStocks(prev => ({ ...prev, [eventId]: false }));
|
||||
setLoadingStocks(prev => ({ ...prev, [event.id]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
// 获取重要性图标
|
||||
const getImportanceIcon = (importance) => {
|
||||
if (importance >= 4) return FaExclamationTriangle;
|
||||
if (importance >= 2) return FaCalendarAlt;
|
||||
return FaClock;
|
||||
// 关闭弹窗
|
||||
const handleCloseModal = () => {
|
||||
setStocksModalOpen(false);
|
||||
setSelectedEventForStocks(null);
|
||||
};
|
||||
|
||||
// 处理卡片点击跳转到事件详情页
|
||||
const handleCardClick = (event) => {
|
||||
navigate(`/event-detail/${event.id}`);
|
||||
};
|
||||
|
||||
// 获取重要性颜色
|
||||
@@ -153,89 +147,28 @@ const HistoricalEvents = ({
|
||||
return `${Math.floor(diffDays / 365)}年前`;
|
||||
};
|
||||
|
||||
// 处理关联描述字段的辅助函数
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
// 处理空值
|
||||
if (!relationDesc) return '';
|
||||
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 如果是对象且包含data数组
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
const firstItem = relationDesc.data[0];
|
||||
if (firstItem) {
|
||||
// 优先使用 query_part,其次使用 sentences
|
||||
return firstItem.query_part || firstItem.sentences || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况返回空字符串
|
||||
return '';
|
||||
};
|
||||
|
||||
// 可展开的文本组件
|
||||
const ExpandableText = ({ text, maxLength = 20 }) => {
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
const [shouldTruncate, setShouldTruncate] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (text && text.length > maxLength) {
|
||||
setShouldTruncate(true);
|
||||
} else {
|
||||
setShouldTruncate(false);
|
||||
}
|
||||
}, [text, maxLength]);
|
||||
|
||||
if (!text) return <Text fontSize="xs">--</Text>;
|
||||
|
||||
const displayText = shouldTruncate && !isOpen
|
||||
? text.substring(0, maxLength) + '...'
|
||||
: text;
|
||||
|
||||
return (
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontSize="xs" noOfLines={isOpen ? undefined : 2} maxW="300px">
|
||||
{displayText}{text.includes('AI合成') ? '' : '(AI合成)'}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
color="blue.500"
|
||||
onClick={onToggle}
|
||||
height="auto"
|
||||
py={0}
|
||||
minH={0}
|
||||
>
|
||||
{isOpen ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} borderLeft="4px solid" borderLeftColor="gray.200">
|
||||
<CardBody>
|
||||
<HStack spacing={4} align="flex-start">
|
||||
<Skeleton boxSize="40px" borderRadius="full" />
|
||||
<VStack align="flex-start" spacing={2} flex="1">
|
||||
<Skeleton height="20px" width="70%" />
|
||||
<Skeleton height="16px" width="40%" />
|
||||
<Skeleton height="14px" width="90%" />
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Box
|
||||
key={i}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
>
|
||||
<VStack align="flex-start" spacing={3}>
|
||||
<Skeleton height="20px" width="70%" />
|
||||
<Skeleton height="16px" width="50%" />
|
||||
<Skeleton height="60px" width="100%" />
|
||||
<Skeleton height="32px" width="100px" />
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,208 +200,163 @@ const HistoricalEvents = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 超预期得分显示 */}
|
||||
{expectationScore && (
|
||||
<Card bg={useColorModeValue('yellow.50', 'yellow.900')} borderColor="yellow.200">
|
||||
<CardBody>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaChartLine} color="yellow.600" boxSize="20px" />
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
|
||||
超预期得分: {expectationScore}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="yellow.700">
|
||||
基于历史事件判断当前事件的超预期情况,满分100分(AI合成)
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 历史事件时间轴 */}
|
||||
<Box position="relative">
|
||||
{/* 时间轴线 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
left="20px"
|
||||
top="20px"
|
||||
bottom="20px"
|
||||
width="2px"
|
||||
background={`linear-gradient(to bottom, ${timelineBg}, #996515)`}
|
||||
zIndex={0}
|
||||
/>
|
||||
|
||||
{/* 事件列表 */}
|
||||
<VStack spacing={6} align="stretch">
|
||||
{events.map((event, index) => {
|
||||
const ImportanceIcon = getImportanceIcon(event.importance);
|
||||
const importanceColor = getImportanceColor(event.importance);
|
||||
const isExpanded = expandedEvents.has(event.id);
|
||||
|
||||
return (
|
||||
<Box key={event.id} position="relative">
|
||||
{/* 时间轴节点 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
left="0"
|
||||
top="20px"
|
||||
width="40px"
|
||||
height="40px"
|
||||
borderRadius="full"
|
||||
bg={cardBg}
|
||||
border="2px solid"
|
||||
borderColor={timelineBg}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
zIndex={1}
|
||||
>
|
||||
<Icon
|
||||
as={ImportanceIcon}
|
||||
color={`${importanceColor}.500`}
|
||||
boxSize="16px"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 事件内容卡片 */}
|
||||
<Box ml="60px">
|
||||
<Card
|
||||
borderLeft="3px solid"
|
||||
borderLeftColor={timelineBg}
|
||||
bg={cardBg}
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="flex-start" spacing={3}>
|
||||
{/* 事件标题和操作 */}
|
||||
<HStack justify="space-between" align="flex-start" w="100%">
|
||||
<VStack align="flex-start" spacing={1} flex="1">
|
||||
<Button
|
||||
variant="link"
|
||||
color={useColorModeValue('blue.600', 'blue.400')}
|
||||
fontWeight="bold"
|
||||
fontSize="md"
|
||||
p={0}
|
||||
h="auto"
|
||||
onClick={() => toggleEventExpansion(event.id)}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{event.title || '未命名事件'}
|
||||
</Button>
|
||||
|
||||
<HStack spacing={3} fontSize="sm" color={textSecondary}>
|
||||
<Text>{formatDate(event.event_date)}</Text>
|
||||
<Text>({getRelativeTime(event.event_date)})</Text>
|
||||
{event.relevance && (
|
||||
<Badge colorScheme="blue" size="sm">
|
||||
相关度: {event.relevance}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
{event.importance && (
|
||||
<Tooltip label={`重要性等级: ${event.importance}/5`}>
|
||||
<Badge colorScheme={importanceColor} size="sm">
|
||||
重要性: {event.importance}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Icon as={FaChartLine} />}
|
||||
rightIcon={<Icon as={expandedStocks.has(event.id) ? FaChevronUp : FaChevronDown} />}
|
||||
onClick={() => toggleStocksExpansion(event)}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
>
|
||||
相关股票
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{/* 事件简介 */}
|
||||
<Text fontSize="sm" color={textSecondary} lineHeight="1.5">
|
||||
{event.content ? `${event.content}(AI合成)` : '暂无内容'}
|
||||
</Text>
|
||||
|
||||
{/* 展开的详细信息 */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
<Box pt={3} borderTop="1px solid" borderTopColor={borderColor}>
|
||||
<VStack align="flex-start" spacing={2}>
|
||||
<Text fontSize="xs" color={textSecondary}>
|
||||
事件ID: {event.id}
|
||||
</Text>
|
||||
{event.source && (
|
||||
<Text fontSize="xs" color={textSecondary}>
|
||||
来源: {event.source}
|
||||
</Text>
|
||||
)}
|
||||
{event.tags && event.tags.length > 0 && (
|
||||
<HStack spacing={1} flexWrap="wrap">
|
||||
<Text fontSize="xs" color={textSecondary}>标签:</Text>
|
||||
{event.tags.map((tag, idx) => (
|
||||
<Badge key={idx} size="sm" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{/* 相关股票列表 Collapse */}
|
||||
<Collapse in={expandedStocks.has(event.id)} animateOpacity>
|
||||
<Box
|
||||
mt={3}
|
||||
pt={3}
|
||||
borderTop="1px solid"
|
||||
borderTopColor={borderColor}
|
||||
bg={useColorModeValue('gray.50', 'gray.750')}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
>
|
||||
{loadingStocks[event.id] ? (
|
||||
<VStack spacing={4} py={8}>
|
||||
<Spinner size="lg" color="blue.500" />
|
||||
<Text color={textSecondary}>加载相关股票数据...</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<StocksList
|
||||
stocks={eventStocks[event.id] || []}
|
||||
eventTradingDate={event.event_date}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
{/* 超预期得分显示 */}
|
||||
{expectationScore && (
|
||||
<Box
|
||||
mb={4}
|
||||
p={3}
|
||||
bg={useColorModeValue('yellow.50', 'yellow.900')}
|
||||
borderColor="yellow.200"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaChartLine} color="yellow.600" boxSize="20px" />
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
|
||||
超预期得分: {expectationScore}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="yellow.700">
|
||||
基于历史事件判断当前事件的超预期情况,满分100分(AI合成)
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 历史事件卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{events.map((event) => {
|
||||
const importanceColor = getImportanceColor(event.importance);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={event.id}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={() => handleCardClick(event)}
|
||||
_hover={{
|
||||
boxShadow: 'lg',
|
||||
borderColor: 'blue.400',
|
||||
transform: 'translateY(-2px)',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 事件名称 */}
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color={useColorModeValue('blue.600', 'blue.400')}
|
||||
noOfLines={2}
|
||||
lineHeight="1.4"
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCardClick(event);
|
||||
}}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{event.title || '未命名事件'}
|
||||
</Text>
|
||||
|
||||
{/* 日期 + Badges */}
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={textSecondary}>
|
||||
{formatDate(getEventDate(event))}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={textSecondary}>
|
||||
({getRelativeTime(getEventDate(event))})
|
||||
</Text>
|
||||
{event.relevance && (
|
||||
<Badge colorScheme="blue" size="sm">
|
||||
相关度: {event.relevance}
|
||||
</Badge>
|
||||
)}
|
||||
{event.importance && (
|
||||
<Badge colorScheme={importanceColor} size="sm">
|
||||
重要性: {event.importance}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 事件描述 */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={nameColor}
|
||||
lineHeight="1.6"
|
||||
noOfLines={4}
|
||||
>
|
||||
{getEventContent(event) ? `${getEventContent(event)}(AI合成)` : '暂无内容'}
|
||||
</Text>
|
||||
|
||||
{/* 相关股票按钮 */}
|
||||
<Button
|
||||
size="sm"
|
||||
leftIcon={<Icon as={FaChartLine} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewStocks(event);
|
||||
}}
|
||||
colorScheme="blue"
|
||||
variant="outline"
|
||||
width="full"
|
||||
>
|
||||
相关股票
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 相关股票 Modal - 条件渲染 */}
|
||||
{stocksModalOpen && (
|
||||
<Modal
|
||||
isOpen={stocksModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
size="6xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedEventForStocks?.title || '历史事件相关股票'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
{loadingStocks[selectedEventForStocks?.id] ? (
|
||||
<VStack spacing={4} py={12}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
<Text color={textSecondary}>加载相关股票数据...</Text>
|
||||
</VStack>
|
||||
) : (
|
||||
<StocksList
|
||||
stocks={eventStocks[selectedEventForStocks?.id] || []}
|
||||
eventTradingDate={getEventDate(selectedEventForStocks)}
|
||||
/>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 股票列表子组件
|
||||
// 股票列表子组件(卡片式布局)
|
||||
const StocksList = ({ stocks, eventTradingDate }) => {
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||
const [expandedStocks, setExpandedStocks] = useState(new Set());
|
||||
|
||||
// 处理股票代码,移除.SZ/.SH后缀
|
||||
const formatStockCode = (stockCode) => {
|
||||
if (!stockCode) return '';
|
||||
return stockCode.replace(/\.(SZ|SH)$/i, '');
|
||||
};
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const dividerColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||
|
||||
// 处理关联描述字段的辅助函数
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
@@ -493,9 +381,41 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
||||
return '';
|
||||
};
|
||||
|
||||
// 切换展开状态
|
||||
const toggleExpand = (stockId) => {
|
||||
const newExpanded = new Set(expandedStocks);
|
||||
if (newExpanded.has(stockId)) {
|
||||
newExpanded.delete(stockId);
|
||||
} else {
|
||||
newExpanded.add(stockId);
|
||||
}
|
||||
setExpandedStocks(newExpanded);
|
||||
};
|
||||
|
||||
// 格式化涨跌幅
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined || isNaN(value)) return '--';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${parseFloat(value).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 获取涨跌幅颜色
|
||||
const getChangeColor = (value) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num === 0) return 'gray.500';
|
||||
return num > 0 ? 'red.500' : 'green.500';
|
||||
};
|
||||
|
||||
// 获取相关度颜色
|
||||
const getCorrelationColor = (correlation) => {
|
||||
if (correlation >= 0.8) return 'red';
|
||||
if (correlation >= 0.6) return 'orange';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return (
|
||||
<Box textAlign="center" py={8} color={textSecondary}>
|
||||
<Box textAlign="center" py={12} color={textSecondary}>
|
||||
<Icon as={FaInfoCircle} boxSize="48px" mb={4} />
|
||||
<Text fontSize="lg" mb={2}>暂无相关股票数据</Text>
|
||||
<Text fontSize="sm">该历史事件暂未关联股票信息</Text>
|
||||
@@ -505,6 +425,7 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 事件交易日提示 */}
|
||||
{eventTradingDate && (
|
||||
<Box mb={4} p={3} bg={useColorModeValue('blue.50', 'blue.900')} borderRadius="md">
|
||||
<Text fontSize="sm" color={useColorModeValue('blue.700', 'blue.300')}>
|
||||
@@ -512,74 +433,115 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<TableContainer>
|
||||
<Table size="md">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>股票代码</Th>
|
||||
<Th>股票名称</Th>
|
||||
<Th>板块</Th>
|
||||
<Th isNumeric>相关度</Th>
|
||||
<Th isNumeric>事件日涨幅</Th>
|
||||
<Th>关联原因</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{stocks.map((stock, index) => (
|
||||
<Tr key={stock.id || index}>
|
||||
<Td fontFamily="mono" fontWeight="medium">
|
||||
<Link
|
||||
href={`https://valuefrontier.cn/company?scode=${stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}`}
|
||||
isExternal
|
||||
color="blue.500"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : ''}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>{stock.stock_name || '--'}</Td>
|
||||
<Td>
|
||||
<Badge size="sm" variant="outline">
|
||||
{stock.sector || '未知'}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
<Badge
|
||||
colorScheme={
|
||||
stock.correlation >= 0.8 ? 'red' :
|
||||
stock.correlation >= 0.6 ? 'orange' : 'green'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{Math.round((stock.correlation || 0) * 100)}%
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td isNumeric>
|
||||
{stock.event_day_change_pct !== null && stock.event_day_change_pct !== undefined ? (
|
||||
<Text
|
||||
fontWeight="medium"
|
||||
color={stock.event_day_change_pct >= 0 ? 'red.500' : 'green.500'}
|
||||
>
|
||||
{stock.event_day_change_pct >= 0 ? '+' : ''}{stock.event_day_change_pct.toFixed(2)}%
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={textSecondary} fontSize="sm">--</Text>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
|
||||
{/* 股票卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{stocks.map((stock, index) => {
|
||||
const stockId = stock.id || index;
|
||||
const isExpanded = expandedStocks.has(stockId);
|
||||
const cleanCode = stock.stock_code ? stock.stock_code.replace(/\.(SZ|SH)$/i, '') : '';
|
||||
const relationDesc = getRelationDesc(stock.relation_desc);
|
||||
const needTruncate = relationDesc && relationDesc.length > 50;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={stockId}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
borderColor: 'blue.300',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 顶部:股票代码 + 名称 + 涨跌幅 */}
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<Text fontSize="xs" noOfLines={2} maxW="300px">
|
||||
{getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}(AI合成)` : '--'}
|
||||
<Link
|
||||
href={`https://valuefrontier.cn/company?scode=${cleanCode}`}
|
||||
isExternal
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color="blue.500"
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{cleanCode}
|
||||
</Link>
|
||||
<Text fontSize="sm" color={nameColor}>
|
||||
{stock.stock_name || '--'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
color={getChangeColor(stock.event_day_change_pct)}
|
||||
>
|
||||
{formatChange(stock.event_day_change_pct)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box borderTop="1px solid" borderColor={dividerColor} />
|
||||
|
||||
{/* 板块和相关度 */}
|
||||
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="xs" color={textSecondary}>板块:</Text>
|
||||
<Badge size="sm" variant="outline">
|
||||
{stock.sector || '未知'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack spacing={2}>
|
||||
<Text fontSize="xs" color={textSecondary}>相关度:</Text>
|
||||
<Badge
|
||||
colorScheme={getCorrelationColor(stock.correlation || 0)}
|
||||
size="sm"
|
||||
>
|
||||
{Math.round((stock.correlation || 0) * 100)}%
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box borderTop="1px solid" borderColor={dividerColor} />
|
||||
|
||||
{/* 关联原因 */}
|
||||
{relationDesc && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={textSecondary} mb={1}>
|
||||
关联原因:
|
||||
</Text>
|
||||
<Collapse in={isExpanded} startingHeight={40}>
|
||||
<Text fontSize="sm" color={nameColor} lineHeight="1.6">
|
||||
{relationDesc}(AI合成)
|
||||
</Text>
|
||||
</Collapse>
|
||||
{needTruncate && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
colorScheme="blue"
|
||||
onClick={() => toggleExpand(stockId)}
|
||||
mt={1}
|
||||
>
|
||||
{isExpanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HistoricalEvents;
|
||||
export default HistoricalEvents;
|
||||
|
||||
@@ -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