Compare commits
58 Commits
f05daa3a78
...
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 | ||
|
|
7ae4bc418f | ||
|
|
0110dc2fdc | ||
|
|
e7e2b3bb11 | ||
|
|
e22a39c5cd | ||
|
|
3b8b749eb1 | ||
|
|
571d5e68bc | ||
|
|
933932b86d | ||
|
|
fc251ede05 | ||
|
|
57c4c3c959 | ||
|
|
e1e82555bf | ||
|
|
b44a0ccd39 | ||
|
|
2d936ca1c7 | ||
|
|
14db374820 | ||
|
|
db472620f3 | ||
|
|
37d98203a3 | ||
|
|
2420ff45a4 | ||
|
|
adaebbf800 | ||
|
|
9fd9fcb731 | ||
|
|
c372832f1f | ||
|
|
5d8ad5e442 |
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",
|
||||
@@ -132,7 +132,6 @@
|
||||
"prettier": "2.2.1",
|
||||
"react-error-overlay": "6.0.9",
|
||||
"sharp": "^0.34.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-node": "^10.9.2",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"yn": "^5.1.0"
|
||||
|
||||
@@ -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,17 +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} onMouseLeave={onHighFreqClose}>
|
||||
<MenuList
|
||||
minW="260px"
|
||||
p={2}
|
||||
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||
onMouseLeave={highFreqMenu.handleMouseLeave}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
|
||||
navigate('/community');
|
||||
highFreqMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
@@ -95,6 +102,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
|
||||
navigate('/concepts');
|
||||
highFreqMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||
@@ -111,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"
|
||||
@@ -122,14 +130,23 @@ 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} onMouseLeave={onMarketReviewClose}>
|
||||
<MenuList
|
||||
minW="260px"
|
||||
p={2}
|
||||
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||
onMouseLeave={marketReviewMenu.handleMouseLeave}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/limit-analyse')}
|
||||
onClick={() => {
|
||||
navigate('/limit-analyse');
|
||||
marketReviewMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/limit-analyse') ? '3px solid' : 'none'}
|
||||
@@ -142,7 +159,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/stocks')}
|
||||
onClick={() => {
|
||||
navigate('/stocks');
|
||||
marketReviewMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/stocks') ? '3px solid' : 'none'}
|
||||
@@ -155,7 +175,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/trading-simulation')}
|
||||
onClick={() => {
|
||||
navigate('/trading-simulation');
|
||||
marketReviewMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/trading-simulation') ? '3px solid' : 'none'}
|
||||
@@ -171,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} onMouseLeave={onAgentCommunityClose}>
|
||||
<MenuList
|
||||
minW="300px"
|
||||
p={4}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
cursor="not-allowed"
|
||||
@@ -200,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} onMouseLeave={onContactUsClose}>
|
||||
<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,22 +41,31 @@ 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} onMouseLeave={onClose}>
|
||||
<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={() => navigate('/community')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/community');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
@@ -69,7 +78,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/concepts')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/concepts');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
@@ -84,7 +96,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
{/* 行情复盘组 */}
|
||||
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">行情复盘</Text>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/limit-analyse')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/limit-analyse');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
@@ -94,7 +109,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/stocks')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/stocks');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
@@ -104,7 +122,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/trading-simulation')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/trading-simulation');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
|
||||
@@ -57,7 +57,7 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
|
||||
>
|
||||
个人中心
|
||||
</MenuButton>
|
||||
<MenuList onMouseEnter={onOpen} onMouseLeave={onClose}>
|
||||
<MenuList onMouseEnter={onOpen}>
|
||||
{/* 用户信息区 */}
|
||||
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
|
||||
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
|
||||
@@ -71,24 +71,36 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
|
||||
</Box>
|
||||
|
||||
{/* 前往个人中心 */}
|
||||
<MenuItem icon={<FiHome />} onClick={() => navigate('/home/center')}>
|
||||
<MenuItem icon={<FiHome />} onClick={() => {
|
||||
onClose(); // 先关闭菜单
|
||||
navigate('/home/center');
|
||||
}}>
|
||||
前往个人中心
|
||||
</MenuItem>
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* 账户管理组 */}
|
||||
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
|
||||
<MenuItem icon={<FiUser />} onClick={() => {
|
||||
onClose(); // 先关闭菜单
|
||||
navigate('/home/profile');
|
||||
}}>
|
||||
个人资料
|
||||
</MenuItem>
|
||||
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
|
||||
<MenuItem icon={<FiSettings />} onClick={() => {
|
||||
onClose(); // 先关闭菜单
|
||||
navigate('/home/settings');
|
||||
}}>
|
||||
账户设置
|
||||
</MenuItem>
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* 功能入口组 */}
|
||||
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
|
||||
<MenuItem icon={<FaCrown />} onClick={() => {
|
||||
onClose(); // 先关闭菜单
|
||||
navigate('/home/pages/account/subscription');
|
||||
}}>
|
||||
订阅管理
|
||||
</MenuItem>
|
||||
|
||||
|
||||
168
src/components/StockChangeIndicators.js
Normal file
168
src/components/StockChangeIndicators.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// src/components/StockChangeIndicators.js
|
||||
// 股票涨跌幅指标组件(通用)
|
||||
|
||||
import React from 'react';
|
||||
import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 股票涨跌幅指标组件(3分天下布局)
|
||||
* @param {Object} props
|
||||
* @param {number} props.avgChange - 平均涨跌幅
|
||||
* @param {number} props.maxChange - 最大涨跌幅
|
||||
* @param {number} props.weekChange - 周涨跌幅
|
||||
*/
|
||||
const StockChangeIndicators = ({
|
||||
avgChange,
|
||||
maxChange,
|
||||
weekChange,
|
||||
}) => {
|
||||
// 根据涨跌幅获取数字颜色(多颜色梯度:5级分级)
|
||||
const getNumberColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.700', 'gray.400');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色
|
||||
if (value === 0) {
|
||||
return 'gray.700';
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨:红色系 → 橙色系
|
||||
if (absValue >= 10) return 'red.900'; // 10%以上:最深红
|
||||
if (absValue >= 5) return 'red.700'; // 5-10%:深红
|
||||
if (absValue >= 3) return 'red.500'; // 3-5%:中红
|
||||
if (absValue >= 1) return 'orange.600'; // 1-3%:橙色
|
||||
return 'orange.400'; // 0-1%:浅橙
|
||||
} else {
|
||||
// 下跌:绿色系 → 青色系
|
||||
if (absValue >= 10) return 'green.900'; // -10%以下:最深绿
|
||||
if (absValue >= 5) return 'green.700'; // -10% ~ -5%:深绿
|
||||
if (absValue >= 3) return 'green.500'; // -5% ~ -3%:中绿
|
||||
if (absValue >= 1) return 'teal.600'; // -3% ~ -1%:青色
|
||||
return 'teal.400'; // -1% ~ 0%:浅青
|
||||
}
|
||||
};
|
||||
|
||||
// 根据涨跌幅获取背景色(永远比文字色浅)
|
||||
const getBgColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.50', 'gray.800');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色背景
|
||||
if (value === 0) {
|
||||
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');
|
||||
if (absValue >= 1) return useColorModeValue('teal.50', 'teal.900');
|
||||
return useColorModeValue('teal.50', 'teal.900');
|
||||
}
|
||||
};
|
||||
|
||||
// 根据涨跌幅获取边框色(比背景深,比文字浅)
|
||||
const getBorderColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.200', 'gray.700');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色边框
|
||||
if (value === 0) {
|
||||
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.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.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
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个指标
|
||||
const renderIndicator = (label, value) => {
|
||||
if (value == null) return null;
|
||||
|
||||
const sign = value > 0 ? '+' : '';
|
||||
// 0值显示为 "0",其他值显示一位小数
|
||||
const numStr = value === 0 ? '0' : Math.abs(value).toFixed(1);
|
||||
const numberColor = getNumberColor(value);
|
||||
const bgColor = getBgColor(value);
|
||||
const borderColor = getBorderColor(value);
|
||||
const labelColor = useColorModeValue('gray.700', 'gray.400');
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgColor}
|
||||
borderWidth="2px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontSize="xs" lineHeight="1.2">
|
||||
<Text as="span" color={labelColor}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text as="span" color={labelColor}>
|
||||
{sign}
|
||||
</Text>
|
||||
<Text as="span" fontWeight="bold" color={numberColor} fontSize="sm">
|
||||
{value < 0 ? '-' : ''}{numStr}
|
||||
</Text>
|
||||
<Text as="span" color={labelColor}>
|
||||
%
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果没有任何数据,不渲染
|
||||
if (avgChange == null && maxChange == null && weekChange == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex width="100%" justify="space-between" align="center" gap={1}>
|
||||
{renderIndicator('平均 ', avgChange)}
|
||||
{renderIndicator('最大 ', maxChange)}
|
||||
{renderIndicator('周涨 ', weekChange)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockChangeIndicators;
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,12 @@ async function startApp() {
|
||||
// Render the app with Router wrapper
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router>
|
||||
<Router
|
||||
future={{
|
||||
// v7_startTransition: true, // 禁用:导致路由切换延迟2秒,影响用户体验
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</Router>
|
||||
</React.StrictMode>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// src/layouts/Auth.js
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
|
||||
// 导入认证相关页面
|
||||
import SignInIllustration from '../views/Authentication/SignIn/SignInIllustration';
|
||||
import SignUpIllustration from '../views/Authentication/SignUp/SignUpIllustration';
|
||||
|
||||
// 认证路由组件 - 已登录用户不能访问登录页
|
||||
const AuthRoute = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// 加载中不做跳转
|
||||
if (isLoading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// 已登录用户跳转到首页
|
||||
if (isAuthenticated) {
|
||||
// 检查是否有记录的重定向路径
|
||||
const redirectPath = localStorage.getItem('redirectPath');
|
||||
if (redirectPath && redirectPath !== '/auth/signin' && redirectPath !== '/auth/sign-up') {
|
||||
localStorage.removeItem('redirectPath');
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
return <Navigate to="/home" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default function Auth() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Box minH="100vh">
|
||||
<Routes>
|
||||
{/* 登录页面 */}
|
||||
<Route
|
||||
path="/signin"
|
||||
element={
|
||||
<AuthRoute>
|
||||
<SignInIllustration />
|
||||
</AuthRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 注册页面 */}
|
||||
<Route
|
||||
path="/sign-up"
|
||||
element={
|
||||
<AuthRoute>
|
||||
<SignUpIllustration />
|
||||
</AuthRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 默认重定向到登录页 */}
|
||||
<Route path="/" element={<Navigate to="/auth/signin" replace />} />
|
||||
<Route path="*" element={<Navigate to="/auth/signin" replace />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// src/layouts/Home.js
|
||||
import React from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,此处不再导入
|
||||
// import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
|
||||
// 导入页面组件
|
||||
import HomePage from "views/Home/HomePage";
|
||||
import ProfilePage from "views/Profile/ProfilePage";
|
||||
import SettingsPage from "views/Settings/SettingsPage";
|
||||
import CenterDashboard from "views/Dashboard/Center";
|
||||
import Subscription from "views/Pages/Account/Subscription";
|
||||
|
||||
// 懒加载隐私政策、用户协议、微信回调和模拟交易页面
|
||||
const PrivacyPolicy = React.lazy(() => import("views/Pages/PrivacyPolicy"));
|
||||
const UserAgreement = React.lazy(() => import("views/Pages/UserAgreement"));
|
||||
const WechatCallback = React.lazy(() => import("views/Pages/WechatCallback"));
|
||||
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
||||
|
||||
// 导入保护路由组件
|
||||
import ProtectedRoute from "../components/ProtectedRoute";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Box minH="100vh">
|
||||
{/* 导航栏已由 MainLayout 提供,此处不再渲染 */}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<Box>
|
||||
<Routes>
|
||||
{/* 首页默认路由 */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route
|
||||
path="/center"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CenterDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 需要登录保护的页面 */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 订阅管理页面 */}
|
||||
<Route
|
||||
path="/pages/account/subscription"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Subscription />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 模拟盘交易页面 */}
|
||||
<Route
|
||||
path="/trading-simulation"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TradingSimulation />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 隐私政策页面 - 无需登录 */}
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||
|
||||
{/* 用户协议页面 - 无需登录 */}
|
||||
<Route path="/user-agreement" element={<UserAgreement />} />
|
||||
|
||||
{/* 微信授权回调页面 - 无需登录 */}
|
||||
<Route path="/wechat-callback" element={<WechatCallback />} />
|
||||
|
||||
{/* 其他可能的路由 */}
|
||||
<Route path="*" element={<HomePage />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
// src/layouts/MainLayout.js
|
||||
// 主布局组件 - 为所有带导航栏的页面提供统一布局
|
||||
import React, { memo } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import React, { memo, Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
import AppFooter from "./AppFooter";
|
||||
import BackToTopButton from "./components/BackToTopButton";
|
||||
import PageTransitionWrapper from "./components/PageTransitionWrapper";
|
||||
import { ANIMATION_CONFIG, BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import PageLoader from "../components/Loading/PageLoader";
|
||||
import { BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
|
||||
|
||||
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
|
||||
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
|
||||
@@ -20,38 +21,27 @@ const MemoizedAppFooter = memo(AppFooter);
|
||||
* 使用 <Outlet /> 渲染子路由,确保导航栏只渲染一次
|
||||
* 页面切换时只有 Outlet 内的内容会更新,导航栏保持不变
|
||||
*
|
||||
* 架构优化(2024-10-30):
|
||||
* - ✅ P0: 组件拆分 - BackToTopButton 独立复用(37行 → 独立文件)
|
||||
* - ✅ P0: 组件拆分 - PageTransitionWrapper 封装复杂逻辑(18行 → 独立文件)
|
||||
* - ✅ P0: 性能优化 - 使用 memo 避免导航栏和页脚重新渲染(性能提升 50%+)
|
||||
* - ✅ P1: 性能优化 - 使用 RAF 节流滚动事件(性能提升 80%)
|
||||
* - ✅ P1: 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
|
||||
* - ✅ P2: 用户体验 - 页面过渡动画(framer-motion)
|
||||
* - ✅ P2: 配置集中 - layoutConfig 统一管理配置常量
|
||||
* - ✅ P3: 用户体验 - 返回顶部按钮(滚动 > 300px 显示)
|
||||
*
|
||||
* 代码优化成果:
|
||||
* - 代码量:115 行 → 42 行(减少 63%)
|
||||
* - 复杂度:内联组件 → 独立模块
|
||||
* - 可维护性:配置分散 → 集中管理
|
||||
* - 可复用性:耦合 → 解耦
|
||||
* 架构优化:
|
||||
* - ✅ 组件拆分 - BackToTopButton 独立复用
|
||||
* - ✅ 性能优化 - 使用 memo 避免导航栏和页脚重新渲染
|
||||
* - ✅ 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
|
||||
* - ✅ 懒加载支持 - Suspense 统一处理懒加载
|
||||
* - ✅ 布局简化 - 直接内联容器逻辑,减少嵌套层级
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<Box minH="100vh" display="flex" flexDirection="column">
|
||||
{/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedHomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - 包含动画、错误边界、懒加载 */}
|
||||
<PageTransitionWrapper
|
||||
location={location}
|
||||
animationConfig={ANIMATION_CONFIG.default}
|
||||
loaderMessage="页面加载中..."
|
||||
>
|
||||
<Outlet />
|
||||
</PageTransitionWrapper>
|
||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
||||
<Box flex="1" w="100%" position="relative" overflow="hidden">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
|
||||
{/* 页脚 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedAppFooter />
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// src/layouts/components/PageTransitionWrapper.js
|
||||
import React, { Suspense, memo } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import ErrorBoundary from '../../components/ErrorBoundary';
|
||||
import PageLoader from '../../components/Loading/PageLoader';
|
||||
|
||||
// 创建 motion 包裹的 Box 组件
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
/**
|
||||
* 页面过渡动画包裹组件
|
||||
*
|
||||
* 功能:
|
||||
* - 页面切换时的过渡动画(AnimatePresence)
|
||||
* - 错误边界隔离(ErrorBoundary)
|
||||
* - 懒加载支持(Suspense)
|
||||
*
|
||||
* 优化:
|
||||
* - ✅ 使用 memo 避免不必要的重新渲染
|
||||
* - ✅ 支持自定义动画配置
|
||||
* - ✅ 错误隔离,确保导航栏不受影响
|
||||
*
|
||||
* @param {React.ReactNode} children - 要渲染的子组件(通常是 <Outlet />)
|
||||
* @param {object} location - 路由位置对象(用于动画 key)
|
||||
* @param {object} animationConfig - 自定义动画配置
|
||||
* @param {string} loaderMessage - 加载时显示的消息
|
||||
*/
|
||||
const PageTransitionWrapper = memo(({
|
||||
children,
|
||||
location,
|
||||
animationConfig = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 }
|
||||
},
|
||||
loaderMessage = '页面加载中...'
|
||||
}) => {
|
||||
return (
|
||||
<Box flex="1" position="relative" overflow="hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<MotionBox
|
||||
key={location.pathname}
|
||||
initial={animationConfig.initial}
|
||||
animate={animationConfig.animate}
|
||||
exit={animationConfig.exit}
|
||||
transition={animationConfig.transition}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{/* 错误边界:隔离页面错误,确保导航栏仍可用 */}
|
||||
<ErrorBoundary>
|
||||
{/* Suspense:支持 React.lazy() 懒加载 */}
|
||||
<Suspense fallback={<PageLoader message={loaderMessage} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
PageTransitionWrapper.displayName = 'PageTransitionWrapper';
|
||||
|
||||
export default PageTransitionWrapper;
|
||||
@@ -609,7 +609,7 @@ function generateEventDescription(industry, importance, seed) {
|
||||
return impacts[importance] + details[seed % details.length];
|
||||
}
|
||||
|
||||
// 生成关键词
|
||||
// 生成关键词(对象数组格式,包含完整信息)
|
||||
function generateKeywords(industry, seed) {
|
||||
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
|
||||
const industryKeywords = {
|
||||
@@ -620,12 +620,100 @@ function generateKeywords(industry, seed) {
|
||||
'消费': ['白酒', '食品', '家电', '零售', '免税'],
|
||||
};
|
||||
|
||||
const keywords = [
|
||||
// 概念描述模板
|
||||
const descriptionTemplates = {
|
||||
'政策': '政策性利好消息对相关行业和板块产生积极影响,带动市场情绪和资金流向。',
|
||||
'利好': '市场积极因素推动相关板块上涨,投资者情绪乐观,资金持续流入。',
|
||||
'业绩': '公司业绩超预期增长,盈利能力提升,市场给予更高估值预期。',
|
||||
'涨停': '强势涨停板显示市场热度,短期资金追捧,板块效应明显。',
|
||||
'龙头': '行业龙头企业具备竞争优势,市场地位稳固,带动板块走势。',
|
||||
'突破': '技术面或基本面出现重大突破,打开上涨空间,吸引资金关注。',
|
||||
'合作': '战略合作为公司带来新的增长点,业务协同效应显著。',
|
||||
'投资': '重大投资项目落地,长期发展空间广阔,市场预期良好。',
|
||||
'芯片': '国产芯片替代加速,自主可控需求强烈,政策和资金支持力度大。',
|
||||
'晶圆': '晶圆产能紧张,供需关系改善,相关企业盈利能力提升。',
|
||||
'封测': '封测环节景气度上行,订单饱满,产能利用率提高。',
|
||||
'AI芯片': '人工智能快速发展带动AI芯片需求爆发,市场空间巨大。',
|
||||
'国产替代': '国产替代进程加速,政策扶持力度大,进口依赖度降低。',
|
||||
'电池': '新能源汽车渗透率提升,动力电池需求旺盛,技术迭代加快。',
|
||||
'光伏': '光伏装机量快速增长,成本持续下降,行业景气度维持高位。',
|
||||
'储能': '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。',
|
||||
'新能源车': '新能源汽车销量高增长,渗透率持续提升,产业链受益明显。',
|
||||
'锂电': '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。',
|
||||
'大模型': '大语言模型技术突破,商业化进程加速,应用场景广阔。',
|
||||
'AI应用': '人工智能应用落地加速,垂直领域渗透率提升,市场空间巨大。',
|
||||
'算力': '算力需求持续增长,数据中心建设加速,相关产业链受益。',
|
||||
'数据': '数据要素市场化改革推进,数据价值释放,相关企业盈利模式清晰。',
|
||||
'机器学习': '机器学习技术成熟,应用场景丰富,商业价值逐步显现。',
|
||||
'创新药': '创新药研发管线丰富,商业化进程顺利,市场给予高估值。',
|
||||
'CRO': 'CRO行业高景气,订单充足,盈利能力稳定增长。',
|
||||
'医疗器械': '医疗器械国产化率提升,技术创新加快,市场份额扩大。',
|
||||
'生物制药': '生物制药技术突破,产品管线丰富,商业化前景广阔。',
|
||||
'仿制药': '仿制药集采常态化,质量优势企业市场份额提升。',
|
||||
'白酒': '白酒消费升级,高端产品量价齐升,龙头企业护城河深厚。',
|
||||
'食品': '食品饮料需求稳定,品牌力强的企业市场份额持续提升。',
|
||||
'家电': '家电消费需求回暖,智能化升级带动产品结构优化。',
|
||||
'零售': '零售行业数字化转型,线上线下融合,运营效率提升。',
|
||||
'免税': '免税政策优化,消费回流加速,行业景气度上行。'
|
||||
};
|
||||
|
||||
const keywordNames = [
|
||||
...commonKeywords.slice(seed % 3, seed % 3 + 3),
|
||||
...(industryKeywords[industry] || []).slice(0, 2)
|
||||
];
|
||||
].slice(0, 5);
|
||||
|
||||
return keywords.slice(0, 5);
|
||||
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
|
||||
|
||||
// 生成历史触发时间(3-5个历史日期)
|
||||
const generateHappenedTimes = (baseSeed) => {
|
||||
const times = [];
|
||||
const count = 3 + (baseSeed % 3); // 3-5个时间点
|
||||
for (let i = 0; i < count; i++) {
|
||||
const daysAgo = 30 + (baseSeed * 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(); // 降序排列
|
||||
};
|
||||
|
||||
// 生成核心相关股票(4-6只)
|
||||
const generateRelatedStocks = (conceptName, baseSeed) => {
|
||||
const stockCount = 4 + (baseSeed % 3); // 4-6只股票
|
||||
const selectedStocks = [];
|
||||
|
||||
for (let i = 0; i < stockCount && i < stockPool.length; i++) {
|
||||
const stockIndex = (baseSeed + i * 7) % stockPool.length;
|
||||
const stock = stockPool[stockIndex];
|
||||
selectedStocks.push({
|
||||
stock_name: stock.stock_name,
|
||||
stock_code: stock.stock_code,
|
||||
reason: relationDescTemplates[(baseSeed + i) % relationDescTemplates.length],
|
||||
change_pct: (Math.random() * 15 - 5).toFixed(2) // -5% ~ +10%
|
||||
});
|
||||
}
|
||||
|
||||
return selectedStocks;
|
||||
};
|
||||
|
||||
// 将字符串数组转换为对象数组,匹配真实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) // 核心相关股票
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -645,12 +733,12 @@ export function generateMockEvents(params = {}) {
|
||||
stock_code = '',
|
||||
} = params;
|
||||
|
||||
// 生成100个事件用于测试
|
||||
const totalEvents = 100;
|
||||
// 生成200个事件用于测试(足够测试分页功能)
|
||||
const totalEvents = 200;
|
||||
const allEvents = [];
|
||||
|
||||
const importanceLevels = ['S', 'A', 'B', 'C'];
|
||||
const baseDate = new Date('2025-01-15');
|
||||
const baseDate = new Date(); // 使用当前日期作为基准
|
||||
|
||||
for (let i = 0; i < totalEvents; i++) {
|
||||
const industry = industries[i % industries.length];
|
||||
@@ -665,27 +753,87 @@ 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) => {
|
||||
const basePrice = 10 + (seed % 90); // 基础价格 10-100
|
||||
const trend = [];
|
||||
|
||||
// 前一天(5个数据点)
|
||||
let price = basePrice;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
price = price + (Math.random() - 0.5) * 0.5;
|
||||
trend.push(parseFloat(price.toFixed(2)));
|
||||
}
|
||||
|
||||
// 当天(5个数据点)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
price = price + (Math.random() - 0.4) * 0.8; // 轻微上涨趋势
|
||||
trend.push(parseFloat(price.toFixed(2)));
|
||||
}
|
||||
|
||||
// 后一天(5个数据点)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
price = price + (Math.random() - 0.45) * 1.0;
|
||||
trend.push(parseFloat(price.toFixed(2)));
|
||||
}
|
||||
|
||||
return trend;
|
||||
};
|
||||
|
||||
// 为每个事件随机选择2-5个相关股票
|
||||
const relatedStockCount = 2 + (i % 4); // 2-5个股票
|
||||
const relatedStocks = [];
|
||||
const industryStocks = stockPool.filter(s => s.industry === industry);
|
||||
const addedStockCodes = new Set(); // 用于去重
|
||||
|
||||
// 优先选择同行业股票
|
||||
if (industryStocks.length > 0) {
|
||||
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
|
||||
relatedStocks.push(industryStocks[j % industryStocks.length].stock_code);
|
||||
const stock = industryStocks[j % industryStocks.length];
|
||||
if (!addedStockCodes.has(stock.stock_code)) {
|
||||
const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4%
|
||||
const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7%
|
||||
|
||||
relatedStocks.push({
|
||||
stock_name: stock.stock_name,
|
||||
stock_code: stock.stock_code,
|
||||
relation_desc: relationDescTemplates[(i + j) % relationDescTemplates.length],
|
||||
daily_change: dailyChange,
|
||||
week_change: weekChange,
|
||||
price_trend: generatePriceTrend(i * 100 + j)
|
||||
});
|
||||
addedStockCodes.add(stock.stock_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果同行业股票不够,从整个 stockPool 中补充
|
||||
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
|
||||
const randomStock = stockPool[relatedStocks.length % stockPool.length];
|
||||
if (!relatedStocks.includes(randomStock.stock_code)) {
|
||||
relatedStocks.push(randomStock.stock_code);
|
||||
let poolIndex = 0;
|
||||
while (relatedStocks.length < relatedStockCount && poolIndex < stockPool.length) {
|
||||
const randomStock = stockPool[poolIndex % stockPool.length];
|
||||
if (!addedStockCodes.has(randomStock.stock_code)) {
|
||||
const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4%
|
||||
const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7%
|
||||
|
||||
relatedStocks.push({
|
||||
stock_name: randomStock.stock_name,
|
||||
stock_code: randomStock.stock_code,
|
||||
relation_desc: relationDescTemplates[(i + poolIndex) % relationDescTemplates.length],
|
||||
daily_change: dailyChange,
|
||||
week_change: weekChange,
|
||||
price_trend: generatePriceTrend(i * 100 + poolIndex)
|
||||
});
|
||||
addedStockCodes.add(randomStock.stock_code);
|
||||
}
|
||||
poolIndex++;
|
||||
}
|
||||
|
||||
// 计算交易日期(模拟下一交易日,这里简单地加1天)
|
||||
const tradingDate = new Date(createdAt);
|
||||
tradingDate.setDate(tradingDate.getDate() + 1);
|
||||
|
||||
allEvents.push({
|
||||
id: i + 1,
|
||||
title: generateEventTitle(industry, i),
|
||||
@@ -696,14 +844,18 @@ export function generateMockEvents(params = {}) {
|
||||
status: 'published',
|
||||
created_at: createdAt.toISOString(),
|
||||
updated_at: createdAt.toISOString(),
|
||||
trading_date: tradingDate.toISOString().split('T')[0], // YYYY-MM-DD 格式
|
||||
hot_score: hotScore,
|
||||
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,
|
||||
related_stocks: relatedStocks, // 添加相关股票列表
|
||||
historical_events: generateHistoricalEvents(industry, i), // 添加历史事件
|
||||
transmission_chain: generateTransmissionChain(industry, i), // 添加传导链数据
|
||||
});
|
||||
}
|
||||
|
||||
@@ -816,3 +968,213 @@ export function generatePopularKeywords(limit = 20) {
|
||||
trend: index % 3 === 0 ? 'up' : index % 3 === 1 ? 'down' : 'stable',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成历史事件对比数据
|
||||
* @param {string} industry - 行业
|
||||
* @param {number} index - 索引
|
||||
* @returns {Array} - 历史事件列表
|
||||
*/
|
||||
function generateHistoricalEvents(industry, index) {
|
||||
const historicalCount = 3 + (index % 3); // 3-5个历史事件
|
||||
const historical = [];
|
||||
const baseDate = new Date();
|
||||
|
||||
for (let i = 0; i < historicalCount; i++) {
|
||||
// 生成过去1-6个月的随机时间
|
||||
const monthsAgo = 1 + Math.floor(Math.random() * 6);
|
||||
const eventDate = new Date(baseDate);
|
||||
eventDate.setMonth(eventDate.getMonth() - monthsAgo);
|
||||
|
||||
const similarityScore = 0.6 + Math.random() * 0.35; // 60%-95%相似度
|
||||
|
||||
historical.push({
|
||||
id: `hist_${industry}_${index}_${i}`,
|
||||
title: generateEventTitle(industry, i + index * 10),
|
||||
created_at: eventDate.toISOString(),
|
||||
related_avg_chg: parseFloat((Math.random() * 15 - 3).toFixed(2)),
|
||||
related_max_chg: parseFloat((Math.random() * 25).toFixed(2)),
|
||||
similarity_score: parseFloat(similarityScore.toFixed(2)),
|
||||
view_count: Math.floor(Math.random() * 3000) + 500,
|
||||
});
|
||||
}
|
||||
|
||||
// 按相似度排序
|
||||
historical.sort((a, b) => b.similarity_score - a.similarity_score);
|
||||
return historical;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成传导链数据
|
||||
* @param {string} industry - 行业
|
||||
* @param {number} index - 索引
|
||||
* @returns {Object} - 传导链数据 { nodes, edges }
|
||||
*/
|
||||
function generateTransmissionChain(industry, index) {
|
||||
const nodeTypes = ['event', 'industry', 'company', 'policy', 'technology', 'market'];
|
||||
const impactTypes = ['positive', 'negative', 'neutral', 'mixed'];
|
||||
const strengthLevels = ['strong', 'medium', 'weak'];
|
||||
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
|
||||
// 主事件节点
|
||||
nodes.push({
|
||||
id: 1,
|
||||
name: '主事件',
|
||||
type: 'event',
|
||||
extra: { is_main_event: true, description: `${industry}重要事件` }
|
||||
});
|
||||
|
||||
// 生成5-8个相关节点
|
||||
const nodeCount = 5 + (index % 4);
|
||||
for (let i = 2; i <= nodeCount; i++) {
|
||||
const nodeType = nodeTypes[i % nodeTypes.length];
|
||||
const industryStock = stockPool.find(s => s.industry === industry);
|
||||
|
||||
let nodeName;
|
||||
if (nodeType === 'company' && industryStock) {
|
||||
nodeName = industryStock.name;
|
||||
} else if (nodeType === 'industry') {
|
||||
nodeName = `${industry}产业`;
|
||||
} else if (nodeType === 'policy') {
|
||||
nodeName = '相关政策';
|
||||
} else if (nodeType === 'technology') {
|
||||
nodeName = '技术创新';
|
||||
} else if (nodeType === 'market') {
|
||||
nodeName = '市场需求';
|
||||
} else {
|
||||
nodeName = `节点${i}`;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: i,
|
||||
name: nodeName,
|
||||
type: nodeType,
|
||||
extra: { description: `${nodeName}相关信息` }
|
||||
});
|
||||
|
||||
// 创建与主事件或其他节点的连接
|
||||
const targetId = i === 2 ? 1 : Math.max(1, Math.floor(Math.random() * (i - 1)) + 1);
|
||||
edges.push({
|
||||
source: targetId,
|
||||
target: i,
|
||||
impact: impactTypes[i % impactTypes.length],
|
||||
strength: strengthLevels[i % strengthLevels.length],
|
||||
description: `传导路径${i}`
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成动态新闻事件(实时要闻·动态追踪专用)
|
||||
* @param {Object} timeRange - 时间范围 { startTime, endTime }
|
||||
* @param {number} count - 生成事件数量,默认30条
|
||||
* @returns {Array} - 事件列表
|
||||
*/
|
||||
export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
const events = [];
|
||||
const importanceLevels = ['S', 'A', 'B', 'C'];
|
||||
|
||||
// 如果没有提供时间范围,默认生成最近24小时的事件
|
||||
let startTime, endTime;
|
||||
if (timeRange) {
|
||||
startTime = new Date(timeRange.startTime);
|
||||
endTime = new Date(timeRange.endTime);
|
||||
} else {
|
||||
endTime = new Date();
|
||||
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); // 24小时前
|
||||
}
|
||||
|
||||
// 计算时间跨度(毫秒)
|
||||
const timeSpan = endTime.getTime() - startTime.getTime();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const industry = industries[i % industries.length];
|
||||
const imp = importanceLevels[i % importanceLevels.length];
|
||||
const eventType = eventTypes[i % eventTypes.length];
|
||||
|
||||
// 在时间范围内随机生成事件时间
|
||||
const randomOffset = Math.random() * timeSpan;
|
||||
const createdAt = new Date(startTime.getTime() + randomOffset);
|
||||
|
||||
// 生成随机热度和收益率
|
||||
const hotScore = Math.max(60, 100 - i * 1.2); // 动态新闻热度更高
|
||||
const relatedAvgChg = (Math.random() * 15 - 3).toFixed(2); // -3% 到 12%
|
||||
const relatedMaxChg = (Math.random() * 25).toFixed(2); // 0% 到 25%
|
||||
const relatedWeekChg = (Math.random() * 30 - 10).toFixed(2); // -10% 到 20%
|
||||
|
||||
// 为每个事件随机选择2-5个相关股票(完整对象)
|
||||
const relatedStockCount = 2 + (i % 4);
|
||||
const relatedStocks = [];
|
||||
const industryStocks = stockPool.filter(s => s.industry === industry);
|
||||
const relationDescriptions = [
|
||||
'直接受益标的',
|
||||
'产业链上游企业',
|
||||
'产业链下游企业',
|
||||
'行业龙头企业',
|
||||
'潜在受益标的',
|
||||
'概念相关个股'
|
||||
];
|
||||
|
||||
// 优先选择同行业股票
|
||||
if (industryStocks.length > 0) {
|
||||
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
|
||||
const stock = industryStocks[j % industryStocks.length];
|
||||
relatedStocks.push({
|
||||
stock_code: stock.stock_code,
|
||||
stock_name: stock.name,
|
||||
relation_desc: relationDescriptions[j % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果同行业股票不够,从整个 stockPool 中补充
|
||||
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
|
||||
const randomStock = stockPool[relatedStocks.length % stockPool.length];
|
||||
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
|
||||
relatedStocks.push({
|
||||
stock_code: randomStock.stock_code,
|
||||
stock_name: randomStock.name,
|
||||
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
events.push({
|
||||
id: `dynamic_${i + 1}`,
|
||||
title: generateEventTitle(industry, i),
|
||||
description: generateEventDescription(industry, imp, i),
|
||||
content: generateEventDescription(industry, imp, i),
|
||||
event_type: eventType,
|
||||
importance: imp,
|
||||
status: 'published',
|
||||
created_at: createdAt.toISOString(),
|
||||
updated_at: createdAt.toISOString(),
|
||||
hot_score: hotScore,
|
||||
view_count: Math.floor(Math.random() * 5000) + 1000, // 1000-6000 浏览量
|
||||
follower_count: Math.floor(Math.random() * 500) + 50, // 50-550 关注数
|
||||
post_count: Math.floor(Math.random() * 100) + 10, // 10-110 帖子数
|
||||
related_avg_chg: parseFloat(relatedAvgChg),
|
||||
related_max_chg: parseFloat(relatedMaxChg),
|
||||
related_week_chg: parseFloat(relatedWeekChg),
|
||||
keywords: generateKeywords(industry, i),
|
||||
is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成
|
||||
industry: industry,
|
||||
related_stocks: relatedStocks,
|
||||
historical_events: generateHistoricalEvents(industry, i),
|
||||
transmission_chain: generateTransmissionChain(industry, i),
|
||||
creator: {
|
||||
username: authorPool[i % authorPool.length],
|
||||
avatar_url: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间倒序排序(最新的在前)
|
||||
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// 事件相关的 Mock API Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords } from '../data/events';
|
||||
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));
|
||||
@@ -111,6 +112,47 @@ export const eventHandlers = [
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取动态新闻(实时要闻·动态追踪专用)
|
||||
http.get('/api/events/dynamic-news', async ({ request }) => {
|
||||
await delay(400);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const count = parseInt(url.searchParams.get('count') || '30');
|
||||
const startTime = url.searchParams.get('start_time');
|
||||
const endTime = url.searchParams.get('end_time');
|
||||
|
||||
console.log('[Mock] 获取动态新闻, count:', count, 'startTime:', startTime, 'endTime:', endTime);
|
||||
|
||||
try {
|
||||
let timeRange = null;
|
||||
if (startTime && endTime) {
|
||||
timeRange = {
|
||||
startTime: new Date(startTime),
|
||||
endTime: new Date(endTime)
|
||||
};
|
||||
}
|
||||
|
||||
const events = generateDynamicNewsEvents(timeRange, count);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: events,
|
||||
total: events.length,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取动态新闻失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取动态新闻失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// ==================== 事件详情相关 ====================
|
||||
|
||||
// 获取事件相关股票
|
||||
@@ -142,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);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// src/routes/components/RouteContainer.js
|
||||
// 路由容器组件 - 提供统一的错误边界、加载状态和主题背景
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import { Box, useColorMode } from '@chakra-ui/react';
|
||||
import ErrorBoundary from '@components/ErrorBoundary';
|
||||
import PageLoader from '@components/Loading/PageLoader';
|
||||
|
||||
/**
|
||||
* RouteContainer - 路由容器组件
|
||||
*
|
||||
* 为路由系统提供统一的外层包装,包含:
|
||||
* 1. 主题感知的背景色(深色/浅色模式)
|
||||
* 2. Suspense 懒加载边界(显示加载提示)
|
||||
* 3. ErrorBoundary 错误边界(隔离路由错误)
|
||||
*
|
||||
* 这个组件确保:
|
||||
* - 所有路由页面都有一致的背景色
|
||||
* - 懒加载组件有统一的加载提示
|
||||
* - 单个路由的错误不会导致整个应用崩溃
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {React.ReactNode} props.children - 子组件(通常是 Routes)
|
||||
* @param {string} [props.loadingMessage='加载页面中...'] - 加载提示文本
|
||||
*
|
||||
* @example
|
||||
* <RouteContainer>
|
||||
* <Routes>
|
||||
* <Route path="/" element={<Home />} />
|
||||
* </Routes>
|
||||
* </RouteContainer>
|
||||
*/
|
||||
export function RouteContainer({
|
||||
children,
|
||||
loadingMessage = "加载页面中..."
|
||||
}) {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
bg={colorMode === 'dark' ? 'gray.800' : 'white'}
|
||||
>
|
||||
{/* Suspense 统一处理懒加载组件的加载状态 */}
|
||||
<Suspense fallback={<PageLoader message={loadingMessage} />}>
|
||||
{/* ErrorBoundary 隔离路由错误,防止整个应用崩溃 */}
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/routes/components/index.js
|
||||
// 统一导出所有路由组件
|
||||
|
||||
export { RouteContainer } from './RouteContainer';
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/routes/constants/index.js
|
||||
// 统一导出所有路由常量
|
||||
|
||||
export { LAYOUT_COMPONENTS } from './layoutComponents';
|
||||
export { PROTECTION_WRAPPER_MAP } from './protectionWrappers';
|
||||
export { PROTECTION_MODES } from './protectionModes';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// src/routes/constants/layoutComponents.js
|
||||
// 布局组件映射表
|
||||
|
||||
import Auth from '@layouts/Auth';
|
||||
import HomeLayout from '@layouts/Home';
|
||||
|
||||
/**
|
||||
* 特殊布局组件映射表
|
||||
*
|
||||
* 用于将字符串标识符映射到实际的组件。
|
||||
* 这些是非懒加载的布局组件,在 routeConfig.js 中通过字符串引用。
|
||||
*
|
||||
* @example
|
||||
* // 在 routeConfig.js 中:
|
||||
* {
|
||||
* path: 'auth/*',
|
||||
* component: 'Auth', // 字符串标识符
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* // 通过 LAYOUT_COMPONENTS['Auth'] 获取实际组件
|
||||
*/
|
||||
export const LAYOUT_COMPONENTS = {
|
||||
Auth,
|
||||
HomeLayout,
|
||||
};
|
||||
14
src/routes/constants/protectionModes.js
Normal file
14
src/routes/constants/protectionModes.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// src/routes/constants/protectionModes.js
|
||||
// 路由保护模式常量
|
||||
|
||||
/**
|
||||
* 路由保护模式
|
||||
* - 'modal': 使用 ProtectedRoute (弹窗模式登录)
|
||||
* - 'redirect': 使用 ProtectedRouteRedirect (跳转模式登录)
|
||||
* - 'public': 公开访问,无需登录
|
||||
*/
|
||||
export const PROTECTION_MODES = {
|
||||
MODAL: 'modal',
|
||||
REDIRECT: 'redirect',
|
||||
PUBLIC: 'public',
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import ProtectedRoute from '@components/ProtectedRoute';
|
||||
import ProtectedRouteRedirect from '@components/ProtectedRouteRedirect';
|
||||
import { PROTECTION_MODES } from '../routeConfig';
|
||||
import { PROTECTION_MODES } from './protectionModes';
|
||||
|
||||
/**
|
||||
* 保护模式包装器映射表
|
||||
|
||||
115
src/routes/homeRoutes.js
Normal file
115
src/routes/homeRoutes.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// src/routes/homeRoutes.js
|
||||
// Home 模块子路由配置
|
||||
|
||||
import { lazyComponents } from './lazy-components';
|
||||
import { PROTECTION_MODES } from './constants/protectionModes';
|
||||
|
||||
/**
|
||||
* Home 模块的子路由配置
|
||||
* 这些路由将作为 /home/* 的嵌套路由
|
||||
*
|
||||
* 注意:
|
||||
* - 使用相对路径(不带前导斜杠)
|
||||
* - 空字符串 '' 表示索引路由,匹配 /home
|
||||
* - 这些路由将通过 Outlet 渲染到父路由中
|
||||
*/
|
||||
export const homeRoutes = [
|
||||
// 首页 - /home
|
||||
{
|
||||
path: '',
|
||||
component: lazyComponents.HomePage,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '首页',
|
||||
description: '价值前沿首页'
|
||||
}
|
||||
},
|
||||
|
||||
// 个人中心 - /home/center
|
||||
{
|
||||
path: 'center',
|
||||
component: lazyComponents.CenterDashboard,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '个人中心',
|
||||
description: '用户个人中心'
|
||||
}
|
||||
},
|
||||
|
||||
// 个人资料 - /home/profile
|
||||
{
|
||||
path: 'profile',
|
||||
component: lazyComponents.ProfilePage,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '个人资料',
|
||||
description: '用户个人资料'
|
||||
}
|
||||
},
|
||||
|
||||
// 账户设置 - /home/settings
|
||||
{
|
||||
path: 'settings',
|
||||
component: lazyComponents.SettingsPage,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '账户设置',
|
||||
description: '用户账户设置'
|
||||
}
|
||||
},
|
||||
|
||||
// 订阅管理 - /home/pages/account/subscription
|
||||
{
|
||||
path: 'pages/account/subscription',
|
||||
component: lazyComponents.Subscription,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '订阅管理',
|
||||
description: '管理订阅服务'
|
||||
}
|
||||
},
|
||||
|
||||
// 隐私政策 - /home/privacy-policy
|
||||
{
|
||||
path: 'privacy-policy',
|
||||
component: lazyComponents.PrivacyPolicy,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '隐私政策',
|
||||
description: '隐私保护政策'
|
||||
}
|
||||
},
|
||||
|
||||
// 用户协议 - /home/user-agreement
|
||||
{
|
||||
path: 'user-agreement',
|
||||
component: lazyComponents.UserAgreement,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '用户协议',
|
||||
description: '用户使用协议'
|
||||
}
|
||||
},
|
||||
|
||||
// 微信授权回调 - /home/wechat-callback
|
||||
{
|
||||
path: 'wechat-callback',
|
||||
component: lazyComponents.WechatCallback,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '微信授权',
|
||||
description: '微信授权回调页面'
|
||||
}
|
||||
},
|
||||
|
||||
// 回退路由 - 匹配任何未定义的 /home/* 路径
|
||||
{
|
||||
path: '*',
|
||||
component: lazyComponents.HomePage,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '首页',
|
||||
description: '价值前沿首页'
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -10,9 +10,8 @@ import { getMainLayoutRoutes, getStandaloneRoutes } from './routeConfig';
|
||||
// 布局组件
|
||||
import MainLayout from '@layouts/MainLayout';
|
||||
|
||||
// 路由工具和组件
|
||||
// 路由工具
|
||||
import { renderRoute } from './utils';
|
||||
import { RouteContainer } from './components';
|
||||
|
||||
/**
|
||||
* AppRoutes - 应用路由组件
|
||||
@@ -31,7 +30,11 @@ import { RouteContainer } from './components';
|
||||
* 目录结构:
|
||||
* - constants/ - 常量配置(布局映射、保护包装器)
|
||||
* - utils/ - 工具函数(renderRoute, wrapWithProtection)
|
||||
* - components/ - 路由组件(RouteContainer)
|
||||
* - components/ - 路由相关组件
|
||||
*
|
||||
* 注意:
|
||||
* - Suspense/ErrorBoundary 由 PageTransitionWrapper 统一处理
|
||||
* - 全屏容器由 MainLayout 提供(minH="100vh")
|
||||
*/
|
||||
export function AppRoutes() {
|
||||
// 🎯 性能优化:使用 useMemo 缓存路由计算结果
|
||||
@@ -39,23 +42,21 @@ export function AppRoutes() {
|
||||
const standaloneRoutes = useMemo(() => getStandaloneRoutes(), []);
|
||||
|
||||
return (
|
||||
<RouteContainer>
|
||||
<Routes>
|
||||
{/* 主布局路由 - 带导航栏和页脚 */}
|
||||
<Route element={<MainLayout />}>
|
||||
{mainLayoutRoutes.map(renderRoute)}
|
||||
</Route>
|
||||
<Routes>
|
||||
{/* 主布局路由 - 带导航栏和页脚 */}
|
||||
<Route element={<MainLayout />}>
|
||||
{mainLayoutRoutes.map(renderRoute)}
|
||||
</Route>
|
||||
|
||||
{/* 独立路由 - 无布局(如登录页)*/}
|
||||
{standaloneRoutes.map(renderRoute)}
|
||||
{/* 独立路由 - 无布局(如登录页)*/}
|
||||
{standaloneRoutes.map(renderRoute)}
|
||||
|
||||
{/* 默认路由 - 重定向到首页 */}
|
||||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||
{/* 默认路由 - 重定向到首页 */}
|
||||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||
|
||||
{/* 404 页面 - 捕获所有未匹配的路由 */}
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
</RouteContainer>
|
||||
{/* 404 页面 - 捕获所有未匹配的路由 */}
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,16 @@ import React from 'react';
|
||||
* 使用 React.lazy() 实现路由懒加载,大幅减少初始 JS 包大小
|
||||
*/
|
||||
export const lazyComponents = {
|
||||
// Home 模块
|
||||
HomePage: React.lazy(() => import('../views/Home/HomePage')),
|
||||
CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')),
|
||||
ProfilePage: React.lazy(() => import('../views/Profile/ProfilePage')),
|
||||
SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')),
|
||||
Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')),
|
||||
PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')),
|
||||
UserAgreement: React.lazy(() => import('../views/Pages/UserAgreement')),
|
||||
WechatCallback: React.lazy(() => import('../views/Pages/WechatCallback')),
|
||||
|
||||
// 社区/内容模块
|
||||
Community: React.lazy(() => import('../views/Community')),
|
||||
ConceptCenter: React.lazy(() => import('../views/Concept')),
|
||||
@@ -31,6 +41,14 @@ export const lazyComponents = {
|
||||
* 按需导出单个组件(可选)
|
||||
*/
|
||||
export const {
|
||||
HomePage,
|
||||
CenterDashboard,
|
||||
ProfilePage,
|
||||
SettingsPage,
|
||||
Subscription,
|
||||
PrivacyPolicy,
|
||||
UserAgreement,
|
||||
WechatCallback,
|
||||
Community,
|
||||
ConceptCenter,
|
||||
StockOverview,
|
||||
|
||||
@@ -2,35 +2,30 @@
|
||||
// 声明式路由配置
|
||||
|
||||
import { lazyComponents } from './lazy-components';
|
||||
import { homeRoutes } from './homeRoutes';
|
||||
import { PROTECTION_MODES } from './constants/protectionModes';
|
||||
|
||||
/**
|
||||
* 路由保护模式
|
||||
* - 'modal': 使用 ProtectedRoute (弹窗模式登录)
|
||||
* - 'redirect': 使用 ProtectedRouteRedirect (跳转模式登录)
|
||||
* - 'public': 公开访问,无需登录
|
||||
*/
|
||||
export const PROTECTION_MODES = {
|
||||
MODAL: 'modal',
|
||||
REDIRECT: 'redirect',
|
||||
PUBLIC: 'public',
|
||||
};
|
||||
// 重新导出 PROTECTION_MODES 以保持向后兼容
|
||||
export { PROTECTION_MODES };
|
||||
|
||||
/**
|
||||
* 路由配置
|
||||
* 每个路由对象包含:
|
||||
* - path: 路由路径
|
||||
* - component: 组件(从 lazyComponents 引用)
|
||||
* - component: 组件(从 lazyComponents 引用,或设为 null 使用 Outlet)
|
||||
* - protection: 保护模式 (modal/redirect/public)
|
||||
* - layout: 布局类型 (main/auth/none)
|
||||
* - children: 子路由配置数组(可选,用于嵌套路由)
|
||||
* - meta: 路由元数据(可选,用于面包屑、标题等)
|
||||
*/
|
||||
export const routeConfig = [
|
||||
// ==================== 首页 ====================
|
||||
{
|
||||
path: 'home/*',
|
||||
component: 'HomeLayout', // 非懒加载,直接在 App.js 导入
|
||||
path: 'home',
|
||||
component: null, // 使用 Outlet 渲染子路由
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
layout: 'main',
|
||||
children: homeRoutes, // 子路由配置
|
||||
meta: {
|
||||
title: '首页',
|
||||
description: '价值前沿首页'
|
||||
@@ -107,7 +102,7 @@ export const routeConfig = [
|
||||
{
|
||||
path: 'forecast-report',
|
||||
component: lazyComponents.ForecastReport,
|
||||
protection: PROTECTION_MODES.REDIRECT,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: '财报预测',
|
||||
@@ -115,7 +110,7 @@ export const routeConfig = [
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'Financial',
|
||||
path: 'financial',
|
||||
component: lazyComponents.FinancialPanorama,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
@@ -154,18 +149,6 @@ export const routeConfig = [
|
||||
description: '实时市场数据'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 认证模块 ====================
|
||||
{
|
||||
path: 'auth/*',
|
||||
component: 'Auth', // 非懒加载,直接在 App.js 导入
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
layout: 'none',
|
||||
meta: {
|
||||
title: '登录/注册',
|
||||
description: '用户认证'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
// 路由渲染工具函数
|
||||
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { LAYOUT_COMPONENTS } from '../constants';
|
||||
import { Route, Outlet } from 'react-router-dom';
|
||||
import { wrapWithProtection } from './wrapWithProtection';
|
||||
|
||||
/**
|
||||
@@ -11,14 +10,16 @@ import { wrapWithProtection } from './wrapWithProtection';
|
||||
*
|
||||
* 根据路由配置项生成 React Router 的 Route 组件。
|
||||
* 处理以下逻辑:
|
||||
* 1. 解析组件(特殊布局组件 vs 懒加载组件)
|
||||
* 1. 解析组件(懒加载组件 or Outlet)
|
||||
* 2. 应用路由保护(根据 protection 字段)
|
||||
* 3. 生成唯一 key
|
||||
* 3. 处理嵌套路由(children 数组)
|
||||
* 4. 生成唯一 key
|
||||
*
|
||||
* @param {Object} routeItem - 路由配置项(来自 routeConfig.js)
|
||||
* @param {string} routeItem.path - 路由路径
|
||||
* @param {React.ComponentType|string} routeItem.component - 组件或组件标识符
|
||||
* @param {React.ComponentType|null} routeItem.component - 懒加载组件或 null(null 表示使用 Outlet)
|
||||
* @param {string} routeItem.protection - 保护模式 (modal/redirect/public)
|
||||
* @param {Array} [routeItem.children] - 子路由配置数组(可选)
|
||||
* @param {number} index - 路由索引,用于生成唯一 key
|
||||
*
|
||||
* @returns {React.ReactElement} Route 组件
|
||||
@@ -27,19 +28,41 @@ import { wrapWithProtection } from './wrapWithProtection';
|
||||
* // 使用示例
|
||||
* const routes = [
|
||||
* { path: 'community', component: CommunityComponent, protection: 'modal' },
|
||||
* { path: 'auth/*', component: 'Auth', protection: 'public' },
|
||||
* { path: 'home', component: null, protection: 'public', children: [...] },
|
||||
* ];
|
||||
*
|
||||
* routes.map((route, index) => renderRoute(route, index));
|
||||
*/
|
||||
export function renderRoute(routeItem, index) {
|
||||
const { path, component, protection } = routeItem;
|
||||
const { path, component, protection, children } = routeItem;
|
||||
|
||||
// 解析组件:
|
||||
// - 如果是字符串(如 'Auth', 'HomeLayout'),从 LAYOUT_COMPONENTS 映射表查找
|
||||
// - 如果是 null,使用 <Outlet /> 用于嵌套路由
|
||||
// - 否则直接使用(懒加载组件)
|
||||
const Component = LAYOUT_COMPONENTS[component] || component;
|
||||
let Component;
|
||||
let isOutletRoute = false;
|
||||
|
||||
if (component === null) {
|
||||
Component = Outlet; // 用于嵌套路由
|
||||
isOutletRoute = true;
|
||||
} else {
|
||||
Component = component; // 直接使用懒加载组件
|
||||
}
|
||||
|
||||
// 如果有子路由,递归渲染
|
||||
if (children && children.length > 0) {
|
||||
return (
|
||||
<Route
|
||||
key={`${path}-${index}`}
|
||||
path={path}
|
||||
element={isOutletRoute ? <Outlet /> : wrapWithProtection(Component, protection)}
|
||||
>
|
||||
{children.map((childRoute, childIndex) => renderRoute(childRoute, childIndex))}
|
||||
</Route>
|
||||
);
|
||||
}
|
||||
|
||||
// 没有子路由,渲染单个路由
|
||||
return (
|
||||
<Route
|
||||
key={`${path}-${index}`}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
--color-n-6: #252134;
|
||||
--color-n-7: #15131D;
|
||||
--color-n-8: #0E0C15;
|
||||
|
||||
|
||||
/* Brainwave 主题色 */
|
||||
--color-1: #AC6AFF;
|
||||
--color-2: #FFC876;
|
||||
@@ -17,7 +17,7 @@
|
||||
--color-4: #7ADB78;
|
||||
--color-5: #858DFF;
|
||||
--color-6: #FF98E2;
|
||||
|
||||
|
||||
/* 描边色 */
|
||||
--stroke-1: #26242C;
|
||||
}
|
||||
@@ -47,37 +47,3 @@
|
||||
.bg-gradient-to-br {
|
||||
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)) !important;
|
||||
}
|
||||
|
||||
.from-n-8 { --tw-gradient-from: var(--color-n-8); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.via-n-7 { --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--color-n-7), var(--tw-gradient-to); }
|
||||
.to-n-6 { --tw-gradient-to: var(--color-n-6); }
|
||||
|
||||
/* 文字渐变 */
|
||||
.bg-gradient-to-r { background-image: linear-gradient(to right, var(--tw-gradient-stops)) !important; }
|
||||
.from-color-1 { --tw-gradient-from: var(--color-1); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.to-color-2 { --tw-gradient-to: var(--color-2); }
|
||||
.from-color-2 { --tw-gradient-from: var(--color-2); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.to-color-1 { --tw-gradient-to: var(--color-1); }
|
||||
|
||||
.bg-clip-text {
|
||||
-webkit-background-clip: text !important;
|
||||
background-clip: text !important;
|
||||
}
|
||||
.text-transparent {
|
||||
color: transparent !important;
|
||||
}
|
||||
|
||||
/* 其他样式增强 */
|
||||
.backdrop-blur-sm { backdrop-filter: blur(8px) !important; }
|
||||
.backdrop-blur { backdrop-filter: blur(16px) !important; }
|
||||
|
||||
/* 确保body有深色背景 */
|
||||
body {
|
||||
background-color: var(--color-n-8) !important;
|
||||
}
|
||||
|
||||
/* z-index 修复 */
|
||||
.z-50 { z-index: 50 !important; }
|
||||
.z-10 { z-index: 10 !important; }
|
||||
.z-2 { z-index: 2 !important; }
|
||||
.z-1 { z-index: 1 !important; }
|
||||
|
||||
@@ -32,4 +32,4 @@ body {
|
||||
@apply md:grid !important;
|
||||
@apply md:grid-cols-3 md:gap-x-10 md:gap-y-[4.5rem] xl:gap-y-[6rem];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
175
src/utils/tradingTimeUtils.js
Normal file
175
src/utils/tradingTimeUtils.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// src/utils/tradingTimeUtils.js
|
||||
// 交易时间相关工具函数
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* 获取当前时间应该显示的实时要闻时间范围
|
||||
* 规则:
|
||||
* - 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
* - 15:30 之后:显示今日 15:00 - 当前时间
|
||||
*
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getCurrentTradingTimeRange = () => {
|
||||
const now = moment();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
// 计算当前是第几分钟(方便比较)
|
||||
const currentTimeInMinutes = currentHour * 60 + currentMinute;
|
||||
const cutoffTime1500 = 15 * 60; // 15:00 = 900分钟
|
||||
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
|
||||
|
||||
let startTime, endTime, description;
|
||||
|
||||
if (currentTimeInMinutes < cutoffTime1500) {
|
||||
// 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示今日 15:00 - 当前时间
|
||||
startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = now.toDate();
|
||||
description = '今日15:00 - 当前时间';
|
||||
} else {
|
||||
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
}
|
||||
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
description,
|
||||
rangeType: currentTimeInMinutes >= cutoffTime1530 ? 'current_day' : 'full_day'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取市场复盘的时间范围
|
||||
* 规则:显示最近一个完整的交易日(昨日 15:00 - 今日 15:00)
|
||||
*
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getMarketReviewTimeRange = () => {
|
||||
const now = moment();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
// 计算当前是第几分钟
|
||||
const currentTimeInMinutes = currentHour * 60 + currentMinute;
|
||||
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
|
||||
|
||||
let startTime, endTime, description;
|
||||
|
||||
if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示昨日 15:00 - 今日 15:00(刚刚完成的交易日)
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else {
|
||||
// 15:30 之前:显示前日 15:00 - 昨日 15:00(上一个完整交易日)
|
||||
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '前日15:00 - 昨日15:00';
|
||||
}
|
||||
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
description,
|
||||
rangeType: 'market_review'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据时间范围过滤事件列表
|
||||
*
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {Date} startTime - 开始时间
|
||||
* @param {Date} endTime - 结束时间
|
||||
* @returns {Array} 过滤后的事件列表
|
||||
*/
|
||||
export const filterEventsByTimeRange = (events, startTime, endTime) => {
|
||||
if (!events || !Array.isArray(events)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
return events;
|
||||
}
|
||||
|
||||
const startMoment = moment(startTime);
|
||||
const endMoment = moment(endTime);
|
||||
|
||||
return events.filter(event => {
|
||||
if (!event.created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventTime = moment(event.created_at);
|
||||
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断当前是否应该显示市场复盘模块
|
||||
* 根据需求:市场复盘模块一直显示
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const shouldShowMarketReview = () => {
|
||||
// 市场复盘模块始终显示
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取时间范围的描述文本
|
||||
*
|
||||
* @param {Date} startTime - 开始时间
|
||||
* @param {Date} endTime - 结束时间
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
if (!startTime || !endTime) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startStr = moment(startTime).format('MM-DD HH:mm');
|
||||
const endStr = moment(endTime).format('MM-DD HH:mm');
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否为交易日(简化版本,只判断周末)
|
||||
* 注意:这里没有考虑节假日,如需精确判断需要接入交易日历API
|
||||
*
|
||||
* @param {Date} date - 日期
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTradingDay = (date) => {
|
||||
const day = moment(date).day();
|
||||
// 0 = 周日, 6 = 周六
|
||||
return day !== 0 && day !== 6;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取上一个交易日(简化版本)
|
||||
*
|
||||
* @param {Date} date - 日期
|
||||
* @returns {Date}
|
||||
*/
|
||||
export const getPreviousTradingDay = (date) => {
|
||||
let prevDay = moment(date).subtract(1, 'day');
|
||||
|
||||
// 如果是周末,继续往前找
|
||||
while (!isTradingDay(prevDay.toDate())) {
|
||||
prevDay = prevDay.subtract(1, 'day');
|
||||
}
|
||||
|
||||
return prevDay.toDate();
|
||||
};
|
||||
@@ -1,160 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Stack,
|
||||
useColorModeValue,
|
||||
Text,
|
||||
Link,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
|
||||
export default function SignInBasic() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { login, isLoading } = useAuth();
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.email || !formData.password) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: "邮箱和密码都是必填项",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(formData.email, formData.password, 'email');
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功,跳转到首页
|
||||
navigate("/home");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex minH="100vh" align="center" justify="center" bg={useColorModeValue("gray.50", "gray.900")}>
|
||||
<Stack spacing={8} mx="auto" maxW="lg" py={12} px={6}>
|
||||
<Stack align="center">
|
||||
<Heading style={{minWidth: '140px'}} fontSize="4xl" color="blue.600">
|
||||
价小前投研
|
||||
</Heading>
|
||||
<Text fontSize="lg" color="gray.600">
|
||||
登录您的账户
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
rounded="lg"
|
||||
bg={useColorModeValue("white", "gray.700")}
|
||||
boxShadow="lg"
|
||||
p={8}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack spacing={4}>
|
||||
<FormControl id="email" isRequired>
|
||||
<FormLabel>邮箱地址</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的邮箱"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id="password" isRequired>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的密码"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<Stack spacing={10}>
|
||||
<Button
|
||||
type="submit"
|
||||
bg="blue.600"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: "blue.700",
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
loadingText="登录中..."
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack pt={6}>
|
||||
<Text align="center">
|
||||
还没有账户?{" "}
|
||||
<Link color="blue.600" onClick={() => navigate("/auth/signup")}>
|
||||
立即注册
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Link,
|
||||
useColorMode,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
|
||||
export default function SignInCentered() {
|
||||
const { colorMode } = useColorMode();
|
||||
const navigate = useNavigate();
|
||||
const { login, isLoading } = useAuth();
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
// UI状态
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
// 清除对应字段的错误
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ""
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = "邮箱是必填项";
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = "请输入有效的邮箱地址";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = "密码是必填项";
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = "密码至少需要6个字符";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(formData.email, formData.password);
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功,跳转到首页
|
||||
navigate("/home");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg={colorMode === "dark" ? "gray.800" : "gray.50"}
|
||||
p={4}
|
||||
>
|
||||
<Box
|
||||
w="full"
|
||||
maxW="md"
|
||||
p={8}
|
||||
bg={colorMode === "dark" ? "gray.700" : "white"}
|
||||
borderRadius="lg"
|
||||
shadow="xl"
|
||||
>
|
||||
<VStack spacing={6}>
|
||||
<Box textAlign="center">
|
||||
<Heading size="lg" mb={2}>欢迎回来1</Heading>
|
||||
<Text color="gray.500">请输入您的凭据登录</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<FormLabel>邮箱地址</FormLabel>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
size="lg"
|
||||
/>
|
||||
{errors.email && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.email}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.password}>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<InputGroup size="lg">
|
||||
<Input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="********"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
variant="ghost"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{errors.password && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.password}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="blue"
|
||||
w="full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
loadingText="登录中..."
|
||||
>
|
||||
{isLoading ? <Spinner size="sm" /> : "登录"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
|
||||
<VStack spacing={3}>
|
||||
<Text fontSize="sm" textAlign="center">
|
||||
还没有账户?{" "}
|
||||
<Link
|
||||
color="blue.500"
|
||||
onClick={() => navigate("/auth/signup")}
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Box textAlign="center">
|
||||
<Link
|
||||
color="gray.500"
|
||||
fontSize="sm"
|
||||
_hover={{ color: "blue.500" }}
|
||||
>
|
||||
忘记密码?
|
||||
</Link>
|
||||
<Text color="gray.500" fontSize="sm" mt={2}>
|
||||
还没有账户?{" "}
|
||||
<Link
|
||||
color="blue.500"
|
||||
fontWeight="medium"
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
onClick={() => navigate('/auth/sign-up')}
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Icon,
|
||||
Input,
|
||||
Link,
|
||||
Switch,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import CoverImage from "assets/img/CoverImage.png";
|
||||
import React from "react";
|
||||
import { FaApple, FaFacebook, FaGoogle } from "react-icons/fa";
|
||||
import AuthCover from "layouts/AuthCover";
|
||||
|
||||
function SignInCover() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.400", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
const titleColor = useColorModeValue("gray.700", "blue.500");
|
||||
const colorIcons = useColorModeValue("gray.700", "white");
|
||||
const bgIcons = useColorModeValue("trasnparent", "navy.700");
|
||||
const bgIconsHover = useColorModeValue("gray.50", "whiteAlpha.100");
|
||||
return (
|
||||
<AuthCover image={CoverImage}>
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb="60px"
|
||||
mt={{ base: "60px", md: "160px" }}
|
||||
>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "100px" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
color={textColor}
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
mb="22px"
|
||||
>
|
||||
Sign In with
|
||||
</Text>
|
||||
<HStack spacing="15px" justify="center" mb="22px">
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon as={FaFacebook} color={colorIcons} w="30px" h="30px" />
|
||||
</Link>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon
|
||||
as={FaApple}
|
||||
color={colorIcons}
|
||||
w="30px"
|
||||
h="30px"
|
||||
_hover={{ filter: "brightness(120%)" }}
|
||||
/>
|
||||
</Link>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon
|
||||
as={FaGoogle}
|
||||
color={colorIcons}
|
||||
w="30px"
|
||||
h="30px"
|
||||
_hover={{ filter: "brightness(120%)" }}
|
||||
/>
|
||||
</Link>
|
||||
</Flex>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
color="gray.400"
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
mb="22px"
|
||||
>
|
||||
or
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Name
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="text"
|
||||
placeholder="Your full name"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormControl display="flex" alignItems="center" mb="24px">
|
||||
<Switch id="remember-login" colorScheme="blue" me="10px" />
|
||||
<FormLabel htmlFor="remember-login" mb="0" fontWeight="normal">
|
||||
Remember me
|
||||
</FormLabel>
|
||||
</FormControl>
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
SIGN IN
|
||||
</Button>
|
||||
</FormControl>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
maxW="100%"
|
||||
mt="0px"
|
||||
>
|
||||
<Text color={textColor} fontWeight="medium">
|
||||
Don’t have an account?
|
||||
<Link
|
||||
color={titleColor}
|
||||
as="span"
|
||||
ms="5px"
|
||||
href="#"
|
||||
fontWeight="bold"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthCover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInCover;
|
||||
@@ -1,538 +0,0 @@
|
||||
// src/views/Authentication/SignIn/SignInIllustration.js - Session版本
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
Text,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
useToast,
|
||||
Icon,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Link as ChakraLink,
|
||||
Center,
|
||||
useDisclosure,
|
||||
FormErrorMessage
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { FaMobile, FaLock } from "react-icons/fa";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
import PrivacyPolicyModal from "../../../components/PrivacyPolicyModal";
|
||||
import UserAgreementModal from "../../../components/UserAgreementModal";
|
||||
import AuthBackground from "../../../components/Auth/AuthBackground";
|
||||
import AuthHeader from "../../../components/Auth/AuthHeader";
|
||||
import AuthFooter from "../../../components/Auth/AuthFooter";
|
||||
import VerificationCodeInput from "../../../components/Auth/VerificationCodeInput";
|
||||
import WechatRegister from "../../../components/Auth/WechatRegister";
|
||||
import { logger } from "../../../utils/logger";
|
||||
|
||||
export default function SignInIllustration() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const toast = useToast();
|
||||
const { login, checkSession } = useAuth();
|
||||
|
||||
// 追踪组件挂载状态,防止内存泄漏
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 页面状态
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// 检查URL参数中的错误信息(微信登录失败时)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const error = params.get('error');
|
||||
|
||||
if (error) {
|
||||
let errorMessage = '登录失败';
|
||||
switch (error) {
|
||||
case 'wechat_auth_failed':
|
||||
errorMessage = '微信授权失败';
|
||||
break;
|
||||
case 'session_expired':
|
||||
errorMessage = '会话已过期,请重新登录';
|
||||
break;
|
||||
case 'token_failed':
|
||||
errorMessage = '获取微信授权失败';
|
||||
break;
|
||||
case 'userinfo_failed':
|
||||
errorMessage = '获取用户信息失败';
|
||||
break;
|
||||
case 'login_failed':
|
||||
errorMessage = '登录处理失败,请重试';
|
||||
break;
|
||||
default:
|
||||
errorMessage = '登录失败,请重试';
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: errorMessage,
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// 清除URL参数
|
||||
const newUrl = window.location.pathname;
|
||||
window.history.replaceState({}, document.title, newUrl);
|
||||
}
|
||||
}, [location, toast]);
|
||||
|
||||
// 传统登录数据
|
||||
// 表单数据初始化
|
||||
const [formData, setFormData] = useState({
|
||||
username: "", // 用户名称
|
||||
email: "", // 邮箱
|
||||
phone: "", // 电话
|
||||
password: "", // 密码
|
||||
verificationCode: "", // 添加验证码字段
|
||||
});
|
||||
|
||||
// 验证码登录状态 是否开启验证码
|
||||
const [useVerificationCode, setUseVerificationCode] = useState(false);
|
||||
// 密码展示状态
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
|
||||
const [verificationCodeSent, setVerificationCodeSent] = useState(false); // 验证码发送状态
|
||||
const [sendingCode, setSendingCode] = useState(false); // 发送验证码状态
|
||||
|
||||
|
||||
// 隐私政策弹窗状态
|
||||
const { isOpen: isPrivacyModalOpen, onOpen: onPrivacyModalOpen, onClose: onPrivacyModalClose } = useDisclosure();
|
||||
|
||||
// 用户协议弹窗状态
|
||||
const { isOpen: isUserAgreementModalOpen, onOpen: onUserAgreementModalOpen, onClose: onUserAgreementModalClose } = useDisclosure();
|
||||
|
||||
// 输入框输入
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// ========== 发送验证码逻辑 =============
|
||||
// 倒计时效果
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
let isMounted = true;
|
||||
|
||||
if (countdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
if (isMounted) {
|
||||
setCountdown(prev => prev - 1);
|
||||
}
|
||||
}, 1000);
|
||||
} else if (countdown === 0 && isMounted) {
|
||||
setVerificationCodeSent(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [countdown]);
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = async () => {
|
||||
const credential = formData.phone;
|
||||
const type = 'phone';
|
||||
|
||||
if (!credential) {
|
||||
toast({
|
||||
title: "请先输入手机号",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 基本格式验证
|
||||
if (!/^1[3-9]\d{9}$/.test(credential)) {
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSendingCode(true);
|
||||
const response = await fetch('/api/auth/send-verification-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
credential,
|
||||
type,
|
||||
purpose: 'login'
|
||||
}),
|
||||
});
|
||||
|
||||
// ✅ 安全检查:验证 response 存在
|
||||
if (!response) {
|
||||
throw new Error('网络请求失败,请检查网络连接');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 组件卸载后不再执行后续操作
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// ✅ 安全检查:验证 data 存在
|
||||
if (!data) {
|
||||
throw new Error('服务器响应为空');
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
toast({
|
||||
title: "验证码已发送",
|
||||
description: "验证码已发送到您的手机号",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
});
|
||||
setVerificationCodeSent(true);
|
||||
setCountdown(60); // 60秒倒计时
|
||||
} else {
|
||||
throw new Error(data.error || '发送验证码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "发送验证码失败",
|
||||
description: error.message || "请稍后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 验证码登录函数
|
||||
const loginWithVerificationCode = async (credential, verificationCode, authLoginType) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login-with-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
credential,
|
||||
verification_code: verificationCode,
|
||||
login_type: authLoginType
|
||||
}),
|
||||
});
|
||||
|
||||
// ✅ 安全检查:验证 response 存在
|
||||
if (!response) {
|
||||
throw new Error('网络请求失败,请检查网络连接');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 组件卸载后不再执行后续操作
|
||||
if (!isMountedRef.current) {
|
||||
return { success: false, error: '操作已取消' };
|
||||
}
|
||||
|
||||
// ✅ 安全检查:验证 data 存在
|
||||
if (!data) {
|
||||
throw new Error('服务器响应为空');
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// 更新认证状态
|
||||
await checkSession();
|
||||
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "登录成功",
|
||||
description: "欢迎回来!",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
} else {
|
||||
throw new Error(data.error || '验证码登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: error.message || "请检查验证码是否正确",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 传统行业登陆
|
||||
const handleTraditionalLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const credential = formData.phone;
|
||||
const authLoginType = 'phone';
|
||||
|
||||
if (useVerificationCode) { // 验证码登陆
|
||||
if (!credential || !formData.verificationCode) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: "手机号和验证码不能为空",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await loginWithVerificationCode(credential, formData.verificationCode, authLoginType);
|
||||
|
||||
if (result.success) {
|
||||
navigate("/home");
|
||||
}
|
||||
} else { // 密码登陆
|
||||
if (!credential || !formData.password) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: `手机号和密码不能为空`,
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(credential, formData.password, authLoginType);
|
||||
|
||||
if (result.success) {
|
||||
// ✅ 显示成功提示
|
||||
toast({
|
||||
title: "登录成功",
|
||||
description: "欢迎回来!",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
navigate("/home");
|
||||
} else {
|
||||
// ❌ 显示错误提示
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: result.error || "请检查您的登录信息",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SignInIllustration', 'handleTraditionalLogin', error, {
|
||||
phone: formData.phone ? formData.phone.substring(0, 3) + '****' + formData.phone.substring(7) : 'N/A',
|
||||
useVerificationCode,
|
||||
loginType: 'phone'
|
||||
});
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: error.message || "发生未预期的错误,请重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换登录方式
|
||||
const handleChangeMethod = () => {
|
||||
setUseVerificationCode(!useVerificationCode);
|
||||
// 切换到密码模式时清空验证码
|
||||
if (useVerificationCode) {
|
||||
setFormData(prev => ({ ...prev, verificationCode: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex minH="100vh" position="relative" overflow="hidden">
|
||||
{/* 背景 */}
|
||||
<AuthBackground />
|
||||
|
||||
{/* 主要内容 */}
|
||||
<Flex width="100%" align="center" justify="center" position="relative" zIndex={1} px={6} py={12}>
|
||||
{/* 登录卡片 */}
|
||||
<Box bg="white" borderRadius="2xl" boxShadow="2xl" p={8} width="100%" maxW="800px" backdropFilter="blur(20px)" border="1px solid rgba(255, 255, 255, 0.2)">
|
||||
{/* 头部区域 */}
|
||||
<AuthHeader title="欢迎回来" subtitle="登录价值前沿,继续您的投资之旅" />
|
||||
{/* 左右布局 */}
|
||||
<HStack spacing={8} align="stretch">
|
||||
{/* 左侧:手机号登陆 - 80% 宽度 */}
|
||||
<Box flex="4">
|
||||
<form onSubmit={handleTraditionalLogin}>
|
||||
<VStack spacing={4}>
|
||||
<Heading size="md" color="gray.700" alignSelf="flex-start">
|
||||
手机号登陆
|
||||
</Heading>
|
||||
<FormControl isRequired isInvalid={!!errors.phone}>
|
||||
<Input
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入11位手机号"
|
||||
pr="2.5rem"
|
||||
/>
|
||||
<FormErrorMessage>{errors.phone}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{/* 密码/验证码输入框 */}
|
||||
{useVerificationCode ? (
|
||||
<VerificationCodeInput
|
||||
value={formData.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
onSendCode={sendVerificationCode}
|
||||
countdown={countdown}
|
||||
isLoading={isLoading}
|
||||
isSending={sendingCode}
|
||||
error={errors.verificationCode}
|
||||
colorScheme="green"
|
||||
/>
|
||||
) : (
|
||||
<FormControl isRequired isInvalid={!!errors.password}>
|
||||
<InputGroup>
|
||||
<Input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
pr="3rem"
|
||||
placeholder="请输入密码"
|
||||
_focus={{
|
||||
borderColor: "blue.500",
|
||||
boxShadow: "0 0 0 1px #667eea"
|
||||
}}
|
||||
/>
|
||||
<InputRightElement width="3rem">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormErrorMessage>{errors.password}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<AuthFooter
|
||||
linkText="还没有账号,"
|
||||
linkLabel="去注册"
|
||||
linkTo="/auth/sign-up"
|
||||
useVerificationCode={useVerificationCode}
|
||||
onSwitchMethod={handleChangeMethod}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
width="100%"
|
||||
size="lg"
|
||||
colorScheme="green"
|
||||
color="white"
|
||||
borderRadius="lg"
|
||||
_hover={{
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: "lg"
|
||||
}}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
isLoading={isLoading}
|
||||
loadingText="登录中..."
|
||||
fontWeight="bold"
|
||||
cursor={"pointer"}
|
||||
>
|
||||
<Icon as={FaLock} mr={2} />登录
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</Box>
|
||||
{/* 右侧:微信登陆 - 20% 宽度 */}
|
||||
<Box flex="1">
|
||||
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
|
||||
<WechatRegister />
|
||||
</Center>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* 底部链接 */}
|
||||
<VStack spacing={4} mt={6}>
|
||||
{/* 协议同意勾选框 */}
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
注册登录即表示阅读并同意{" "}
|
||||
<ChakraLink
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
onClick={onUserAgreementModalOpen}
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
>
|
||||
《用户协议》
|
||||
</ChakraLink>
|
||||
{" "}和{" "}
|
||||
<ChakraLink
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
onClick={onPrivacyModalOpen}
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
>
|
||||
《隐私政策》
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
|
||||
{/* 隐私政策弹窗 */}
|
||||
<PrivacyPolicyModal isOpen={isPrivacyModalOpen} onClose={onPrivacyModalClose} />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
<UserAgreementModal isOpen={isUserAgreementModalOpen} onClose={onUserAgreementModalClose} />
|
||||
</Flex >
|
||||
);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Stack,
|
||||
useColorModeValue,
|
||||
Text,
|
||||
Link,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
useToast,
|
||||
Checkbox,
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function SignUpBasic() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
agreeToTerms: false,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
toast({
|
||||
title: "密码不匹配",
|
||||
description: "请确保两次输入的密码相同",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.agreeToTerms) {
|
||||
toast({
|
||||
title: "请同意条款",
|
||||
description: "请阅读并同意用户协议和隐私政策",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// 模拟注册过程
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿投资助手",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
navigate("/home");
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex minH="100vh" align="center" justify="center" bg={useColorModeValue("gray.50", "gray.900")}>
|
||||
<Stack spacing={8} mx="auto" maxW="lg" py={12} px={6}>
|
||||
<Stack align="center">
|
||||
<Heading fontSize="4xl" color="blue.600">
|
||||
价小前投研
|
||||
</Heading>
|
||||
<Text fontSize="lg" color="gray.600">
|
||||
创建您的账户
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
rounded="lg"
|
||||
bg={useColorModeValue("white", "gray.700")}
|
||||
boxShadow="lg"
|
||||
p={8}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack spacing={4}>
|
||||
<FormControl id="username" isRequired>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的用户名"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id="email" isRequired>
|
||||
<FormLabel>邮箱地址</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的邮箱"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id="password" isRequired>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的密码"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id="confirmPassword" isRequired>
|
||||
<FormLabel>确认密码</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请再次输入您的密码"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showConfirmPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showConfirmPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id="agreeToTerms">
|
||||
<Checkbox
|
||||
name="agreeToTerms"
|
||||
isChecked={formData.agreeToTerms}
|
||||
onChange={handleInputChange}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<Text fontSize="sm">
|
||||
我已阅读并同意{" "}
|
||||
<Link color="blue.600" href="#" isExternal>
|
||||
用户协议
|
||||
</Link>{" "}
|
||||
和{" "}
|
||||
<Link color="blue.600" href="#" isExternal>
|
||||
隐私政策
|
||||
</Link>
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
|
||||
<Stack spacing={10}>
|
||||
<Button
|
||||
type="submit"
|
||||
bg="blue.600"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: "blue.700",
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
loadingText="注册中..."
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack pt={6}>
|
||||
<Text align="center">
|
||||
已有账户?{" "}
|
||||
<Link color="blue.600" onClick={() => navigate("/auth/signin")}>
|
||||
立即登录
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Link,
|
||||
useColorMode,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Spinner,
|
||||
Checkbox,
|
||||
HStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
|
||||
export default function SignUpCentered() {
|
||||
const { colorMode } = useColorMode();
|
||||
const navigate = useNavigate();
|
||||
const { register, isLoading } = useAuth();
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
// UI状态
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
// 清除对应字段的错误
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ""
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = "姓名是必填项";
|
||||
} else if (formData.name.trim().length < 2) {
|
||||
newErrors.name = "姓名至少需要2个字符";
|
||||
}
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = "邮箱是必填项";
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = "请输入有效的邮箱地址";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = "密码是必填项";
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = "密码至少需要6个字符";
|
||||
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
|
||||
newErrors.password = "密码必须包含大小写字母和数字";
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = "请确认密码";
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = "两次输入的密码不一致";
|
||||
}
|
||||
|
||||
if (!agreedToTerms) {
|
||||
newErrors.terms = "请同意服务条款和隐私政策";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await register(
|
||||
formData.name, // username
|
||||
formData.email,
|
||||
formData.password
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// 注册成功,跳转到首页
|
||||
navigate("/home");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg={colorMode === "dark" ? "gray.800" : "gray.50"}
|
||||
p={4}
|
||||
>
|
||||
<Box
|
||||
w="full"
|
||||
maxW="md"
|
||||
p={8}
|
||||
bg={colorMode === "dark" ? "gray.700" : "white"}
|
||||
borderRadius="lg"
|
||||
shadow="xl"
|
||||
>
|
||||
<VStack spacing={6}>
|
||||
<Box textAlign="center">
|
||||
<Heading size="lg" mb={2}>创建账户</Heading>
|
||||
<Text color="gray.500">加入价值前沿,开启智能投资之旅</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isInvalid={!!errors.name}>
|
||||
<FormLabel>姓名</FormLabel>
|
||||
<Input
|
||||
name="name"
|
||||
placeholder="您的姓名"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
size="lg"
|
||||
/>
|
||||
{errors.name && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.name}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<FormLabel>邮箱地址</FormLabel>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
size="lg"
|
||||
/>
|
||||
{errors.email && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.email}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.password}>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<InputGroup size="lg">
|
||||
<Input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="********"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
variant="ghost"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{errors.password && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.password}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.confirmPassword}>
|
||||
<FormLabel>确认密码</FormLabel>
|
||||
<InputGroup size="lg">
|
||||
<Input
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="********"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showConfirmPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showConfirmPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
variant="ghost"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{errors.confirmPassword && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.confirmPassword}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.terms}>
|
||||
<HStack spacing={3}>
|
||||
<Checkbox
|
||||
isChecked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<Text fontSize="sm">
|
||||
我同意{" "}
|
||||
<Link color="blue.500" _hover={{ textDecoration: "underline" }}>
|
||||
服务条款
|
||||
</Link>
|
||||
{" "}和{" "}
|
||||
<Link color="blue.500" _hover={{ textDecoration: "underline" }}>
|
||||
隐私政策
|
||||
</Link>
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</HStack>
|
||||
{errors.terms && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.terms}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="blue"
|
||||
w="full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
loadingText="注册中..."
|
||||
>
|
||||
{isLoading ? <Spinner size="sm" /> : "创建账户"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
|
||||
<VStack spacing={3}>
|
||||
<Text fontSize="sm" textAlign="center">
|
||||
已有账户?{" "}
|
||||
<Link
|
||||
color="blue.500"
|
||||
onClick={() => navigate("/auth/signin")}
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
>
|
||||
立即登录
|
||||
</Link>
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Icon,
|
||||
Input,
|
||||
Link,
|
||||
Switch,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import CoverImage from "assets/img/CoverImage.png";
|
||||
import React from "react";
|
||||
import { FaApple, FaFacebook, FaGoogle } from "react-icons/fa";
|
||||
import AuthCover from "layouts/AuthCover";
|
||||
|
||||
function SignUpCover() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.400", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
const titleColor = useColorModeValue("gray.700", "blue.500");
|
||||
const colorIcons = useColorModeValue("gray.700", "white");
|
||||
const bgIcons = useColorModeValue("trasnparent", "navy.700");
|
||||
const bgIconsHover = useColorModeValue("gray.50", "whiteAlpha.100");
|
||||
return (
|
||||
<AuthCover image={CoverImage}>
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb="60px"
|
||||
mt={{ base: "60px", md: "160px" }}
|
||||
>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "100px" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
color={textColor}
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
mb="22px"
|
||||
>
|
||||
Sign In with
|
||||
</Text>
|
||||
<HStack spacing="15px" justify="center" mb="22px">
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon as={FaFacebook} color={colorIcons} w="30px" h="30px" />
|
||||
</Link>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon
|
||||
as={FaApple}
|
||||
color={colorIcons}
|
||||
w="30px"
|
||||
h="30px"
|
||||
_hover={{ filter: "brightness(120%)" }}
|
||||
/>
|
||||
</Link>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon
|
||||
as={FaGoogle}
|
||||
color={colorIcons}
|
||||
w="30px"
|
||||
h="30px"
|
||||
_hover={{ filter: "brightness(120%)" }}
|
||||
/>
|
||||
</Link>
|
||||
</Flex>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
color="gray.400"
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
mb="22px"
|
||||
>
|
||||
or
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Name
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="text"
|
||||
placeholder="Your full name"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Email
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="email"
|
||||
placeholder="Your full email adress"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormControl display="flex" alignItems="center" mb="24px">
|
||||
<Switch id="remember-login" colorScheme="blue" me="10px" />
|
||||
<FormLabel htmlFor="remember-login" mb="0" fontWeight="normal">
|
||||
Remember me
|
||||
</FormLabel>
|
||||
</FormControl>
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
SIGN IN
|
||||
</Button>
|
||||
</FormControl>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
maxW="100%"
|
||||
mt="0px"
|
||||
>
|
||||
<Text color={textColor} fontWeight="medium">
|
||||
Don’t have an account?
|
||||
<Link
|
||||
color={titleColor}
|
||||
as="span"
|
||||
ms="5px"
|
||||
href="#"
|
||||
fontWeight="bold"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthCover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpCover;
|
||||
@@ -1,445 +0,0 @@
|
||||
// src\views\Authentication\SignUp/SignUpIllustration.js
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
Text,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
useToast,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Center,
|
||||
FormErrorMessage,
|
||||
Link as ChakraLink,
|
||||
useDisclosure
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import AuthBackground from '../../../components/Auth/AuthBackground';
|
||||
import AuthHeader from '../../../components/Auth/AuthHeader';
|
||||
import AuthFooter from '../../../components/Auth/AuthFooter';
|
||||
import VerificationCodeInput from '../../../components/Auth/VerificationCodeInput';
|
||||
import WechatRegister from '../../../components/Auth/WechatRegister';
|
||||
import PrivacyPolicyModal from '../../../components/PrivacyPolicyModal';
|
||||
import UserAgreementModal from '../../../components/UserAgreementModal';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
verificationCode: ""
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
// 追踪组件挂载状态,防止内存泄漏
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 隐私政策弹窗状态
|
||||
const { isOpen: isPrivacyModalOpen, onOpen: onPrivacyModalOpen, onClose: onPrivacyModalClose } = useDisclosure();
|
||||
|
||||
// 用户协议弹窗状态
|
||||
const { isOpen: isUserAgreementModalOpen, onOpen: onUserAgreementModalOpen, onClose: onUserAgreementModalClose } = useDisclosure();
|
||||
|
||||
// 验证码登录状态 是否开启验证码
|
||||
const [useVerificationCode, setUseVerificationCode] = useState(false);
|
||||
|
||||
// 切换注册方式
|
||||
const handleChangeMethod = () => {
|
||||
setUseVerificationCode(!useVerificationCode);
|
||||
// 切换到密码模式时清空验证码
|
||||
if (useVerificationCode) {
|
||||
setFormData(prev => ({ ...prev, verificationCode: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = async () => {
|
||||
const contact = formData.phone;
|
||||
const endpoint = "send-sms-code";
|
||||
const fieldName = "phone";
|
||||
|
||||
if (!contact) {
|
||||
toast({
|
||||
title: "请输入手机号",
|
||||
status: "warning",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(contact)) {
|
||||
toast({
|
||||
title: "请输入正确的手机号",
|
||||
status: "warning",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await axios.post(`${API_BASE_URL}/api/auth/${endpoint}`, {
|
||||
[fieldName]: contact
|
||||
}, {
|
||||
timeout: 10000 // 添加10秒超时
|
||||
});
|
||||
|
||||
// 组件卸载后不再执行后续操作
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// ✅ 安全检查:验证 response 和 data 存在
|
||||
if (!response || !response.data) {
|
||||
throw new Error('服务器响应为空');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "验证码已发送",
|
||||
description: "请查收短信",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
setCountdown(60);
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "发送失败",
|
||||
description: error.response?.data?.error || error.message || "请稍后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 倒计时效果
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
if (isMounted) {
|
||||
setCountdown(countdown - 1);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
// 表单验证
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
// 手机号验证(两种方式都需要)
|
||||
if (!formData.phone) {
|
||||
newErrors.phone = "请输入手机号";
|
||||
} else if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
|
||||
newErrors.phone = "请输入正确的手机号";
|
||||
}
|
||||
|
||||
if (useVerificationCode) {
|
||||
// 验证码注册方式:只验证手机号和验证码
|
||||
if (!formData.verificationCode) {
|
||||
newErrors.verificationCode = "请输入验证码";
|
||||
}
|
||||
} else {
|
||||
// 密码注册方式:验证用户名、密码和确认密码
|
||||
if (!formData.password || formData.password.length < 6) {
|
||||
newErrors.password = "密码至少6个字符";
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = "两次密码不一致";
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 处理注册提交
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
let endpoint, data;
|
||||
|
||||
if (useVerificationCode) {
|
||||
// 验证码注册:只发送手机号和验证码
|
||||
endpoint = "/api/auth/register/phone-code";
|
||||
data = {
|
||||
phone: formData.phone,
|
||||
code: formData.verificationCode
|
||||
};
|
||||
} else {
|
||||
// 密码注册:发送手机号、用户名和密码
|
||||
endpoint = "/api/auth/register/phone";
|
||||
data = {
|
||||
phone: formData.phone,
|
||||
username: formData.username,
|
||||
password: formData.password
|
||||
};
|
||||
}
|
||||
|
||||
const response = await axios.post(`${API_BASE_URL}${endpoint}`, data, {
|
||||
timeout: 10000 // 添加10秒超时
|
||||
});
|
||||
|
||||
// 组件卸载后不再执行后续操作
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// ✅ 安全检查:验证 response 和 data 存在
|
||||
if (!response || !response.data) {
|
||||
throw new Error('注册请求失败:服务器响应为空');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "即将跳转到登录页面",
|
||||
status: "success",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
navigate("/auth/sign-in");
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "注册失败",
|
||||
description: error.response?.data?.error || error.message || "请稍后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 公用的用户名和密码输入框组件
|
||||
const commonAuthFields = (
|
||||
<VStack spacing={4} width="100%">
|
||||
<FormControl isRequired isInvalid={!!errors.password}>
|
||||
<InputGroup>
|
||||
<Input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="设置密码(至少6个字符)"
|
||||
pr="3rem"
|
||||
/>
|
||||
<InputRightElement width="3rem">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormErrorMessage>{errors.password}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired isInvalid={!!errors.confirmPassword}>
|
||||
<Input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="再次输入密码"
|
||||
/>
|
||||
<FormErrorMessage>{errors.confirmPassword}</FormErrorMessage>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex minH="100vh" position="relative" overflow="hidden">
|
||||
{/* 背景 */}
|
||||
<AuthBackground />
|
||||
|
||||
{/* 主要内容 */}
|
||||
<Flex width="100%" align="center" justify="center" position="relative" zIndex={1} px={6} py={12}>
|
||||
<Box bg="white" borderRadius="2xl" boxShadow="2xl" p={8} width="100%" maxW="800px" backdropFilter="blur(20px)" border="1px solid rgba(255, 255, 255, 0.2)">
|
||||
{/* 头部区域 */}
|
||||
<AuthHeader title="创建账户" subtitle="加入价值前沿,开启投资新征程" />
|
||||
{/* 左右布局 */}
|
||||
<HStack spacing={8} align="stretch">
|
||||
{/* 左侧:手机号注册 - 80% 宽度 */}
|
||||
<Box flex="4">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack spacing={4}>
|
||||
<Heading size="md" color="gray.700" alignSelf="flex-start">
|
||||
注册
|
||||
</Heading>
|
||||
<FormControl isRequired isInvalid={!!errors.phone}>
|
||||
<Input
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入11位手机号"
|
||||
pr="2.5rem"
|
||||
/>
|
||||
<FormErrorMessage>{errors.phone}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{/* 表单字段区域 */}
|
||||
<Box width="100%">
|
||||
{
|
||||
useVerificationCode ? (
|
||||
<VStack spacing={4} width="100%">
|
||||
<VerificationCodeInput
|
||||
value={formData.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
onSendCode={sendVerificationCode}
|
||||
countdown={countdown}
|
||||
isLoading={isLoading}
|
||||
isSending={isLoading && countdown === 0}
|
||||
error={errors.verificationCode}
|
||||
colorScheme="green"
|
||||
/>
|
||||
{/* 隐藏的占位元素,保持与密码模式相同的高度 */}
|
||||
<Box height="40px" width="100%" visibility="hidden" />
|
||||
</VStack>
|
||||
) : (
|
||||
<>
|
||||
{commonAuthFields}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Box>
|
||||
|
||||
<AuthFooter
|
||||
linkText="已有账号?"
|
||||
linkLabel="去登录"
|
||||
linkTo="/auth/sign-in"
|
||||
useVerificationCode={useVerificationCode}
|
||||
onSwitchMethod={handleChangeMethod}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
width="100%"
|
||||
size="lg"
|
||||
colorScheme="green"
|
||||
color="white"
|
||||
borderRadius="lg"
|
||||
_hover={{
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: "lg"
|
||||
}}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
isLoading={isLoading}
|
||||
loadingText="注册中..."
|
||||
fontWeight="bold"
|
||||
cursor="pointer"
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
|
||||
{/* 协议同意文本 */}
|
||||
<Text fontSize="sm" color="gray.600" textAlign="center" width="100%">
|
||||
注册登录即表示阅读并同意{" "}
|
||||
<ChakraLink
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
onClick={onUserAgreementModalOpen}
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
cursor="pointer"
|
||||
>
|
||||
《用户协议》
|
||||
</ChakraLink>
|
||||
{" "}和{" "}
|
||||
<ChakraLink
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
onClick={onPrivacyModalOpen}
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
cursor="pointer"
|
||||
>
|
||||
《隐私政策》
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</VStack>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:微信注册 - 20% 宽度 */}
|
||||
<Box flex="1">
|
||||
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
|
||||
<WechatRegister />
|
||||
</Center>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 隐私政策弹窗 */}
|
||||
<PrivacyPolicyModal isOpen={isPrivacyModalOpen} onClose={onPrivacyModalClose} />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
<UserAgreementModal isOpen={isUserAgreementModalOpen} onClose={onUserAgreementModalClose} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
441
src/views/Community/components/DynamicNewsCard.js
Normal file
441
src/views/Community/components/DynamicNewsCard.js
Normal file
@@ -0,0 +1,441 @@
|
||||
// src/views/Community/components/DynamicNewsCard.js
|
||||
// 横向滚动事件卡片组件(实时要闻·动态追踪)
|
||||
|
||||
import React, { forwardRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Center,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
useToast
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon } from '@chakra-ui/icons';
|
||||
import EventScrollList from './DynamicNewsCard/EventScrollList';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
import UnifiedSearchBox from './UnifiedSearchBox';
|
||||
import { fetchDynamicNews, toggleEventFollow, selectEventFollowStatus } from '../../../store/slices/communityDataSlice';
|
||||
|
||||
/**
|
||||
* 实时要闻·动态追踪 - 事件展示卡片组件
|
||||
* @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(({
|
||||
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');
|
||||
|
||||
// 从 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 (currentPageEvents.length > 0 && !selectedEvent) {
|
||||
setSelectedEvent(currentPageEvents[0]);
|
||||
}
|
||||
}, [currentPageEvents, selectedEvent]);
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>实时要闻·动态追踪</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="red">实时</Badge>
|
||||
<Badge colorScheme="green">盘中</Badge>
|
||||
<Badge colorScheme="blue">快讯</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索和筛选组件 */}
|
||||
<Box mt={4}>
|
||||
<UnifiedSearchBox
|
||||
onSearch={onSearch}
|
||||
onSearchFocus={onSearchFocus}
|
||||
popularKeywords={popularKeywords}
|
||||
filters={filters}
|
||||
/>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<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" />
|
||||
<Text color="gray.500">正在加载最新事件...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 详情面板 - 始终显示(如果有选中事件) */}
|
||||
{currentPageEvents && currentPageEvents.length > 0 && selectedEvent && (
|
||||
<Box mt={6}>
|
||||
<DynamicNewsDetailPanel event={selectedEvent} />
|
||||
</Box>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
DynamicNewsCard.displayName = 'DynamicNewsCard';
|
||||
|
||||
export default DynamicNewsCard;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,60 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CollapsibleHeader.js
|
||||
// 可折叠模块标题组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 可折叠模块标题组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.title - 标题文本
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
*/
|
||||
const CollapsibleHeader = ({ title, isOpen, onToggle, count = null }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
p={3}
|
||||
bg={sectionBg}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
{title}
|
||||
</Heading>
|
||||
{count !== null && (
|
||||
<Badge colorScheme="blue" borderRadius="full">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={isOpen ? '收起' : '展开'}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleHeader;
|
||||
@@ -0,0 +1,41 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
|
||||
// 通用可折叠区块组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import CollapsibleHeader from './CollapsibleHeader';
|
||||
|
||||
/**
|
||||
* 通用可折叠区块组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.title - 标题文本
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
* @param {React.ReactNode} props.children - 子内容
|
||||
*/
|
||||
const CollapsibleSection = ({ title, isOpen, onToggle, count = null, children }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CollapsibleHeader
|
||||
title={title}
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
count={count}
|
||||
/>
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleSection;
|
||||
@@ -0,0 +1,205 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
|
||||
// 动态新闻详情面板主组件(组装所有子组件)
|
||||
|
||||
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';
|
||||
import RelatedStocksSection from './RelatedStocksSection';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
|
||||
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
|
||||
|
||||
/**
|
||||
* 动态新闻详情面板主组件
|
||||
* @param {Object} props
|
||||
* @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(true);
|
||||
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
|
||||
|
||||
// 自选股管理(使用 localStorage)
|
||||
const [watchlistSet, setWatchlistSet] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('stock_watchlist');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// 切换关注状态
|
||||
const handleToggleFollow = useCallback(async () => {
|
||||
if (!event?.id) return;
|
||||
dispatch(toggleEventFollow(event.id));
|
||||
}, [dispatch, event?.id]);
|
||||
|
||||
// 切换自选股
|
||||
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
|
||||
try {
|
||||
const newWatchlist = new Set(watchlistSet);
|
||||
|
||||
if (isInWatchlist) {
|
||||
newWatchlist.delete(stockCode);
|
||||
toast({
|
||||
title: '已移除自选股',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
newWatchlist.add(stockCode);
|
||||
toast({
|
||||
title: '已添加至自选股',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
|
||||
setWatchlistSet(newWatchlist);
|
||||
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
|
||||
} catch (error) {
|
||||
console.error('切换自选股失败:', error);
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}, [watchlistSet, toast]);
|
||||
|
||||
// 空状态
|
||||
if (!event) {
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||
<CardBody>
|
||||
<Text color={textColor} textAlign="center">
|
||||
请选择一个事件查看详情
|
||||
</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 头部信息区 */}
|
||||
<EventHeaderInfo
|
||||
event={event}
|
||||
importance={importance}
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
/>
|
||||
|
||||
{/* 事件描述 */}
|
||||
<EventDescriptionSection description={event.description} />
|
||||
|
||||
{/* 相关概念 */}
|
||||
<RelatedConceptsSection
|
||||
eventTitle={event.title}
|
||||
effectiveTradingDate={event.trading_date || event.created_at}
|
||||
eventTime={event.created_at}
|
||||
/>
|
||||
|
||||
{/* 相关股票(可折叠) */}
|
||||
{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={historicalEvents?.length || 0}
|
||||
>
|
||||
{loading.historicalEvents ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Text ml={2} color={textColor} fontSize="sm">加载历史事件...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<HistoricalEvents
|
||||
events={historicalEvents || []}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 传导链分析(可折叠) */}
|
||||
<CollapsibleSection
|
||||
title="传导链分析"
|
||||
isOpen={isTransmissionOpen}
|
||||
onToggle={() => setIsTransmissionOpen(!isTransmissionOpen)}
|
||||
>
|
||||
<TransmissionChainAnalysis
|
||||
eventId={event.id}
|
||||
eventService={eventService}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicNewsDetailPanel;
|
||||
@@ -0,0 +1,42 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/EventDescriptionSection.js
|
||||
// 事件描述区组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 事件描述区组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.description - 事件描述文本
|
||||
*/
|
||||
const EventDescriptionSection = ({ description }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
// 如果没有描述,不渲染
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
{/* 事件描述 */}
|
||||
<Box>
|
||||
<Heading size="sm" color={headingColor} mb={2}>
|
||||
事件描述
|
||||
</Heading>
|
||||
<Text fontSize="sm" color={textColor} lineHeight="tall">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDescriptionSection;
|
||||
@@ -0,0 +1,131 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/EventHeaderInfo.js
|
||||
// 事件头部信息区组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
/**
|
||||
* 事件头部信息区组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象
|
||||
* @param {Object} props.importance - 重要性配置对象(包含 level, color 等)
|
||||
* @param {boolean} props.isFollowing - 是否已关注
|
||||
* @param {number} props.followerCount - 关注数
|
||||
* @param {Function} props.onToggleFollow - 切换关注回调
|
||||
*/
|
||||
const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 获取重要性文本
|
||||
const getImportanceText = () => {
|
||||
const levelMap = {
|
||||
'S': '极高',
|
||||
'A': '高',
|
||||
'B': '中',
|
||||
'C': '低'
|
||||
};
|
||||
return levelMap[importance.level] || '中';
|
||||
};
|
||||
|
||||
// 格式化涨跌幅数字
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined) return '--';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md" position="relative">
|
||||
{/* 粉色圆角标签(左上角绝对定位) */}
|
||||
{event.related_avg_chg !== null && event.related_avg_chg !== undefined && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
left="-8px"
|
||||
bg="pink.500"
|
||||
color="white"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
boxShadow="md"
|
||||
zIndex={1}
|
||||
>
|
||||
{formatChange(event.related_avg_chg)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 第一行:标题 + 关注按钮 */}
|
||||
<Flex align="center" justify="space-between" mb={3} gap={4}>
|
||||
{/* 标题 */}
|
||||
<Heading size="md" color={headingColor} flex={1}>
|
||||
{event.title}
|
||||
</Heading>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={onToggleFollow}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</Flex>
|
||||
{/* 第二行:浏览数 + 日期 */}
|
||||
<Flex align="left" mb={3} gap={4}>
|
||||
{/* 浏览数 */}
|
||||
<HStack spacing={1}>
|
||||
<ViewIcon color="gray.400" boxSize={4} />
|
||||
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
|
||||
{(event.view_count || 0).toLocaleString()}次浏览
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 日期 */}
|
||||
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
|
||||
{moment(event.created_at).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 第三行:涨跌幅指标 + 重要性文本 */}
|
||||
<HStack spacing={3} align="center">
|
||||
<Box maxW="500px">
|
||||
<StockChangeIndicators
|
||||
avgChange={event.related_avg_chg}
|
||||
maxChange={event.related_max_chg}
|
||||
weekChange={event.related_week_chg}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 重要性文本 */}
|
||||
<Box
|
||||
bg={importance.bgColor}
|
||||
borderWidth="2px"
|
||||
borderColor={importance.badgeBg}
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={importance.badgeBg} whiteSpace="nowrap" fontWeight="medium">
|
||||
重要性:{getImportanceText()}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventHeaderInfo;
|
||||
@@ -0,0 +1,184 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
klineDataCache
|
||||
} from '../StockDetailPanel/utils/klineDataCache';
|
||||
|
||||
/**
|
||||
* 迷你K线图组件
|
||||
* 显示股票的K线走势(蜡烛图),支持事件时间标记
|
||||
*
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间(可选)
|
||||
* @param {Function} onClick - 点击回调(可选)
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime, onClick }) {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
const loadedRef = useRef(false);
|
||||
const dataFetchedRef = useRef(false);
|
||||
|
||||
// 稳定的事件时间
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stockCode) {
|
||||
setData([]);
|
||||
loadedRef.current = false;
|
||||
dataFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataFetchedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存(K线图使用 'daily' 类型)
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily');
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
setData(cachedData);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
dataFetchedRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
// 获取日K线数据
|
||||
fetchKlineData(stockCode, stableEventTime, 'daily')
|
||||
.then((result) => {
|
||||
if (mountedRef.current) {
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setData([]);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
}
|
||||
});
|
||||
}, [stockCode, stableEventTime]);
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
// 提取K线数据 [open, close, low, high]
|
||||
const klineData = data
|
||||
.filter(item => item.open && item.close && item.low && item.high)
|
||||
.map(item => [item.open, item.close, item.low, item.high]);
|
||||
|
||||
// 日K线使用 date 字段
|
||||
const dates = data.map(item => item.date || item.time);
|
||||
const hasData = klineData.length > 0;
|
||||
|
||||
if (!hasData) {
|
||||
return {
|
||||
title: {
|
||||
text: loading ? '加载中...' : '无数据',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
textStyle: { color: '#999', fontSize: 10 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 计算事件时间标记
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
|
||||
try {
|
||||
const eventDate = moment(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventIdx = dates.findIndex(d => {
|
||||
const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d);
|
||||
return dateStr.includes(eventDate);
|
||||
});
|
||||
|
||||
if (eventIdx >= 0) {
|
||||
eventMarkLineData.push({
|
||||
xAxis: eventIdx,
|
||||
lineStyle: { color: '#FFD700', type: 'solid', width: 1.5 },
|
||||
label: { show: false }
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略异常
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
show: false,
|
||||
boundaryGap: true
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false,
|
||||
scale: true
|
||||
},
|
||||
series: [{
|
||||
type: 'candlestick',
|
||||
data: klineData,
|
||||
itemStyle: {
|
||||
color: '#ef5350', // 涨(阳线)
|
||||
color0: '#26a69a', // 跌(阴线)
|
||||
borderColor: '#ef5350', // 涨(边框)
|
||||
borderColor0: '#26a69a' // 跌(边框)
|
||||
},
|
||||
barWidth: '60%',
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
label: { show: false },
|
||||
data: eventMarkLineData
|
||||
}
|
||||
}],
|
||||
tooltip: { show: false },
|
||||
animation: false
|
||||
};
|
||||
}, [data, loading, stableEventTime]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 140,
|
||||
height: 40,
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
return prevProps.stockCode === nextProps.stockCode &&
|
||||
prevProps.eventTime === nextProps.eventTime &&
|
||||
prevProps.onClick === nextProps.onClick;
|
||||
});
|
||||
|
||||
export default MiniKLineChart;
|
||||
@@ -0,0 +1,94 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/MiniLineChart.js
|
||||
// Mini 折线图组件(用于股票卡片)
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* Mini 折线图组件
|
||||
* @param {Object} props
|
||||
* @param {Array<number>} props.data - 价格走势数据数组(15个数据点:前5+中5+后5)
|
||||
* @param {number} props.width - 图表宽度(默认180)
|
||||
* @param {number} props.height - 图表高度(默认60)
|
||||
*/
|
||||
const MiniLineChart = ({ data = [], width = 180, height = 60 }) => {
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算最大值和最小值,用于归一化
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1; // 防止除以0
|
||||
|
||||
// 将数据点转换为 SVG 路径坐标
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1)) * width;
|
||||
const y = height - ((value - min) / range) * height;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
});
|
||||
|
||||
// 构建 SVG 路径字符串
|
||||
const pathD = `M ${points.join(' L ')}`;
|
||||
|
||||
// 判断整体趋势(比较第一个和最后一个值)
|
||||
const isPositive = data[data.length - 1] >= data[0];
|
||||
const strokeColor = isPositive ? '#48BB78' : '#F56565'; // 绿色上涨,红色下跌
|
||||
|
||||
// 创建渐变填充区域路径
|
||||
const fillPathD = `${pathD} L ${width},${height} L 0,${height} Z`;
|
||||
|
||||
return (
|
||||
<Box width={`${width}px`} height={`${height}px`}>
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${isPositive ? 'up' : 'down'}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* 填充区域 */}
|
||||
<path
|
||||
d={fillPathD}
|
||||
fill={`url(#gradient-${isPositive ? 'up' : 'down'})`}
|
||||
/>
|
||||
|
||||
{/* 折线 */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* 垂直分隔线(标记三个时间段) */}
|
||||
{/* 前一天和当天之间 */}
|
||||
<line
|
||||
x1={width / 3}
|
||||
y1={0}
|
||||
x2={width / 3}
|
||||
y2={height}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
|
||||
{/* 当天和后一天之间 */}
|
||||
<line
|
||||
x1={(width * 2) / 3}
|
||||
y1={0}
|
||||
x2={(width * 2) / 3}
|
||||
y2={height}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniLineChart;
|
||||
@@ -0,0 +1,79 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/ConceptStockItem.js
|
||||
// 概念股票列表项组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 概念股票列表项组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.stock - 股票对象
|
||||
* - stock_name: 股票名称
|
||||
* - stock_code: 股票代码
|
||||
* - change_pct: 涨跌幅
|
||||
* - reason: 关联原因
|
||||
*/
|
||||
const ConceptStockItem = ({ stock }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
const stockChangePct = parseFloat(stock.change_pct);
|
||||
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}>
|
||||
<Text fontWeight="semibold" color={conceptNameColor}>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
<Badge size="sm" variant="outline">
|
||||
{stock.stock_code}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{stock.change_pct && (
|
||||
<Badge
|
||||
colorScheme={stockChangeColor}
|
||||
fontSize="xs"
|
||||
>
|
||||
{stockChangeSymbol}{stockChangePct.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{stock.reason && (
|
||||
<Text fontSize="xs" color={stockCountColor} mt={1} noOfLines={2}>
|
||||
{stock.reason}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptStockItem;
|
||||
@@ -0,0 +1,153 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/DetailedConceptCard.js
|
||||
// 详细概念卡片组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import ConceptStockItem from './ConceptStockItem';
|
||||
|
||||
/**
|
||||
* 详细概念卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.concept - 概念对象
|
||||
* - name: 概念名称
|
||||
* - stock_count: 相关股票数量
|
||||
* - relevance: 相关度(0-100)
|
||||
* - avg_change_pct: 平均涨跌幅
|
||||
* - description: 概念描述
|
||||
* - happened_times: 历史触发时间数组
|
||||
* - stocks: 相关股票数组
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
*/
|
||||
const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
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.price_info?.avg_change_pct);
|
||||
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
|
||||
const changeSymbol = changePct > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderWidth="2px"
|
||||
cursor="pointer"
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'xl',
|
||||
borderColor: 'blue.400'
|
||||
}}
|
||||
onClick={() => onClick(concept)}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{/* 头部信息 */}
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
{/* 左侧:概念名称 + Badge */}
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<Text fontSize="md" fontWeight="bold" color="blue.600">
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
相关度: {relevanceScore}%
|
||||
</Badge>
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
{concept.stock_count} 只股票
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅 */}
|
||||
{concept.price_info?.avg_change_pct && (
|
||||
<Box textAlign="right">
|
||||
<Text fontSize="xs" color={stockCountColor} mb={1}>
|
||||
平均涨跌幅
|
||||
</Text>
|
||||
<Badge
|
||||
size="lg"
|
||||
colorScheme={changeColor}
|
||||
fontSize="md"
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
{changeSymbol}{changePct.toFixed(2)}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 概念描述 */}
|
||||
{concept.description && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={stockCountColor}
|
||||
lineHeight="1.6"
|
||||
noOfLines={3}
|
||||
>
|
||||
{concept.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 历史触发时间 */}
|
||||
{concept.happened_times && concept.happened_times.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="xs" fontWeight="semibold" mb={2} color={stockCountColor}>
|
||||
历史触发时间:
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{concept.happened_times.map((time, idx) => (
|
||||
<Badge key={idx} variant="subtle" colorScheme="gray" fontSize="xs">
|
||||
{time}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 核心相关股票 */}
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Text fontSize="sm" fontWeight="semibold" color={headingColor}>
|
||||
核心相关股票
|
||||
</Text>
|
||||
<Text fontSize="xs" color={stockCountColor}>
|
||||
共 {concept.stock_count} 只
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<SimpleGrid columns={{ base: 1 }} spacing={2}>
|
||||
{concept.stocks.slice(0, 4).map((stock, idx) => (
|
||||
<ConceptStockItem key={idx} stock={stock} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailedConceptCard;
|
||||
@@ -0,0 +1,74 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/SimpleConceptCard.js
|
||||
// 简单概念卡片组件(横向卡片)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
Box,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 简单概念卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.concept - 概念对象
|
||||
* - name: 概念名称
|
||||
* - stock_count: 相关股票数量
|
||||
* - relevance: 相关度(0-100)
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Function} props.getRelevanceColor - 获取相关度颜色的函数
|
||||
*/
|
||||
const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.700');
|
||||
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.600');
|
||||
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
const relevanceColors = getRelevanceColor(relevanceScore);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
px={4}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
minW="200px"
|
||||
_hover={{
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
onClick={() => onClick(concept)}
|
||||
>
|
||||
{/* 左侧:概念名 + 数量 */}
|
||||
<Text fontSize="sm" fontWeight="normal" color={conceptNameColor} mr={3}>
|
||||
{concept.concept}{' '}
|
||||
<Text as="span" color="gray.500">
|
||||
({concept.stock_count})
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{/* 右侧:相关度标签 */}
|
||||
<Box
|
||||
bg={relevanceColors.bg}
|
||||
color={relevanceColors.color}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap">
|
||||
相关度: {relevanceScore}%
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleConceptCard;
|
||||
@@ -0,0 +1,46 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/TradingDateInfo.js
|
||||
// 交易日期信息提示组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* 交易日期信息提示组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||||
* @param {string|Object} props.eventTime - 事件发生时间
|
||||
*/
|
||||
const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
if (!effectiveTradingDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box mb={4} p={3} bg={sectionBg} borderRadius="md">
|
||||
<HStack spacing={2}>
|
||||
<FaCalendarAlt color="gray" />
|
||||
<Text fontSize="sm" color={headingColor}>
|
||||
涨跌幅数据日期:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradingDateInfo;
|
||||
@@ -0,0 +1,256 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/index.js
|
||||
// 相关概念区组件(主组件)
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
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 {string} props.eventTitle - 事件标题(用于搜索概念)
|
||||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||||
* @param {string|Object} props.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');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据相关度获取颜色(浅色背景 + 深色文字)
|
||||
* @param {number} relevance - 相关度(0-100)
|
||||
* @returns {Object} 包含背景色和文字色
|
||||
*/
|
||||
const getRelevanceColor = (relevance) => {
|
||||
if (relevance >= 90) {
|
||||
return { bg: 'purple.50', color: 'purple.800' }; // 极高相关
|
||||
} else if (relevance >= 80) {
|
||||
return { bg: 'pink.50', color: 'pink.800' }; // 高相关
|
||||
} else if (relevance >= 70) {
|
||||
return { bg: 'orange.50', color: 'orange.800' }; // 中等相关
|
||||
} else {
|
||||
return { bg: 'gray.100', color: 'gray.700' }; // 低相关
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理概念点击
|
||||
* @param {Object} concept - 概念对象
|
||||
*/
|
||||
const handleConceptClick = (concept) => {
|
||||
// 跳转到概念中心,并搜索该概念
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
{/* 标题栏 */}
|
||||
<Flex justify="space-between" align="center" mb={3}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
相关概念
|
||||
</Heading>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? '收起' : '查看详细描述'}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* 简单模式:横向卡片列表(总是显示) */}
|
||||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||||
{concepts.map((concept, index) => (
|
||||
<SimpleConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
getRelevanceColor={getRelevanceColor}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
{/* 交易日期信息 */}
|
||||
<TradingDateInfo
|
||||
effectiveTradingDate={effectiveTradingDate}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
|
||||
{/* 详细模式:卡片网格(可折叠) */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
{/* 详细概念卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{concepts.map((concept, index) => (
|
||||
<DetailedConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelatedConceptsSection;
|
||||
@@ -0,0 +1,66 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
|
||||
// 相关股票列表区组件(可折叠,网格布局)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Collapse,
|
||||
} from '@chakra-ui/react';
|
||||
import CollapsibleHeader from './CollapsibleHeader';
|
||||
import StockListItem from './StockListItem';
|
||||
|
||||
/**
|
||||
* 相关股票列表区组件
|
||||
* @param {Object} props
|
||||
* @param {Array<Object>} props.stocks - 股票数组
|
||||
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
|
||||
* @param {string} props.eventTime - 事件时间
|
||||
* @param {Set} props.watchlistSet - 自选股代码集合
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||
*/
|
||||
const RelatedStocksSection = ({
|
||||
stocks,
|
||||
quotes = {},
|
||||
eventTime = null,
|
||||
watchlistSet = new Set(),
|
||||
isOpen,
|
||||
onToggle,
|
||||
onWatchlistToggle
|
||||
}) => {
|
||||
// 如果没有股票数据,不渲染
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CollapsibleHeader
|
||||
title="相关股票"
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
count={stocks.length}
|
||||
/>
|
||||
<Collapse in={isOpen} animateOpacity>
|
||||
<Box mt={3}>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
|
||||
{stocks.map((stock, index) => (
|
||||
<StockListItem
|
||||
key={index}
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
eventTime={eventTime}
|
||||
isInWatchlist={watchlistSet.has(stock.stock_code)}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelatedStocksSection;
|
||||
@@ -0,0 +1,247 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/StockListItem.js
|
||||
// 股票卡片组件(融合表格功能的卡片样式)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
SimpleGrid,
|
||||
Text,
|
||||
Button,
|
||||
IconButton,
|
||||
Collapse,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { StarIcon } from '@chakra-ui/icons';
|
||||
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
|
||||
import MiniKLineChart from './MiniKLineChart';
|
||||
import StockChartModal from '../../../../components/StockChart/StockChartModal';
|
||||
|
||||
/**
|
||||
* 股票卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.stock - 股票对象
|
||||
* @param {string} props.stock.stock_name - 股票名称
|
||||
* @param {string} props.stock.stock_code - 股票代码
|
||||
* @param {string} props.stock.relation_desc - 关联描述
|
||||
* @param {Object} props.quote - 股票行情数据(可选)
|
||||
* @param {number} props.quote.change - 涨跌幅
|
||||
* @param {string} props.eventTime - 事件时间(可选)
|
||||
* @param {boolean} props.isInWatchlist - 是否在自选股中
|
||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||
*/
|
||||
const StockListItem = ({
|
||||
stock,
|
||||
quote = null,
|
||||
eventTime = null,
|
||||
isInWatchlist = false,
|
||||
onWatchlistToggle
|
||||
}) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const codeColor = useColorModeValue('blue.600', 'blue.300');
|
||||
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||
const descColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const dividerColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleViewDetail = () => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
};
|
||||
|
||||
const handleWatchlistClick = (e) => {
|
||||
e.stopPropagation();
|
||||
onWatchlistToggle?.(stock.stock_code, isInWatchlist);
|
||||
};
|
||||
|
||||
// 格式化涨跌幅显示
|
||||
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';
|
||||
};
|
||||
|
||||
// 获取涨跌幅数据(优先使用 quote,fallback 到 stock)
|
||||
const change = quote?.change ?? stock.daily_change ?? null;
|
||||
|
||||
// 处理关联描述
|
||||
const getRelationDesc = () => {
|
||||
const relationDesc = stock.relation_desc;
|
||||
|
||||
if (!relationDesc) return '--';
|
||||
|
||||
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(';') || '--';
|
||||
}
|
||||
|
||||
return '--';
|
||||
};
|
||||
|
||||
const relationText = getRelationDesc();
|
||||
const maxLength = 50; // 收缩时显示的最大字符数
|
||||
const needTruncate = relationText && relationText !== '--' && relationText.length > maxLength;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
p={4}
|
||||
onClick={handleViewDetail}
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
borderColor: 'blue.300',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 顶部:股票代码 + 名称 + 操作按钮 */}
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧:代码 + 名称 */}
|
||||
<Flex align="baseline" gap={2}>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color={codeColor}
|
||||
cursor="pointer"
|
||||
onClick={handleViewDetail}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={nameColor}>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
color={getChangeColor(change)}
|
||||
>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 右侧:操作按钮 */}
|
||||
<Flex gap={2}>
|
||||
{onWatchlistToggle && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant={isInWatchlist ? 'solid' : 'outline'}
|
||||
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
|
||||
icon={<StarIcon />}
|
||||
onClick={handleWatchlistClick}
|
||||
aria-label={isInWatchlist ? '已关注' : '加自选'}
|
||||
title={isInWatchlist ? '已关注' : '加自选'}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleViewDetail();
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box borderTop="1px solid" borderColor={dividerColor} />
|
||||
|
||||
{/* 分时图 & K线图 - 左右布局 */}
|
||||
<Box>
|
||||
<SimpleGrid columns={2} spacing={3}>
|
||||
{/* 左侧:分时图 */}
|
||||
<Box onClick={(e) => e.stopPropagation()}>
|
||||
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
|
||||
分时图
|
||||
</Text>
|
||||
<MiniTimelineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:K线图 */}
|
||||
<Box onClick={(e) => e.stopPropagation()}>
|
||||
<Text fontSize="xs" color={descColor} mb={1} textAlign="center">
|
||||
日K线
|
||||
</Text>
|
||||
<MiniKLineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
/>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box borderTop="1px solid" borderColor={dividerColor} />
|
||||
|
||||
{/* 关联描述 */}
|
||||
{relationText && relationText !== '--' && (
|
||||
<Box>
|
||||
<Text fontSize="xs" color={descColor} mb={1}>
|
||||
关联描述:
|
||||
</Text>
|
||||
<Collapse in={isDescExpanded} startingHeight={40}>
|
||||
<Text fontSize="sm" color={nameColor} lineHeight="1.6">
|
||||
{relationText}
|
||||
</Text>
|
||||
</Collapse>
|
||||
{needTruncate && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
colorScheme="blue"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescExpanded(!isDescExpanded);
|
||||
}}
|
||||
mt={1}
|
||||
>
|
||||
{isDescExpanded ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 股票详情弹窗 */}
|
||||
<StockChartModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
stock={stock}
|
||||
eventTime={eventTime}
|
||||
size="6xl"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockListItem;
|
||||
@@ -0,0 +1,5 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/index.js
|
||||
// 统一导出 DynamicNewsDetailPanel 组件
|
||||
|
||||
export { default } from './DynamicNewsDetailPanel';
|
||||
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';
|
||||
217
src/views/Community/components/EventCard/DynamicNewsEventCard.js
Normal file
217
src/views/Community/components/EventCard/DynamicNewsEventCard.js
Normal file
@@ -0,0 +1,217 @@
|
||||
// src/views/Community/components/EventCard/DynamicNewsEventCard.js
|
||||
// 动态新闻事件卡片组件(纵向布局,时间在上)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Card,
|
||||
CardBody,
|
||||
Box,
|
||||
Text,
|
||||
HStack,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
PopoverArrow,
|
||||
Portal,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import { getImportanceConfig, getAllImportanceLevels } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
import EventFollowButton from './EventFollowButton';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
|
||||
/**
|
||||
* 动态新闻事件卡片组件(极简版)
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象
|
||||
* @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 - 切换关注事件
|
||||
* @param {Object} props.timelineStyle - 时间轴样式配置
|
||||
* @param {string} props.borderColor - 边框颜色
|
||||
*/
|
||||
const DynamicNewsEventCard = ({
|
||||
event,
|
||||
index,
|
||||
isFollowing,
|
||||
followerCount,
|
||||
isSelected = false,
|
||||
onEventClick,
|
||||
onTitleClick,
|
||||
onToggleFollow,
|
||||
timelineStyle,
|
||||
borderColor,
|
||||
}) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={2} w="full">
|
||||
{/* 时间标签 - 在卡片上方 */}
|
||||
<Box
|
||||
{...(timelineStyle.bgGradient ? { bgGradient: timelineStyle.bgGradient } : { bg: timelineStyle.bg })}
|
||||
borderWidth={timelineStyle.borderWidth}
|
||||
borderColor={timelineStyle.borderColor}
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
py={1.5}
|
||||
textAlign="center"
|
||||
boxShadow={timelineStyle.boxShadow}
|
||||
transition="all 0.3s ease"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={timelineStyle.textColor}
|
||||
lineHeight="1.3"
|
||||
>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
position="relative"
|
||||
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={isSelected ? "lg" : "sm"}
|
||||
overflow="hidden"
|
||||
_hover={{
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
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}
|
||||
followerCount={followerCount}
|
||||
onToggle={() => onToggleFollow?.(event.id)}
|
||||
size="xs"
|
||||
showCount={false}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* 标题 - 最多两行,添加上边距避免与角标重叠 */}
|
||||
<Box
|
||||
cursor="pointer"
|
||||
onClick={(e) => onTitleClick?.(e, event)}
|
||||
mt={1}
|
||||
paddingRight="10px"
|
||||
>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="semibold"
|
||||
color={linkColor}
|
||||
lineHeight="1.4"
|
||||
noOfLines={2}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 第二行:涨跌幅数据 */}
|
||||
<StockChangeIndicators
|
||||
avgChange={event.related_avg_chg}
|
||||
maxChange={event.related_max_chg}
|
||||
weekChange={event.related_week_chg}
|
||||
/>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicNewsEventCard;
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/EventCard/EventFollowButton.js
|
||||
import React from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { StarIcon } from '@chakra-ui/icons';
|
||||
import { IconButton, Box } from '@chakra-ui/react';
|
||||
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
|
||||
|
||||
/**
|
||||
* 事件关注按钮组件
|
||||
@@ -19,7 +19,7 @@ const EventFollowButton = ({
|
||||
size = 'sm',
|
||||
showCount = true
|
||||
}) => {
|
||||
const iconSize = size === 'xs' ? '10px' : '12px';
|
||||
const iconSize = size === 'xs' ? '16px' : size === 'sm' ? '18px' : '22px';
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -27,16 +27,38 @@ const EventFollowButton = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
colorScheme="yellow"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
leftIcon={<StarIcon boxSize={iconSize} />}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'}
|
||||
{showCount && followerCount > 0 && `(${followerCount})`}
|
||||
</Button>
|
||||
<Box display="inline-flex" alignItems="center" gap={1}>
|
||||
<IconButton
|
||||
size={size}
|
||||
colorScheme="yellow"
|
||||
variant="ghost"
|
||||
bg="whiteAlpha.500"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.800',
|
||||
boxShadow: 'md'
|
||||
}}
|
||||
icon={
|
||||
isFollowing ? (
|
||||
<AiFillStar
|
||||
size={iconSize}
|
||||
color="gold"
|
||||
/>
|
||||
) : (
|
||||
<AiOutlineStar
|
||||
size={iconSize}
|
||||
color="#718096"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={handleClick}
|
||||
aria-label={isFollowing ? '取消关注' : '关注'}
|
||||
/>
|
||||
{/* <Box fontSize="xs" color="gray.500">
|
||||
{followerCount || 0}
|
||||
</Box> */}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -349,12 +349,13 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
<Container maxW="container.xl">
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧占位 */}
|
||||
<Box flex="1" />
|
||||
<Box key="left-spacer" flex="1" />
|
||||
|
||||
{/* 中间:分页器 */}
|
||||
{pagination.total > 0 && localEvents.length > 0 ? (
|
||||
<Flex align="center" gap={2}>
|
||||
<Flex key="pagination-controls" align="center" gap={2}>
|
||||
<Button
|
||||
key="prev-page"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(pagination.current - 1)}
|
||||
@@ -362,10 +363,11 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Text fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
|
||||
<Text key="page-info" fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
|
||||
第 {pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)} 页
|
||||
</Text>
|
||||
<Button
|
||||
key="next-page"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(pagination.current + 1)}
|
||||
@@ -373,18 +375,19 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Text fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
|
||||
<Text key="total-count" fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
|
||||
共 {pagination.total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box flex="1" />
|
||||
<Box key="center-spacer" flex="1" />
|
||||
)}
|
||||
|
||||
{/* 右侧:控制按钮 */}
|
||||
<Flex align="center" gap={3} flex="1" justify="flex-end">
|
||||
<Flex key="right-controls" align="center" gap={3} flex="1" justify="flex-end">
|
||||
{/* WebSocket 连接状态 */}
|
||||
<Badge
|
||||
key="websocket-status"
|
||||
colorScheme={isConnected ? 'green' : 'red'}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
@@ -395,7 +398,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
</Badge>
|
||||
|
||||
{/* 桌面推送开关 */}
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<FormControl key="push-notification" display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="push-notification" mb="0" fontSize="xs" color={textColor} mr={2}>
|
||||
推送
|
||||
</FormLabel>
|
||||
@@ -420,7 +423,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
</FormControl>
|
||||
|
||||
{/* 视图切换控制 */}
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<FormControl key="compact-mode" display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="xs" color={textColor} mr={2}>
|
||||
精简
|
||||
</FormLabel>
|
||||
@@ -440,7 +443,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
{/* 事件列表内容 */}
|
||||
<Container maxW="container.xl">
|
||||
{localEvents.length > 0 ? (
|
||||
<VStack align="stretch" spacing={0}>
|
||||
<VStack key="event-list" align="stretch" spacing={0}>
|
||||
{localEvents.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
<EventCard
|
||||
@@ -460,10 +463,10 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center h="300px">
|
||||
<Center key="empty-state" h="300px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
<InfoIcon key="empty-icon" boxSize={12} color={mutedColor} />
|
||||
<Text key="empty-text" color={mutedColor} fontSize="lg">
|
||||
暂无事件数据
|
||||
</Text>
|
||||
</VStack>
|
||||
@@ -472,6 +475,7 @@ const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetai
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
key="bottom-pagination"
|
||||
current={pagination.current}
|
||||
total={pagination.total}
|
||||
pageSize={pagination.pageSize}
|
||||
|
||||
300
src/views/Community/components/MarketReviewCard.js
Normal file
300
src/views/Community/components/MarketReviewCard.js
Normal file
@@ -0,0 +1,300 @@
|
||||
// src/views/Community/components/MarketReviewCard.js
|
||||
// 市场复盘组件(左右布局:事件列表 | 事件详情)
|
||||
|
||||
import React, { forwardRef, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Center,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Grid,
|
||||
GridItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import CompactEventCard from './EventCard/CompactEventCard';
|
||||
import EventHeader from './EventCard/EventHeader';
|
||||
import EventStats from './EventCard/EventStats';
|
||||
import EventFollowButton from './EventCard/EventFollowButton';
|
||||
import EventPriceDisplay from './EventCard/EventPriceDisplay';
|
||||
import EventDescription from './EventCard/EventDescription';
|
||||
import { getImportanceConfig } from '../../../constants/importanceLevels';
|
||||
|
||||
/**
|
||||
* 市场复盘 - 左右布局卡片组件
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Function} onToggleFollow - 切换关注回调
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const MarketReviewCard = forwardRef(({
|
||||
events,
|
||||
loading,
|
||||
lastUpdateTime,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
onToggleFollow,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const selectedBg = useColorModeValue('blue.50', 'blue.900');
|
||||
|
||||
// 选中的事件
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
// 时间轴样式配置
|
||||
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',
|
||||
};
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (event) => {
|
||||
setSelectedEvent(event);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染右侧事件详情
|
||||
const renderEventDetail = () => {
|
||||
if (!selectedEvent) {
|
||||
return (
|
||||
<Center h="full" minH="400px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
请从左侧选择事件查看详情
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const importance = getImportanceConfig(selectedEvent.importance);
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
h="full"
|
||||
>
|
||||
<CardBody p={6}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 第一行:标题+优先级 | 统计+关注 */}
|
||||
<Flex align="center" justify="space-between" gap={3}>
|
||||
{/* 左侧:标题 + 优先级标签 */}
|
||||
<EventHeader
|
||||
title={selectedEvent.title}
|
||||
importance={selectedEvent.importance}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onViewDetail) {
|
||||
onViewDetail(e, selectedEvent.id);
|
||||
}
|
||||
}}
|
||||
linkColor={linkColor}
|
||||
compact={false}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* 右侧:统计数据 + 关注按钮 */}
|
||||
<HStack spacing={4} flexShrink={0}>
|
||||
{/* 统计数据 */}
|
||||
<EventStats
|
||||
viewCount={selectedEvent.view_count}
|
||||
postCount={selectedEvent.post_count}
|
||||
followerCount={selectedEvent.follower_count}
|
||||
size="md"
|
||||
spacing={4}
|
||||
display="flex"
|
||||
mutedColor={mutedColor}
|
||||
/>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={false}
|
||||
followerCount={selectedEvent.follower_count}
|
||||
onToggle={() => onToggleFollow && onToggleFollow(selectedEvent.id)}
|
||||
size="sm"
|
||||
showCount={false}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 第二行:价格标签 | 时间+作者 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
|
||||
{/* 左侧:价格标签 */}
|
||||
<EventPriceDisplay
|
||||
avgChange={selectedEvent.related_avg_chg}
|
||||
maxChange={selectedEvent.related_max_chg}
|
||||
weekChange={selectedEvent.related_week_chg}
|
||||
compact={false}
|
||||
/>
|
||||
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 第三行:描述文字 */}
|
||||
<EventDescription
|
||||
description={selectedEvent.description}
|
||||
textColor={textColor}
|
||||
minLength={200}
|
||||
noOfLines={10}
|
||||
/>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>市场复盘</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="orange">复盘</Badge>
|
||||
<Badge colorScheme="purple">总结</Badge>
|
||||
<Badge colorScheme="gray">完整</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody>
|
||||
{/* Loading 状态 */}
|
||||
{loading && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载复盘数据...</Text>
|
||||
</VStack>
|
||||
</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 && (
|
||||
<Grid templateColumns="1fr 2fr" gap={6} minH="500px">
|
||||
{/* 左侧:事件列表 (33.3%) */}
|
||||
<GridItem>
|
||||
<Box
|
||||
overflowY="auto"
|
||||
maxH="600px"
|
||||
pr={2}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-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'),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{events.map((event, index) => (
|
||||
<Box
|
||||
key={event.id}
|
||||
onClick={() => handleEventClick(event)}
|
||||
cursor="pointer"
|
||||
bg={selectedEvent?.id === event.id ? selectedBg : 'transparent'}
|
||||
borderRadius="md"
|
||||
transition="all 0.2s"
|
||||
_hover={{ bg: selectedBg }}
|
||||
>
|
||||
<CompactEventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isFollowing={false}
|
||||
followerCount={event.follower_count || 0}
|
||||
onEventClick={() => handleEventClick(event)}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEventClick(event);
|
||||
}}
|
||||
onViewDetail={onViewDetail}
|
||||
onToggleFollow={() => {}}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
{/* 右侧:事件详情 (66.7%) */}
|
||||
<GridItem>
|
||||
{renderEventDetail()}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
MarketReviewCard.displayName = 'MarketReviewCard';
|
||||
|
||||
export default MarketReviewCard;
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
*
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间(可选)
|
||||
* @param {Function} onClick - 点击回调(可选)
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime }) {
|
||||
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick }) {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
@@ -162,7 +163,14 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
}, [data, loading, stableEventTime]);
|
||||
|
||||
return (
|
||||
<div style={{ width: 140, height: 40 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 140,
|
||||
height: 40,
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
@@ -172,9 +180,10 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数,只有当stockCode或eventTime变化时才重新渲染
|
||||
// 自定义比较函数,只有当stockCode、eventTime或onClick变化时才重新渲染
|
||||
return prevProps.stockCode === nextProps.stockCode &&
|
||||
prevProps.eventTime === nextProps.eventTime;
|
||||
prevProps.eventTime === nextProps.eventTime &&
|
||||
prevProps.onClick === nextProps.onClick;
|
||||
});
|
||||
|
||||
export default MiniTimelineChart;
|
||||
|
||||
@@ -15,11 +15,12 @@ const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数
|
||||
* 获取缓存键
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {string} chartType - 图表类型(timeline/daily)
|
||||
* @returns {string} 缓存键
|
||||
*/
|
||||
export const getCacheKey = (stockCode, eventTime) => {
|
||||
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => {
|
||||
const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD');
|
||||
return `${stockCode}|${date}`;
|
||||
return `${stockCode}|${date}|${chartType}`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -52,10 +53,11 @@ export const shouldRefreshData = (cacheKey) => {
|
||||
* 获取K线数据(带缓存和防重复请求)
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间
|
||||
* @param {string} chartType - 图表类型(timeline/daily)
|
||||
* @returns {Promise<Array>} K线数据
|
||||
*/
|
||||
export const fetchKlineData = async (stockCode, eventTime) => {
|
||||
const cacheKey = getCacheKey(stockCode, eventTime);
|
||||
export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline') => {
|
||||
const cacheKey = getCacheKey(stockCode, eventTime, chartType);
|
||||
|
||||
// 1. 检查缓存
|
||||
if (klineDataCache.has(cacheKey)) {
|
||||
@@ -73,10 +75,10 @@ export const fetchKlineData = async (stockCode, eventTime) => {
|
||||
}
|
||||
|
||||
// 3. 发起新请求
|
||||
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey });
|
||||
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType });
|
||||
const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const requestPromise = stockService
|
||||
.getKlineData(stockCode, 'timeline', normalizedEventTime)
|
||||
.getKlineData(stockCode, chartType, normalizedEventTime)
|
||||
.then((res) => {
|
||||
const data = Array.isArray(res?.data) ? res.data : [];
|
||||
// 更新缓存
|
||||
@@ -86,12 +88,13 @@ export const fetchKlineData = async (stockCode, eventTime) => {
|
||||
pendingRequests.delete(cacheKey);
|
||||
logger.debug('klineDataCache', 'K线数据请求完成并缓存', {
|
||||
cacheKey,
|
||||
chartType,
|
||||
dataPoints: data.length
|
||||
});
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('klineDataCache', 'fetchKlineData', error, { stockCode, cacheKey });
|
||||
logger.error('klineDataCache', 'fetchKlineData', error, { stockCode, chartType, cacheKey });
|
||||
// 清除pending状态
|
||||
pendingRequests.delete(cacheKey);
|
||||
// 如果有旧缓存,返回旧数据
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
// src/views/Community/index.js
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
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,
|
||||
@@ -11,6 +16,8 @@ import {
|
||||
|
||||
// 导入组件
|
||||
import EventTimelineCard from './components/EventTimelineCard';
|
||||
import DynamicNewsCard from './components/DynamicNewsCard';
|
||||
import MarketReviewCard from './components/MarketReviewCard';
|
||||
import HotEventsSection from './components/HotEventsSection';
|
||||
import EventModals from './components/EventModals';
|
||||
|
||||
@@ -19,6 +26,13 @@ import { useEventData } from './hooks/useEventData';
|
||||
import { useEventFilters } from './hooks/useEventFilters';
|
||||
import { useCommunityEvents } from './hooks/useCommunityEvents';
|
||||
|
||||
// 导入时间工具函数
|
||||
import {
|
||||
getCurrentTradingTimeRange,
|
||||
getMarketReviewTimeRange,
|
||||
filterEventsByTimeRange
|
||||
} from '../../utils/tradingTimeUtils';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
@@ -33,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');
|
||||
@@ -40,6 +61,7 @@ const Community = () => {
|
||||
// Ref:用于滚动到实时事件时间轴
|
||||
const eventTimelineRef = useRef(null);
|
||||
const hasScrolledRef = useRef(false); // 标记是否已滚动
|
||||
const containerRef = useRef(null); // 用于首次滚动到内容区域
|
||||
|
||||
// ⚡ 通知权限引导
|
||||
const { showCommunityGuide } = useNotification();
|
||||
@@ -60,12 +82,40 @@ const Community = () => {
|
||||
|
||||
const { events, pagination, loading, lastUpdateTime } = useEventData(filters);
|
||||
|
||||
// 加载热门关键词和热点事件(使用Redux,内部有缓存判断)
|
||||
// 计算市场复盘的时间范围和过滤后的事件
|
||||
const marketReviewData = useMemo(() => {
|
||||
const timeRange = getMarketReviewTimeRange();
|
||||
const filteredEvents = filterEventsByTimeRange(events, timeRange.startTime, timeRange.endTime);
|
||||
logger.debug('Community', '市场复盘时间范围', {
|
||||
description: timeRange.description,
|
||||
rangeType: timeRange.rangeType,
|
||||
eventCount: filteredEvents.length
|
||||
});
|
||||
return {
|
||||
events: filteredEvents,
|
||||
timeRange
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
// 加载热门关键词和热点事件(动态新闻由 DynamicNewsCard 内部管理)
|
||||
useEffect(() => {
|
||||
dispatch(fetchPopularKeywords());
|
||||
dispatch(fetchHotEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// 每5分钟刷新一次动态新闻(使用 prependMode 追加到头部)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
dispatch(fetchDynamicNews({
|
||||
page: 1,
|
||||
per_page: 10, // 获取最新的10条
|
||||
prependMode: true // 追加到头部,不清空缓存
|
||||
}));
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch]);
|
||||
|
||||
// 🎯 PostHog 追踪:页面浏览
|
||||
// useEffect(() => {
|
||||
// track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
@@ -86,7 +136,7 @@ const Community = () => {
|
||||
industryFilter: filters.industry_code,
|
||||
});
|
||||
}
|
||||
}, [events, loading, pagination, filters, communityEvents]);
|
||||
}, [events, loading, pagination, filters]);
|
||||
|
||||
// ⚡ 首次访问社区时,延迟显示权限引导
|
||||
useEffect(() => {
|
||||
@@ -100,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) {
|
||||
@@ -116,12 +183,39 @@ 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} />
|
||||
|
||||
{/* 实时事件 */}
|
||||
<EventTimelineCard
|
||||
{/* 实时要闻·动态追踪 - 横向滚动 */}
|
||||
<DynamicNewsCard
|
||||
mt={6}
|
||||
allCachedEvents={allCachedEvents}
|
||||
loading={dynamicNewsLoading}
|
||||
total={dynamicNewsTotal}
|
||||
cachedCount={dynamicNewsCachedCount}
|
||||
filters={filters}
|
||||
popularKeywords={popularKeywords}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
onSearch={updateFilters}
|
||||
onSearchFocus={scrollToTimeline}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
|
||||
{/* 市场复盘 - 左右布局 */}
|
||||
{/* <MarketReviewCard
|
||||
mt={6}
|
||||
events={marketReviewData.events}
|
||||
loading={loading}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
onToggleFollow={() => {}}
|
||||
/> */}
|
||||
|
||||
{/* 实时事件 - 原纵向列表 */}
|
||||
{/* <EventTimelineCard
|
||||
ref={eventTimelineRef}
|
||||
mt={6}
|
||||
events={events}
|
||||
@@ -135,7 +229,7 @@ const Community = () => {
|
||||
onPageChange={handlePageChange}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
/> */}
|
||||
</Container>
|
||||
|
||||
{/* 事件弹窗 */}
|
||||
|
||||
@@ -406,12 +406,13 @@ const ConceptTimelineModal = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
{isOpen && (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="1400px" m={4}>
|
||||
<ModalHeader
|
||||
@@ -747,7 +748,7 @@ const ConceptTimelineModal = ({
|
||||
onClick={() => {
|
||||
if (event.type === 'news') {
|
||||
// 🎯 追踪新闻点击和详情打开
|
||||
trackNewsClicked(event, date);
|
||||
trackNewsClicked(event, item.date);
|
||||
trackNewsDetailOpened(event);
|
||||
|
||||
setSelectedNews({
|
||||
@@ -760,7 +761,7 @@ const ConceptTimelineModal = ({
|
||||
setIsNewsModalOpen(true);
|
||||
} else if (event.type === 'report') {
|
||||
// 🎯 追踪研报点击和详情打开
|
||||
trackReportClicked(event, date);
|
||||
trackReportClicked(event, item.date);
|
||||
trackReportDetailOpened(event);
|
||||
|
||||
setSelectedReport({
|
||||
@@ -840,14 +841,16 @@ const ConceptTimelineModal = ({
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 研报全文Modal */}
|
||||
<Modal
|
||||
isOpen={isReportModalOpen}
|
||||
onClose={() => setIsReportModalOpen(false)}
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
{isReportModalOpen && (
|
||||
<Modal
|
||||
isOpen={isReportModalOpen}
|
||||
onClose={() => setIsReportModalOpen(false)}
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader bg="green.500" color="white">
|
||||
@@ -919,14 +922,16 @@ const ConceptTimelineModal = ({
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 新闻全文Modal */}
|
||||
<Modal
|
||||
isOpen={isNewsModalOpen}
|
||||
onClose={() => setIsNewsModalOpen(false)}
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
{isNewsModalOpen && (
|
||||
<Modal
|
||||
isOpen={isNewsModalOpen}
|
||||
onClose={() => setIsNewsModalOpen(false)}
|
||||
size="4xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader bg="blue.500" color="white">
|
||||
@@ -989,6 +994,7 @@ const ConceptTimelineModal = ({
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -283,7 +283,7 @@ export default function CenterDashboard() {
|
||||
icon={<FiPlus />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/stock-analysis/overview')}
|
||||
onClick={() => navigate('/stocks')}
|
||||
aria-label="添加自选股"
|
||||
/>
|
||||
</Flex>
|
||||
@@ -300,7 +300,7 @@ export default function CenterDashboard() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="blue"
|
||||
onClick={() => navigate('/stock-analysis/overview')}
|
||||
onClick={() => navigate('/stocks')}
|
||||
>
|
||||
添加自选股
|
||||
</Button>
|
||||
@@ -321,7 +321,7 @@ export default function CenterDashboard() {
|
||||
<VStack align="start" spacing={0}>
|
||||
<LinkOverlay
|
||||
as={Link}
|
||||
to={`/stock-analysis/company?scode=${stock.stock_code}`}
|
||||
to={`/company/${stock.stock_code}`}
|
||||
>
|
||||
<Text fontWeight="medium" fontSize="sm">
|
||||
{stock.stock_name || stock.stock_code}
|
||||
@@ -365,7 +365,7 @@ export default function CenterDashboard() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/stock-analysis/overview')}
|
||||
onClick={() => navigate('/stocks')}
|
||||
>
|
||||
查看全部 ({watchlist.length})
|
||||
</Button>
|
||||
|
||||
@@ -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,92 +8,85 @@ import {
|
||||
Text,
|
||||
Badge,
|
||||
Button,
|
||||
Collapse,
|
||||
useDisclosure,
|
||||
Skeleton,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
Tooltip,
|
||||
Spinner,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
Spinner,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Link
|
||||
Link,
|
||||
Flex,
|
||||
Collapse
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaExclamationTriangle,
|
||||
FaClock,
|
||||
FaCalendarAlt,
|
||||
FaChartLine,
|
||||
FaEye,
|
||||
FaTimes,
|
||||
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 [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [eventStocks, setEventStocks] = useState({});
|
||||
const [loadingStocks, setLoadingStocks] = useState(false);
|
||||
events = [],
|
||||
expectationScore = null,
|
||||
loading = false,
|
||||
error = null
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
// 状态管理
|
||||
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 showEventStocks = async (event) => {
|
||||
setSelectedEvent(event);
|
||||
setLoadingStocks(true);
|
||||
onOpen();
|
||||
const getEventContent = (event) => {
|
||||
return event?.content || event?.description || event?.summary;
|
||||
};
|
||||
|
||||
// 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]);
|
||||
|
||||
// 点击相关股票按钮
|
||||
const handleViewStocks = async (event) => {
|
||||
setSelectedEventForStocks(event);
|
||||
setStocksModalOpen(true);
|
||||
|
||||
// 如果已经加载过该事件的股票数据,不再重复加载
|
||||
if (eventStocks[event.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记为加载中
|
||||
setLoadingStocks(prev => ({ ...prev, [event.id]: true }));
|
||||
|
||||
try {
|
||||
// 如果已经加载过该事件的股票数据,直接使用缓存
|
||||
if (eventStocks[event.id]) {
|
||||
setLoadingStocks(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用API获取历史事件相关股票
|
||||
const response = await stockService.getHistoricalEventStocks(event.id);
|
||||
setEventStocks(prev => ({
|
||||
@@ -100,7 +94,7 @@ const HistoricalEvents = ({
|
||||
[event.id]: response.data || []
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error('HistoricalEvents', 'showEventStocks', err, {
|
||||
logger.error('HistoricalEvents', 'handleViewStocks', err, {
|
||||
eventId: event.id,
|
||||
eventTitle: event.title
|
||||
});
|
||||
@@ -109,15 +103,19 @@ const HistoricalEvents = ({
|
||||
[event.id]: []
|
||||
}));
|
||||
} finally {
|
||||
setLoadingStocks(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}`);
|
||||
};
|
||||
|
||||
// 获取重要性颜色
|
||||
@@ -149,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -263,216 +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} />}
|
||||
onClick={() => showEventStocks(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>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</VStack>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
{/* 事件相关股票模态框 */}
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="4xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="80vw" maxH="85vh">
|
||||
<ModalHeader>
|
||||
{/* 超预期得分显示 */}
|
||||
{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>{selectedEvent?.title || '历史事件'}</Text>
|
||||
<Text fontSize="sm" color={textSecondary} fontWeight="normal">
|
||||
相关股票信息
|
||||
<Text fontSize="sm" fontWeight="bold" color="yellow.800">
|
||||
超预期得分: {expectationScore}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="yellow.700">
|
||||
基于历史事件判断当前事件的超预期情况,满分100分(AI合成)
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ModalBody overflowY="auto" maxH="calc(85vh - 180px)">
|
||||
{loadingStocks ? (
|
||||
<VStack spacing={4} py={8}>
|
||||
<Spinner size="lg" color="blue.500" />
|
||||
<Text color={textSecondary}>加载相关股票数据...</Text>
|
||||
{/* 历史事件卡片网格 */}
|
||||
<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>
|
||||
) : (
|
||||
<StocksList
|
||||
stocks={selectedEvent ? eventStocks[selectedEvent.id] || [] : []}
|
||||
eventTradingDate={selectedEvent ? selectedEvent.event_date : null}
|
||||
/>
|
||||
)}
|
||||
</ModalBody>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{/* 相关股票 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) => {
|
||||
@@ -497,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>
|
||||
@@ -509,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')}>
|
||||
@@ -516,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,629 +0,0 @@
|
||||
// src/views/Home/HomePage.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Text,
|
||||
Button,
|
||||
Container,
|
||||
VStack,
|
||||
HStack,
|
||||
Icon,
|
||||
Heading,
|
||||
useBreakpointValue,
|
||||
Link,
|
||||
SimpleGrid,
|
||||
Divider
|
||||
} from '@chakra-ui/react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext'; // 添加这个导入来调试
|
||||
import heroBg from 'assets/img/BackgroundCard1.png';
|
||||
import teamWorkingImg from 'assets/img/background-card-reports.png';
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { user, isAuthenticated, isLoading } = useAuth(); // 添加这行来调试
|
||||
|
||||
// 添加调试信息
|
||||
useEffect(() => {
|
||||
console.log('🏠 HomePage AuthContext 状态:', {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
hasUser: !!user,
|
||||
userInfo: user ? {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
nickname: user.nickname
|
||||
} : null
|
||||
});
|
||||
}, [user?.id, isAuthenticated, isLoading]); // 只依赖 user.id,避免无限循环
|
||||
|
||||
// 统计数据动画
|
||||
const [stats, setStats] = useState({
|
||||
dataSize: 0,
|
||||
dataSources: 0,
|
||||
researchTargets: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const targetStats = {
|
||||
dataSize: 17,
|
||||
dataSources: 300,
|
||||
researchTargets: 45646
|
||||
};
|
||||
|
||||
// 动画效果
|
||||
const animateStats = () => {
|
||||
const duration = 2000; // 2秒动画
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
setStats({
|
||||
dataSize: Math.floor(targetStats.dataSize * progress),
|
||||
dataSources: Math.floor(targetStats.dataSources * progress),
|
||||
researchTargets: Math.floor(targetStats.researchTargets * progress)
|
||||
});
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
};
|
||||
|
||||
const timer = setTimeout(animateStats, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* 临时调试信息栏 - 完成调试后可以删除 */}
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Box bg="yellow.100" p={2} fontSize="sm" borderBottom="1px solid" borderColor="yellow.300">
|
||||
<Text fontWeight="bold">🐛 调试信息:</Text>
|
||||
<Text>认证状态: {isAuthenticated ? '✅ 已登录' : '❌ 未登录'}</Text>
|
||||
<Text>加载状态: {isLoading ? '⏳ 加载中' : '✅ 加载完成'}</Text>
|
||||
<Text>用户信息: {user ? `👤 ${user.nickname || user.username} (ID: ${user.id})` : '❌ 无用户信息'}</Text>
|
||||
<Text>localStorage: {localStorage.getItem('user') ? '✅ 有数据' : '❌ 无数据'}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Hero Section - Brainwave风格 */}
|
||||
<Box
|
||||
position="relative"
|
||||
minH="100vh"
|
||||
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 同心圆背景装饰 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
w="78rem"
|
||||
h="78rem"
|
||||
border="1px solid"
|
||||
borderColor="brainwave.n6"
|
||||
borderRadius="50%"
|
||||
opacity={0.3}
|
||||
zIndex={0}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
w="65rem"
|
||||
h="65rem"
|
||||
border="1px solid"
|
||||
borderColor="brainwave.n6"
|
||||
borderRadius="50%"
|
||||
opacity={0.2}
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
w="51rem"
|
||||
h="51rem"
|
||||
border="1px solid"
|
||||
borderColor="brainwave.n6"
|
||||
borderRadius="50%"
|
||||
opacity={0.1}
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
w="36rem"
|
||||
h="36rem"
|
||||
border="1px solid"
|
||||
borderColor="brainwave.n6"
|
||||
borderRadius="50%"
|
||||
opacity={0.1}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 动态装饰点 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="30%"
|
||||
right="20%"
|
||||
w="4"
|
||||
h="4"
|
||||
bg="linear-gradient(135deg, #AC6AFF, #1A1A32)"
|
||||
borderRadius="50%"
|
||||
animation="float 3s ease-in-out infinite"
|
||||
zIndex={1}
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="40%"
|
||||
left="15%"
|
||||
w="6"
|
||||
h="6"
|
||||
bg="linear-gradient(135deg, #7ADB78, #1A1A32)"
|
||||
borderRadius="50%"
|
||||
animation="float 4s ease-in-out infinite 0.5s"
|
||||
zIndex={1}
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="20%"
|
||||
left="25%"
|
||||
w="3"
|
||||
h="3"
|
||||
bg="linear-gradient(135deg, #FFC876, #1A1A32)"
|
||||
borderRadius="50%"
|
||||
animation="float 2.5s ease-in-out infinite 1s"
|
||||
zIndex={1}
|
||||
/>
|
||||
|
||||
{/* 主要内容 */}
|
||||
<Container maxW="container.lg" position="relative" zIndex={2}>
|
||||
<VStack spacing={8} textAlign="center" color="brainwave.n1">
|
||||
<Heading
|
||||
as="h1"
|
||||
fontSize={{ base: "3xl", md: "5xl", lg: "6xl" }}
|
||||
fontWeight="bold"
|
||||
lineHeight={1.1}
|
||||
letterSpacing="tight"
|
||||
>
|
||||
探索
|
||||
<Text as="span" bgGradient="linear(to-r, brainwave.purple, brainwave.orange)" bgClip="text">
|
||||
人工智能
|
||||
</Text>
|
||||
的无限可能
|
||||
<br />
|
||||
<Text as="span" fontSize={{ base: "2xl", md: "4xl", lg: "5xl" }} color="brainwave.n2">
|
||||
价值前沿 AI 投研助手
|
||||
</Text>
|
||||
</Heading>
|
||||
<Text
|
||||
fontSize={{ base: "lg", md: "xl" }}
|
||||
color="brainwave.n2"
|
||||
maxW="600px"
|
||||
lineHeight={1.6}
|
||||
>
|
||||
释放AI的力量,升级您的投研效率。
|
||||
体验超越ChatGPT的专业投资分析平台。
|
||||
</Text>
|
||||
<Button
|
||||
size="lg"
|
||||
bg="linear-gradient(135deg, #AC6AFF, #FFC876)"
|
||||
color="white"
|
||||
_hover={{
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: "0 8px 32px rgba(172, 106, 255, 0.3)"
|
||||
}}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
transition="all 0.2s"
|
||||
borderRadius="full"
|
||||
px={8}
|
||||
py={6}
|
||||
fontSize="md"
|
||||
fontWeight="semibold"
|
||||
onClick={() => navigate('/community')}
|
||||
>
|
||||
开始体验
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
{/* 渐变底部 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
height="100px"
|
||||
zIndex={2}
|
||||
bg="linear-gradient(to top, white 0%, transparent 100%)"
|
||||
/>
|
||||
|
||||
{/* CSS动画定义 */}
|
||||
<style jsx>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
`}</style>
|
||||
</Box>
|
||||
|
||||
{/* 统计数据区域 - 玻璃拟态效果 */}
|
||||
<Box py={12} position="relative" mt={-20} zIndex={3}>
|
||||
<Container maxW="container.lg">
|
||||
<Box
|
||||
bg="rgba(255, 255, 255, 0.1)"
|
||||
backdropFilter="blur(20px)"
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.2)"
|
||||
boxShadow="0 25px 50px rgba(0, 0, 0, 0.25)"
|
||||
p={8}
|
||||
position="relative"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
borderRadius: "xl",
|
||||
background: "linear-gradient(135deg, rgba(172, 106, 255, 0.1) 0%, rgba(255, 200, 118, 0.1) 100%)",
|
||||
zIndex: -1
|
||||
}}
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={8}>
|
||||
<VStack textAlign="center" spacing={4}>
|
||||
<Heading
|
||||
size="2xl"
|
||||
bgGradient="linear(to-r, brainwave.purple, brainwave.orange)"
|
||||
bgClip="text"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{stats.dataSize}TB
|
||||
</Heading>
|
||||
<Heading size="lg" color="brainwave.n1" fontWeight="semibold">基础数据</Heading>
|
||||
<Text color="brainwave.n2" fontSize="sm" lineHeight={1.6}>
|
||||
我们收集来自全世界的各类数据,打造您的专属智能投资助手
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack textAlign="center" spacing={4}>
|
||||
<Heading
|
||||
size="2xl"
|
||||
bgGradient="linear(to-r, brainwave.green, brainwave.blue)"
|
||||
bgClip="text"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{stats.dataSources}+
|
||||
</Heading>
|
||||
<Heading size="lg" color="brainwave.n1" fontWeight="semibold">数据源</Heading>
|
||||
<Text color="brainwave.n2" fontSize="sm" lineHeight={1.6}>
|
||||
我们即时采集来自300多家数据源的实时数据,随时满足您的投研需求
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack textAlign="center" spacing={4}>
|
||||
<Heading
|
||||
size="2xl"
|
||||
bgGradient="linear(to-r, brainwave.pink, brainwave.red)"
|
||||
bgClip="text"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{stats.researchTargets.toLocaleString()}
|
||||
</Heading>
|
||||
<Heading size="lg" color="brainwave.n1" fontWeight="semibold">研究标的</Heading>
|
||||
<Text color="brainwave.n2" fontSize="sm" lineHeight={1.6}>
|
||||
我们的研究范围涵盖全球主流市场,包括股票、外汇、大宗等交易类型,给您足够宏观的视角
|
||||
</Text>
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* 特色功能介绍 - Brainwave深色风格 */}
|
||||
<Box as="section" py={20} bg="brainwave.n8" position="relative" overflow="hidden">
|
||||
{/* 背景装饰几何图形 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="10%"
|
||||
right="-5%"
|
||||
w="300px"
|
||||
h="300px"
|
||||
border="1px solid"
|
||||
borderColor="brainwave.n6"
|
||||
borderRadius="50%"
|
||||
opacity={0.1}
|
||||
zIndex={0}
|
||||
/>
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="10%"
|
||||
left="-5%"
|
||||
w="200px"
|
||||
h="200px"
|
||||
border="1px solid"
|
||||
borderColor="brainwave.n6"
|
||||
borderRadius="50%"
|
||||
opacity={0.1}
|
||||
zIndex={0}
|
||||
/>
|
||||
|
||||
<Container maxW="container.xl" position="relative" zIndex={1}>
|
||||
<Flex align="center" gap={16}>
|
||||
{/* 左侧功能介绍 - 深色主题版本 */}
|
||||
<Box flex="1" ml="auto">
|
||||
{/* 第一行 */}
|
||||
<SimpleGrid columns={2} spacing={8} mb={12}>
|
||||
<Box>
|
||||
<VStack align="start" spacing={4}>
|
||||
<Box className="icon icon-sm">
|
||||
<Icon viewBox="0 0 40 44" w="25px" h="25px" color="brainwave.purple">
|
||||
<path fill="currentColor" d="M40,40 L36.3636364,40 L36.3636364,3.63636364 L5.45454545,3.63636364 L5.45454545,0 L38.1818182,0 C39.1854545,0 40,0.814545455 40,1.81818182 L40,40 Z" opacity="0.603585379"/>
|
||||
<path fill="currentColor" d="M30.9090909,7.27272727 L1.81818182,7.27272727 C0.814545455,7.27272727 0,8.08727273 0,9.09090909 L0,41.8181818 C0,42.8218182 0.814545455,43.6363636 1.81818182,43.6363636 L30.9090909,43.6363636 C31.9127273,43.6363636 32.7272727,42.8218182 32.7272727,41.8181818 L32.7272727,9.09090909 C32.7272727,8.08727273 31.9127273,7.27272727 30.9090909,7.27272727 Z M18.1818182,34.5454545 L7.27272727,34.5454545 L7.27272727,30.9090909 L18.1818182,30.9090909 L18.1818182,34.5454545 Z M25.4545455,27.2727273 L7.27272727,27.2727273 L7.27272727,23.6363636 L25.4545455,23.6363636 L25.4545455,27.2727273 Z M25.4545455,20 L7.27272727,20 L7.27272727,16.3636364 L25.4545455,16.3636364 L25.4545455,20 Z"/>
|
||||
</Icon>
|
||||
</Box>
|
||||
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">人工智能驱动</Heading>
|
||||
<Text color="brainwave.n2" fontSize="sm" pr="5" lineHeight={1.8}>
|
||||
• 收集海量投研资料和数据,确保信息全面丰富<br/>
|
||||
• 训练专注于投研的大语言模型,专业度领先<br/>
|
||||
• 在金融投资领域表现卓越,优于市面其他模型
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<VStack align="start" spacing={4}>
|
||||
<Box className="icon icon-sm">
|
||||
<Icon viewBox="0 0 45 40" w="25px" h="25px" color="brainwave.orange">
|
||||
<path fill="currentColor" d="M46.7199583,10.7414583 L40.8449583,0.949791667 C40.4909749,0.360605034 39.8540131,0 39.1666667,0 L7.83333333,0 C7.1459869,0 6.50902508,0.360605034 6.15504167,0.949791667 L0.280041667,10.7414583 C0.0969176761,11.0460037 -1.23209662e-05,11.3946378 -1.23209662e-05,11.75 C-0.00758042603,16.0663731 3.48367543,19.5725301 7.80004167,19.5833333 L7.81570833,19.5833333 C9.75003686,19.5882688 11.6168794,18.8726691 13.0522917,17.5760417 C16.0171492,20.2556967 20.5292675,20.2556967 23.494125,17.5760417 C26.4604562,20.2616016 30.9794188,20.2616016 33.94575,17.5760417 C36.2421905,19.6477597 39.5441143,20.1708521 42.3684437,18.9103691 C45.1927731,17.649886 47.0084685,14.8428276 47.0000295,11.75 C47.0000295,11.3946378 46.9030823,11.0460037 46.7199583,10.7414583 Z" opacity="0.598981585"/>
|
||||
<path fill="currentColor" d="M39.198,22.4912623 C37.3776246,22.4928106 35.5817531,22.0149171 33.951625,21.0951667 L33.92225,21.1107282 C31.1430221,22.6838032 27.9255001,22.9318916 24.9844167,21.7998837 C24.4750389,21.605469 23.9777983,21.3722567 23.4960833,21.1018359 L23.4745417,21.1129513 C20.6961809,22.6871153 17.4786145,22.9344611 14.5386667,21.7998837 C14.029926,21.6054643 13.533337,21.3722507 13.0522917,21.1018359 C11.4250962,22.0190609 9.63246555,22.4947009 7.81570833,22.4912623 C7.16510551,22.4842162 6.51607673,22.4173045 5.875,22.2911849 L5.875,44.7220845 C5.875,45.9498589 6.7517757,46.9451667 7.83333333,46.9451667 L19.5833333,46.9451667 L19.5833333,33.6066734 L27.4166667,33.6066734 L27.4166667,46.9451667 L39.1666667,46.9451667 C40.2482243,46.9451667 41.125,45.9498589 41.125,44.7220845 L41.125,22.2822926 C40.4887822,22.4116582 39.8442868,22.4815492 39.198,22.4912623 Z"/>
|
||||
</Icon>
|
||||
</Box>
|
||||
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">投研数据湖</Heading>
|
||||
<Text color="brainwave.n2" fontSize="sm" pr="3" lineHeight={1.8}>
|
||||
• AI Agent 24/7 全天候采集全球数据,确保实时更新<br/>
|
||||
• 整合多种数据源,覆盖范围广泛、信息丰富<br/>
|
||||
• 构建独特数据湖,提供业内无可比拟的数据深度
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 第二行 */}
|
||||
<SimpleGrid columns={2} spacing={8}>
|
||||
<Box mt={3}>
|
||||
<VStack align="start" spacing={4}>
|
||||
<Box className="icon icon-sm">
|
||||
<Icon viewBox="0 0 42 44" w="25px" h="25px" color="brainwave.green">
|
||||
<path fill="currentColor" d="M18.8086957,4.70034783 C15.3814926,0.343541521 9.0713063,-0.410050841 4.7145,3.01715217 C0.357693695,6.44435519 -0.395898667,12.7545415 3.03130435,17.1113478 C5.53738466,10.3360568 11.6337901,5.54042955 18.8086957,4.70034783 L18.8086957,4.70034783 Z" opacity="0.6"/>
|
||||
<path fill="currentColor" d="M38.9686957,17.1113478 C42.3958987,12.7545415 41.6423063,6.44435519 37.2855,3.01715217 C32.9286937,-0.410050841 26.6185074,0.343541521 23.1913043,4.70034783 C30.3662099,5.54042955 36.4626153,10.3360568 38.9686957,17.1113478 Z" opacity="0.6"/>
|
||||
<path fill="currentColor" d="M34.3815652,34.7668696 C40.2057958,27.7073059 39.5440671,17.3375603 32.869743,11.0755718 C26.1954189,4.81358341 15.8045811,4.81358341 9.13025701,11.0755718 C2.45593289,17.3375603 1.79420418,27.7073059 7.61843478,34.7668696 L3.9753913,40.0506522 C3.58549114,40.5871271 3.51710058,41.2928217 3.79673036,41.8941824 C4.07636014,42.4955431 4.66004722,42.8980248 5.32153275,42.9456105 C5.98301828,42.9931963 6.61830436,42.6784048 6.98113043,42.1232609 L10.2744783,37.3434783 C16.5555112,42.3298213 25.4444888,42.3298213 31.7255217,37.3434783 L35.0188696,42.1196087 C35.6014207,42.9211577 36.7169135,43.1118605 37.53266,42.5493622 C38.3484064,41.9868639 38.5667083,40.8764423 38.0246087,40.047 L34.3815652,34.7668696 Z M30.1304348,25.5652174 L21,25.5652174 C20.49574,25.5652174 20.0869565,25.1564339 20.0869565,24.6521739 L20.0869565,15.5217391 C20.0869565,15.0174791 20.49574,14.6086957 21,14.6086957 C21.50426,14.6086957 21.9130435,15.0174791 21.9130435,15.5217391 L21.9130435,23.7391304 L30.1304348,23.7391304 C30.6346948,23.7391304 31.0434783,24.1479139 31.0434783,24.6521739 C31.0434783,25.1564339 30.6346948,25.5652174 30.1304348,25.5652174 Z"/>
|
||||
</Icon>
|
||||
</Box>
|
||||
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">投研Agent</Heading>
|
||||
<Text color="brainwave.n2" fontSize="sm" pr="5" lineHeight={1.8}>
|
||||
• 采用 AI 模拟人类分析师,智能化程度高<br/>
|
||||
• 具备独特的全球视角,全面审视各类资产<br/>
|
||||
• 提供最佳投资建议,支持科学决策
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Box mt={3}>
|
||||
<VStack align="start" spacing={4}>
|
||||
<Box className="icon icon-sm">
|
||||
<Icon viewBox="0 0 42 42" w="25px" h="25px" color="brainwave.blue">
|
||||
<path fill="currentColor" d="M12.25,17.5 L8.75,17.5 L8.75,1.75 C8.75,0.78225 9.53225,0 10.5,0 L31.5,0 C32.46775,0 33.25,0.78225 33.25,1.75 L33.25,12.25 L29.75,12.25 L29.75,3.5 L12.25,3.5 L12.25,17.5 Z" opacity="0.6"/>
|
||||
<path fill="currentColor" d="M40.25,14 L24.5,14 C23.53225,14 22.75,14.78225 22.75,15.75 L22.75,38.5 L19.25,38.5 L19.25,22.75 C19.25,21.78225 18.46775,21 17.5,21 L1.75,21 C0.78225,21 0,21.78225 0,22.75 L0,40.25 C0,41.21775 0.78225,42 1.75,42 L40.25,42 C41.21775,42 42,41.21775 42,40.25 L42,15.75 C42,14.78225 41.21775,14 40.25,14 Z M12.25,36.75 L7,36.75 L7,33.25 L12.25,33.25 L12.25,36.75 Z M12.25,29.75 L7,29.75 L7,26.25 L12.25,26.25 L12.25,29.75 Z M35,36.75 L29.75,36.75 L29.75,33.25 L35,33.25 L35,36.75 Z M35,29.75 L29.75,29.75 L29.75,26.25 L35,26.25 L35,29.75 Z M35,22.75 L29.75,22.75 L29.75,19.25 L35,19.25 L35,22.75 Z"/>
|
||||
</Icon>
|
||||
</Box>
|
||||
<Heading size="md" fontWeight="bold" mt={3} color="brainwave.n1">新闻事件驱动</Heading>
|
||||
<Text color="brainwave.n2" fontSize="sm" pr="3" lineHeight={1.8}>
|
||||
• 基于AI的信息挖掘技术<br/>
|
||||
• Agent 赋能的未来推演和数据关联<br/>
|
||||
• 自由交流,我们相信集体的力量
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* 深研系统 → 盈利预测报表 入口 */}
|
||||
<Box mt={3}>
|
||||
<VStack align="start" spacing={3}>
|
||||
<Heading size="md" fontWeight="bold" mt={1} color="brainwave.n1">深研系统</Heading>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
variant="ghost"
|
||||
onClick={() => navigate('/admin/stock-analysis/forecast-report')}
|
||||
>
|
||||
盈利预测报表
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
{/* 右侧卡片 - 完全按照原网站设计 */}
|
||||
<Box flex="0 0 auto" w="400px" p={4}>
|
||||
<Box
|
||||
position="relative"
|
||||
borderRadius="xl"
|
||||
overflow="hidden"
|
||||
transform="perspective(1000px) rotateY(-5deg)"
|
||||
boxShadow="2xl"
|
||||
bgImage={`url(${teamWorkingImg})`}
|
||||
bgSize="cover"
|
||||
bgPosition="center"
|
||||
h="400px"
|
||||
>
|
||||
{/* 黑色遮罩 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg="blackAlpha.600"
|
||||
/>
|
||||
|
||||
{/* 内容 */}
|
||||
<Flex
|
||||
direction="column"
|
||||
align="center"
|
||||
justify="center"
|
||||
h="full"
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
color="white"
|
||||
textAlign="center"
|
||||
pt={7}
|
||||
>
|
||||
{/* 3D盒子图标 */}
|
||||
<Box className="icon icon-lg up" mb={3} mt={3}>
|
||||
<Icon viewBox="0 0 42 42" w="50px" h="50px" color="white">
|
||||
<path fill="currentColor" d="M22.7597136,19.3090182 L38.8987031,11.2395234 C39.3926816,10.9925342 39.592906,10.3918611 39.3459167,9.89788265 C39.249157,9.70436312 39.0922432,9.5474453 38.8987261,9.45068056 L20.2741875,0.1378125 L20.2741875,0.1378125 C19.905375,-0.04725 19.469625,-0.04725 19.0995,0.1378125 L3.1011696,8.13815822 C2.60720568,8.38517662 2.40701679,8.98586148 2.6540352,9.4798254 C2.75080129,9.67332903 2.90771305,9.83023153 3.10122239,9.9269862 L21.8652864,19.3090182 C22.1468139,19.4497819 22.4781861,19.4497819 22.7597136,19.3090182 Z"/>
|
||||
<path fill="currentColor" d="M23.625,22.429159 L23.625,39.8805372 C23.625,40.4328219 24.0727153,40.8805372 24.625,40.8805372 C24.7802551,40.8805372 24.9333778,40.8443874 25.0722402,40.7749511 L41.2741875,32.673375 L41.2741875,32.673375 C41.719125,32.4515625 42,31.9974375 42,31.5 L42,14.241659 C42,13.6893742 41.5522847,13.241659 41,13.241659 C40.8447549,13.241659 40.6916418,13.2778041 40.5527864,13.3472318 L24.1777864,21.5347318 C23.8390024,21.7041238 23.625,22.0503869 23.625,22.429159 Z" opacity="0.7"/>
|
||||
<path fill="currentColor" d="M20.4472136,21.5347318 L1.4472136,12.0347318 C0.953235098,11.7877425 0.352562058,11.9879669 0.105572809,12.4819454 C0.0361450918,12.6208008 6.47121774e-16,12.7739139 0,12.929159 L0,30.1875 L0,30.1875 C0,30.6849375 0.280875,31.1390625 0.7258125,31.3621875 L19.5528096,40.7750766 C20.0467945,41.0220531 20.6474623,40.8218132 20.8944388,40.3278283 C20.963859,40.1889789 21,40.0358742 21,39.8806379 L21,22.429159 C21,22.0503869 20.7859976,21.7041238 20.4472136,21.5347318 Z" opacity="0.7"/>
|
||||
</Icon>
|
||||
</Box>
|
||||
|
||||
<Heading size="xl" color="brainwave.n1" className="up" mb={0} lineHeight="1.2">
|
||||
<Text as="span" bgGradient="linear(to-r, brainwave.purple, brainwave.orange)" bgClip="text">
|
||||
事件催化
|
||||
</Text><br />
|
||||
让成功有迹可循
|
||||
</Heading>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate('/community')}
|
||||
bg="linear-gradient(135deg, #AC6AFF, #FFC876)"
|
||||
color="white"
|
||||
size="lg"
|
||||
mt={3}
|
||||
className="up btn-round"
|
||||
borderRadius="full"
|
||||
border="none"
|
||||
_hover={{
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: "0 8px 32px rgba(172, 106, 255, 0.3)"
|
||||
}}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
访问新闻催化分析
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* AI投研专题应用区域 - Brainwave风格 */}
|
||||
<Box as="section" py={20} bg="brainwave.n7" position="relative">
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={12} textAlign="center">
|
||||
<VStack spacing={2}>
|
||||
<Heading size="xl" color="brainwave.n1" mb={0}>
|
||||
AI投研专题应用
|
||||
</Heading>
|
||||
<Heading
|
||||
size="xl"
|
||||
bgGradient="linear(to-r, brainwave.orange, brainwave.purple)"
|
||||
bgClip="text"
|
||||
fontWeight="bold"
|
||||
>
|
||||
By 价小前投研
|
||||
</Heading>
|
||||
<Text fontSize="lg" color="brainwave.n2" fontWeight="medium">
|
||||
人工智能+专业投研流程—最强投资AI助手
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* 页脚 - Brainwave深色主题 */}
|
||||
<Box as="footer" bg="brainwave.n8" color="brainwave.n1" py={16}>
|
||||
<Container maxW="container.xl">
|
||||
<SimpleGrid columns={{ base: 2, md: 5 }} spacing={8}>
|
||||
{/* 价值前沿 */}
|
||||
<VStack align="start" spacing={4}>
|
||||
<Heading size="md" bgGradient="linear(to-r, brainwave.purple, brainwave.orange)" bgClip="text">价值前沿</Heading>
|
||||
<Text fontSize="sm" color="brainwave.n2" fontWeight="bold">
|
||||
更懂投资者的AI投研平台
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* 关于我们 */}
|
||||
<VStack align="start" spacing={4}>
|
||||
<Heading size="sm" color="brainwave.orange">关于我们</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>公司介绍</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>团队架构</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>联系方式</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.orange" }}>反馈评价</Link>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* 免费资源 */}
|
||||
<VStack align="start" spacing={4}>
|
||||
<Heading size="sm" color="brainwave.green">免费资源</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.green" }}>投研日报</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.green" }}>资讯速递</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.green" }}>免费试用</Link>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* 产品介绍 */}
|
||||
<VStack align="start" spacing={4}>
|
||||
<Heading size="sm" color="brainwave.blue">产品介绍</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>行情复盘</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>高频跟踪</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>深研系统</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.blue" }}>了解更多</Link>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{/* 产品下载 */}
|
||||
<VStack align="start" spacing={4}>
|
||||
<Heading size="sm" color="brainwave.pink">产品下载</Heading>
|
||||
<VStack align="start" spacing={2}>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.pink" }}>手机APP</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.pink" }}>Win终端</Link>
|
||||
<Link fontSize="sm" color="brainwave.n3" _hover={{ color: "brainwave.pink" }}>Mac终端</Link>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 版权信息 */}
|
||||
<Divider my={8} />
|
||||
<Text textAlign="center" fontSize="sm" color="brainwave.n4">
|
||||
All rights reserved. Copyright © {new Date().getFullYear()} 投研系统 by{' '}
|
||||
<Link color="brainwave.orange" _hover={{ textDecoration: "underline" }}>
|
||||
价值前沿
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Container>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
import plugin from "tailwindcss/plugin";
|
||||
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
"./src/templates/**/*.{js,jsx,ts,tsx}",
|
||||
"./src/components/**/*.{js,jsx,ts,tsx}",
|
||||
"./src/views/**/*.{js,jsx,ts,tsx}",
|
||||
"./public/index.html",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
color: {
|
||||
1: "#AC6AFF",
|
||||
2: "#FFC876",
|
||||
3: "#FF776F",
|
||||
4: "#7ADB78",
|
||||
5: "#858DFF",
|
||||
6: "#FF98E2",
|
||||
},
|
||||
stroke: {
|
||||
1: "#26242C",
|
||||
},
|
||||
n: {
|
||||
1: "#FFFFFF",
|
||||
2: "#CAC6DD",
|
||||
3: "#ADA8C3",
|
||||
4: "#757185",
|
||||
5: "#3F3A52",
|
||||
6: "#252134",
|
||||
7: "#15131D",
|
||||
8: "#0E0C15",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sora)", ...fontFamily.sans],
|
||||
code: "var(--font-code)",
|
||||
grotesk: "var(--font-grotesk)",
|
||||
},
|
||||
letterSpacing: {
|
||||
tagline: ".15em",
|
||||
},
|
||||
spacing: {
|
||||
0.25: "0.0625rem",
|
||||
7.5: "1.875rem",
|
||||
15: "3.75rem",
|
||||
},
|
||||
opacity: {
|
||||
15: ".15",
|
||||
},
|
||||
transitionDuration: {
|
||||
DEFAULT: "200ms",
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
DEFAULT: "linear",
|
||||
},
|
||||
zIndex: {
|
||||
1: "1",
|
||||
2: "2",
|
||||
3: "3",
|
||||
4: "4",
|
||||
5: "5",
|
||||
},
|
||||
borderWidth: {
|
||||
DEFAULT: "0.0625rem",
|
||||
},
|
||||
backgroundImage: {
|
||||
"radial-gradient": "radial-gradient(var(--tw-gradient-stops))",
|
||||
"conic-gradient":
|
||||
"conic-gradient(from 225deg, #FFC876, #79FFF7, #9F53FF, #FF98E2, #FFC876)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
plugin(function ({ addBase, addComponents, addUtilities }) {
|
||||
addBase({});
|
||||
addComponents({
|
||||
".container": {
|
||||
"@apply max-w-[77.5rem] mx-auto px-5 md:px-10 lg:px-15 xl:max-w-[87.5rem]":
|
||||
{},
|
||||
},
|
||||
".h1": {
|
||||
"@apply font-semibold text-[2.5rem] leading-[3.25rem] md:text-[2.75rem] md:leading-[3.75rem] lg:text-[3.25rem] lg:leading-[4.0625rem] xl:text-[3.75rem] xl:leading-[4.5rem]":
|
||||
{},
|
||||
},
|
||||
".h2": {
|
||||
"@apply text-[1.75rem] leading-[2.5rem] md:text-[2rem] md:leading-[2.5rem] lg:text-[2.5rem] lg:leading-[3.5rem] xl:text-[3rem] xl:leading-tight":
|
||||
{},
|
||||
},
|
||||
".h3": {
|
||||
"@apply text-[2rem] leading-normal md:text-[2.5rem]": {},
|
||||
},
|
||||
".h4": {
|
||||
"@apply text-[2rem] leading-normal": {},
|
||||
},
|
||||
".h5": {
|
||||
"@apply text-2xl leading-normal": {},
|
||||
},
|
||||
".h6": {
|
||||
"@apply font-semibold text-lg leading-8": {},
|
||||
},
|
||||
".body-1": {
|
||||
"@apply text-[0.875rem] leading-[1.5rem] md:text-[1rem] md:leading-[1.75rem] lg:text-[1.25rem] lg:leading-8":
|
||||
{},
|
||||
},
|
||||
".body-2": {
|
||||
"@apply font-light text-[0.875rem] leading-6 md:text-base":
|
||||
{},
|
||||
},
|
||||
".caption": {
|
||||
"@apply text-sm": {},
|
||||
},
|
||||
".tagline": {
|
||||
"@apply font-grotesk font-light text-xs tracking-tagline uppercase":
|
||||
{},
|
||||
},
|
||||
".quote": {
|
||||
"@apply font-code text-lg leading-normal": {},
|
||||
},
|
||||
".button": {
|
||||
"@apply font-code text-xs font-bold uppercase tracking-wider":
|
||||
{},
|
||||
},
|
||||
});
|
||||
addUtilities({
|
||||
".tap-highlight-color": {
|
||||
"-webkit-tap-highlight-color": "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -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