Compare commits
26 Commits
feature_lo
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df41ef2e61 | ||
| 0d84ffe87f | |||
|
|
cb84b0238a | ||
|
|
433fc4a0f5 | ||
|
|
5bac525147 | ||
|
|
a049d0365b | ||
|
|
fdbb6ceff5 | ||
|
|
35f8b5195a | ||
|
|
77aafd5661 | ||
|
|
ce1bf29270 | ||
|
|
ac7a6991bc | ||
|
|
4435ef9392 | ||
|
|
224c6a12d4 | ||
|
|
d0d8b1ebde | ||
|
|
bf8aff9e7e | ||
|
|
376b5f66cd | ||
|
|
f3c7e016ac | ||
| 8417ab17be | |||
| dd59cb6385 | |||
| 512aca16d8 | |||
|
|
6a51fc3c88 | ||
|
|
7cca5e73c0 | ||
|
|
112fbbd42d | ||
|
|
3a4dade8ec | ||
|
|
6f81259f8c | ||
|
|
864844a52b |
131
CLAUDE.md
131
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 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 install:clean # Clean install (removes node_modules and package-lock)
|
||||
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
|
||||
282
app.py
282
app.py
@@ -97,6 +97,38 @@ def get_trading_day_near_date(target_date):
|
||||
return trading_days[-1] if trading_days else None
|
||||
|
||||
|
||||
def get_previous_trading_day(target_date):
|
||||
"""
|
||||
获取指定日期的上一个交易日
|
||||
如果目标日期不是交易日,先找到对应的交易日,然后返回前一个交易日
|
||||
"""
|
||||
if not trading_days:
|
||||
load_trading_days()
|
||||
|
||||
if not trading_days:
|
||||
return None
|
||||
|
||||
# 如果目标日期是datetime,转换为date
|
||||
if isinstance(target_date, datetime):
|
||||
target_date = target_date.date()
|
||||
|
||||
# 确保目标日期是交易日
|
||||
if target_date not in trading_days_set:
|
||||
target_date = get_trading_day_near_date(target_date)
|
||||
if not target_date:
|
||||
return None
|
||||
|
||||
# 查找上一个交易日
|
||||
try:
|
||||
index = trading_days.index(target_date)
|
||||
if index > 0:
|
||||
return trading_days[index - 1]
|
||||
else:
|
||||
return None # 没有上一个交易日
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
# 应用启动时加载交易日数据
|
||||
load_trading_days()
|
||||
|
||||
@@ -1849,6 +1881,15 @@ def send_verification_code():
|
||||
if not credential or not code_type:
|
||||
return jsonify({'success': False, 'error': '缺少必要参数'}), 400
|
||||
|
||||
# 清理格式字符(空格、横线、括号等)
|
||||
if code_type == 'phone':
|
||||
# 移除手机号中的空格、横线、括号、加号等格式字符
|
||||
credential = re.sub(r'[\s\-\(\)\+]', '', credential)
|
||||
print(f"📱 清理后的手机号: {credential}")
|
||||
elif code_type == 'email':
|
||||
# 邮箱只移除空格
|
||||
credential = credential.strip()
|
||||
|
||||
# 生成验证码
|
||||
verification_code = generate_verification_code()
|
||||
|
||||
@@ -1907,6 +1948,17 @@ def login_with_verification_code():
|
||||
if not credential or not verification_code or not login_type:
|
||||
return jsonify({'success': False, 'error': '缺少必要参数'}), 400
|
||||
|
||||
# 清理格式字符(空格、横线、括号等)
|
||||
if login_type == 'phone':
|
||||
# 移除手机号中的空格、横线、括号、加号等格式字符
|
||||
original_credential = credential
|
||||
credential = re.sub(r'[\s\-\(\)\+]', '', credential)
|
||||
if original_credential != credential:
|
||||
print(f"📱 登录时清理手机号: {original_credential} -> {credential}")
|
||||
elif login_type == 'email':
|
||||
# 邮箱只移除前后空格
|
||||
credential = credential.strip()
|
||||
|
||||
# 检查验证码
|
||||
session_key = f'verification_code_{login_type}_{credential}_login'
|
||||
stored_code_info = session.get(session_key)
|
||||
@@ -1968,12 +2020,51 @@ def login_with_verification_code():
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# 如果用户不存在,自动创建新用户
|
||||
if not user:
|
||||
try:
|
||||
# 生成用户名
|
||||
if login_type == 'phone':
|
||||
# 使用手机号生成用户名
|
||||
base_username = f"用户{credential[-4:]}"
|
||||
elif login_type == 'email':
|
||||
# 使用邮箱前缀生成用户名
|
||||
base_username = credential.split('@')[0]
|
||||
else:
|
||||
base_username = "新用户"
|
||||
|
||||
# 确保用户名唯一
|
||||
username = base_username
|
||||
counter = 1
|
||||
while User.is_username_taken(username):
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# 创建新用户
|
||||
user = User(username=username, email=credential)
|
||||
user.email_confirmed = True
|
||||
user = User(username=username)
|
||||
|
||||
# 设置手机号或邮箱
|
||||
if login_type == 'phone':
|
||||
user.phone = credential
|
||||
elif login_type == 'email':
|
||||
user.email = credential
|
||||
|
||||
# 设置默认密码(使用随机密码,用户后续可以修改)
|
||||
user.set_password(uuid.uuid4().hex)
|
||||
user.status = 'active'
|
||||
user.nickname = username
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
is_new_user = True
|
||||
print(f"✅ 自动创建新用户: {username}, {login_type}: {credential}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 创建用户失败: {e}")
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': '创建用户失败'}), 500
|
||||
|
||||
# 清除验证码
|
||||
session.pop(session_key, None)
|
||||
|
||||
@@ -1989,10 +2080,13 @@ def login_with_verification_code():
|
||||
# 更新最后登录时间
|
||||
user.update_last_seen()
|
||||
|
||||
# 根据是否为新用户返回不同的消息
|
||||
message = '注册成功,欢迎加入!' if is_new_user else '登录成功'
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '注册成功' if is_new_user else '登录成功',
|
||||
'isNewUser': is_new_user,
|
||||
'message': message,
|
||||
'is_new_user': is_new_user,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
@@ -2004,6 +2098,59 @@ def login_with_verification_code():
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"验证码登录错误: {e}")
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': '登录失败'}), 500
|
||||
|
||||
|
||||
@app.route('/api/auth/register', methods=['POST'])
|
||||
def register():
|
||||
"""用户注册 - 使用Session"""
|
||||
username = request.form.get('username')
|
||||
email = request.form.get('email')
|
||||
password = request.form.get('password')
|
||||
|
||||
# 验证输入
|
||||
if not all([username, email, password]):
|
||||
return jsonify({'success': False, 'error': '所有字段都是必填的'}), 400
|
||||
|
||||
# 检查用户名和邮箱是否已存在
|
||||
if User.is_username_taken(username):
|
||||
return jsonify({'success': False, 'error': '用户名已存在'}), 400
|
||||
|
||||
if User.is_email_taken(email):
|
||||
return jsonify({'success': False, 'error': '邮箱已被使用'}), 400
|
||||
|
||||
try:
|
||||
# 创建新用户
|
||||
user = User(username=username, email=email)
|
||||
user.set_password(password)
|
||||
user.email_confirmed = True # 暂时默认已确认
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# 自动登录
|
||||
session.permanent = True
|
||||
session['user_id'] = user.id
|
||||
session['username'] = user.username
|
||||
session['logged_in'] = True
|
||||
|
||||
# Flask-Login 登录
|
||||
login_user(user, remember=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '注册成功',
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'nickname': user.nickname or user.username,
|
||||
'email': user.email
|
||||
}
|
||||
}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"验证码登录/注册错误: {e}")
|
||||
@@ -2619,11 +2766,19 @@ def wechat_callback():
|
||||
state = request.args.get('state')
|
||||
error = request.args.get('error')
|
||||
|
||||
print(f"🎯 [CALLBACK] 微信回调被调用!code={code[:10] if code else None}..., state={state[:8] if state else None}..., error={error}")
|
||||
# 错误处理:用户拒绝授权
|
||||
if error:
|
||||
if state in wechat_qr_sessions:
|
||||
wechat_qr_sessions[state]['status'] = 'auth_denied'
|
||||
wechat_qr_sessions[state]['error'] = '用户拒绝授权'
|
||||
print(f"❌ 用户拒绝授权: state={state}")
|
||||
return redirect('/auth/signin?error=wechat_auth_denied')
|
||||
|
||||
# 错误处理
|
||||
if error or not code or not state:
|
||||
print(f"❌ [CALLBACK] 参数错误: error={error}, has_code={bool(code)}, has_state={bool(state)}")
|
||||
# 参数验证
|
||||
if not code or not state:
|
||||
if state in wechat_qr_sessions:
|
||||
wechat_qr_sessions[state]['status'] = 'auth_failed'
|
||||
wechat_qr_sessions[state]['error'] = '授权参数缺失'
|
||||
return redirect('/auth/signin?error=wechat_auth_failed')
|
||||
|
||||
# 验证state
|
||||
@@ -2643,20 +2798,28 @@ def wechat_callback():
|
||||
return redirect('/auth/signin?error=session_expired')
|
||||
|
||||
try:
|
||||
# 获取access_token
|
||||
print(f"🔑 [CALLBACK] 开始获取 access_token...")
|
||||
# 步骤1: 用户已扫码并授权(微信回调过来说明用户已完成扫码+授权)
|
||||
session_data['status'] = 'scanned'
|
||||
print(f"✅ 微信扫码回调: state={state}, code={code[:10]}...")
|
||||
|
||||
# 步骤2: 获取access_token
|
||||
token_data = get_wechat_access_token(code)
|
||||
if not token_data:
|
||||
print(f"❌ [CALLBACK] 获取 access_token 失败")
|
||||
session_data['status'] = 'auth_failed'
|
||||
session_data['error'] = '获取访问令牌失败'
|
||||
print(f"❌ 获取微信access_token失败: state={state}")
|
||||
return redirect('/auth/signin?error=token_failed')
|
||||
|
||||
print(f"✅ [CALLBACK] 获取 access_token 成功, openid={token_data.get('openid', '')[:8]}...")
|
||||
# 步骤3: Token获取成功,标记为已授权
|
||||
session_data['status'] = 'authorized'
|
||||
print(f"✅ 微信授权成功: openid={token_data['openid']}")
|
||||
|
||||
# 获取用户信息
|
||||
print(f"👤 [CALLBACK] 开始获取用户信息...")
|
||||
# 步骤4: 获取用户信息
|
||||
user_info = get_wechat_userinfo(token_data['access_token'], token_data['openid'])
|
||||
if not user_info:
|
||||
print(f"❌ [CALLBACK] 获取用户信息失败")
|
||||
session_data['status'] = 'auth_failed'
|
||||
session_data['error'] = '获取用户信息失败'
|
||||
print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}")
|
||||
return redirect('/auth/signin?error=userinfo_failed')
|
||||
|
||||
print(f"✅ [CALLBACK] 获取用户信息成功, nickname={user_info.get('nickname', 'N/A')}")
|
||||
@@ -2736,23 +2899,31 @@ def wechat_callback():
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# 更新 wechat_qr_sessions 状态,供前端轮询检测
|
||||
print(f"🔍 [DEBUG] state={state}, state in wechat_qr_sessions: {state in wechat_qr_sessions}")
|
||||
is_new_user = True
|
||||
print(f"✅ 微信扫码自动创建新用户: {username}, openid: {openid}")
|
||||
|
||||
# 更新最后登录时间
|
||||
user.update_last_seen()
|
||||
|
||||
# 设置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)
|
||||
|
||||
# 更新微信session状态,供前端轮询检测
|
||||
if state in wechat_qr_sessions:
|
||||
session_item = wechat_qr_sessions[state]
|
||||
mode = session_item.get('mode')
|
||||
print(f"🔍 [DEBUG] session_item mode: {mode}, is_new_user: {is_new_user}")
|
||||
# 不是绑定模式才更新为登录状态
|
||||
if not mode:
|
||||
new_status = 'register_success' if is_new_user else 'login_success'
|
||||
session_item['status'] = new_status
|
||||
session_item['user_info'] = {
|
||||
'user_id': user.id,
|
||||
'is_new_user': is_new_user
|
||||
}
|
||||
print(f"✅ [DEBUG] 更新 wechat_qr_sessions 状态: {new_status}, user_id: {user.id}")
|
||||
else:
|
||||
print(f"⚠️ [DEBUG] 跳过状态更新,因为 mode={mode}")
|
||||
# 仅处理登录/注册流程,不处理绑定流程
|
||||
if not session_item.get('mode'):
|
||||
# 更新状态和用户信息
|
||||
session_item['status'] = 'register_ready' if is_new_user else 'login_ready'
|
||||
session_item['user_info'] = {'user_id': user.id}
|
||||
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
|
||||
|
||||
# 返回一个简单的成功页面(前端轮询会检测到状态变化)
|
||||
return '''
|
||||
@@ -2772,10 +2943,16 @@ def wechat_callback():
|
||||
''', 200
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ [CALLBACK] 微信登录失败: {e}")
|
||||
print(f"❌ 微信登录失败: {e}")
|
||||
import traceback
|
||||
print(f"❌ [CALLBACK] 错误堆栈:\n{traceback.format_exc()}")
|
||||
traceback.print_exc()
|
||||
db.session.rollback()
|
||||
|
||||
# 更新session状态为失败
|
||||
if state in wechat_qr_sessions:
|
||||
wechat_qr_sessions[state]['status'] = 'auth_failed'
|
||||
wechat_qr_sessions[state]['error'] = str(e)
|
||||
|
||||
return redirect('/auth/signin?error=login_failed')
|
||||
|
||||
|
||||
@@ -6450,6 +6627,9 @@ def api_get_events():
|
||||
event_status = request.args.get('status', 'active')
|
||||
importance = request.args.get('importance', 'all')
|
||||
|
||||
# 交易日筛选参数
|
||||
tday = request.args.get('tday') # 交易日,格式:YYYY-MM-DD 或 YYYY/M/D
|
||||
|
||||
# 日期筛选参数
|
||||
start_date = request.args.get('start_date')
|
||||
end_date = request.args.get('end_date')
|
||||
@@ -6535,7 +6715,41 @@ def api_get_events():
|
||||
text(f"JSON_SEARCH(keywords, 'one', '%{search_query}%') IS NOT NULL")
|
||||
)
|
||||
)
|
||||
if recent_days:
|
||||
|
||||
# 交易日筛选逻辑
|
||||
if tday:
|
||||
from datetime import datetime, timedelta, time
|
||||
try:
|
||||
# 解析交易日参数,支持 YYYY-MM-DD 和 YYYY/M/D 格式
|
||||
if '/' in tday:
|
||||
target_tday = datetime.strptime(tday, '%Y/%m/%d').date()
|
||||
else:
|
||||
target_tday = datetime.strptime(tday, '%Y-%m-%d').date()
|
||||
|
||||
# 获取该交易日的上一个交易日
|
||||
prev_tday = get_previous_trading_day(target_tday)
|
||||
|
||||
if prev_tday:
|
||||
# 计算时间范围:[前一个交易日 15:00, 当前交易日 15:00]
|
||||
start_datetime = datetime.combine(prev_tday, time(15, 0, 0))
|
||||
end_datetime = datetime.combine(target_tday, time(15, 0, 0))
|
||||
|
||||
query = query.filter(
|
||||
Event.created_at >= start_datetime,
|
||||
Event.created_at <= end_datetime
|
||||
)
|
||||
else:
|
||||
# 如果没有上一个交易日,则筛选当天的事件
|
||||
start_datetime = datetime.combine(target_tday, time(0, 0, 0))
|
||||
end_datetime = datetime.combine(target_tday, time(15, 0, 0))
|
||||
query = query.filter(
|
||||
Event.created_at >= start_datetime,
|
||||
Event.created_at <= end_datetime
|
||||
)
|
||||
except (ValueError, TypeError) as e:
|
||||
# 日期格式错误,忽略该参数
|
||||
app.logger.warning(f"无效的交易日参数: {tday}, 错误: {e}")
|
||||
elif recent_days:
|
||||
from datetime import datetime, timedelta
|
||||
cutoff_date = datetime.now() - timedelta(days=recent_days)
|
||||
query = query.filter(Event.created_at >= cutoff_date)
|
||||
@@ -6641,6 +6855,8 @@ def api_get_events():
|
||||
applied_filters['type'] = event_type
|
||||
if importance != 'all':
|
||||
applied_filters['importance'] = importance
|
||||
if tday:
|
||||
applied_filters['tday'] = tday
|
||||
if start_date:
|
||||
applied_filters['start_date'] = start_date
|
||||
if end_date:
|
||||
|
||||
@@ -160,11 +160,12 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(credential)) {
|
||||
// 追踪手机号验证失败
|
||||
// 清理手机号格式字符(空格、横线、括号等)
|
||||
const cleanedCredential = credential.replace(/[\s\-\(\)\+]/g, '');
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(cleanedCredential)) {
|
||||
authEvents.trackPhoneNumberValidated(credential, false, 'invalid_format');
|
||||
authEvents.trackFormValidationError('phone', 'invalid_format', '请输入有效的手机号');
|
||||
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -180,7 +181,7 @@ export default function AuthFormContent() {
|
||||
setSendingCode(true);
|
||||
|
||||
const requestData = {
|
||||
credential: credential.trim(), // 添加 trim() 防止空格
|
||||
credential: cleanedCredential, // 使用清理后的手机号
|
||||
type: 'phone',
|
||||
purpose: config.api.purpose
|
||||
};
|
||||
@@ -221,13 +222,13 @@ export default function AuthFormContent() {
|
||||
|
||||
// ❌ 移除成功 toast,静默处理
|
||||
logger.info('AuthFormContent', '验证码发送成功', {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7),
|
||||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7),
|
||||
dev_code: data.dev_code
|
||||
});
|
||||
|
||||
// ✅ 开发环境下在控制台显示验证码
|
||||
if (data.dev_code) {
|
||||
console.log(`%c✅ [验证码] ${credential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
|
||||
console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
|
||||
}
|
||||
|
||||
setVerificationCodeSent(true);
|
||||
@@ -244,7 +245,7 @@ export default function AuthFormContent() {
|
||||
});
|
||||
|
||||
logger.api.error('POST', '/api/auth/send-verification-code', error, {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7)
|
||||
});
|
||||
|
||||
// ✅ 显示错误提示给用户
|
||||
@@ -286,7 +287,10 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
// 清理手机号格式字符(空格、横线、括号等)
|
||||
const cleanedPhone = phone.replace(/[\s\-\(\)\+]/g, '');
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(cleanedPhone)) {
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -300,13 +304,13 @@ export default function AuthFormContent() {
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = {
|
||||
credential: phone.trim(), // 添加 trim() 防止空格
|
||||
credential: cleanedPhone, // 使用清理后的手机号
|
||||
verification_code: verificationCode.trim(), // 添加 trim() 防止空格
|
||||
login_type: 'phone',
|
||||
};
|
||||
|
||||
logger.api.request('POST', '/api/auth/login-with-code', {
|
||||
credential: phone.substring(0, 3) + '****' + phone.substring(7),
|
||||
credential: cleanedPhone.substring(0, 3) + '****' + cleanedPhone.substring(7),
|
||||
verification_code: verificationCode.substring(0, 2) + '****',
|
||||
login_type: 'phone'
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ const getStatusColor = (status) => {
|
||||
case WECHAT_STATUS.EXPIRED: return "orange.600"; // ✅ 橙色文字
|
||||
case WECHAT_STATUS.LOGIN_SUCCESS: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.REGISTER_SUCCESS: return "green.600";
|
||||
case WECHAT_STATUS.AUTH_DENIED: return "red.600"; // ✅ 红色文字
|
||||
case WECHAT_STATUS.AUTH_FAILED: return "red.600"; // ✅ 红色文字
|
||||
default: return "gray.600";
|
||||
}
|
||||
};
|
||||
@@ -248,6 +250,33 @@ export default function WechatRegister() {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理用户拒绝授权
|
||||
else if (status === WECHAT_STATUS.AUTH_DENIED) {
|
||||
clearTimers();
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "授权已取消",
|
||||
description: "您已取消微信授权登录",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理授权失败
|
||||
else if (status === WECHAT_STATUS.AUTH_FAILED) {
|
||||
clearTimers();
|
||||
if (isMountedRef.current) {
|
||||
const errorMsg = response.error || "授权过程出现错误";
|
||||
toast({
|
||||
title: "授权失败",
|
||||
description: errorMsg,
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
|
||||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||||
|
||||
60
src/components/RiskDisclaimer/RiskDisclaimer.js
Normal file
60
src/components/RiskDisclaimer/RiskDisclaimer.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// src/components/RiskDisclaimer/RiskDisclaimer.js
|
||||
import React from 'react';
|
||||
import { Box, Text, HStack, Icon, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 风险提示组件
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.text - 风险提示文本内容
|
||||
* @param {string} props.variant - 文本变体类型 ('default', 'homepage', 'section')
|
||||
* @param {Object} props.sx - 额外的样式对象
|
||||
*/
|
||||
const RiskDisclaimer = ({
|
||||
text,
|
||||
variant = 'default',
|
||||
sx = {},
|
||||
mt = 0,
|
||||
mb = 0,
|
||||
...rest
|
||||
}) => {
|
||||
// 极简风格 - 透明背景,固定灰色文字
|
||||
const textColor = '#999999'; // 固定中性灰,不受主题影响
|
||||
|
||||
// 预定义的文本变体
|
||||
const textVariants = {
|
||||
homepage: '风险提示:解析内容由价值前沿人工采集整理自新闻、公告、研报等公开信息,团队辛苦编写,未经许可严禁转载。站内所有文章均不构成投资建议,请投资者注意风险,独立审慎决策。',
|
||||
default: '风险提示:解析内容由价值前沿人工采集整理自新闻、公告、研报等公开信息,团队辛苦编写,未经许可严禁转载。本产品内容均不构成投资建议,请投资者注意风险,独立审慎决策。',
|
||||
section: '风险提示:解析内容由价值前沿人工采集整理自新闻、公告、研报等公开信息,团队辛苦编写,未经许可严禁转载。本部分产品内容均不构成投资建议,请投资者注意风险,独立审慎决策。'
|
||||
};
|
||||
|
||||
// 使用传入的text或预定义的variant
|
||||
const displayText = text || textVariants[variant] || textVariants.default;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="transparent"
|
||||
p={0}
|
||||
mt={mt}
|
||||
mb={mb}
|
||||
width="100%"
|
||||
sx={sx}
|
||||
{...rest}
|
||||
>
|
||||
<HStack spacing={0} align="flex-start">
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={textColor}
|
||||
lineHeight="1.6"
|
||||
fontWeight="normal"
|
||||
textAlign="center"
|
||||
width="100%"
|
||||
>
|
||||
{displayText}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RiskDisclaimer;
|
||||
2
src/components/RiskDisclaimer/index.js
Normal file
2
src/components/RiskDisclaimer/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// src/components/RiskDisclaimer/index.js
|
||||
export { default } from './RiskDisclaimer';
|
||||
@@ -7,6 +7,7 @@ import moment from 'moment';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -563,19 +564,8 @@ const StockChartAntdModal = ({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 调试信息 */}
|
||||
{process.env.NODE_ENV === 'development' && chartData && (
|
||||
<div style={{ marginTop: 16, padding: 12, backgroundColor: '#f0f0f0', borderRadius: 6, fontSize: '12px' }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>调试信息:</Text>
|
||||
<Text>数据条数: {chartData.data ? chartData.data.length : 0}</Text>
|
||||
<br />
|
||||
<Text>交易日期: {chartData.trade_date}</Text>
|
||||
<br />
|
||||
<Text>图表类型: {activeChartType}</Text>
|
||||
<br />
|
||||
<Text>原始事件时间: {eventTime}</Text>
|
||||
</div>
|
||||
)}
|
||||
{/* 风险提示 */}
|
||||
<RiskDisclaimer variant="default" />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
|
||||
const StockChartModal = ({
|
||||
isOpen,
|
||||
@@ -545,6 +546,11 @@ const StockChartModal = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 风险提示 */}
|
||||
<Box px={4} pb={4}>
|
||||
<RiskDisclaimer variant="default" />
|
||||
</Box>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && chartData && (
|
||||
<Box p={4} bg="gray.50" fontSize="xs" color="gray.600">
|
||||
<Text fontWeight="bold">调试信息:</Text>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, VStack, HStack, Text, Link, useColorModeValue } from '@chakra-ui/react';
|
||||
import RiskDisclaimer from '../components/RiskDisclaimer';
|
||||
|
||||
/**
|
||||
* 应用通用页脚组件
|
||||
@@ -10,6 +11,7 @@ const AppFooter = () => {
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={2}>
|
||||
<RiskDisclaimer />
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
|
||||
@@ -155,5 +155,222 @@ export const conceptHandlers = [
|
||||
total: stocks.length,
|
||||
concept_id: conceptId
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取最新交易日期
|
||||
http.get('http://111.198.58.126:16801/price/latest', async () => {
|
||||
await delay(200);
|
||||
|
||||
const today = new Date();
|
||||
const dateStr = today.toISOString().split('T')[0].replace(/-/g, '');
|
||||
|
||||
console.log('[Mock Concept] 获取最新交易日期:', dateStr);
|
||||
|
||||
return HttpResponse.json({
|
||||
latest_date: dateStr,
|
||||
timestamp: today.toISOString()
|
||||
});
|
||||
}),
|
||||
|
||||
// 搜索概念(硬编码 URL)
|
||||
http.post('http://111.198.58.126:16801/search', async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { query = '', size = 20, page = 1, sort_by = 'change_pct' } = body;
|
||||
|
||||
console.log('[Mock Concept] 搜索概念 (硬编码URL):', { query, size, page, sort_by });
|
||||
|
||||
let results = generatePopularConcepts(size);
|
||||
|
||||
if (query) {
|
||||
results = results.filter(item =>
|
||||
item.concept.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (sort_by === 'change_pct') {
|
||||
results.sort((a, b) => b.price_info.avg_change_pct - a.price_info.avg_change_pct);
|
||||
} else if (sort_by === 'stock_count') {
|
||||
results.sort((a, b) => b.stock_count - a.stock_count);
|
||||
} else if (sort_by === 'hot_score') {
|
||||
results.sort((a, b) => b.hot_score - a.hot_score);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
results,
|
||||
total: results.length,
|
||||
page,
|
||||
size,
|
||||
message: '搜索成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Concept] 搜索失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ results: [], total: 0, error: '搜索失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取统计数据
|
||||
http.get('http://111.198.58.126:16801/statistics', async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const minStockCount = parseInt(url.searchParams.get('min_stock_count') || '3');
|
||||
const days = parseInt(url.searchParams.get('days') || '7');
|
||||
|
||||
console.log('[Mock Concept] 获取统计数据:', { minStockCount, days });
|
||||
|
||||
return HttpResponse.json({
|
||||
total_concepts: 150,
|
||||
active_concepts: 120,
|
||||
avg_stock_count: 25,
|
||||
top_concepts: generatePopularConcepts(10),
|
||||
min_stock_count: minStockCount,
|
||||
days: days,
|
||||
updated_at: new Date().toISOString()
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取概念价格时间序列
|
||||
http.get('http://111.198.58.126:16801/concept/:conceptId/price-timeseries', async ({ params, request }) => {
|
||||
await delay(300);
|
||||
|
||||
const { conceptId } = params;
|
||||
const url = new URL(request.url);
|
||||
const startDate = url.searchParams.get('start_date');
|
||||
const endDate = url.searchParams.get('end_date');
|
||||
|
||||
console.log('[Mock Concept] 获取价格时间序列:', { conceptId, startDate, endDate });
|
||||
|
||||
// 生成时间序列数据
|
||||
const timeseries = [];
|
||||
const start = new Date(startDate || '2024-01-01');
|
||||
const end = new Date(endDate || new Date());
|
||||
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24));
|
||||
|
||||
for (let i = 0; i <= daysDiff; i++) {
|
||||
const date = new Date(start);
|
||||
date.setDate(date.getDate() + i);
|
||||
|
||||
// 跳过周末
|
||||
if (date.getDay() !== 0 && date.getDay() !== 6) {
|
||||
timeseries.push({
|
||||
trade_date: date.toISOString().split('T')[0], // 改为 trade_date
|
||||
avg_change_pct: parseFloat((Math.random() * 8 - 2).toFixed(2)), // 转为数值
|
||||
stock_count: Math.floor(Math.random() * 30) + 10,
|
||||
volume: Math.floor(Math.random() * 1000000000)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
concept_id: conceptId,
|
||||
timeseries: timeseries,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取概念相关新闻 (search_china_news)
|
||||
http.get('http://111.198.58.126:21891/search_china_news', async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get('query');
|
||||
const exactMatch = url.searchParams.get('exact_match');
|
||||
const startDate = url.searchParams.get('start_date');
|
||||
const endDate = url.searchParams.get('end_date');
|
||||
const topK = parseInt(url.searchParams.get('top_k') || '100');
|
||||
|
||||
console.log('[Mock Concept] 搜索中国新闻:', { query, exactMatch, startDate, endDate, topK });
|
||||
|
||||
// 生成新闻数据
|
||||
const news = [];
|
||||
const newsCount = Math.min(topK, Math.floor(Math.random() * 15) + 5); // 5-20 条新闻
|
||||
|
||||
for (let i = 0; i < newsCount; i++) {
|
||||
const daysAgo = Math.floor(Math.random() * 100); // 0-100 天前
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
|
||||
const hour = Math.floor(Math.random() * 24);
|
||||
const minute = Math.floor(Math.random() * 60);
|
||||
const publishedTime = `${date.toISOString().split('T')[0]} ${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`;
|
||||
|
||||
news.push({
|
||||
id: `news_${i}`,
|
||||
title: `${query || '概念'}板块动态:${['利好政策发布', '行业景气度提升', '龙头企业业绩超预期', '技术突破进展', '市场需求旺盛'][i % 5]}`,
|
||||
detail: `${query || '概念'}相关新闻详细内容。近期${query || '概念'}板块表现活跃,市场关注度持续上升。多家券商研报指出,${query || '概念'}行业前景广阔,建议重点关注龙头企业投资机会。`,
|
||||
description: `${query || '概念'}板块最新动态摘要...`,
|
||||
source: ['新浪财经', '东方财富网', '财联社', '证券时报', '中国证券报', '上海证券报'][Math.floor(Math.random() * 6)],
|
||||
published_time: publishedTime,
|
||||
url: `https://finance.sina.com.cn/stock/news/${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}/news_${i}.html`
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间降序排序
|
||||
news.sort((a, b) => new Date(b.published_time) - new Date(a.published_time));
|
||||
|
||||
// 返回数组(不是对象)
|
||||
return HttpResponse.json(news);
|
||||
}),
|
||||
|
||||
// 获取概念相关研报 (search)
|
||||
http.get('http://111.198.58.126:8811/search', async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get('query');
|
||||
const mode = url.searchParams.get('mode');
|
||||
const exactMatch = url.searchParams.get('exact_match');
|
||||
const size = parseInt(url.searchParams.get('size') || '30');
|
||||
const startDate = url.searchParams.get('start_date');
|
||||
|
||||
console.log('[Mock Concept] 搜索研报:', { query, mode, exactMatch, size, startDate });
|
||||
|
||||
// 生成研报数据
|
||||
const reports = [];
|
||||
const reportCount = Math.min(size, Math.floor(Math.random() * 10) + 3); // 3-12 份研报
|
||||
|
||||
const publishers = ['中信证券', '国泰君安', '华泰证券', '招商证券', '海通证券', '广发证券', '申万宏源', '兴业证券'];
|
||||
const authors = ['张明', '李华', '王强', '刘洋', '陈杰', '赵敏'];
|
||||
const ratings = ['买入', '增持', '中性', '减持', '强烈推荐'];
|
||||
const securityNames = ['行业研究', '公司研究', '策略研究', '宏观研究', '固收研究'];
|
||||
|
||||
for (let i = 0; i < reportCount; i++) {
|
||||
const daysAgo = Math.floor(Math.random() * 100); // 0-100 天前
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
|
||||
const declareDate = `${date.toISOString().split('T')[0]} ${String(Math.floor(Math.random() * 24)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}:00`;
|
||||
|
||||
reports.push({
|
||||
id: `report_${i}`,
|
||||
report_title: `${query || '概念'}行业${['深度研究报告', '投资策略分析', '行业景气度跟踪', '估值分析报告', '竞争格局研究'][i % 5]}`,
|
||||
content: `${query || '概念'}行业研究报告内容摘要。\n\n核心观点:\n1. ${query || '概念'}行业景气度持续向好,市场规模预计将保持高速增长。\n2. 龙头企业凭借技术优势和规模效应,市场份额有望进一步提升。\n3. 政策支持力度加大,为行业发展提供有力保障。\n\n投资建议:建议重点关注行业龙头企业,给予"${ratings[Math.floor(Math.random() * ratings.length)]}"评级。`,
|
||||
abstract: `本报告深入分析了${query || '概念'}行业的发展趋势、竞争格局和投资机会,认为行业具备良好的成长性...`,
|
||||
publisher: publishers[Math.floor(Math.random() * publishers.length)],
|
||||
author: authors[Math.floor(Math.random() * authors.length)],
|
||||
declare_date: declareDate,
|
||||
rating: ratings[Math.floor(Math.random() * ratings.length)],
|
||||
security_name: securityNames[Math.floor(Math.random() * securityNames.length)],
|
||||
content_url: `https://pdf.dfcfw.com/pdf/H3_${1000000 + i}_1_${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}.pdf`
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间降序排序
|
||||
reports.sort((a, b) => new Date(b.declare_date) - new Date(a.declare_date));
|
||||
|
||||
// 返回符合组件期望的格式
|
||||
return HttpResponse.json({
|
||||
results: reports,
|
||||
total: reports.length,
|
||||
query: query,
|
||||
mode: mode
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
@@ -923,4 +923,157 @@ export const eventHandlers = [
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 历史事件对比相关 ====================
|
||||
|
||||
// 获取历史事件列表
|
||||
http.get('/api/events/:eventId/historical', async ({ params }) => {
|
||||
await delay(400);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 获取历史事件列表, eventId:', eventId);
|
||||
|
||||
// 生成历史事件数据
|
||||
const generateHistoricalEvents = (count = 5) => {
|
||||
const events = [];
|
||||
const eventTitles = [
|
||||
'芯片产业链政策扶持升级',
|
||||
'新能源汽车销量创历史新高',
|
||||
'人工智能大模型技术突破',
|
||||
'半导体设备国产化加速',
|
||||
'数字经济政策利好发布',
|
||||
'新能源产业链整合提速',
|
||||
'医药创新药获批上市',
|
||||
'5G应用场景扩展',
|
||||
'智能驾驶技术迭代升级',
|
||||
'储能行业景气度上行'
|
||||
];
|
||||
|
||||
const importanceLevels = [1, 2, 3, 4, 5];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const daysAgo = Math.floor(Math.random() * 180) + 30; // 30-210 天前
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
|
||||
const importance = importanceLevels[Math.floor(Math.random() * importanceLevels.length)];
|
||||
|
||||
events.push({
|
||||
id: `hist_event_${i + 1}`,
|
||||
title: eventTitles[i % eventTitles.length],
|
||||
description: `${eventTitles[i % eventTitles.length]}的详细描述。该事件对相关产业链产生重要影响,市场关注度高,相关概念股表现活跃。`,
|
||||
date: date.toISOString().split('T')[0],
|
||||
importance: importance,
|
||||
similarity: parseFloat((Math.random() * 0.3 + 0.7).toFixed(2)), // 0.7-1.0
|
||||
impact_sectors: [
|
||||
['半导体', '芯片设计', 'EDA'],
|
||||
['新能源汽车', '锂电池', '充电桩'],
|
||||
['人工智能', '算力', '大模型'],
|
||||
['半导体设备', '国产替代', '集成电路'],
|
||||
['数字经济', '云计算', '大数据']
|
||||
][i % 5],
|
||||
affected_stocks_count: Math.floor(Math.random() * 30) + 10, // 10-40 只股票
|
||||
avg_change_pct: parseFloat((Math.random() * 10 - 2).toFixed(2)), // -2% to +8%
|
||||
created_at: date.toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// 按日期降序排序
|
||||
return events.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
};
|
||||
|
||||
try {
|
||||
const historicalEvents = generateHistoricalEvents(5);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: historicalEvents,
|
||||
total: historicalEvents.length,
|
||||
message: '获取历史事件列表成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取历史事件列表失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取历史事件列表失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取历史事件相关股票
|
||||
http.get('/api/historical-events/:eventId/stocks', async ({ params }) => {
|
||||
await delay(500);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 获取历史事件相关股票, eventId:', eventId);
|
||||
|
||||
// 生成历史事件相关股票数据
|
||||
const generateHistoricalEventStocks = (count = 10) => {
|
||||
const stocks = [];
|
||||
const sectors = ['半导体', '新能源', '医药', '消费电子', '人工智能', '5G通信'];
|
||||
const stockNames = [
|
||||
'中芯国际', '长江存储', '华为海思', '紫光国微', '兆易创新',
|
||||
'宁德时代', '比亚迪', '隆基绿能', '阳光电源', '亿纬锂能',
|
||||
'恒瑞医药', '迈瑞医疗', '药明康德', '泰格医药', '康龙化成',
|
||||
'立讯精密', '歌尔声学', '京东方A', 'TCL科技', '海康威视',
|
||||
'科大讯飞', '商汤科技', '寒武纪', '海光信息', '中兴通讯'
|
||||
];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const stockCode = `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`;
|
||||
const changePct = (Math.random() * 15 - 3).toFixed(2); // -3% ~ +12%
|
||||
const correlation = (Math.random() * 0.4 + 0.6).toFixed(2); // 0.6 ~ 1.0
|
||||
|
||||
stocks.push({
|
||||
id: `stock_${i}`,
|
||||
stock_code: `${stockCode}.${Math.random() > 0.5 ? 'SH' : 'SZ'}`,
|
||||
stock_name: stockNames[i % stockNames.length],
|
||||
sector: sectors[Math.floor(Math.random() * sectors.length)],
|
||||
correlation: parseFloat(correlation),
|
||||
event_day_change_pct: parseFloat(changePct),
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
query_part: `该公司是${sectors[Math.floor(Math.random() * sectors.length)]}行业龙头,受事件影响显著,市场关注度高,订单量同比增长${Math.floor(Math.random() * 50 + 20)}%`,
|
||||
sentences: `根据行业研究报告,该公司在${sectors[Math.floor(Math.random() * sectors.length)]}领域具有核心技术优势,产能利用率达到${Math.floor(Math.random() * 20 + 80)}%,随着事件的深入发展,公司业绩有望持续受益。机构预测未来三年复合增长率将达到${Math.floor(Math.random() * 30 + 15)}%以上`,
|
||||
match_score: correlation > 0.8 ? '好' : (correlation > 0.6 ? '中' : '一般'),
|
||||
author: ['中信证券', '国泰君安', '华泰证券', '招商证券'][Math.floor(Math.random() * 4)],
|
||||
declare_date: new Date(Date.now() - Math.floor(Math.random() * 90) * 24 * 60 * 60 * 1000).toISOString(),
|
||||
report_title: `${stockNames[i % stockNames.length]}深度研究报告`
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 按相关度降序排序
|
||||
return stocks.sort((a, b) => b.correlation - a.correlation);
|
||||
};
|
||||
|
||||
try {
|
||||
const stocks = generateHistoricalEventStocks(15);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: stocks,
|
||||
message: '获取历史事件相关股票成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取历史事件相关股票失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取历史事件相关股票失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -12,6 +12,7 @@ import { stockHandlers } from './stock';
|
||||
import { companyHandlers } from './company';
|
||||
import { marketHandlers } from './market';
|
||||
import { financialHandlers } from './financial';
|
||||
import { limitAnalyseHandlers } from './limitAnalyse';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
@@ -28,5 +29,6 @@ export const handlers = [
|
||||
...companyHandlers,
|
||||
...marketHandlers,
|
||||
...financialHandlers,
|
||||
...limitAnalyseHandlers,
|
||||
// ...userHandlers,
|
||||
];
|
||||
|
||||
344
src/mocks/handlers/limitAnalyse.js
Normal file
344
src/mocks/handlers/limitAnalyse.js
Normal file
@@ -0,0 +1,344 @@
|
||||
// src/mocks/handlers/limitAnalyse.js
|
||||
// 涨停分析相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 生成可用日期列表(最近30个交易日)
|
||||
const generateAvailableDates = () => {
|
||||
const dates = [];
|
||||
const today = new Date();
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < 60 && count < 30; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dayOfWeek = date.getDay();
|
||||
|
||||
// 跳过周末
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const dateStr = `${year}${month}${day}`;
|
||||
|
||||
// 返回包含 date 和 count 字段的对象
|
||||
dates.push({
|
||||
date: dateStr,
|
||||
count: Math.floor(Math.random() * 80) + 30 // 30-110 只涨停股票
|
||||
});
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
};
|
||||
|
||||
// 生成板块数据
|
||||
const generateSectors = (count = 8) => {
|
||||
const sectorNames = [
|
||||
'人工智能', 'ChatGPT', '数字经济',
|
||||
'新能源汽车', '光伏', '锂电池',
|
||||
'半导体', '芯片', '5G通信',
|
||||
'医疗器械', '创新药', '中药',
|
||||
'白酒', '食品饮料', '消费电子',
|
||||
'军工', '航空航天', '新材料'
|
||||
];
|
||||
|
||||
const sectors = [];
|
||||
for (let i = 0; i < Math.min(count, sectorNames.length); i++) {
|
||||
const stockCount = Math.floor(Math.random() * 15) + 5;
|
||||
const stocks = [];
|
||||
|
||||
for (let j = 0; j < stockCount; j++) {
|
||||
stocks.push({
|
||||
code: `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`,
|
||||
name: `${sectorNames[i]}股票${j + 1}`,
|
||||
latest_limit_time: `${Math.floor(Math.random() * 4) + 9}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`,
|
||||
limit_up_count: Math.floor(Math.random() * 3) + 1,
|
||||
price: (Math.random() * 100 + 10).toFixed(2),
|
||||
change_pct: (Math.random() * 5 + 5).toFixed(2),
|
||||
turnover_rate: (Math.random() * 30 + 5).toFixed(2),
|
||||
volume: Math.floor(Math.random() * 100000000 + 10000000),
|
||||
amount: (Math.random() * 1000000000 + 100000000).toFixed(2),
|
||||
limit_type: Math.random() > 0.7 ? '一字板' : (Math.random() > 0.5 ? 'T字板' : '普通涨停'),
|
||||
封单金额: (Math.random() * 500000000).toFixed(2),
|
||||
});
|
||||
}
|
||||
|
||||
sectors.push({
|
||||
sector_name: sectorNames[i],
|
||||
stock_count: stockCount,
|
||||
avg_limit_time: `${Math.floor(Math.random() * 2) + 10}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`,
|
||||
stocks: stocks,
|
||||
});
|
||||
}
|
||||
|
||||
return sectors;
|
||||
};
|
||||
|
||||
// 生成高位股数据(用于 HighPositionStocks 组件)
|
||||
const generateHighPositionStocks = () => {
|
||||
const stocks = [];
|
||||
const stockNames = [
|
||||
'宁德时代', '比亚迪', '隆基绿能', '东方财富', '中际旭创',
|
||||
'京东方A', '海康威视', '立讯精密', '三一重工', '恒瑞医药',
|
||||
'三六零', '东方通信', '贵州茅台', '五粮液', '中国平安'
|
||||
];
|
||||
const industries = [
|
||||
'锂电池', '新能源汽车', '光伏', '金融科技', '通信设备',
|
||||
'显示器件', '安防设备', '电子元件', '工程机械', '医药制造',
|
||||
'网络安全', '通信服务', '白酒', '食品饮料', '保险'
|
||||
];
|
||||
|
||||
for (let i = 0; i < stockNames.length; i++) {
|
||||
const code = `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`;
|
||||
const continuousDays = Math.floor(Math.random() * 8) + 2; // 2-9连板
|
||||
const price = parseFloat((Math.random() * 100 + 20).toFixed(2));
|
||||
const increaseRate = parseFloat((Math.random() * 3 + 8).toFixed(2)); // 8%-11%
|
||||
const turnoverRate = parseFloat((Math.random() * 20 + 5).toFixed(2)); // 5%-25%
|
||||
|
||||
stocks.push({
|
||||
stock_code: code,
|
||||
stock_name: stockNames[i],
|
||||
price: price,
|
||||
increase_rate: increaseRate,
|
||||
continuous_limit_up: continuousDays,
|
||||
industry: industries[i],
|
||||
turnover_rate: turnoverRate,
|
||||
});
|
||||
}
|
||||
|
||||
// 按连板天数降序排序
|
||||
stocks.sort((a, b) => b.continuous_limit_up - a.continuous_limit_up);
|
||||
|
||||
return stocks;
|
||||
};
|
||||
|
||||
// 生成高位股统计数据
|
||||
const generateHighPositionStatistics = (stocks) => {
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return {
|
||||
total_count: 0,
|
||||
avg_continuous_days: 0,
|
||||
max_continuous_days: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const totalCount = stocks.length;
|
||||
const sumDays = stocks.reduce((sum, stock) => sum + stock.continuous_limit_up, 0);
|
||||
const maxDays = Math.max(...stocks.map(s => s.continuous_limit_up));
|
||||
|
||||
return {
|
||||
total_count: totalCount,
|
||||
avg_continuous_days: parseFloat((sumDays / totalCount).toFixed(1)),
|
||||
max_continuous_days: maxDays,
|
||||
};
|
||||
};
|
||||
|
||||
// 生成词云数据
|
||||
const generateWordCloudData = () => {
|
||||
const keywords = [
|
||||
'人工智能', 'ChatGPT', 'AI芯片', '大模型', '算力',
|
||||
'新能源', '光伏', '锂电池', '储能', '充电桩',
|
||||
'半导体', '芯片', 'EDA', '国产替代', '集成电路',
|
||||
'医疗', '创新药', 'CXO', '医疗器械', '生物医药',
|
||||
'消费', '白酒', '食品', '零售', '餐饮',
|
||||
'金融', '券商', '保险', '银行', '金融科技'
|
||||
];
|
||||
|
||||
return keywords.map(keyword => ({
|
||||
text: keyword,
|
||||
value: Math.floor(Math.random() * 50) + 10,
|
||||
category: ['科技', '新能源', '医疗', '消费', '金融'][Math.floor(Math.random() * 5)],
|
||||
}));
|
||||
};
|
||||
|
||||
// 生成每日分析数据
|
||||
const generateDailyAnalysis = (date) => {
|
||||
const sectorNames = [
|
||||
'公告', '人工智能', 'ChatGPT', '数字经济',
|
||||
'新能源汽车', '光伏', '锂电池',
|
||||
'半导体', '芯片', '5G通信',
|
||||
'医疗器械', '创新药', '其他'
|
||||
];
|
||||
|
||||
const stockNameTemplates = [
|
||||
'龙头', '科技', '新能源', '智能', '数字', '云计算', '创新',
|
||||
'生物', '医疗', '通信', '电子', '材料', '能源', '互联'
|
||||
];
|
||||
|
||||
// 生成 sector_data(SectorDetails 组件需要的格式)
|
||||
const sectorData = {};
|
||||
let totalStocks = 0;
|
||||
|
||||
sectorNames.forEach((sectorName, sectorIdx) => {
|
||||
const stockCount = Math.floor(Math.random() * 12) + 3; // 每个板块 3-15 只股票
|
||||
const stocks = [];
|
||||
|
||||
for (let i = 0; i < stockCount; i++) {
|
||||
const code = `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`;
|
||||
const continuousDays = Math.floor(Math.random() * 6) + 1; // 1-6连板
|
||||
const ztHour = Math.floor(Math.random() * 5) + 9; // 9-13点
|
||||
const ztMinute = Math.floor(Math.random() * 60);
|
||||
const ztSecond = Math.floor(Math.random() * 60);
|
||||
const ztTime = `2024-10-28 ${String(ztHour).padStart(2, '0')}:${String(ztMinute).padStart(2, '0')}:${String(ztSecond).padStart(2, '0')}`;
|
||||
|
||||
const stockName = `${stockNameTemplates[i % stockNameTemplates.length]}${sectorName === '公告' ? '公告' : ''}股份${i + 1}`;
|
||||
|
||||
stocks.push({
|
||||
scode: code,
|
||||
sname: stockName,
|
||||
zt_time: ztTime,
|
||||
formatted_time: `${String(ztHour).padStart(2, '0')}:${String(ztMinute).padStart(2, '0')}`,
|
||||
continuous_days: continuousDays === 1 ? '首板' : `${continuousDays}连板`,
|
||||
brief: `${sectorName}板块异动,${stockName}因${sectorName === '公告' ? '重大公告利好' : '板块热点'}涨停。公司是${sectorName}行业龙头企业之一。`,
|
||||
summary: `${sectorName}概念持续活跃`,
|
||||
first_time: `2024-10-${String(28 - (continuousDays - 1)).padStart(2, '0')}`,
|
||||
change_pct: parseFloat((Math.random() * 2 + 9).toFixed(2)), // 9%-11%
|
||||
core_sectors: [
|
||||
sectorName,
|
||||
sectorNames[Math.floor(Math.random() * sectorNames.length)],
|
||||
sectorNames[Math.floor(Math.random() * sectorNames.length)]
|
||||
].filter((v, i, a) => a.indexOf(v) === i) // 去重
|
||||
});
|
||||
}
|
||||
|
||||
sectorData[sectorName] = {
|
||||
count: stockCount,
|
||||
stocks: stocks.sort((a, b) => a.zt_time.localeCompare(b.zt_time)) // 按涨停时间排序
|
||||
};
|
||||
|
||||
totalStocks += stockCount;
|
||||
});
|
||||
|
||||
// 统计数据
|
||||
const morningCount = Math.floor(totalStocks * 0.35); // 早盘涨停
|
||||
const announcementCount = sectorData['公告']?.count || 0;
|
||||
const topSector = sectorNames.filter(s => s !== '公告' && s !== '其他')
|
||||
.reduce((max, name) =>
|
||||
(sectorData[name]?.count || 0) > (sectorData[max]?.count || 0) ? name : max
|
||||
, '人工智能');
|
||||
|
||||
return {
|
||||
date: date,
|
||||
total_stocks: totalStocks,
|
||||
total_sectors: Object.keys(sectorData).length,
|
||||
sector_data: sectorData, // 👈 SectorDetails 组件需要的数据
|
||||
summary: {
|
||||
top_sector: topSector,
|
||||
top_sector_count: sectorData[topSector]?.count || 0,
|
||||
announcement_stocks: announcementCount,
|
||||
zt_time_distribution: {
|
||||
morning: morningCount,
|
||||
afternoon: totalStocks - morningCount,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Mock Handlers
|
||||
export const limitAnalyseHandlers = [
|
||||
// 1. 获取可用日期列表
|
||||
http.get('http://111.198.58.126:5001/api/v1/dates/available', async () => {
|
||||
await delay(300);
|
||||
|
||||
const availableDates = generateAvailableDates();
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
events: availableDates,
|
||||
message: '可用日期列表获取成功',
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 获取每日分析数据
|
||||
http.get('http://111.198.58.126:5001/api/v1/analysis/daily/:date', async ({ params }) => {
|
||||
await delay(500);
|
||||
|
||||
const { date } = params;
|
||||
const data = generateDailyAnalysis(date);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data,
|
||||
message: `${date} 每日分析数据获取成功`,
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 获取词云数据
|
||||
http.get('http://111.198.58.126:5001/api/v1/analysis/wordcloud/:date', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { date } = params;
|
||||
const wordCloudData = generateWordCloudData();
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: wordCloudData,
|
||||
message: `${date} 词云数据获取成功`,
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 混合搜索(POST)
|
||||
http.post('http://111.198.58.126:5001/api/v1/stocks/search/hybrid', async ({ request }) => {
|
||||
await delay(400);
|
||||
|
||||
const body = await request.json();
|
||||
const { query, type = 'all', mode = 'hybrid' } = body;
|
||||
|
||||
// 生成模拟搜索结果
|
||||
const results = [];
|
||||
const count = Math.floor(Math.random() * 10) + 5;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
results.push({
|
||||
code: `${Math.random() > 0.5 ? '6' : '0'}${String(Math.floor(Math.random() * 100000)).padStart(5, '0')}`,
|
||||
name: `${query || '搜索'}相关股票${i + 1}`,
|
||||
sector: ['人工智能', 'ChatGPT', '新能源'][Math.floor(Math.random() * 3)],
|
||||
limit_date: new Date().toISOString().split('T')[0].replace(/-/g, ''),
|
||||
limit_time: `${Math.floor(Math.random() * 4) + 9}:${String(Math.floor(Math.random() * 60)).padStart(2, '0')}`,
|
||||
price: (Math.random() * 100 + 10).toFixed(2),
|
||||
change_pct: (Math.random() * 10).toFixed(2),
|
||||
match_score: (Math.random() * 0.5 + 0.5).toFixed(2),
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
query: query,
|
||||
type: type,
|
||||
mode: mode,
|
||||
results: results,
|
||||
total: results.length,
|
||||
},
|
||||
message: '搜索完成',
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 获取高位股列表(涨停股票列表)
|
||||
http.get('http://111.198.58.126:5001/api/limit-analyse/high-position-stocks', async ({ request }) => {
|
||||
await delay(400);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
console.log('[Mock LimitAnalyse] 获取高位股列表:', { date });
|
||||
|
||||
const stocks = generateHighPositionStocks();
|
||||
const statistics = generateHighPositionStatistics(stocks);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stocks: stocks,
|
||||
statistics: statistics,
|
||||
date: date,
|
||||
},
|
||||
message: '高位股数据获取成功',
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -147,6 +147,8 @@ export const WECHAT_STATUS = {
|
||||
LOGIN_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
REGISTER_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
EXPIRED: 'expired',
|
||||
AUTH_DENIED: 'auth_denied', // 用户拒绝授权
|
||||
AUTH_FAILED: 'auth_failed', // 授权失败
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -157,6 +159,8 @@ export const STATUS_MESSAGES = {
|
||||
[WECHAT_STATUS.SCANNED]: '扫码成功,请在手机上确认',
|
||||
[WECHAT_STATUS.AUTHORIZED]: '授权成功,正在登录...',
|
||||
[WECHAT_STATUS.EXPIRED]: '二维码已过期',
|
||||
[WECHAT_STATUS.AUTH_DENIED]: '用户取消授权',
|
||||
[WECHAT_STATUS.AUTH_FAILED]: '授权失败,请重试',
|
||||
};
|
||||
|
||||
export default authService;
|
||||
|
||||
@@ -17,6 +17,7 @@ import SubscriptionUpgradeModal from '../../../components/SubscriptionUpgradeMod
|
||||
import moment from 'moment';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import RiskDisclaimer from '../../../components/RiskDisclaimer';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TabPane } = Tabs;
|
||||
@@ -1037,6 +1038,11 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
className="stock-detail-panel"
|
||||
>
|
||||
<AntdTabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||
|
||||
{/* 风险提示 */}
|
||||
<div style={{ marginTop: '24px', paddingBottom: '20px' }}>
|
||||
<RiskDisclaimer variant="default" />
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
{/* 事件讨论模态框 */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents';
|
||||
import RiskDisclaimer from '../../components/RiskDisclaimer';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -825,6 +826,11 @@ const ConceptTimelineModal = ({
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 风险提示 */}
|
||||
<Box px={6}>
|
||||
<RiskDisclaimer variant="default" />
|
||||
</Box>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter borderTop="1px solid" borderColor="gray.200">
|
||||
|
||||
@@ -274,7 +274,69 @@ export const useConceptEvents = ({ navigate } = {}) => {
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
// ========== 别名函数 - 为保持向后兼容性 ==========
|
||||
|
||||
/**
|
||||
* 追踪概念搜索(别名函数)
|
||||
* @alias trackSearchQuerySubmitted
|
||||
*/
|
||||
const trackConceptSearched = useCallback((query, resultCount = 0) => {
|
||||
return trackSearchQuerySubmitted(query, resultCount);
|
||||
}, [trackSearchQuerySubmitted]);
|
||||
|
||||
/**
|
||||
* 追踪筛选器应用(通用包装函数)
|
||||
* @param {string} filterType - 筛选类型 (sort/date/view_mode)
|
||||
* @param {any} filterValue - 筛选值
|
||||
* @param {any} previousValue - 之前的值
|
||||
*/
|
||||
const trackFilterApplied = useCallback((filterType, filterValue, previousValue = null) => {
|
||||
if (filterType === 'sort') {
|
||||
return trackSortChanged(filterValue, previousValue);
|
||||
} else if (filterType === 'date') {
|
||||
return trackDateChanged(filterValue, previousValue);
|
||||
} else if (filterType === 'view_mode') {
|
||||
return trackViewModeChanged(filterValue, previousValue);
|
||||
}
|
||||
}, [trackSortChanged, trackDateChanged, trackViewModeChanged]);
|
||||
|
||||
/**
|
||||
* 追踪概念股票列表查看
|
||||
* @param {string} conceptName - 概念名称
|
||||
* @param {number} stockCount - 股票数量
|
||||
*/
|
||||
const trackConceptStocksViewed = useCallback((conceptName, stockCount = 0) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_DETAIL_VIEWED, {
|
||||
concept_name: conceptName,
|
||||
stock_count: stockCount,
|
||||
view_type: 'stocks_list',
|
||||
source: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '📈 Concept Stocks Viewed', {
|
||||
conceptName,
|
||||
stockCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念时间轴查看(别名函数)
|
||||
* @alias trackConceptDetailViewed
|
||||
*/
|
||||
const trackConceptTimelineViewed = useCallback((conceptName, conceptId) => {
|
||||
return trackConceptDetailViewed(conceptName, conceptId);
|
||||
}, [trackConceptDetailViewed]);
|
||||
|
||||
/**
|
||||
* 追踪分页变化(别名函数 - 不同时态)
|
||||
* @alias trackPageChanged
|
||||
*/
|
||||
const trackPageChange = useCallback((page, filters = {}) => {
|
||||
return trackPageChanged(page, filters);
|
||||
}, [trackPageChanged]);
|
||||
|
||||
return {
|
||||
// 原有函数
|
||||
trackConceptListViewed,
|
||||
trackSearchInitiated,
|
||||
trackSearchQuerySubmitted,
|
||||
@@ -288,5 +350,12 @@ export const useConceptEvents = ({ navigate } = {}) => {
|
||||
trackStockDetailViewed,
|
||||
trackPaywallShown,
|
||||
trackUpgradeClicked,
|
||||
|
||||
// 别名函数 - 为保持向后兼容性
|
||||
trackConceptSearched,
|
||||
trackFilterApplied,
|
||||
trackConceptStocksViewed,
|
||||
trackConceptTimelineViewed,
|
||||
trackPageChange,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { getFormattedTextProps } from '../../../utils/textUtils';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import RiskDisclaimer from '../../../components/RiskDisclaimer';
|
||||
import './WordCloud.css';
|
||||
import {
|
||||
BarChart, Bar,
|
||||
@@ -598,6 +599,9 @@ export const StockDetailModal = ({ isOpen, onClose, selectedStock }) => {
|
||||
))}
|
||||
</Wrap>
|
||||
</Box>
|
||||
|
||||
{/* 风险提示 */}
|
||||
<RiskDisclaimer variant="default" />
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { StarIcon, ViewIcon, TimeIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { getFormattedTextProps } from '../../../utils/textUtils';
|
||||
|
||||
const SectorDetails = ({ sortedSectors, totalStocks }) => {
|
||||
const SectorDetails = ({ sortedSectors, totalStocks, onStockClick }) => {
|
||||
// 使用 useRef 来维持展开状态,避免重新渲染时重置
|
||||
const expandedSectorsRef = useRef([]);
|
||||
const [expandedSectors, setExpandedSectors] = useState([]);
|
||||
@@ -194,6 +194,8 @@ const SectorDetails = ({ sortedSectors, totalStocks }) => {
|
||||
bg: 'gray.50'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onStockClick && onStockClick(stock)}
|
||||
>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
|
||||
@@ -60,6 +60,8 @@ export default function LimitAnalyse() {
|
||||
const [wordCloudData, setWordCloudData] = useState([]);
|
||||
const [searchResults, setSearchResults] = useState(null);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [selectedStock, setSelectedStock] = useState(null);
|
||||
const [isStockDetailOpen, setIsStockDetailOpen] = useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
@@ -243,6 +245,20 @@ export default function LimitAnalyse() {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理股票点击
|
||||
const handleStockClick = (stock) => {
|
||||
setSelectedStock(stock);
|
||||
setIsStockDetailOpen(true);
|
||||
// 🎯 追踪股票详情查看
|
||||
trackStockDetailViewed(stock.scode, stock.sname, 'sector_details');
|
||||
};
|
||||
|
||||
// 关闭股票详情弹窗
|
||||
const handleCloseStockDetail = () => {
|
||||
setIsStockDetailOpen(false);
|
||||
setSelectedStock(null);
|
||||
};
|
||||
|
||||
// 处理板块数据排序
|
||||
const getSortedSectorData = () => {
|
||||
if (!dailyData?.sector_data) return [];
|
||||
@@ -470,6 +486,7 @@ export default function LimitAnalyse() {
|
||||
<SectorDetails
|
||||
sortedSectors={getSortedSectorData()}
|
||||
totalStocks={dailyData?.total_stocks || 0}
|
||||
onStockClick={handleStockClick}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -496,6 +513,13 @@ export default function LimitAnalyse() {
|
||||
onStockClick={() => {}}
|
||||
/>
|
||||
|
||||
{/* 股票详情弹窗 */}
|
||||
<StockDetailModal
|
||||
isOpen={isStockDetailOpen}
|
||||
onClose={handleCloseStockDetail}
|
||||
selectedStock={selectedStock}
|
||||
/>
|
||||
|
||||
{/* 浮动按钮 */}
|
||||
<Box position="fixed" bottom={8} right={8} zIndex={1000}>
|
||||
<VStack spacing={3}>
|
||||
|
||||
@@ -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