Compare commits
38 Commits
c6a6444d9a
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df41ef2e61 | ||
| 0d84ffe87f | |||
|
|
cb84b0238a | ||
|
|
433fc4a0f5 | ||
|
|
5bac525147 | ||
|
|
a049d0365b | ||
|
|
fdbb6ceff5 | ||
|
|
35f8b5195a | ||
|
|
77aafd5661 | ||
|
|
ce1bf29270 | ||
|
|
ac7a6991bc | ||
|
|
4435ef9392 | ||
|
|
224c6a12d4 | ||
|
|
d0d8b1ebde | ||
|
|
bf8aff9e7e | ||
|
|
376b5f66cd | ||
|
|
f3c7e016ac | ||
|
|
ad21398e1c | ||
|
|
0e1cc11330 | ||
|
|
e9b54ce10d | ||
|
|
e5ab99bae6 | ||
|
|
8632e40c94 | ||
|
|
173b13bc70 | ||
|
|
02cd234def | ||
|
|
e3a953559f | ||
|
|
78e4b8f696 | ||
|
|
1cf6169370 | ||
| 8417ab17be | |||
| dd59cb6385 | |||
|
|
e3721b22ff | ||
|
|
357b8bbdd7 | ||
| 512aca16d8 | |||
|
|
6a51fc3c88 | ||
|
|
7cca5e73c0 | ||
|
|
112fbbd42d | ||
|
|
3a4dade8ec | ||
|
|
6f81259f8c | ||
|
|
864844a52b |
@@ -11,7 +11,8 @@
|
||||
"Bash(npm install)",
|
||||
"Bash(npm run start:mock)",
|
||||
"Bash(npm install fsevents@latest --save-optional --force)",
|
||||
"Bash(python -m py_compile:*)"
|
||||
"Bash(python -m py_compile:*)",
|
||||
"Bash(ps -p 20502,53360 -o pid,command)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 开发环境配置(连接真实后端)
|
||||
# 使用方式: npm start
|
||||
# 使用方式: npm run start:dev
|
||||
|
||||
# React 构建优化配置
|
||||
GENERATE_SOURCEMAP=false
|
||||
@@ -18,3 +18,10 @@ REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 开发环境标识
|
||||
REACT_APP_ENV=development
|
||||
|
||||
# PostHog 配置(开发环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
11
.env.mock
11
.env.mock
@@ -35,3 +35,14 @@ REACT_APP_ENABLE_MOCK=true
|
||||
|
||||
# Mock 环境标识
|
||||
REACT_APP_ENV=mock
|
||||
|
||||
# PostHog 配置(Mock 环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
# PostHog Debug 模式(Mock 环境永久启用)
|
||||
# 在浏览器 Console 中打印详细的事件追踪日志
|
||||
REACT_APP_POSTHOG_DEBUG=true
|
||||
|
||||
42
.env.test
Normal file
42
.env.test
Normal file
@@ -0,0 +1,42 @@
|
||||
# ========================================
|
||||
# 本地测试环境(前后端都在本地)
|
||||
# ========================================
|
||||
# 使用方式: npm run start:test
|
||||
#
|
||||
# 工作原理:
|
||||
# 1. concurrently 同时启动前端和后端
|
||||
# 2. 前端: localhost:3000
|
||||
# 3. 后端: localhost:5001 (python app_2.py)
|
||||
# 4. 数据: 本地数据库
|
||||
#
|
||||
# 适用场景:
|
||||
# - 调试后端代码
|
||||
# - 性能测试
|
||||
# - 离线开发
|
||||
# - 数据库调试
|
||||
# ========================================
|
||||
|
||||
# 环境标识
|
||||
REACT_APP_ENV=test
|
||||
NODE_ENV=development
|
||||
|
||||
# Mock 配置(关闭 MSW)
|
||||
REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 后端 API 地址(本地后端)
|
||||
REACT_APP_API_URL=http://localhost:5001
|
||||
|
||||
# PostHog 配置(测试环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
# React 构建优化配置
|
||||
GENERATE_SOURCEMAP=true # 测试环境保留 sourcemap 便于调试
|
||||
SKIP_PREFLIGHT_CHECK=true
|
||||
DISABLE_ESLINT_PLUGIN=false # 测试环境开启 ESLint
|
||||
TSC_COMPILE_ON_ERROR=true
|
||||
IMAGE_INLINE_SIZE_LIMIT=10000
|
||||
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
|
||||
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:
|
||||
|
||||
14
package.json
14
package.json
@@ -93,9 +93,14 @@
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco start",
|
||||
"start:mock": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
|
||||
"prestart": "kill-port 3000",
|
||||
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start",
|
||||
"start:real": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.local craco start",
|
||||
"start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development craco start",
|
||||
"start:test": "concurrently \"python app_2.py\" \"npm run frontend:test\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
|
||||
"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:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
||||
"test": "craco test --env=jsdom",
|
||||
@@ -105,12 +110,14 @@
|
||||
"rollback": "bash scripts/rollback-from-local.sh",
|
||||
"lint:check": "eslint . --ext=js,jsx; exit 0",
|
||||
"lint:fix": "eslint . --ext=js,jsx --fix; exit 0",
|
||||
"install:clean": "rm -rf node_modules/ && rm -rf package-lock.json && npm install && npm start"
|
||||
"clean": "rm -rf node_modules/ package-lock.json",
|
||||
"reinstall": "npm run clean && npm install"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@craco/craco": "^7.1.0",
|
||||
"ajv": "^8.17.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"concurrently": "^8.2.2",
|
||||
"env-cmd": "^11.0.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-prettier": "3.4.0",
|
||||
@@ -119,6 +126,7 @@
|
||||
"imagemin": "^9.0.1",
|
||||
"imagemin-mozjpeg": "^10.0.0",
|
||||
"imagemin-pngquant": "^10.0.0",
|
||||
"kill-port": "^2.0.1",
|
||||
"msw": "^2.11.5",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "2.2.1",
|
||||
|
||||
@@ -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 });
|
||||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||||
|
||||
@@ -51,6 +51,7 @@ import SubscriptionButton from '../Subscription/SubscriptionButton';
|
||||
import SubscriptionModal from '../Subscription/SubscriptionModal';
|
||||
import { CrownIcon, TooltipContent } from '../Subscription/CrownTooltip';
|
||||
import InvestmentCalendar from '../../views/Community/components/InvestmentCalendar';
|
||||
import { useNavigationEvents } from '../../hooks/useNavigationEvents';
|
||||
|
||||
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
|
||||
const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
@@ -61,6 +62,9 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
// ⚠️ 必须在组件顶层调用所有Hooks(不能在JSX中调用)
|
||||
const borderColorValue = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'secondary_nav' });
|
||||
|
||||
// 定义二级导航结构
|
||||
const secondaryNavConfig = {
|
||||
'/community': {
|
||||
@@ -162,7 +166,11 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => navigate(item.path)}
|
||||
onClick={() => {
|
||||
// 🎯 追踪侧边栏菜单点击
|
||||
navEvents.trackSidebarMenuClicked(item.label, item.path, 2, false);
|
||||
navigate(item.path);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
bg={isActive ? 'blue.50' : 'transparent'}
|
||||
@@ -313,6 +321,9 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
// ⚠️ 必须在组件顶层调用所有Hooks(不能在JSX中调用)
|
||||
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'top_nav' });
|
||||
|
||||
// 辅助函数:判断导航项是否激活
|
||||
const isActive = useCallback((paths) => {
|
||||
return paths.some(path => location.pathname.includes(path));
|
||||
@@ -337,7 +348,11 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
</MenuButton>
|
||||
<MenuList minW="260px" p={2}>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/community')}
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
|
||||
navigate('/community');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/community') ? '3px solid' : 'none'}
|
||||
@@ -353,7 +368,11 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/concepts')}
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
|
||||
navigate('/concepts');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/concepts') ? '3px solid' : 'none'}
|
||||
@@ -489,6 +508,9 @@ export default function HomeNavbar() {
|
||||
const brandHover = useColorModeValue('blue.600', 'blue.300');
|
||||
const toast = useToast();
|
||||
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'main_navbar' });
|
||||
|
||||
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
|
||||
const userId = user?.id;
|
||||
const prevUserIdRef = React.useRef(userId);
|
||||
@@ -882,7 +904,11 @@ export default function HomeNavbar() {
|
||||
color={brandText}
|
||||
cursor="pointer"
|
||||
_hover={{ color: brandHover }}
|
||||
onClick={() => navigate('/home')}
|
||||
onClick={() => {
|
||||
// 🎯 追踪Logo点击
|
||||
navEvents.trackLogoClicked();
|
||||
navigate('/home');
|
||||
}}
|
||||
style={{ minWidth: isMobile ? '100px' : '140px' }}
|
||||
noOfLines={1}
|
||||
>
|
||||
@@ -912,7 +938,13 @@ export default function HomeNavbar() {
|
||||
<IconButton
|
||||
aria-label="切换主题"
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={toggleColorMode}
|
||||
onClick={() => {
|
||||
// 🎯 追踪主题切换
|
||||
const fromTheme = colorMode;
|
||||
const toTheme = colorMode === 'light' ? 'dark' : 'light';
|
||||
navEvents.trackThemeChanged(fromTheme, toTheme);
|
||||
toggleColorMode();
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
minW={{ base: '36px', md: '40px' }}
|
||||
|
||||
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>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents';
|
||||
|
||||
// Icons
|
||||
import {
|
||||
@@ -54,6 +55,14 @@ export default function SubscriptionContent() {
|
||||
// Auth context
|
||||
const { user } = useAuth();
|
||||
|
||||
// 🎯 初始化订阅埋点Hook(传入当前订阅信息)
|
||||
const subscriptionEvents = useSubscriptionEvents({
|
||||
currentSubscription: {
|
||||
plan: user?.subscription_plan || 'free',
|
||||
status: user?.subscription_status || 'none'
|
||||
}
|
||||
});
|
||||
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
@@ -161,6 +170,13 @@ export default function SubscriptionContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 追踪定价方案选择
|
||||
subscriptionEvents.trackPricingPlanSelected(
|
||||
plan.name,
|
||||
selectedCycle,
|
||||
selectedCycle === 'monthly' ? plan.monthly_price : plan.yearly_price
|
||||
);
|
||||
|
||||
setSelectedPlan(plan);
|
||||
onPaymentModalOpen();
|
||||
};
|
||||
@@ -170,6 +186,17 @@ export default function SubscriptionContent() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const price = selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price;
|
||||
|
||||
// 🎯 追踪支付发起
|
||||
subscriptionEvents.trackPaymentInitiated({
|
||||
planName: selectedPlan.name,
|
||||
paymentMethod: 'wechat_pay',
|
||||
amount: price,
|
||||
billingCycle: selectedCycle,
|
||||
orderId: null // Will be set after order creation
|
||||
});
|
||||
|
||||
const response = await fetch('/api/payment/create-order', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -204,6 +231,13 @@ export default function SubscriptionContent() {
|
||||
throw new Error('网络错误');
|
||||
}
|
||||
} catch (error) {
|
||||
// 🎯 追踪支付失败
|
||||
subscriptionEvents.trackPaymentFailed({
|
||||
planName: selectedPlan.name,
|
||||
paymentMethod: 'wechat_pay',
|
||||
amount: selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price
|
||||
}, error.message);
|
||||
|
||||
toast({
|
||||
title: '创建订单失败',
|
||||
description: error.message,
|
||||
@@ -251,6 +285,26 @@ export default function SubscriptionContent() {
|
||||
setAutoCheckInterval(null);
|
||||
|
||||
logger.info('SubscriptionContent', '自动检测到支付成功', { orderId });
|
||||
|
||||
// 🎯 追踪支付成功
|
||||
subscriptionEvents.trackPaymentSuccessful({
|
||||
planName: selectedPlan?.name,
|
||||
paymentMethod: 'wechat_pay',
|
||||
amount: paymentOrder?.amount,
|
||||
billingCycle: selectedCycle,
|
||||
orderId: orderId,
|
||||
transactionId: data.transaction_id
|
||||
});
|
||||
|
||||
// 🎯 追踪订阅创建
|
||||
subscriptionEvents.trackSubscriptionCreated({
|
||||
plan: selectedPlan?.name,
|
||||
billingCycle: selectedCycle,
|
||||
amount: paymentOrder?.amount,
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: null // Will be calculated by backend
|
||||
});
|
||||
|
||||
toast({
|
||||
title: '支付成功!',
|
||||
description: '订阅已激活,正在跳转...',
|
||||
|
||||
325
src/hooks/useDashboardEvents.js
Normal file
325
src/hooks/useDashboardEvents.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// src/hooks/useDashboardEvents.js
|
||||
// 个人中心(Dashboard/Center)事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 个人中心事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.pageType - 页面类型 ('center' | 'profile' | 'settings')
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useDashboardEvents = ({ pageType = 'center', navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
const eventMap = {
|
||||
'center': RETENTION_EVENTS.DASHBOARD_CENTER_VIEWED,
|
||||
'profile': RETENTION_EVENTS.PROFILE_PAGE_VIEWED,
|
||||
'settings': RETENTION_EVENTS.SETTINGS_PAGE_VIEWED,
|
||||
};
|
||||
|
||||
const eventName = eventMap[pageType] || RETENTION_EVENTS.DASHBOARD_VIEWED;
|
||||
|
||||
track(eventName, {
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', `📊 Dashboard Page Viewed: ${pageType}`);
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪功能卡片点击
|
||||
* @param {string} cardName - 卡片名称 ('watchlist' | 'following_events' | 'comments' | 'subscription')
|
||||
* @param {Object} cardData - 卡片数据
|
||||
*/
|
||||
const trackFunctionCardClicked = useCallback((cardName, cardData = {}) => {
|
||||
if (!cardName) {
|
||||
logger.warn('useDashboardEvents', 'Card name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.FUNCTION_CARD_CLICKED, {
|
||||
card_name: cardName,
|
||||
data_count: cardData.count || 0,
|
||||
has_data: Boolean(cardData.count && cardData.count > 0),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '🎴 Function Card Clicked', {
|
||||
cardName,
|
||||
count: cardData.count,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股列表查看
|
||||
* @param {number} stockCount - 自选股数量
|
||||
* @param {boolean} hasRealtime - 是否有实时行情
|
||||
*/
|
||||
const trackWatchlistViewed = useCallback((stockCount = 0, hasRealtime = false) => {
|
||||
track('Watchlist Viewed', {
|
||||
stock_count: stockCount,
|
||||
has_realtime: hasRealtime,
|
||||
is_empty: stockCount === 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '⭐ Watchlist Viewed', {
|
||||
stockCount,
|
||||
hasRealtime,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股点击
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
* @param {number} position - 在列表中的位置
|
||||
*/
|
||||
const trackWatchlistStockClicked = useCallback((stock, position = 0) => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useDashboardEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source: 'watchlist',
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '🎯 Watchlist Stock Clicked', {
|
||||
stockCode: stock.code,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股添加
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
* @param {string} source - 来源 ('search' | 'stock_detail' | 'manual')
|
||||
*/
|
||||
const trackWatchlistStockAdded = useCallback((stock, source = 'manual') => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useDashboardEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Watchlist Stock Added', {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '➕ Watchlist Stock Added', {
|
||||
stockCode: stock.code,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪自选股移除
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
*/
|
||||
const trackWatchlistStockRemoved = useCallback((stock) => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useDashboardEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Watchlist Stock Removed', {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '➖ Watchlist Stock Removed', {
|
||||
stockCode: stock.code,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪关注的事件列表查看
|
||||
* @param {number} eventCount - 关注的事件数量
|
||||
*/
|
||||
const trackFollowingEventsViewed = useCallback((eventCount = 0) => {
|
||||
track('Following Events Viewed', {
|
||||
event_count: eventCount,
|
||||
is_empty: eventCount === 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '📌 Following Events Viewed', {
|
||||
eventCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪关注的事件点击
|
||||
* @param {Object} event - 事件对象
|
||||
* @param {number} event.id - 事件ID
|
||||
* @param {string} event.title - 事件标题
|
||||
* @param {number} position - 在列表中的位置
|
||||
*/
|
||||
const trackFollowingEventClicked = useCallback((event, position = 0) => {
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useDashboardEvents', 'Event object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
news_id: event.id,
|
||||
news_title: event.title || '',
|
||||
source: 'following_events',
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '📰 Following Event Clicked', {
|
||||
eventId: event.id,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪事件评论列表查看
|
||||
* @param {number} commentCount - 评论数量
|
||||
*/
|
||||
const trackCommentsViewed = useCallback((commentCount = 0) => {
|
||||
track('Event Comments Viewed', {
|
||||
comment_count: commentCount,
|
||||
is_empty: commentCount === 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '💬 Comments Viewed', {
|
||||
commentCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪订阅信息查看
|
||||
* @param {Object} subscription - 订阅信息
|
||||
* @param {string} subscription.plan - 订阅计划 ('free' | 'pro' | 'enterprise')
|
||||
* @param {string} subscription.status - 订阅状态 ('active' | 'expired' | 'cancelled')
|
||||
*/
|
||||
const trackSubscriptionViewed = useCallback((subscription = {}) => {
|
||||
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
|
||||
subscription_plan: subscription.plan || 'free',
|
||||
subscription_status: subscription.status || 'unknown',
|
||||
is_paid_user: subscription.plan !== 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '💳 Subscription Viewed', {
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪升级按钮点击
|
||||
* @param {string} currentPlan - 当前计划
|
||||
* @param {string} targetPlan - 目标计划
|
||||
* @param {string} source - 来源位置
|
||||
*/
|
||||
const trackUpgradePlanClicked = useCallback((currentPlan = 'free', targetPlan = 'pro', source = 'dashboard') => {
|
||||
track(RETENTION_EVENTS.UPGRADE_PLAN_CLICKED, {
|
||||
current_plan: currentPlan,
|
||||
target_plan: targetPlan,
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '⬆️ Upgrade Plan Clicked', {
|
||||
currentPlan,
|
||||
targetPlan,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪个人资料更新
|
||||
* @param {Array<string>} updatedFields - 更新的字段列表
|
||||
*/
|
||||
const trackProfileUpdated = useCallback((updatedFields = []) => {
|
||||
track(RETENTION_EVENTS.PROFILE_UPDATED, {
|
||||
updated_fields: updatedFields,
|
||||
field_count: updatedFields.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '✏️ Profile Updated', {
|
||||
updatedFields,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪设置更改
|
||||
* @param {string} settingName - 设置名称
|
||||
* @param {any} oldValue - 旧值
|
||||
* @param {any} newValue - 新值
|
||||
*/
|
||||
const trackSettingChanged = useCallback((settingName, oldValue, newValue) => {
|
||||
if (!settingName) {
|
||||
logger.warn('useDashboardEvents', 'Setting name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SETTINGS_CHANGED, {
|
||||
setting_name: settingName,
|
||||
old_value: String(oldValue),
|
||||
new_value: String(newValue),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useDashboardEvents', '⚙️ Setting Changed', {
|
||||
settingName,
|
||||
oldValue,
|
||||
newValue,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
// 功能卡片事件
|
||||
trackFunctionCardClicked,
|
||||
|
||||
// 自选股相关事件
|
||||
trackWatchlistViewed,
|
||||
trackWatchlistStockClicked,
|
||||
trackWatchlistStockAdded,
|
||||
trackWatchlistStockRemoved,
|
||||
|
||||
// 关注事件相关
|
||||
trackFollowingEventsViewed,
|
||||
trackFollowingEventClicked,
|
||||
|
||||
// 评论相关
|
||||
trackCommentsViewed,
|
||||
|
||||
// 订阅相关
|
||||
trackSubscriptionViewed,
|
||||
trackUpgradePlanClicked,
|
||||
|
||||
// 个人资料和设置
|
||||
trackProfileUpdated,
|
||||
trackSettingChanged,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDashboardEvents;
|
||||
293
src/hooks/useNavigationEvents.js
Normal file
293
src/hooks/useNavigationEvents.js
Normal file
@@ -0,0 +1,293 @@
|
||||
// src/hooks/useNavigationEvents.js
|
||||
// 导航和菜单事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 导航事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.component - 组件名称 ('top_nav' | 'sidebar' | 'breadcrumb' | 'footer')
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useNavigationEvents = ({ component = 'navigation' } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪顶部导航点击
|
||||
* @param {string} itemName - 导航项名称
|
||||
* @param {string} path - 导航目标路径
|
||||
* @param {string} category - 导航分类 ('main' | 'user' | 'utility')
|
||||
*/
|
||||
const trackTopNavClicked = useCallback((itemName, path = '', category = 'main') => {
|
||||
if (!itemName) {
|
||||
logger.warn('useNavigationEvents', 'trackTopNavClicked: itemName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.TOP_NAV_CLICKED, {
|
||||
item_name: itemName,
|
||||
path,
|
||||
category,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🔝 Top Navigation Clicked', {
|
||||
itemName,
|
||||
path,
|
||||
category,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪侧边栏菜单点击
|
||||
* @param {string} itemName - 菜单项名称
|
||||
* @param {string} path - 目标路径
|
||||
* @param {number} level - 菜单层级 (1=主菜单, 2=子菜单)
|
||||
* @param {boolean} isExpanded - 是否展开状态
|
||||
*/
|
||||
const trackSidebarMenuClicked = useCallback((itemName, path = '', level = 1, isExpanded = false) => {
|
||||
if (!itemName) {
|
||||
logger.warn('useNavigationEvents', 'trackSidebarMenuClicked: itemName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SIDEBAR_MENU_CLICKED, {
|
||||
item_name: itemName,
|
||||
path,
|
||||
level,
|
||||
is_expanded: isExpanded,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '📂 Sidebar Menu Clicked', {
|
||||
itemName,
|
||||
path,
|
||||
level,
|
||||
isExpanded,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪通用菜单项点击
|
||||
* @param {string} itemName - 菜单项名称
|
||||
* @param {string} menuType - 菜单类型 ('dropdown' | 'context' | 'tab')
|
||||
* @param {string} path - 目标路径
|
||||
*/
|
||||
const trackMenuItemClicked = useCallback((itemName, menuType = 'dropdown', path = '') => {
|
||||
if (!itemName) {
|
||||
logger.warn('useNavigationEvents', 'trackMenuItemClicked: itemName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.MENU_ITEM_CLICKED, {
|
||||
item_name: itemName,
|
||||
menu_type: menuType,
|
||||
path,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '📋 Menu Item Clicked', {
|
||||
itemName,
|
||||
menuType,
|
||||
path,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪面包屑导航点击
|
||||
* @param {string} itemName - 面包屑项名称
|
||||
* @param {string} path - 目标路径
|
||||
* @param {number} position - 在面包屑中的位置
|
||||
* @param {number} totalItems - 面包屑总项数
|
||||
*/
|
||||
const trackBreadcrumbClicked = useCallback((itemName, path = '', position = 0, totalItems = 0) => {
|
||||
if (!itemName) {
|
||||
logger.warn('useNavigationEvents', 'trackBreadcrumbClicked: itemName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.BREADCRUMB_CLICKED, {
|
||||
item_name: itemName,
|
||||
path,
|
||||
position,
|
||||
total_items: totalItems,
|
||||
is_last: position === totalItems - 1,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🍞 Breadcrumb Clicked', {
|
||||
itemName,
|
||||
position,
|
||||
totalItems,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪Logo点击(返回首页)
|
||||
*/
|
||||
const trackLogoClicked = useCallback(() => {
|
||||
track('Logo Clicked', {
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🏠 Logo Clicked');
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪用户菜单展开
|
||||
* @param {Object} user - 用户对象
|
||||
* @param {number} menuItemCount - 菜单项数量
|
||||
*/
|
||||
const trackUserMenuOpened = useCallback((user = {}, menuItemCount = 0) => {
|
||||
track('User Menu Opened', {
|
||||
user_id: user.id || null,
|
||||
menu_item_count: menuItemCount,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '👤 User Menu Opened', {
|
||||
userId: user.id,
|
||||
menuItemCount,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪通知中心打开
|
||||
* @param {number} unreadCount - 未读通知数量
|
||||
*/
|
||||
const trackNotificationCenterOpened = useCallback((unreadCount = 0) => {
|
||||
track('Notification Center Opened', {
|
||||
unread_count: unreadCount,
|
||||
has_unread: unreadCount > 0,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🔔 Notification Center Opened', {
|
||||
unreadCount,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪语言切换
|
||||
* @param {string} fromLanguage - 原语言
|
||||
* @param {string} toLanguage - 目标语言
|
||||
*/
|
||||
const trackLanguageChanged = useCallback((fromLanguage, toLanguage) => {
|
||||
if (!fromLanguage || !toLanguage) {
|
||||
logger.warn('useNavigationEvents', 'trackLanguageChanged: both languages are required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Language Changed', {
|
||||
from_language: fromLanguage,
|
||||
to_language: toLanguage,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🌐 Language Changed', {
|
||||
fromLanguage,
|
||||
toLanguage,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪主题切换(深色/浅色模式)
|
||||
* @param {string} fromTheme - 原主题
|
||||
* @param {string} toTheme - 目标主题
|
||||
*/
|
||||
const trackThemeChanged = useCallback((fromTheme, toTheme) => {
|
||||
if (!fromTheme || !toTheme) {
|
||||
logger.warn('useNavigationEvents', 'trackThemeChanged: both themes are required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Theme Changed', {
|
||||
from_theme: fromTheme,
|
||||
to_theme: toTheme,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '🎨 Theme Changed', {
|
||||
fromTheme,
|
||||
toTheme,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪快捷键使用
|
||||
* @param {string} shortcut - 快捷键组合 (如 'Ctrl+K', 'Cmd+/')
|
||||
* @param {string} action - 触发的动作
|
||||
*/
|
||||
const trackShortcutUsed = useCallback((shortcut, action = '') => {
|
||||
if (!shortcut) {
|
||||
logger.warn('useNavigationEvents', 'trackShortcutUsed: shortcut is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Keyboard Shortcut Used', {
|
||||
shortcut,
|
||||
action,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '⌨️ Keyboard Shortcut Used', {
|
||||
shortcut,
|
||||
action,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
/**
|
||||
* 追踪返回按钮点击
|
||||
* @param {string} fromPage - 当前页面
|
||||
* @param {string} toPage - 返回到的页面
|
||||
*/
|
||||
const trackBackButtonClicked = useCallback((fromPage = '', toPage = '') => {
|
||||
track('Back Button Clicked', {
|
||||
from_page: fromPage,
|
||||
to_page: toPage,
|
||||
component,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useNavigationEvents', '◀️ Back Button Clicked', {
|
||||
fromPage,
|
||||
toPage,
|
||||
});
|
||||
}, [track, component]);
|
||||
|
||||
return {
|
||||
// 导航点击事件
|
||||
trackTopNavClicked,
|
||||
trackSidebarMenuClicked,
|
||||
trackMenuItemClicked,
|
||||
trackBreadcrumbClicked,
|
||||
trackLogoClicked,
|
||||
|
||||
// 用户交互事件
|
||||
trackUserMenuOpened,
|
||||
trackNotificationCenterOpened,
|
||||
|
||||
// 设置变更事件
|
||||
trackLanguageChanged,
|
||||
trackThemeChanged,
|
||||
|
||||
// 其他交互
|
||||
trackShortcutUsed,
|
||||
trackBackButtonClicked,
|
||||
};
|
||||
};
|
||||
|
||||
export default useNavigationEvents;
|
||||
334
src/hooks/useProfileEvents.js
Normal file
334
src/hooks/useProfileEvents.js
Normal file
@@ -0,0 +1,334 @@
|
||||
// src/hooks/useProfileEvents.js
|
||||
// 个人资料和设置事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 个人资料和设置事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.pageType - 页面类型 ('profile' | 'settings' | 'security')
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useProfileEvents = ({ pageType = 'profile' } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪个人资料字段编辑开始
|
||||
* @param {string} fieldName - 字段名称 ('nickname' | 'email' | 'phone' | 'avatar' | 'bio')
|
||||
*/
|
||||
const trackProfileFieldEditStarted = useCallback((fieldName) => {
|
||||
if (!fieldName) {
|
||||
logger.warn('useProfileEvents', 'trackProfileFieldEditStarted: fieldName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Profile Field Edit Started', {
|
||||
field_name: fieldName,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '✏️ Profile Field Edit Started', {
|
||||
fieldName,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪个人资料更新成功
|
||||
* @param {Array<string>} updatedFields - 更新的字段列表
|
||||
* @param {Object} changes - 变更详情
|
||||
*/
|
||||
const trackProfileUpdated = useCallback((updatedFields = [], changes = {}) => {
|
||||
if (!updatedFields || updatedFields.length === 0) {
|
||||
logger.warn('useProfileEvents', 'trackProfileUpdated: updatedFields array is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.PROFILE_UPDATED, {
|
||||
updated_fields: updatedFields,
|
||||
field_count: updatedFields.length,
|
||||
changes: changes,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '✅ Profile Updated', {
|
||||
updatedFields,
|
||||
fieldCount: updatedFields.length,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪个人资料更新失败
|
||||
* @param {Array<string>} attemptedFields - 尝试更新的字段
|
||||
* @param {string} errorMessage - 错误信息
|
||||
*/
|
||||
const trackProfileUpdateFailed = useCallback((attemptedFields = [], errorMessage = '') => {
|
||||
track('Profile Update Failed', {
|
||||
attempted_fields: attemptedFields,
|
||||
error_message: errorMessage,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '❌ Profile Update Failed', {
|
||||
attemptedFields,
|
||||
errorMessage,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪头像上传
|
||||
* @param {string} uploadMethod - 上传方式 ('file_upload' | 'url' | 'camera' | 'default_avatar')
|
||||
* @param {number} fileSize - 文件大小(bytes)
|
||||
*/
|
||||
const trackAvatarUploaded = useCallback((uploadMethod = 'file_upload', fileSize = 0) => {
|
||||
track('Avatar Uploaded', {
|
||||
upload_method: uploadMethod,
|
||||
file_size: fileSize,
|
||||
file_size_mb: (fileSize / (1024 * 1024)).toFixed(2),
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '🖼️ Avatar Uploaded', {
|
||||
uploadMethod,
|
||||
fileSize,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪密码更改
|
||||
* @param {boolean} success - 是否成功
|
||||
* @param {string} errorReason - 失败原因
|
||||
*/
|
||||
const trackPasswordChanged = useCallback((success = true, errorReason = '') => {
|
||||
track('Password Changed', {
|
||||
success,
|
||||
error_reason: errorReason || null,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', success ? '🔒 Password Changed Successfully' : '❌ Password Change Failed', {
|
||||
success,
|
||||
errorReason,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪邮箱验证发起
|
||||
* @param {string} email - 邮箱地址
|
||||
*/
|
||||
const trackEmailVerificationSent = useCallback((email = '') => {
|
||||
track('Email Verification Sent', {
|
||||
email_provided: Boolean(email),
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '📧 Email Verification Sent', {
|
||||
emailProvided: Boolean(email),
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪手机号验证发起
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
const trackPhoneVerificationSent = useCallback((phone = '') => {
|
||||
track('Phone Verification Sent', {
|
||||
phone_provided: Boolean(phone),
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '📱 Phone Verification Sent', {
|
||||
phoneProvided: Boolean(phone),
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪账号绑定(微信、邮箱、手机等)
|
||||
* @param {string} accountType - 账号类型 ('wechat' | 'email' | 'phone')
|
||||
* @param {boolean} success - 是否成功
|
||||
*/
|
||||
const trackAccountBound = useCallback((accountType, success = true) => {
|
||||
if (!accountType) {
|
||||
logger.warn('useProfileEvents', 'trackAccountBound: accountType is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Account Bound', {
|
||||
account_type: accountType,
|
||||
success,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', success ? '🔗 Account Bound' : '❌ Account Bind Failed', {
|
||||
accountType,
|
||||
success,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪账号解绑
|
||||
* @param {string} accountType - 账号类型
|
||||
* @param {boolean} success - 是否成功
|
||||
*/
|
||||
const trackAccountUnbound = useCallback((accountType, success = true) => {
|
||||
if (!accountType) {
|
||||
logger.warn('useProfileEvents', 'trackAccountUnbound: accountType is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Account Unbound', {
|
||||
account_type: accountType,
|
||||
success,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', success ? '🔓 Account Unbound' : '❌ Account Unbind Failed', {
|
||||
accountType,
|
||||
success,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪设置项更改
|
||||
* @param {string} settingName - 设置名称
|
||||
* @param {any} oldValue - 旧值
|
||||
* @param {any} newValue - 新值
|
||||
* @param {string} category - 设置分类 ('notification' | 'privacy' | 'display' | 'advanced')
|
||||
*/
|
||||
const trackSettingChanged = useCallback((settingName, oldValue, newValue, category = 'general') => {
|
||||
if (!settingName) {
|
||||
logger.warn('useProfileEvents', 'trackSettingChanged: settingName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SETTINGS_CHANGED, {
|
||||
setting_name: settingName,
|
||||
old_value: String(oldValue),
|
||||
new_value: String(newValue),
|
||||
category,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '⚙️ Setting Changed', {
|
||||
settingName,
|
||||
oldValue,
|
||||
newValue,
|
||||
category,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪通知偏好更改
|
||||
* @param {Object} preferences - 通知偏好设置
|
||||
* @param {boolean} preferences.email - 邮件通知
|
||||
* @param {boolean} preferences.push - 推送通知
|
||||
* @param {boolean} preferences.sms - 短信通知
|
||||
*/
|
||||
const trackNotificationPreferencesChanged = useCallback((preferences = {}) => {
|
||||
track('Notification Preferences Changed', {
|
||||
email_enabled: preferences.email || false,
|
||||
push_enabled: preferences.push || false,
|
||||
sms_enabled: preferences.sms || false,
|
||||
total_enabled: Object.values(preferences).filter(Boolean).length,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '🔔 Notification Preferences Changed', {
|
||||
preferences,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪隐私设置更改
|
||||
* @param {string} privacySetting - 隐私设置名称
|
||||
* @param {boolean} isPublic - 是否公开
|
||||
*/
|
||||
const trackPrivacySettingChanged = useCallback((privacySetting, isPublic = false) => {
|
||||
if (!privacySetting) {
|
||||
logger.warn('useProfileEvents', 'trackPrivacySettingChanged: privacySetting is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Privacy Setting Changed', {
|
||||
privacy_setting: privacySetting,
|
||||
is_public: isPublic,
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '🔐 Privacy Setting Changed', {
|
||||
privacySetting,
|
||||
isPublic,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
/**
|
||||
* 追踪账号删除请求
|
||||
* @param {string} reason - 删除原因
|
||||
*/
|
||||
const trackAccountDeletionRequested = useCallback((reason = '') => {
|
||||
track('Account Deletion Requested', {
|
||||
reason,
|
||||
has_reason: Boolean(reason),
|
||||
page_type: pageType,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useProfileEvents', '🗑️ Account Deletion Requested', {
|
||||
reason,
|
||||
pageType,
|
||||
});
|
||||
}, [track, pageType]);
|
||||
|
||||
return {
|
||||
// 个人资料编辑
|
||||
trackProfileFieldEditStarted,
|
||||
trackProfileUpdated,
|
||||
trackProfileUpdateFailed,
|
||||
trackAvatarUploaded,
|
||||
|
||||
// 安全和验证
|
||||
trackPasswordChanged,
|
||||
trackEmailVerificationSent,
|
||||
trackPhoneVerificationSent,
|
||||
|
||||
// 账号绑定
|
||||
trackAccountBound,
|
||||
trackAccountUnbound,
|
||||
|
||||
// 设置更改
|
||||
trackSettingChanged,
|
||||
trackNotificationPreferencesChanged,
|
||||
trackPrivacySettingChanged,
|
||||
|
||||
// 账号管理
|
||||
trackAccountDeletionRequested,
|
||||
};
|
||||
};
|
||||
|
||||
export default useProfileEvents;
|
||||
244
src/hooks/useSearchEvents.js
Normal file
244
src/hooks/useSearchEvents.js
Normal file
@@ -0,0 +1,244 @@
|
||||
// src/hooks/useSearchEvents.js
|
||||
// 全局搜索功能事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 全局搜索事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.context - 搜索上下文 ('global' | 'stock' | 'news' | 'concept' | 'simulation')
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useSearchEvents = ({ context = 'global' } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪搜索开始(聚焦搜索框)
|
||||
* @param {string} placeholder - 搜索框提示文本
|
||||
*/
|
||||
const trackSearchInitiated = useCallback((placeholder = '') => {
|
||||
track(RETENTION_EVENTS.SEARCH_INITIATED, {
|
||||
context,
|
||||
placeholder,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🔍 Search Initiated', {
|
||||
context,
|
||||
placeholder,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索查询提交
|
||||
* @param {string} query - 搜索查询词
|
||||
* @param {number} resultCount - 搜索结果数量
|
||||
* @param {Object} filters - 应用的筛选条件
|
||||
*/
|
||||
const trackSearchQuerySubmitted = useCallback((query, resultCount = 0, filters = {}) => {
|
||||
if (!query) {
|
||||
logger.warn('useSearchEvents', 'trackSearchQuerySubmitted: query is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
query_length: query.length,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
context,
|
||||
filters: filters,
|
||||
filter_count: Object.keys(filters).length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context,
|
||||
filters,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '❌ Search No Results', {
|
||||
query,
|
||||
context,
|
||||
});
|
||||
} else {
|
||||
logger.debug('useSearchEvents', '✅ Search Query Submitted', {
|
||||
query,
|
||||
resultCount,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索结果点击
|
||||
* @param {Object} result - 被点击的搜索结果
|
||||
* @param {string} result.type - 结果类型 ('stock' | 'news' | 'concept' | 'event')
|
||||
* @param {string} result.id - 结果ID
|
||||
* @param {string} result.title - 结果标题
|
||||
* @param {number} position - 在搜索结果中的位置
|
||||
* @param {string} query - 搜索查询词
|
||||
*/
|
||||
const trackSearchResultClicked = useCallback((result, position = 0, query = '') => {
|
||||
if (!result || !result.type) {
|
||||
logger.warn('useSearchEvents', 'trackSearchResultClicked: result object with type is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_RESULT_CLICKED, {
|
||||
result_type: result.type,
|
||||
result_id: result.id || result.code || '',
|
||||
result_title: result.title || result.name || '',
|
||||
position,
|
||||
query,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🎯 Search Result Clicked', {
|
||||
type: result.type,
|
||||
id: result.id || result.code,
|
||||
position,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索筛选应用
|
||||
* @param {Object} filters - 应用的筛选条件
|
||||
* @param {string} filterType - 筛选类型 ('sort' | 'category' | 'date_range' | 'price_range')
|
||||
* @param {any} filterValue - 筛选值
|
||||
*/
|
||||
const trackSearchFilterApplied = useCallback((filterType, filterValue, filters = {}) => {
|
||||
if (!filterType) {
|
||||
logger.warn('useSearchEvents', 'trackSearchFilterApplied: filterType is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: filterType,
|
||||
filter_value: String(filterValue),
|
||||
all_filters: filters,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🔍 Search Filter Applied', {
|
||||
filterType,
|
||||
filterValue,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索建议点击(自动完成)
|
||||
* @param {string} suggestion - 被点击的搜索建议
|
||||
* @param {number} position - 在建议列表中的位置
|
||||
* @param {string} source - 建议来源 ('history' | 'popular' | 'related')
|
||||
*/
|
||||
const trackSearchSuggestionClicked = useCallback((suggestion, position = 0, source = 'popular') => {
|
||||
if (!suggestion) {
|
||||
logger.warn('useSearchEvents', 'trackSearchSuggestionClicked: suggestion is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Search Suggestion Clicked', {
|
||||
suggestion,
|
||||
position,
|
||||
source,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '💡 Search Suggestion Clicked', {
|
||||
suggestion,
|
||||
position,
|
||||
source,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索历史查看
|
||||
* @param {number} historyCount - 历史记录数量
|
||||
*/
|
||||
const trackSearchHistoryViewed = useCallback((historyCount = 0) => {
|
||||
track('Search History Viewed', {
|
||||
history_count: historyCount,
|
||||
has_history: historyCount > 0,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '📜 Search History Viewed', {
|
||||
historyCount,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪搜索历史清除
|
||||
*/
|
||||
const trackSearchHistoryCleared = useCallback(() => {
|
||||
track('Search History Cleared', {
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🗑️ Search History Cleared', {
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
/**
|
||||
* 追踪热门搜索词点击
|
||||
* @param {string} keyword - 被点击的热门关键词
|
||||
* @param {number} position - 在列表中的位置
|
||||
* @param {number} heatScore - 热度分数
|
||||
*/
|
||||
const trackPopularKeywordClicked = useCallback((keyword, position = 0, heatScore = 0) => {
|
||||
if (!keyword) {
|
||||
logger.warn('useSearchEvents', 'trackPopularKeywordClicked: keyword is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Popular Keyword Clicked', {
|
||||
keyword,
|
||||
position,
|
||||
heat_score: heatScore,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSearchEvents', '🔥 Popular Keyword Clicked', {
|
||||
keyword,
|
||||
position,
|
||||
context,
|
||||
});
|
||||
}, [track, context]);
|
||||
|
||||
return {
|
||||
// 搜索流程事件
|
||||
trackSearchInitiated,
|
||||
trackSearchQuerySubmitted,
|
||||
trackSearchResultClicked,
|
||||
|
||||
// 筛选和建议
|
||||
trackSearchFilterApplied,
|
||||
trackSearchSuggestionClicked,
|
||||
|
||||
// 历史和热门
|
||||
trackSearchHistoryViewed,
|
||||
trackSearchHistoryCleared,
|
||||
trackPopularKeywordClicked,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSearchEvents;
|
||||
394
src/hooks/useSubscriptionEvents.js
Normal file
394
src/hooks/useSubscriptionEvents.js
Normal file
@@ -0,0 +1,394 @@
|
||||
// src/hooks/useSubscriptionEvents.js
|
||||
// 订阅和支付事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack } from './usePostHogRedux';
|
||||
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 订阅和支付事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Object} options.currentSubscription - 当前订阅信息
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useSubscriptionEvents = ({ currentSubscription = null } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
/**
|
||||
* 追踪付费墙展示
|
||||
* @param {string} feature - 被限制的功能名称
|
||||
* @param {string} requiredPlan - 需要的订阅计划
|
||||
* @param {string} triggerLocation - 触发位置
|
||||
*/
|
||||
const trackPaywallShown = useCallback((feature, requiredPlan = 'pro', triggerLocation = '') => {
|
||||
if (!feature) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPaywallShown: feature is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
|
||||
feature,
|
||||
required_plan: requiredPlan,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
trigger_location: triggerLocation,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🚧 Paywall Shown', {
|
||||
feature,
|
||||
requiredPlan,
|
||||
triggerLocation,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪付费墙关闭
|
||||
* @param {string} feature - 功能名称
|
||||
* @param {string} closeMethod - 关闭方式 ('dismiss' | 'upgrade_clicked' | 'back_button')
|
||||
*/
|
||||
const trackPaywallDismissed = useCallback((feature, closeMethod = 'dismiss') => {
|
||||
if (!feature) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPaywallDismissed: feature is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYWALL_DISMISSED, {
|
||||
feature,
|
||||
close_method: closeMethod,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '❌ Paywall Dismissed', {
|
||||
feature,
|
||||
closeMethod,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪升级按钮点击
|
||||
* @param {string} targetPlan - 目标订阅计划
|
||||
* @param {string} source - 来源位置
|
||||
* @param {string} feature - 关联的功能(如果从付费墙点击)
|
||||
*/
|
||||
const trackUpgradePlanClicked = useCallback((targetPlan = 'pro', source = '', feature = '') => {
|
||||
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
target_plan: targetPlan,
|
||||
source,
|
||||
feature: feature || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '⬆️ Upgrade Plan Clicked', {
|
||||
currentPlan: currentSubscription?.plan,
|
||||
targetPlan,
|
||||
source,
|
||||
feature,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪订阅页面查看
|
||||
* @param {string} source - 来源
|
||||
*/
|
||||
const trackSubscriptionPageViewed = useCallback((source = '') => {
|
||||
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
subscription_status: currentSubscription?.status || 'unknown',
|
||||
is_paid_user: currentSubscription?.plan && currentSubscription.plan !== 'free',
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '💳 Subscription Page Viewed', {
|
||||
currentPlan: currentSubscription?.plan,
|
||||
source,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪定价计划查看
|
||||
* @param {string} planName - 计划名称 ('free' | 'pro' | 'enterprise')
|
||||
* @param {number} price - 价格
|
||||
*/
|
||||
const trackPricingPlanViewed = useCallback((planName, price = 0) => {
|
||||
if (!planName) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPricingPlanViewed: planName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Pricing Plan Viewed', {
|
||||
plan_name: planName,
|
||||
price,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '👀 Pricing Plan Viewed', {
|
||||
planName,
|
||||
price,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪定价计划选择
|
||||
* @param {string} planName - 选择的计划名称
|
||||
* @param {string} billingCycle - 计费周期 ('monthly' | 'yearly')
|
||||
* @param {number} price - 价格
|
||||
*/
|
||||
const trackPricingPlanSelected = useCallback((planName, billingCycle = 'monthly', price = 0) => {
|
||||
if (!planName) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPricingPlanSelected: planName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Pricing Plan Selected', {
|
||||
plan_name: planName,
|
||||
billing_cycle: billingCycle,
|
||||
price,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '✅ Pricing Plan Selected', {
|
||||
planName,
|
||||
billingCycle,
|
||||
price,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付页面查看
|
||||
* @param {string} planName - 购买的计划
|
||||
* @param {number} amount - 支付金额
|
||||
*/
|
||||
const trackPaymentPageViewed = useCallback((planName, amount = 0) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, {
|
||||
plan_name: planName,
|
||||
amount,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '💰 Payment Page Viewed', {
|
||||
planName,
|
||||
amount,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付方式选择
|
||||
* @param {string} paymentMethod - 支付方式 ('wechat_pay' | 'alipay' | 'credit_card')
|
||||
* @param {number} amount - 支付金额
|
||||
*/
|
||||
const trackPaymentMethodSelected = useCallback((paymentMethod, amount = 0) => {
|
||||
if (!paymentMethod) {
|
||||
logger.warn('useSubscriptionEvents', 'trackPaymentMethodSelected: paymentMethod is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(REVENUE_EVENTS.PAYMENT_METHOD_SELECTED, {
|
||||
payment_method: paymentMethod,
|
||||
amount,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '💳 Payment Method Selected', {
|
||||
paymentMethod,
|
||||
amount,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付发起
|
||||
* @param {Object} paymentInfo - 支付信息
|
||||
* @param {string} paymentInfo.planName - 计划名称
|
||||
* @param {string} paymentInfo.paymentMethod - 支付方式
|
||||
* @param {number} paymentInfo.amount - 金额
|
||||
* @param {string} paymentInfo.billingCycle - 计费周期
|
||||
* @param {string} paymentInfo.orderId - 订单ID
|
||||
*/
|
||||
const trackPaymentInitiated = useCallback((paymentInfo = {}) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_INITIATED, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
billing_cycle: paymentInfo.billingCycle,
|
||||
order_id: paymentInfo.orderId,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🚀 Payment Initiated', {
|
||||
planName: paymentInfo.planName,
|
||||
amount: paymentInfo.amount,
|
||||
paymentMethod: paymentInfo.paymentMethod,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付成功
|
||||
* @param {Object} paymentInfo - 支付信息
|
||||
*/
|
||||
const trackPaymentSuccessful = useCallback((paymentInfo = {}) => {
|
||||
track(REVENUE_EVENTS.PAYMENT_SUCCESSFUL, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
billing_cycle: paymentInfo.billingCycle,
|
||||
order_id: paymentInfo.orderId,
|
||||
transaction_id: paymentInfo.transactionId,
|
||||
previous_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '✅ Payment Successful', {
|
||||
planName: paymentInfo.planName,
|
||||
amount: paymentInfo.amount,
|
||||
orderId: paymentInfo.orderId,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪支付失败
|
||||
* @param {Object} paymentInfo - 支付信息
|
||||
* @param {string} errorReason - 失败原因
|
||||
*/
|
||||
const trackPaymentFailed = useCallback((paymentInfo = {}, errorReason = '') => {
|
||||
track(REVENUE_EVENTS.PAYMENT_FAILED, {
|
||||
plan_name: paymentInfo.planName,
|
||||
payment_method: paymentInfo.paymentMethod,
|
||||
amount: paymentInfo.amount,
|
||||
error_reason: errorReason,
|
||||
order_id: paymentInfo.orderId,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '❌ Payment Failed', {
|
||||
planName: paymentInfo.planName,
|
||||
errorReason,
|
||||
orderId: paymentInfo.orderId,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪订阅创建成功
|
||||
* @param {Object} subscription - 订阅信息
|
||||
*/
|
||||
const trackSubscriptionCreated = useCallback((subscription = {}) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_CREATED, {
|
||||
plan_name: subscription.plan,
|
||||
billing_cycle: subscription.billingCycle,
|
||||
amount: subscription.amount,
|
||||
start_date: subscription.startDate,
|
||||
end_date: subscription.endDate,
|
||||
previous_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🎉 Subscription Created', {
|
||||
plan: subscription.plan,
|
||||
billingCycle: subscription.billingCycle,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪订阅续费
|
||||
* @param {Object} subscription - 订阅信息
|
||||
*/
|
||||
const trackSubscriptionRenewed = useCallback((subscription = {}) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_RENEWED, {
|
||||
plan_name: subscription.plan,
|
||||
amount: subscription.amount,
|
||||
previous_end_date: subscription.previousEndDate,
|
||||
new_end_date: subscription.newEndDate,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🔄 Subscription Renewed', {
|
||||
plan: subscription.plan,
|
||||
amount: subscription.amount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪订阅取消
|
||||
* @param {string} reason - 取消原因
|
||||
* @param {boolean} cancelImmediately - 是否立即取消
|
||||
*/
|
||||
const trackSubscriptionCancelled = useCallback((reason = '', cancelImmediately = false) => {
|
||||
track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
|
||||
plan_name: currentSubscription?.plan,
|
||||
reason,
|
||||
has_reason: Boolean(reason),
|
||||
cancel_immediately: cancelImmediately,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', '🚫 Subscription Cancelled', {
|
||||
plan: currentSubscription?.plan,
|
||||
reason,
|
||||
cancelImmediately,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
/**
|
||||
* 追踪优惠券应用
|
||||
* @param {string} couponCode - 优惠券代码
|
||||
* @param {number} discountAmount - 折扣金额
|
||||
* @param {boolean} success - 是否成功
|
||||
*/
|
||||
const trackCouponApplied = useCallback((couponCode, discountAmount = 0, success = true) => {
|
||||
if (!couponCode) {
|
||||
logger.warn('useSubscriptionEvents', 'trackCouponApplied: couponCode is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Coupon Applied', {
|
||||
coupon_code: couponCode,
|
||||
discount_amount: discountAmount,
|
||||
success,
|
||||
current_plan: currentSubscription?.plan || 'free',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useSubscriptionEvents', success ? '🎟️ Coupon Applied' : '❌ Coupon Failed', {
|
||||
couponCode,
|
||||
discountAmount,
|
||||
success,
|
||||
});
|
||||
}, [track, currentSubscription]);
|
||||
|
||||
return {
|
||||
// 付费墙事件
|
||||
trackPaywallShown,
|
||||
trackPaywallDismissed,
|
||||
trackUpgradePlanClicked,
|
||||
|
||||
// 订阅页面事件
|
||||
trackSubscriptionPageViewed,
|
||||
trackPricingPlanViewed,
|
||||
trackPricingPlanSelected,
|
||||
|
||||
// 支付流程事件
|
||||
trackPaymentPageViewed,
|
||||
trackPaymentMethodSelected,
|
||||
trackPaymentInitiated,
|
||||
trackPaymentSuccessful,
|
||||
trackPaymentFailed,
|
||||
|
||||
// 订阅管理事件
|
||||
trackSubscriptionCreated,
|
||||
trackSubscriptionRenewed,
|
||||
trackSubscriptionCancelled,
|
||||
|
||||
// 优惠券事件
|
||||
trackCouponApplied,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSubscriptionEvents;
|
||||
@@ -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>
|
||||
|
||||
@@ -19,7 +19,10 @@ export async function startMockServiceWorker() {
|
||||
|
||||
try {
|
||||
await worker.start({
|
||||
// 不显示未拦截的请求警告(可选)
|
||||
// 🎯 智能穿透模式(关键配置)
|
||||
// 'bypass': 未定义 Mock 的请求自动转发到真实后端
|
||||
// 'warn': 未定义的请求会显示警告(调试用)
|
||||
// 'error': 未定义的请求会抛出错误(严格模式)
|
||||
onUnhandledRequest: 'bypass',
|
||||
|
||||
// 自定义 Service Worker URL(如果需要)
|
||||
@@ -27,7 +30,7 @@ export async function startMockServiceWorker() {
|
||||
url: '/mockServiceWorker.js',
|
||||
},
|
||||
|
||||
// 静默模式(不在控制台打印启动消息)
|
||||
// 是否在控制台显示启动日志和拦截日志 静默模式(不在控制台打印启动消息)
|
||||
quiet: false,
|
||||
});
|
||||
|
||||
@@ -36,11 +39,11 @@ export async function startMockServiceWorker() {
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
console.log(
|
||||
'%c提示: 所有 API 请求将使用本地 Mock 数据',
|
||||
'%c智能穿透模式:已定义 Mock → 返回假数据 | 未定义 Mock → 转发到 ' + (process.env.REACT_APP_API_URL || '真实后端'),
|
||||
'color: #FF9800; font-size: 12px;'
|
||||
);
|
||||
console.log(
|
||||
'%c要禁用 Mock,请设置 REACT_APP_ENABLE_MOCK=false',
|
||||
'%c查看 src/mocks/handlers/ 目录管理 Mock 接口',
|
||||
'color: #2196F3; font-size: 12px;'
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,11 +2,21 @@
|
||||
import React from 'react';
|
||||
import { Card, Input, Radio, Form, Button } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { useSearchEvents } from '../../../hooks/useSearchEvents';
|
||||
|
||||
const SearchBox = ({ onSearch }) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// 🎯 初始化搜索埋点Hook
|
||||
const searchEvents = useSearchEvents({ context: 'community' });
|
||||
|
||||
const handleSubmit = (values) => {
|
||||
// 🎯 追踪搜索查询提交(在调用onSearch之前)
|
||||
if (values.q) {
|
||||
searchEvents.trackSearchQuerySubmitted(values.q, 0, {
|
||||
search_type: values.search_type || 'topic'
|
||||
});
|
||||
}
|
||||
onSearch(values);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 事件讨论模态框 */}
|
||||
|
||||
281
src/views/Community/hooks/useCommunityEvents.js
Normal file
281
src/views/Community/hooks/useCommunityEvents.js
Normal file
@@ -0,0 +1,281 @@
|
||||
// src/views/Community/hooks/useCommunityEvents.js
|
||||
// 新闻催化分析页面事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 新闻催化分析(Community)事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useCommunityEvents', '📰 Community Page Viewed');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪新闻列表查看
|
||||
* @param {Object} params - 列表参数
|
||||
* @param {number} params.totalCount - 新闻总数
|
||||
* @param {string} params.sortBy - 排序方式 ('new' | 'hot' | 'returns')
|
||||
* @param {string} params.importance - 重要性筛选 ('all' | 'high' | 'medium' | 'low')
|
||||
* @param {string} params.dateRange - 日期范围
|
||||
* @param {string} params.industryFilter - 行业筛选
|
||||
*/
|
||||
const trackNewsListViewed = useCallback((params = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
total_count: params.totalCount || 0,
|
||||
sort_by: params.sortBy || 'new',
|
||||
importance_filter: params.importance || 'all',
|
||||
date_range: params.dateRange || 'all',
|
||||
industry_filter: params.industryFilter || 'all',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useCommunityEvents', '📋 News List Viewed', params);
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪新闻文章点击
|
||||
* @param {Object} news - 新闻对象
|
||||
* @param {number} news.id - 新闻ID
|
||||
* @param {string} news.title - 新闻标题
|
||||
* @param {string} news.importance - 重要性等级
|
||||
* @param {number} position - 在列表中的位置
|
||||
* @param {string} source - 点击来源 ('list' | 'search' | 'recommendation')
|
||||
*/
|
||||
const trackNewsArticleClicked = useCallback((news, position = 0, source = 'list') => {
|
||||
if (!news || !news.id) {
|
||||
logger.warn('useCommunityEvents', 'trackNewsArticleClicked: news object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
position,
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useCommunityEvents', '🖱️ News Article Clicked', {
|
||||
id: news.id,
|
||||
position,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪新闻详情打开
|
||||
* @param {Object} news - 新闻对象
|
||||
* @param {number} news.id - 新闻ID
|
||||
* @param {string} news.title - 新闻标题
|
||||
* @param {string} news.importance - 重要性等级
|
||||
* @param {string} viewMode - 查看模式 ('modal' | 'page')
|
||||
*/
|
||||
const trackNewsDetailOpened = useCallback((news, viewMode = 'modal') => {
|
||||
if (!news || !news.id) {
|
||||
logger.warn('useCommunityEvents', 'trackNewsDetailOpened: news object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
view_mode: viewMode,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useCommunityEvents', '📖 News Detail Opened', {
|
||||
id: news.id,
|
||||
viewMode,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪新闻标签页切换
|
||||
* @param {string} tabName - 标签名称 ('related_stocks' | 'related_concepts' | 'timeline')
|
||||
* @param {number} newsId - 新闻ID
|
||||
*/
|
||||
const trackNewsTabClicked = useCallback((tabName, newsId = null) => {
|
||||
if (!tabName) {
|
||||
logger.warn('useCommunityEvents', 'trackNewsTabClicked: tabName is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
tab_name: tabName,
|
||||
news_id: newsId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useCommunityEvents', '📑 News Tab Clicked', {
|
||||
tabName,
|
||||
newsId,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪新闻筛选应用
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @param {string} filters.importance - 重要性筛选
|
||||
* @param {string} filters.dateRange - 日期范围
|
||||
* @param {string} filters.industryClassification - 行业分类
|
||||
* @param {string} filters.industryCode - 行业代码
|
||||
*/
|
||||
const trackNewsFilterApplied = useCallback((filters = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
|
||||
importance: filters.importance || 'all',
|
||||
date_range: filters.dateRange || 'all',
|
||||
industry_classification: filters.industryClassification || 'all',
|
||||
industry_code: filters.industryCode || 'all',
|
||||
filter_count: Object.keys(filters).filter(key => filters[key] && filters[key] !== 'all').length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useCommunityEvents', '🔍 News Filter Applied', filters);
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪新闻排序方式变更
|
||||
* @param {string} sortBy - 排序方式 ('new' | 'hot' | 'returns')
|
||||
* @param {string} previousSort - 之前的排序方式
|
||||
*/
|
||||
const trackNewsSorted = useCallback((sortBy, previousSort = 'new') => {
|
||||
if (!sortBy) {
|
||||
logger.warn('useCommunityEvents', 'trackNewsSorted: sortBy is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_SORTED, {
|
||||
sort_by: sortBy,
|
||||
previous_sort: previousSort,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useCommunityEvents', '🔄 News Sorted', {
|
||||
sortBy,
|
||||
previousSort,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪搜索事件(新闻搜索)
|
||||
* @param {string} query - 搜索关键词
|
||||
* @param {number} resultCount - 搜索结果数量
|
||||
*/
|
||||
const trackNewsSearched = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
context: 'community_news',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'community_news',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('useCommunityEvents', '🔍 News Searched', {
|
||||
query,
|
||||
resultCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪相关股票点击(从新闻详情)
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
* @param {number} newsId - 关联的新闻ID
|
||||
*/
|
||||
const trackRelatedStockClicked = useCallback((stock, newsId = null) => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useCommunityEvents', 'trackRelatedStockClicked: stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source: 'news_related_stocks',
|
||||
news_id: newsId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useCommunityEvents', '🎯 Related Stock Clicked', {
|
||||
stockCode: stock.code,
|
||||
newsId,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪相关概念点击(从新闻详情)
|
||||
* @param {Object} concept - 概念对象
|
||||
* @param {string} concept.code - 概念代码
|
||||
* @param {string} concept.name - 概念名称
|
||||
* @param {number} newsId - 关联的新闻ID
|
||||
*/
|
||||
const trackRelatedConceptClicked = useCallback((concept, newsId = null) => {
|
||||
if (!concept || !concept.code) {
|
||||
logger.warn('useCommunityEvents', 'trackRelatedConceptClicked: concept object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_code: concept.code,
|
||||
concept_name: concept.name || '',
|
||||
source: 'news_related_concepts',
|
||||
news_id: newsId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useCommunityEvents', '🏷️ Related Concept Clicked', {
|
||||
conceptCode: concept.code,
|
||||
newsId,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
// 页面级事件
|
||||
trackNewsListViewed,
|
||||
|
||||
// 新闻交互事件
|
||||
trackNewsArticleClicked,
|
||||
trackNewsDetailOpened,
|
||||
trackNewsTabClicked,
|
||||
|
||||
// 筛选和排序事件
|
||||
trackNewsFilterApplied,
|
||||
trackNewsSorted,
|
||||
|
||||
// 搜索事件
|
||||
trackNewsSearched,
|
||||
|
||||
// 关联内容点击事件
|
||||
trackRelatedStockClicked,
|
||||
trackRelatedConceptClicked,
|
||||
};
|
||||
};
|
||||
|
||||
export default useCommunityEvents;
|
||||
@@ -17,6 +17,7 @@ import EventModals from './components/EventModals';
|
||||
// 导入自定义 Hooks
|
||||
import { useEventData } from './hooks/useEventData';
|
||||
import { useEventFilters } from './hooks/useEventFilters';
|
||||
import { useCommunityEvents } from './hooks/useCommunityEvents';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
@@ -28,7 +29,7 @@ import { RETENTION_EVENTS } from '../../lib/constants';
|
||||
const Community = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { track } = usePostHogTrack(); // PostHog 追踪
|
||||
const { track } = usePostHogTrack(); // PostHog 追踪(保留用于兼容)
|
||||
|
||||
// Redux状态
|
||||
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
|
||||
@@ -47,6 +48,9 @@ const Community = () => {
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [selectedEventForStock, setSelectedEventForStock] = useState(null);
|
||||
|
||||
// 🎯 初始化Community埋点Hook
|
||||
const communityEvents = useCommunityEvents({ navigate });
|
||||
|
||||
// 自定义 Hooks
|
||||
const { filters, updateFilters, handlePageChange, handleEventClick, handleViewDetail } = useEventFilters({
|
||||
navigate,
|
||||
@@ -63,13 +67,26 @@ const Community = () => {
|
||||
}, [dispatch]);
|
||||
|
||||
// 🎯 PostHog 追踪:页面浏览
|
||||
// useEffect(() => {
|
||||
// track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
// timestamp: new Date().toISOString(),
|
||||
// has_hot_events: hotEvents && hotEvents.length > 0,
|
||||
// has_keywords: popularKeywords && popularKeywords.length > 0,
|
||||
// });
|
||||
// }, [track]); // 只在组件挂载时执行一次
|
||||
|
||||
// 🎯 追踪新闻列表查看(当事件列表加载完成后)
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
has_hot_events: hotEvents && hotEvents.length > 0,
|
||||
has_keywords: popularKeywords && popularKeywords.length > 0,
|
||||
});
|
||||
}, [track]); // 只在组件挂载时执行一次
|
||||
if (events && events.length > 0 && !loading) {
|
||||
communityEvents.trackNewsListViewed({
|
||||
totalCount: pagination?.total || events.length,
|
||||
sortBy: filters.sort,
|
||||
importance: filters.importance,
|
||||
dateRange: filters.date_range,
|
||||
industryFilter: filters.industry_code,
|
||||
});
|
||||
}
|
||||
}, [events, loading, pagination, filters, communityEvents]);
|
||||
|
||||
// ⚡ 首次访问社区时,延迟显示权限引导
|
||||
useEffect(() => {
|
||||
|
||||
103
src/views/Company/hooks/useCompanyEvents.js
Normal file
103
src/views/Company/hooks/useCompanyEvents.js
Normal file
@@ -0,0 +1,103 @@
|
||||
// src/views/Company/hooks/useCompanyEvents.js
|
||||
// 公司详情页面事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 公司详情页面事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.stockCode - 当前股票代码
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useCompanyEvents = ({ stockCode } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMPANY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
stock_code: stockCode || null,
|
||||
});
|
||||
logger.debug('useCompanyEvents', '📊 Company Page Viewed', { stockCode });
|
||||
}, [track, stockCode]);
|
||||
|
||||
/**
|
||||
* 追踪股票搜索/切换
|
||||
* @param {string} newStockCode - 新的股票代码
|
||||
* @param {string} previousStockCode - 之前的股票代码
|
||||
*/
|
||||
const trackStockSearched = useCallback((newStockCode, previousStockCode = null) => {
|
||||
if (!newStockCode) return;
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_SEARCHED, {
|
||||
query: newStockCode,
|
||||
stock_code: newStockCode,
|
||||
previous_stock_code: previousStockCode,
|
||||
context: 'company_page',
|
||||
});
|
||||
|
||||
logger.debug('useCompanyEvents', '🔍 Stock Searched', {
|
||||
newStockCode,
|
||||
previousStockCode,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪 Tab 切换
|
||||
* @param {number} tabIndex - Tab 索引 (0: 公司概览, 1: 股票行情, 2: 财务全景, 3: 盈利预测)
|
||||
* @param {string} tabName - Tab 名称
|
||||
* @param {number} previousTabIndex - 之前的 Tab 索引
|
||||
*/
|
||||
const trackTabChanged = useCallback((tabIndex, tabName, previousTabIndex = null) => {
|
||||
track(RETENTION_EVENTS.TAB_CHANGED, {
|
||||
tab_index: tabIndex,
|
||||
tab_name: tabName,
|
||||
previous_tab_index: previousTabIndex,
|
||||
stock_code: stockCode,
|
||||
context: 'company_page',
|
||||
});
|
||||
|
||||
logger.debug('useCompanyEvents', '🔄 Tab Changed', {
|
||||
tabIndex,
|
||||
tabName,
|
||||
previousTabIndex,
|
||||
stockCode,
|
||||
});
|
||||
}, [track, stockCode]);
|
||||
|
||||
/**
|
||||
* 追踪加入自选股
|
||||
* @param {string} stock_code - 股票代码
|
||||
*/
|
||||
const trackWatchlistAdded = useCallback((stock_code) => {
|
||||
track(RETENTION_EVENTS.WATCHLIST_ADDED, {
|
||||
stock_code,
|
||||
source: 'company_page',
|
||||
});
|
||||
|
||||
logger.debug('useCompanyEvents', '⭐ Watchlist Added', { stock_code });
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪移除自选股
|
||||
* @param {string} stock_code - 股票代码
|
||||
*/
|
||||
const trackWatchlistRemoved = useCallback((stock_code) => {
|
||||
track(RETENTION_EVENTS.WATCHLIST_REMOVED, {
|
||||
stock_code,
|
||||
source: 'company_page',
|
||||
});
|
||||
|
||||
logger.debug('useCompanyEvents', '❌ Watchlist Removed', { stock_code });
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
trackStockSearched,
|
||||
trackTabChanged,
|
||||
trackWatchlistAdded,
|
||||
trackWatchlistRemoved,
|
||||
};
|
||||
};
|
||||
@@ -34,6 +34,8 @@ import FinancialPanorama from './FinancialPanorama';
|
||||
import ForecastReport from './ForecastReport';
|
||||
import MarketDataView from './MarketDataView';
|
||||
import CompanyOverview from './CompanyOverview';
|
||||
// 导入 PostHog 追踪 Hook
|
||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||
|
||||
const CompanyIndex = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -42,7 +44,18 @@ const CompanyIndex = () => {
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const toast = useToast();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
|
||||
// 🎯 PostHog 事件追踪
|
||||
const {
|
||||
trackStockSearched,
|
||||
trackTabChanged,
|
||||
trackWatchlistAdded,
|
||||
trackWatchlistRemoved,
|
||||
} = useCompanyEvents({ stockCode });
|
||||
|
||||
// Tab 索引状态(用于追踪 Tab 切换)
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||||
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const tabBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const activeBg = useColorModeValue('blue.500', 'blue.400');
|
||||
@@ -86,6 +99,9 @@ const CompanyIndex = () => {
|
||||
|
||||
const handleSearch = () => {
|
||||
if (inputCode && inputCode !== stockCode) {
|
||||
// 🎯 追踪股票搜索
|
||||
trackStockSearched(inputCode, stockCode);
|
||||
|
||||
setStockCode(inputCode);
|
||||
setSearchParams({ scode: inputCode });
|
||||
}
|
||||
@@ -123,6 +139,10 @@ const CompanyIndex = () => {
|
||||
|
||||
logger.api.response('DELETE', url, resp.status);
|
||||
if (!resp.ok) throw new Error('删除失败');
|
||||
|
||||
// 🎯 追踪移除自选
|
||||
trackWatchlistRemoved(stockCode);
|
||||
|
||||
setIsInWatchlist(false);
|
||||
toast({ title: '已从自选移除', status: 'info', duration: 1500 });
|
||||
} else {
|
||||
@@ -140,6 +160,10 @@ const CompanyIndex = () => {
|
||||
|
||||
logger.api.response('POST', url, resp.status);
|
||||
if (!resp.ok) throw new Error('添加失败');
|
||||
|
||||
// 🎯 追踪加入自选
|
||||
trackWatchlistAdded(stockCode);
|
||||
|
||||
setIsInWatchlist(true);
|
||||
toast({ title: '已加入自选', status: 'success', duration: 1500 });
|
||||
}
|
||||
@@ -226,7 +250,18 @@ const CompanyIndex = () => {
|
||||
{/* 数据展示区域 */}
|
||||
<Card bg={bgColor} shadow="lg">
|
||||
<CardBody p={0}>
|
||||
<Tabs variant="soft-rounded" colorScheme="blue" size="lg">
|
||||
<Tabs
|
||||
variant="soft-rounded"
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
index={currentTabIndex}
|
||||
onChange={(index) => {
|
||||
const tabNames = ['公司概览', '股票行情', '财务全景', '盈利预测'];
|
||||
// 🎯 追踪 Tab 切换
|
||||
trackTabChanged(index, tabNames[index], currentTabIndex);
|
||||
setCurrentTabIndex(index);
|
||||
}}
|
||||
>
|
||||
<TabList p={4} bg={tabBg}>
|
||||
<Tab
|
||||
_selected={{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import { useDashboardEvents } from '../../hooks/useDashboardEvents';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
@@ -72,6 +73,12 @@ export default function CenterDashboard() {
|
||||
const userId = user?.id;
|
||||
const prevUserIdRef = React.useRef(userId);
|
||||
|
||||
// 🎯 初始化Dashboard埋点Hook
|
||||
const dashboardEvents = useDashboardEvents({
|
||||
pageType: 'center',
|
||||
navigate
|
||||
});
|
||||
|
||||
// 颜色主题
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
@@ -101,14 +108,33 @@ export default function CenterDashboard() {
|
||||
const je = await e.json();
|
||||
const jc = await c.json();
|
||||
if (jw.success) {
|
||||
setWatchlist(Array.isArray(jw.data) ? jw.data : []);
|
||||
const watchlistData = Array.isArray(jw.data) ? jw.data : [];
|
||||
setWatchlist(watchlistData);
|
||||
|
||||
// 🎯 追踪自选股列表查看
|
||||
if (watchlistData.length > 0) {
|
||||
dashboardEvents.trackWatchlistViewed(watchlistData.length, true);
|
||||
}
|
||||
|
||||
// 加载实时行情
|
||||
if (jw.data && jw.data.length > 0) {
|
||||
loadRealtimeQuotes();
|
||||
}
|
||||
}
|
||||
if (je.success) setFollowingEvents(Array.isArray(je.data) ? je.data : []);
|
||||
if (jc.success) setEventComments(Array.isArray(jc.data) ? jc.data : []);
|
||||
if (je.success) {
|
||||
const eventsData = Array.isArray(je.data) ? je.data : [];
|
||||
setFollowingEvents(eventsData);
|
||||
|
||||
// 🎯 追踪关注的事件列表查看
|
||||
dashboardEvents.trackFollowingEventsViewed(eventsData.length);
|
||||
}
|
||||
if (jc.success) {
|
||||
const commentsData = Array.isArray(jc.data) ? jc.data : [];
|
||||
setEventComments(commentsData);
|
||||
|
||||
// 🎯 追踪评论列表查看
|
||||
dashboardEvents.trackCommentsViewed(commentsData.length);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Center', 'loadData', err, {
|
||||
userId,
|
||||
|
||||
346
src/views/EventDetail/hooks/useEventDetailEvents.js
Normal file
346
src/views/EventDetail/hooks/useEventDetailEvents.js
Normal file
@@ -0,0 +1,346 @@
|
||||
// src/views/EventDetail/hooks/useEventDetailEvents.js
|
||||
// 事件详情页面事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 事件详情(EventDetail)事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Object} options.event - 事件对象
|
||||
* @param {number} options.event.id - 事件ID
|
||||
* @param {string} options.event.title - 事件标题
|
||||
* @param {string} options.event.importance - 重要性等级
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useEventDetailEvents = ({ event, navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useEventDetailEvents', 'Event object is required for page view tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.EVENT_DETAIL_VIEWED, {
|
||||
event_id: event.id,
|
||||
event_title: event.title || '',
|
||||
importance: event.importance || 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', '📄 Event Detail Page Viewed', {
|
||||
eventId: event.id,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪事件分析内容查看
|
||||
* @param {Object} analysisData - 分析数据
|
||||
* @param {string} analysisData.type - 分析类型 ('market_impact' | 'stock_correlation' | 'timeline')
|
||||
* @param {number} analysisData.relatedStockCount - 相关股票数量
|
||||
* @param {number} analysisData.timelineEventCount - 时间线事件数量
|
||||
*/
|
||||
const trackEventAnalysisViewed = useCallback((analysisData = {}) => {
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useEventDetailEvents', 'Event object is required for analysis tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.EVENT_ANALYSIS_VIEWED, {
|
||||
event_id: event.id,
|
||||
analysis_type: analysisData.type || 'overview',
|
||||
related_stock_count: analysisData.relatedStockCount || 0,
|
||||
timeline_event_count: analysisData.timelineEventCount || 0,
|
||||
has_market_impact: Boolean(analysisData.marketImpact),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', '📊 Event Analysis Viewed', {
|
||||
eventId: event.id,
|
||||
analysisType: analysisData.type,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪事件时间线点击
|
||||
* @param {Object} timelineItem - 时间线项目
|
||||
* @param {string} timelineItem.id - 时间线项目ID
|
||||
* @param {string} timelineItem.date - 时间线日期
|
||||
* @param {string} timelineItem.title - 时间线标题
|
||||
* @param {number} position - 在时间线中的位置
|
||||
*/
|
||||
const trackEventTimelineClicked = useCallback((timelineItem, position = 0) => {
|
||||
if (!timelineItem || !timelineItem.id) {
|
||||
logger.warn('useEventDetailEvents', 'Timeline item is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useEventDetailEvents', 'Event object is required for timeline tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.EVENT_TIMELINE_CLICKED, {
|
||||
event_id: event.id,
|
||||
timeline_item_id: timelineItem.id,
|
||||
timeline_date: timelineItem.date || '',
|
||||
timeline_title: timelineItem.title || '',
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', '⏰ Event Timeline Clicked', {
|
||||
eventId: event.id,
|
||||
timelineItemId: timelineItem.id,
|
||||
position,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪相关股票点击(从事件详情)
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
* @param {number} position - 在列表中的位置
|
||||
*/
|
||||
const trackRelatedStockClicked = useCallback((stock, position = 0) => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useEventDetailEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useEventDetailEvents', 'Event object is required for stock tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source: 'event_detail_related_stocks',
|
||||
event_id: event.id,
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', '🎯 Related Stock Clicked', {
|
||||
stockCode: stock.code,
|
||||
eventId: event.id,
|
||||
position,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪相关概念点击(从事件详情)
|
||||
* @param {Object} concept - 概念对象
|
||||
* @param {string} concept.code - 概念代码
|
||||
* @param {string} concept.name - 概念名称
|
||||
* @param {number} position - 在列表中的位置
|
||||
*/
|
||||
const trackRelatedConceptClicked = useCallback((concept, position = 0) => {
|
||||
if (!concept || !concept.code) {
|
||||
logger.warn('useEventDetailEvents', 'Concept object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useEventDetailEvents', 'Event object is required for concept tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_code: concept.code,
|
||||
concept_name: concept.name || '',
|
||||
source: 'event_detail_related_concepts',
|
||||
event_id: event.id,
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', '🏷️ Related Concept Clicked', {
|
||||
conceptCode: concept.code,
|
||||
eventId: event.id,
|
||||
position,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪标签页切换
|
||||
* @param {string} tabName - 标签名称 ('overview' | 'related_stocks' | 'related_concepts' | 'timeline')
|
||||
*/
|
||||
const trackTabClicked = useCallback((tabName) => {
|
||||
if (!tabName) {
|
||||
logger.warn('useEventDetailEvents', 'Tab name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useEventDetailEvents', 'Event object is required for tab tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
tab_name: tabName,
|
||||
event_id: event.id,
|
||||
context: 'event_detail',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', '📑 Tab Clicked', {
|
||||
tabName,
|
||||
eventId: event.id,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪事件收藏/取消收藏
|
||||
* @param {boolean} isFavorited - 是否收藏
|
||||
*/
|
||||
const trackEventFavoriteToggled = useCallback((isFavorited) => {
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useEventDetailEvents', 'Event object is required for favorite tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
const eventName = isFavorited ? 'Event Favorited' : 'Event Unfavorited';
|
||||
|
||||
track(eventName, {
|
||||
event_id: event.id,
|
||||
event_title: event.title || '',
|
||||
action: isFavorited ? 'add' : 'remove',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', `${isFavorited ? '⭐' : '☆'} Event Favorite Toggled`, {
|
||||
eventId: event.id,
|
||||
isFavorited,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪事件分享
|
||||
* @param {string} shareMethod - 分享方式 ('wechat' | 'link' | 'qrcode')
|
||||
*/
|
||||
const trackEventShared = useCallback((shareMethod) => {
|
||||
if (!shareMethod) {
|
||||
logger.warn('useEventDetailEvents', 'Share method is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useEventDetailEvents', 'Event object is required for share tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.CONTENT_SHARED, {
|
||||
content_type: 'event',
|
||||
content_id: event.id,
|
||||
content_title: event.title || '',
|
||||
share_method: shareMethod,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', '📤 Event Shared', {
|
||||
eventId: event.id,
|
||||
shareMethod,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪评论点赞/取消点赞
|
||||
* @param {string} commentId - 评论ID
|
||||
* @param {boolean} isLiked - 是否点赞
|
||||
*/
|
||||
const trackCommentLiked = useCallback((commentId, isLiked) => {
|
||||
if (!commentId) {
|
||||
logger.warn('useEventDetailEvents', 'Comment ID is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(isLiked ? 'Comment Liked' : 'Comment Unliked', {
|
||||
comment_id: commentId,
|
||||
event_id: event?.id,
|
||||
action: isLiked ? 'like' : 'unlike',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', `${isLiked ? '❤️' : '🤍'} Comment ${isLiked ? 'Liked' : 'Unliked'}`, {
|
||||
commentId,
|
||||
eventId: event?.id,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪添加评论
|
||||
* @param {string} commentId - 评论ID
|
||||
* @param {number} contentLength - 评论内容长度
|
||||
*/
|
||||
const trackCommentAdded = useCallback((commentId, contentLength = 0) => {
|
||||
if (!event || !event.id) {
|
||||
logger.warn('useEventDetailEvents', 'Event object is required for comment tracking');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Comment Added', {
|
||||
comment_id: commentId,
|
||||
event_id: event.id,
|
||||
content_length: contentLength,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', '💬 Comment Added', {
|
||||
commentId,
|
||||
eventId: event.id,
|
||||
contentLength,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
/**
|
||||
* 追踪删除评论
|
||||
* @param {string} commentId - 评论ID
|
||||
*/
|
||||
const trackCommentDeleted = useCallback((commentId) => {
|
||||
if (!commentId) {
|
||||
logger.warn('useEventDetailEvents', 'Comment ID is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Comment Deleted', {
|
||||
comment_id: commentId,
|
||||
event_id: event?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useEventDetailEvents', '🗑️ Comment Deleted', {
|
||||
commentId,
|
||||
eventId: event?.id,
|
||||
});
|
||||
}, [track, event]);
|
||||
|
||||
return {
|
||||
// 页面级事件
|
||||
trackEventAnalysisViewed,
|
||||
|
||||
// 交互事件
|
||||
trackEventTimelineClicked,
|
||||
trackRelatedStockClicked,
|
||||
trackRelatedConceptClicked,
|
||||
trackTabClicked,
|
||||
|
||||
// 用户行为事件
|
||||
trackEventFavoriteToggled,
|
||||
trackEventShared,
|
||||
|
||||
// 社交互动事件
|
||||
trackCommentLiked,
|
||||
trackCommentAdded,
|
||||
trackCommentDeleted,
|
||||
};
|
||||
};
|
||||
|
||||
export default useEventDetailEvents;
|
||||
@@ -75,6 +75,7 @@ import TransmissionChainAnalysis from './components/TransmissionChainAnalysis';
|
||||
import { eventService } from '../../services/eventService';
|
||||
import { debugEventService } from '../../utils/debugEventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useEventDetailEvents } from './hooks/useEventDetailEvents';
|
||||
|
||||
// 临时调试代码 - 生产环境测试后请删除
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -110,7 +111,7 @@ const StatCard = ({ icon, label, value, color }) => {
|
||||
};
|
||||
|
||||
// 帖子组件
|
||||
const PostItem = ({ post, onRefresh }) => {
|
||||
const PostItem = ({ post, onRefresh, eventEvents }) => {
|
||||
const [showComments, setShowComments] = useState(false);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
@@ -145,8 +146,14 @@ const PostItem = ({ post, onRefresh }) => {
|
||||
try {
|
||||
const result = await eventService.likePost(post.id);
|
||||
if (result.success) {
|
||||
setLiked(result.liked);
|
||||
const newLikedState = result.liked;
|
||||
setLiked(newLikedState);
|
||||
setLikesCount(result.likes_count);
|
||||
|
||||
// 🎯 追踪评论点赞
|
||||
if (eventEvents && eventEvents.trackCommentLiked) {
|
||||
eventEvents.trackCommentLiked(post.id, newLikedState);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -166,6 +173,14 @@ const PostItem = ({ post, onRefresh }) => {
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 🎯 追踪添加评论
|
||||
if (eventEvents && eventEvents.trackCommentAdded) {
|
||||
eventEvents.trackCommentAdded(
|
||||
result.data?.id || post.id,
|
||||
newComment.length
|
||||
);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '评论发表成功',
|
||||
status: 'success',
|
||||
@@ -192,6 +207,11 @@ const PostItem = ({ post, onRefresh }) => {
|
||||
try {
|
||||
const result = await eventService.deletePost(post.id);
|
||||
if (result.success) {
|
||||
// 🎯 追踪删除评论
|
||||
if (eventEvents && eventEvents.trackCommentDeleted) {
|
||||
eventEvents.trackCommentDeleted(post.id);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
@@ -348,6 +368,15 @@ const EventDetail = () => {
|
||||
const [postsLoading, setPostsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// 🎯 初始化事件详情埋点Hook(传入event对象)
|
||||
const eventEvents = useEventDetailEvents({
|
||||
event: eventData ? {
|
||||
id: eventData.id,
|
||||
title: eventData.title,
|
||||
importance: eventData.importance
|
||||
} : null
|
||||
});
|
||||
const [newPostContent, setNewPostContent] = useState('');
|
||||
const [newPostTitle, setNewPostTitle] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -380,9 +409,11 @@ const EventDetail = () => {
|
||||
setEventData(eventResponse.data);
|
||||
|
||||
// 总是尝试加载相关股票(权限在组件内部检查)
|
||||
let stocksCount = 0;
|
||||
try {
|
||||
const stocksResponse = await eventService.getRelatedStocks(actualEventId);
|
||||
setRelatedStocks(stocksResponse.data || []);
|
||||
stocksCount = stocksResponse.data?.length || 0;
|
||||
} catch (e) {
|
||||
logger.warn('EventDetail', '加载相关股票失败', { eventId: actualEventId, error: e.message });
|
||||
setRelatedStocks([]);
|
||||
@@ -399,13 +430,25 @@ const EventDetail = () => {
|
||||
}
|
||||
|
||||
// 历史事件所有用户都可以访问,但免费用户只看到前2条
|
||||
let timelineCount = 0;
|
||||
try {
|
||||
const eventsResponse = await eventService.getHistoricalEvents(actualEventId);
|
||||
setHistoricalEvents(eventsResponse.data || []);
|
||||
timelineCount = eventsResponse.data?.length || 0;
|
||||
} catch (e) {
|
||||
logger.warn('EventDetail', '历史事件加载失败', { eventId: actualEventId, error: e.message });
|
||||
}
|
||||
|
||||
// 🎯 追踪事件分析内容查看(数据加载完成后)
|
||||
if (eventResponse.data && eventEvents) {
|
||||
eventEvents.trackEventAnalysisViewed({
|
||||
type: 'overview',
|
||||
relatedStockCount: stocksCount,
|
||||
timelineEventCount: timelineCount,
|
||||
marketImpact: eventResponse.data.market_impact
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'loadEventData', err, { eventId: actualEventId });
|
||||
setError(err.message || '加载事件数据失败');
|
||||
@@ -800,7 +843,12 @@ const EventDetail = () => {
|
||||
</VStack>
|
||||
) : posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<PostItem key={post.id} post={post} onRefresh={loadPosts} />
|
||||
<PostItem
|
||||
key={post.id}
|
||||
post={post}
|
||||
onRefresh={loadPosts}
|
||||
eventEvents={eventEvents}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Box
|
||||
|
||||
@@ -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}>
|
||||
|
||||
252
src/views/LimitAnalyse/hooks/useLimitAnalyseEvents.js
Normal file
252
src/views/LimitAnalyse/hooks/useLimitAnalyseEvents.js
Normal file
@@ -0,0 +1,252 @@
|
||||
// src/views/LimitAnalyse/hooks/useLimitAnalyseEvents.js
|
||||
// 涨停分析页面事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 涨停分析事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪方法集合
|
||||
*/
|
||||
export const useLimitAnalyseEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 页面浏览追踪 - 组件加载时自动触发
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.LIMIT_ANALYSE_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useLimitAnalyseEvents', '👁️ Limit Analyse Page Viewed');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪日期选择
|
||||
* @param {string} date - 选择的日期(YYYYMMDD 格式)
|
||||
* @param {string} previousDate - 之前的日期
|
||||
*/
|
||||
const trackDateSelected = useCallback((date, previousDate = null) => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'date',
|
||||
filter_value: date,
|
||||
previous_value: previousDate,
|
||||
context: 'limit_analyse',
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '📅 Date Selected', {
|
||||
date,
|
||||
previousDate,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪每日统计数据查看
|
||||
* @param {Object} stats - 统计数据
|
||||
* @param {string} date - 日期
|
||||
*/
|
||||
const trackDailyStatsViewed = useCallback((stats, date) => {
|
||||
if (!stats) return;
|
||||
|
||||
track(RETENTION_EVENTS.LIMIT_ANALYSE_PAGE_VIEWED, {
|
||||
date,
|
||||
total_stocks: stats.total_stocks,
|
||||
sector_count: stats.sectors?.length || 0,
|
||||
hot_sector: stats.hot_sector?.name,
|
||||
view_type: 'daily_stats',
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '📊 Daily Stats Viewed', {
|
||||
date,
|
||||
totalStocks: stats.total_stocks,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪板块展开/收起
|
||||
* @param {string} sectorName - 板块名称
|
||||
* @param {boolean} isExpanded - 是否展开
|
||||
* @param {number} stockCount - 板块内股票数量
|
||||
*/
|
||||
const trackSectorToggled = useCallback((sectorName, isExpanded, stockCount = 0) => {
|
||||
track(RETENTION_EVENTS.LIMIT_SECTOR_EXPANDED, {
|
||||
sector_name: sectorName,
|
||||
action: isExpanded ? 'expand' : 'collapse',
|
||||
stock_count: stockCount,
|
||||
source: 'limit_analyse',
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '🔽 Sector Toggled', {
|
||||
sectorName,
|
||||
isExpanded,
|
||||
stockCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪板块点击
|
||||
* @param {Object} sector - 板块对象
|
||||
*/
|
||||
const trackSectorClicked = useCallback((sector) => {
|
||||
track(RETENTION_EVENTS.LIMIT_BOARD_CLICKED, {
|
||||
sector_name: sector.name,
|
||||
stock_count: sector.count,
|
||||
source: 'limit_analyse',
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '🎯 Sector Clicked', {
|
||||
sectorName: sector.name,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪涨停股票点击
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} sectorName - 所属板块
|
||||
*/
|
||||
const trackLimitStockClicked = useCallback((stock, sectorName = '') => {
|
||||
track(RETENTION_EVENTS.LIMIT_STOCK_CLICKED, {
|
||||
stock_code: stock.code || stock.stock_code,
|
||||
stock_name: stock.name || stock.stock_name,
|
||||
sector_name: sectorName,
|
||||
limit_time: stock.limit_time,
|
||||
source: 'limit_analyse',
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '📈 Limit Stock Clicked', {
|
||||
stockCode: stock.code || stock.stock_code,
|
||||
sectorName,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪搜索发起
|
||||
* @param {string} query - 搜索关键词
|
||||
* @param {string} searchType - 搜索类型(all/sector/stock)
|
||||
* @param {string} searchMode - 搜索模式(hybrid/standard)
|
||||
*/
|
||||
const trackSearchInitiated = useCallback((query, searchType = 'all', searchMode = 'hybrid') => {
|
||||
track(RETENTION_EVENTS.SEARCH_INITIATED, {
|
||||
context: 'limit_analyse',
|
||||
});
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
category: 'limit_analyse',
|
||||
search_type: searchType,
|
||||
search_mode: searchMode,
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '🔍 Search Initiated', {
|
||||
query,
|
||||
searchType,
|
||||
searchMode,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪搜索结果点击
|
||||
* @param {Object} result - 搜索结果对象
|
||||
* @param {number} position - 在结果列表中的位置
|
||||
*/
|
||||
const trackSearchResultClicked = useCallback((result, position = 0) => {
|
||||
track(RETENTION_EVENTS.SEARCH_RESULT_CLICKED, {
|
||||
result_type: result.type,
|
||||
result_id: result.id || result.code,
|
||||
result_name: result.name,
|
||||
position,
|
||||
context: 'limit_analyse',
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '🎯 Search Result Clicked', {
|
||||
type: result.type,
|
||||
name: result.name,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪高位股查看
|
||||
* @param {string} date - 日期
|
||||
* @param {Object} stats - 高位股统计数据
|
||||
*/
|
||||
const trackHighPositionStocksViewed = useCallback((date, stats = {}) => {
|
||||
track(RETENTION_EVENTS.LIMIT_ANALYSE_PAGE_VIEWED, {
|
||||
date,
|
||||
view_type: 'high_position_stocks',
|
||||
total_count: stats.total_count || 0,
|
||||
max_consecutive_days: stats.max_consecutive_days || 0,
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '📊 High Position Stocks Viewed', {
|
||||
date,
|
||||
stats,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪板块分析查看(分布图/关联图)
|
||||
* @param {string} date - 日期
|
||||
* @param {string} analysisType - 分析类型(distribution/relation/wordcloud)
|
||||
*/
|
||||
const trackSectorAnalysisViewed = useCallback((date, analysisType) => {
|
||||
track(RETENTION_EVENTS.LIMIT_SECTOR_ANALYSIS_VIEWED, {
|
||||
date,
|
||||
analysis_type: analysisType,
|
||||
source: 'limit_analyse',
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '📊 Sector Analysis Viewed', {
|
||||
date,
|
||||
analysisType,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪数据刷新
|
||||
* @param {string} date - 刷新的日期
|
||||
*/
|
||||
const trackDataRefreshed = useCallback((date) => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'refresh',
|
||||
filter_value: date,
|
||||
context: 'limit_analyse',
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '🔄 Data Refreshed', { date });
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪股票详情Modal打开
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} stockName - 股票名称
|
||||
*/
|
||||
const trackStockDetailViewed = useCallback((stockCode, stockName) => {
|
||||
track(RETENTION_EVENTS.STOCK_DETAIL_VIEWED, {
|
||||
stock_code: stockCode,
|
||||
stock_name: stockName,
|
||||
source: 'limit_analyse_modal',
|
||||
});
|
||||
|
||||
logger.debug('useLimitAnalyseEvents', '👁️ Stock Detail Modal Opened', {
|
||||
stockCode,
|
||||
stockName,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
trackDateSelected,
|
||||
trackDailyStatsViewed,
|
||||
trackSectorToggled,
|
||||
trackSectorClicked,
|
||||
trackLimitStockClicked,
|
||||
trackSearchInitiated,
|
||||
trackSearchResultClicked,
|
||||
trackHighPositionStocksViewed,
|
||||
trackSectorAnalysisViewed,
|
||||
trackDataRefreshed,
|
||||
trackStockDetailViewed,
|
||||
};
|
||||
};
|
||||
@@ -48,6 +48,7 @@ import { AdvancedSearch, SearchResultsModal } from './components/SearchComponent
|
||||
// 导入高位股统计组件
|
||||
import HighPositionStocks from './components/HighPositionStocks';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useLimitAnalyseEvents } from './hooks/useLimitAnalyseEvents';
|
||||
|
||||
// 主组件
|
||||
export default function LimitAnalyse() {
|
||||
@@ -59,9 +60,26 @@ 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();
|
||||
|
||||
// 🎯 PostHog 事件追踪
|
||||
const {
|
||||
trackDateSelected,
|
||||
trackDailyStatsViewed,
|
||||
trackSectorToggled,
|
||||
trackSectorClicked,
|
||||
trackLimitStockClicked,
|
||||
trackSearchInitiated,
|
||||
trackSearchResultClicked,
|
||||
trackHighPositionStocksViewed,
|
||||
trackSectorAnalysisViewed,
|
||||
trackDataRefreshed,
|
||||
trackStockDetailViewed,
|
||||
} = useLimitAnalyseEvents();
|
||||
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const accentColor = useColorModeValue('blue.500', 'blue.300');
|
||||
@@ -126,6 +144,9 @@ export default function LimitAnalyse() {
|
||||
if (data.success) {
|
||||
setDailyData(data.data);
|
||||
|
||||
// 🎯 追踪每日统计数据查看
|
||||
trackDailyStatsViewed(data.data, date);
|
||||
|
||||
// 获取词云数据
|
||||
fetchWordCloudData(date);
|
||||
|
||||
@@ -169,14 +190,26 @@ export default function LimitAnalyse() {
|
||||
|
||||
// 处理日期选择
|
||||
const handleDateChange = (date) => {
|
||||
const previousDateStr = dateStr;
|
||||
setSelectedDate(date);
|
||||
const dateString = formatDateStr(date);
|
||||
setDateStr(dateString);
|
||||
|
||||
// 🎯 追踪日期选择
|
||||
trackDateSelected(dateString, previousDateStr);
|
||||
|
||||
fetchDailyAnalysis(dateString);
|
||||
};
|
||||
|
||||
// 处理搜索
|
||||
const handleSearch = async (searchParams) => {
|
||||
// 🎯 追踪搜索开始
|
||||
trackSearchInitiated(
|
||||
searchParams.query,
|
||||
searchParams.type || 'all',
|
||||
searchParams.mode || 'hybrid'
|
||||
);
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/v1/stocks/search/hybrid`, {
|
||||
@@ -212,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 [];
|
||||
@@ -439,6 +486,7 @@ export default function LimitAnalyse() {
|
||||
<SectorDetails
|
||||
sortedSectors={getSortedSectorData()}
|
||||
totalStocks={dailyData?.total_stocks || 0}
|
||||
onStockClick={handleStockClick}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -465,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}>
|
||||
|
||||
@@ -44,11 +44,15 @@ import {
|
||||
import { EditIcon, CheckIcon, CloseIcon, AddIcon } from '@chakra-ui/icons';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useProfileEvents } from '../../hooks/useProfileEvents';
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, updateUser } = useAuth();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 🎯 初始化个人资料埋点Hook
|
||||
const profileEvents = useProfileEvents({ pageType: 'profile' });
|
||||
const [newTag, setNewTag] = useState('');
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const fileInputRef = useRef();
|
||||
@@ -95,6 +99,12 @@ export default function ProfilePage() {
|
||||
updateUser(updatedData);
|
||||
setIsEditing(false);
|
||||
|
||||
// 🎯 追踪个人资料更新成功
|
||||
const updatedFields = Object.keys(formData).filter(
|
||||
key => user?.[key] !== formData[key]
|
||||
);
|
||||
profileEvents.trackProfileUpdated(updatedFields, updatedData);
|
||||
|
||||
// ✅ 保留关键操作提示
|
||||
toast({
|
||||
title: "个人资料更新成功",
|
||||
@@ -105,6 +115,10 @@ export default function ProfilePage() {
|
||||
} catch (error) {
|
||||
logger.error('ProfilePage', 'handleSaveProfile', error, { userId: user?.id });
|
||||
|
||||
// 🎯 追踪个人资料更新失败
|
||||
const attemptedFields = Object.keys(formData);
|
||||
profileEvents.trackProfileUpdateFailed(attemptedFields, error.message);
|
||||
|
||||
// ✅ 保留错误提示
|
||||
toast({
|
||||
title: "更新失败",
|
||||
@@ -128,6 +142,9 @@ export default function ProfilePage() {
|
||||
reader.onload = (e) => {
|
||||
updateUser({ avatar_url: e.target.result });
|
||||
|
||||
// 🎯 追踪头像上传
|
||||
profileEvents.trackAvatarUploaded('file_upload', file.size);
|
||||
|
||||
// ✅ 保留关键操作提示
|
||||
toast({
|
||||
title: "头像更新成功",
|
||||
|
||||
@@ -59,12 +59,16 @@ import { FaWeixin, FaMobile, FaEnvelope } from 'react-icons/fa';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useProfileEvents } from '../../hooks/useProfileEvents';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, updateUser, logout } = useAuth();
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const toast = useToast();
|
||||
|
||||
// 🎯 初始化设置页面埋点Hook
|
||||
const profileEvents = useProfileEvents({ pageType: 'settings' });
|
||||
|
||||
// 模态框状态
|
||||
const { isOpen: isPasswordOpen, onOpen: onPasswordOpen, onClose: onPasswordClose } = useDisclosure();
|
||||
const { isOpen: isPhoneOpen, onOpen: onPhoneOpen, onClose: onPhoneClose } = useDisclosure();
|
||||
@@ -209,9 +213,12 @@ export default function SettingsPage() {
|
||||
|
||||
if (response.ok && data.success) {
|
||||
const isFirstSet = passwordStatus.needsFirstTimeSetup;
|
||||
|
||||
|
||||
// 🎯 追踪密码修改成功
|
||||
profileEvents.trackPasswordChanged(true);
|
||||
|
||||
toast({
|
||||
title: isFirstSet ? "密码设置成功" : "密码修改成功",
|
||||
title: isFirstSet ? "密码设置成功" : "密码修改成功",
|
||||
description: isFirstSet ? "您现在可以使用手机号+密码登录了" : "请重新登录",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
@@ -220,7 +227,7 @@ export default function SettingsPage() {
|
||||
|
||||
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
onPasswordClose();
|
||||
|
||||
|
||||
// 刷新密码状态
|
||||
fetchPasswordStatus();
|
||||
|
||||
@@ -234,6 +241,9 @@ export default function SettingsPage() {
|
||||
throw new Error(data.error || '密码修改失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// 🎯 追踪密码修改失败
|
||||
profileEvents.trackPasswordChanged(false, error.message);
|
||||
|
||||
toast({
|
||||
title: "修改失败",
|
||||
description: error.message,
|
||||
@@ -364,6 +374,9 @@ export default function SettingsPage() {
|
||||
email_confirmed: data.user.email_confirmed
|
||||
});
|
||||
|
||||
// 🎯 追踪邮箱绑定成功
|
||||
profileEvents.trackAccountBound('email', true);
|
||||
|
||||
toast({
|
||||
title: "邮箱绑定成功",
|
||||
status: "success",
|
||||
@@ -374,6 +387,9 @@ export default function SettingsPage() {
|
||||
setEmailForm({ email: '', verificationCode: '' });
|
||||
onEmailClose();
|
||||
} catch (error) {
|
||||
// 🎯 追踪邮箱绑定失败
|
||||
profileEvents.trackAccountBound('email', false);
|
||||
|
||||
toast({
|
||||
title: "绑定失败",
|
||||
description: error.message,
|
||||
@@ -397,6 +413,13 @@ export default function SettingsPage() {
|
||||
|
||||
updateUser(notifications);
|
||||
|
||||
// 🎯 追踪通知偏好更改
|
||||
profileEvents.trackNotificationPreferencesChanged({
|
||||
email: notifications.email_notifications,
|
||||
push: notifications.system_updates,
|
||||
sms: notifications.sms_notifications
|
||||
});
|
||||
|
||||
// ❌ 移除设置保存成功toast
|
||||
logger.info('SettingsPage', '通知设置已保存');
|
||||
} catch (error) {
|
||||
|
||||
@@ -28,7 +28,9 @@ import { FiTrendingUp, FiTrendingDown, FiDollarSign, FiPieChart, FiTarget, FiAct
|
||||
import DonutChart from '../../../components/Charts/DonutChart';
|
||||
import IconBox from '../../../components/Icons/IconBox';
|
||||
|
||||
export default function AccountOverview({ account }) {
|
||||
export default function AccountOverview({ account, tradingEvents }) {
|
||||
// tradingEvents 已传递,可用于将来添加的账户重置等功能
|
||||
// 例如: tradingEvents.trackAccountReset(beforeResetData)
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const profitColor = account?.totalProfit >= 0 ? 'green.500' : 'red.500';
|
||||
|
||||
@@ -64,20 +64,38 @@ const calculateChange = (currentPrice, avgPrice) => {
|
||||
return { change, changePercent };
|
||||
};
|
||||
|
||||
export default function PositionsList({ positions, account, onSellStock }) {
|
||||
export default function PositionsList({ positions, account, onSellStock, tradingEvents }) {
|
||||
const [selectedPosition, setSelectedPosition] = useState(null);
|
||||
const [sellQuantity, setSellQuantity] = useState(0);
|
||||
const [orderType, setOrderType] = useState('MARKET');
|
||||
const [limitPrice, setLimitPrice] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [hasTracked, setHasTracked] = React.useState(false);
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 🎯 追踪持仓查看 - 组件加载时触发一次
|
||||
React.useEffect(() => {
|
||||
if (!hasTracked && positions && positions.length > 0 && tradingEvents && tradingEvents.trackSimulationHoldingsViewed) {
|
||||
const totalMarketValue = positions.reduce((sum, pos) => sum + (pos.marketValue || pos.quantity * pos.currentPrice || 0), 0);
|
||||
const totalCost = positions.reduce((sum, pos) => sum + (pos.totalCost || pos.quantity * pos.avgPrice || 0), 0);
|
||||
const totalProfit = positions.reduce((sum, pos) => sum + (pos.profit || 0), 0);
|
||||
|
||||
tradingEvents.trackSimulationHoldingsViewed({
|
||||
count: positions.length,
|
||||
totalValue: totalMarketValue,
|
||||
totalCost,
|
||||
profitLoss: totalProfit,
|
||||
});
|
||||
setHasTracked(true);
|
||||
}
|
||||
}, [positions, tradingEvents, hasTracked]);
|
||||
|
||||
// 格式化货币
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
@@ -102,6 +120,17 @@ export default function PositionsList({ positions, account, onSellStock }) {
|
||||
setSelectedPosition(position);
|
||||
setSellQuantity(position.availableQuantity); // 默认全部可卖数量
|
||||
setLimitPrice(position.currentPrice?.toString() || position.avgPrice.toString());
|
||||
|
||||
// 🎯 追踪卖出按钮点击
|
||||
if (tradingEvents && tradingEvents.trackSellButtonClicked) {
|
||||
tradingEvents.trackSellButtonClicked({
|
||||
stockCode: position.stockCode,
|
||||
stockName: position.stockName,
|
||||
quantity: position.quantity,
|
||||
profitLoss: position.profit || 0,
|
||||
}, 'holdings');
|
||||
}
|
||||
|
||||
onOpen();
|
||||
};
|
||||
|
||||
@@ -110,6 +139,8 @@ export default function PositionsList({ positions, account, onSellStock }) {
|
||||
if (!selectedPosition || sellQuantity <= 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
const price = orderType === 'LIMIT' ? parseFloat(limitPrice) : selectedPosition.currentPrice || selectedPosition.avgPrice;
|
||||
|
||||
try {
|
||||
const result = await onSellStock(
|
||||
selectedPosition.stockCode,
|
||||
@@ -126,6 +157,20 @@ export default function PositionsList({ positions, account, onSellStock }) {
|
||||
orderType,
|
||||
orderId: result.orderId
|
||||
});
|
||||
|
||||
// 🎯 追踪卖出成功
|
||||
if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) {
|
||||
tradingEvents.trackSimulationOrderPlaced({
|
||||
stockCode: selectedPosition.stockCode,
|
||||
stockName: selectedPosition.stockName,
|
||||
direction: 'sell',
|
||||
quantity: sellQuantity,
|
||||
price,
|
||||
orderType,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '卖出成功',
|
||||
description: `已卖出 ${selectedPosition.stockName} ${sellQuantity} 股`,
|
||||
@@ -142,6 +187,21 @@ export default function PositionsList({ positions, account, onSellStock }) {
|
||||
quantity: sellQuantity,
|
||||
orderType
|
||||
});
|
||||
|
||||
// 🎯 追踪卖出失败
|
||||
if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) {
|
||||
tradingEvents.trackSimulationOrderPlaced({
|
||||
stockCode: selectedPosition.stockCode,
|
||||
stockName: selectedPosition.stockName,
|
||||
direction: 'sell',
|
||||
quantity: sellQuantity,
|
||||
price,
|
||||
orderType,
|
||||
success: false,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '卖出失败',
|
||||
description: error.message,
|
||||
|
||||
@@ -34,18 +34,31 @@ import {
|
||||
import { FiSearch, FiFilter, FiClock, FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
export default function TradingHistory({ history, onCancelOrder }) {
|
||||
export default function TradingHistory({ history, onCancelOrder, tradingEvents }) {
|
||||
const [filterType, setFilterType] = useState('ALL'); // ALL, BUY, SELL
|
||||
const [filterStatus, setFilterStatus] = useState('ALL'); // ALL, FILLED, PENDING, CANCELLED
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('createdAt'); // createdAt, stockCode, amount
|
||||
const [sortOrder, setSortOrder] = useState('desc'); // desc, asc
|
||||
|
||||
const [hasTracked, setHasTracked] = React.useState(false);
|
||||
|
||||
const toast = useToast();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 🎯 追踪历史记录查看 - 组件加载时触发一次
|
||||
React.useEffect(() => {
|
||||
if (!hasTracked && history && history.length > 0 && tradingEvents && tradingEvents.trackSimulationHistoryViewed) {
|
||||
tradingEvents.trackSimulationHistoryViewed({
|
||||
count: history.length,
|
||||
filterBy: 'all',
|
||||
dateRange: 'all',
|
||||
});
|
||||
setHasTracked(true);
|
||||
}
|
||||
}, [history, tradingEvents, hasTracked]);
|
||||
|
||||
// 格式化货币
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
|
||||
@@ -55,7 +55,7 @@ import { FiSearch, FiTrendingUp, FiTrendingDown, FiDollarSign, FiZap, FiTarget }
|
||||
// 导入现有的高质量组件
|
||||
import IconBox from '../../../components/Icons/IconBox';
|
||||
|
||||
export default function TradingPanel({ account, onBuyStock, onSellStock, searchStocks }) {
|
||||
export default function TradingPanel({ account, onBuyStock, onSellStock, searchStocks, tradingEvents }) {
|
||||
const [activeTab, setActiveTab] = useState(0); // 0: 买入, 1: 卖出
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedStock, setSelectedStock] = useState(null);
|
||||
@@ -87,7 +87,7 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS
|
||||
const results = await searchStocks(searchTerm);
|
||||
// 转换为组件需要的格式
|
||||
const formattedResults = results.map(stock => [
|
||||
stock.stock_code,
|
||||
stock.stock_code,
|
||||
{
|
||||
name: stock.stock_name,
|
||||
price: stock.current_price || 0, // 使用后端返回的真实价格
|
||||
@@ -97,10 +97,20 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS
|
||||
]);
|
||||
setFilteredStocks(formattedResults);
|
||||
setShowStockList(true);
|
||||
|
||||
// 🎯 追踪股票搜索
|
||||
if (tradingEvents && tradingEvents.trackSimulationStockSearched) {
|
||||
tradingEvents.trackSimulationStockSearched(searchTerm, formattedResults.length);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('TradingPanel', 'handleStockSearch', error, { searchTerm });
|
||||
setFilteredStocks([]);
|
||||
setShowStockList(false);
|
||||
|
||||
// 🎯 追踪搜索无结果
|
||||
if (tradingEvents && tradingEvents.trackSimulationStockSearched) {
|
||||
tradingEvents.trackSimulationStockSearched(searchTerm, 0);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setFilteredStocks([]);
|
||||
@@ -109,7 +119,7 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS
|
||||
}, 300); // 300ms 防抖
|
||||
|
||||
return () => clearTimeout(searchDebounced);
|
||||
}, [searchTerm, searchStocks]);
|
||||
}, [searchTerm, searchStocks, tradingEvents]);
|
||||
|
||||
// 选择股票
|
||||
const handleSelectStock = (code, stock) => {
|
||||
@@ -169,6 +179,9 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
const price = orderType === 'LIMIT' ? parseFloat(limitPrice) : selectedStock.price;
|
||||
const direction = activeTab === 0 ? 'buy' : 'sell';
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (activeTab === 0) {
|
||||
@@ -197,6 +210,19 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS
|
||||
orderType
|
||||
});
|
||||
|
||||
// 🎯 追踪下单成功
|
||||
if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) {
|
||||
tradingEvents.trackSimulationOrderPlaced({
|
||||
stockCode: selectedStock.code,
|
||||
stockName: selectedStock.name,
|
||||
direction,
|
||||
quantity,
|
||||
price,
|
||||
orderType,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ 保留交易成功toast(关键用户操作反馈)
|
||||
toast({
|
||||
title: activeTab === 0 ? '买入成功' : '卖出成功',
|
||||
@@ -217,6 +243,20 @@ export default function TradingPanel({ account, onBuyStock, onSellStock, searchS
|
||||
orderType
|
||||
});
|
||||
|
||||
// 🎯 追踪下单失败
|
||||
if (tradingEvents && tradingEvents.trackSimulationOrderPlaced) {
|
||||
tradingEvents.trackSimulationOrderPlaced({
|
||||
stockCode: selectedStock.code,
|
||||
stockName: selectedStock.name,
|
||||
direction,
|
||||
quantity,
|
||||
price,
|
||||
orderType,
|
||||
success: false,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ 保留交易失败toast(关键用户操作错误反馈)
|
||||
toast({
|
||||
title: activeTab === 0 ? '买入失败' : '卖出失败',
|
||||
|
||||
303
src/views/TradingSimulation/hooks/useTradingSimulationEvents.js
Normal file
303
src/views/TradingSimulation/hooks/useTradingSimulationEvents.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// src/views/TradingSimulation/hooks/useTradingSimulationEvents.js
|
||||
// 模拟盘交易事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 模拟盘交易事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Object} options.portfolio - 账户信息
|
||||
* @param {number} options.portfolio.totalValue - 总资产
|
||||
* @param {number} options.portfolio.availableCash - 可用资金
|
||||
* @param {number} options.portfolio.holdingsCount - 持仓数量
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useTradingSimulationEvents = ({ portfolio, navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.TRADING_SIMULATION_ENTERED, {
|
||||
total_value: portfolio?.totalValue || 0,
|
||||
available_cash: portfolio?.availableCash || 0,
|
||||
holdings_count: portfolio?.holdingsCount || 0,
|
||||
has_holdings: Boolean(portfolio?.holdingsCount && portfolio.holdingsCount > 0),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '🎮 Trading Simulation Entered', {
|
||||
totalValue: portfolio?.totalValue,
|
||||
holdingsCount: portfolio?.holdingsCount,
|
||||
});
|
||||
}, [track, portfolio]);
|
||||
|
||||
/**
|
||||
* 追踪股票搜索(模拟盘内)
|
||||
* @param {string} query - 搜索关键词
|
||||
* @param {number} resultCount - 搜索结果数量
|
||||
*/
|
||||
const trackSimulationStockSearched = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.SIMULATION_STOCK_SEARCHED, {
|
||||
query,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'trading_simulation',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '🔍 Simulation Stock Searched', {
|
||||
query,
|
||||
resultCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪下单操作
|
||||
* @param {Object} order - 订单信息
|
||||
* @param {string} order.stockCode - 股票代码
|
||||
* @param {string} order.stockName - 股票名称
|
||||
* @param {string} order.direction - 买卖方向 ('buy' | 'sell')
|
||||
* @param {number} order.quantity - 数量
|
||||
* @param {number} order.price - 价格
|
||||
* @param {string} order.orderType - 订单类型 ('market' | 'limit')
|
||||
* @param {boolean} order.success - 是否成功
|
||||
*/
|
||||
const trackSimulationOrderPlaced = useCallback((order) => {
|
||||
if (!order || !order.stockCode) {
|
||||
logger.warn('useTradingSimulationEvents', 'Order object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.SIMULATION_ORDER_PLACED, {
|
||||
stock_code: order.stockCode,
|
||||
stock_name: order.stockName || '',
|
||||
direction: order.direction,
|
||||
quantity: order.quantity,
|
||||
price: order.price,
|
||||
order_type: order.orderType || 'market',
|
||||
order_value: order.quantity * order.price,
|
||||
success: order.success,
|
||||
error_message: order.errorMessage || null,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '📝 Simulation Order Placed', {
|
||||
stockCode: order.stockCode,
|
||||
direction: order.direction,
|
||||
quantity: order.quantity,
|
||||
success: order.success,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪持仓查看
|
||||
* @param {Object} holdings - 持仓信息
|
||||
* @param {number} holdings.count - 持仓数量
|
||||
* @param {number} holdings.totalValue - 持仓总市值
|
||||
* @param {number} holdings.totalCost - 持仓总成本
|
||||
* @param {number} holdings.profitLoss - 总盈亏
|
||||
*/
|
||||
const trackSimulationHoldingsViewed = useCallback((holdings = {}) => {
|
||||
track(RETENTION_EVENTS.SIMULATION_HOLDINGS_VIEWED, {
|
||||
holdings_count: holdings.count || 0,
|
||||
total_value: holdings.totalValue || 0,
|
||||
total_cost: holdings.totalCost || 0,
|
||||
profit_loss: holdings.profitLoss || 0,
|
||||
profit_loss_percent: holdings.totalCost ? ((holdings.profitLoss / holdings.totalCost) * 100).toFixed(2) : 0,
|
||||
has_profit: holdings.profitLoss > 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '💼 Simulation Holdings Viewed', {
|
||||
count: holdings.count,
|
||||
profitLoss: holdings.profitLoss,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪持仓股票点击
|
||||
* @param {Object} holding - 持仓对象
|
||||
* @param {string} holding.stockCode - 股票代码
|
||||
* @param {string} holding.stockName - 股票名称
|
||||
* @param {number} holding.profitLoss - 盈亏金额
|
||||
* @param {number} position - 在列表中的位置
|
||||
*/
|
||||
const trackHoldingClicked = useCallback((holding, position = 0) => {
|
||||
if (!holding || !holding.stockCode) {
|
||||
logger.warn('useTradingSimulationEvents', 'Holding object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: holding.stockCode,
|
||||
stock_name: holding.stockName || '',
|
||||
source: 'simulation_holdings',
|
||||
profit_loss: holding.profitLoss || 0,
|
||||
position,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '🎯 Holding Clicked', {
|
||||
stockCode: holding.stockCode,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪历史交易记录查看
|
||||
* @param {Object} history - 历史记录信息
|
||||
* @param {number} history.count - 交易记录数量
|
||||
* @param {string} history.filterBy - 筛选条件 ('all' | 'buy' | 'sell')
|
||||
* @param {string} history.dateRange - 日期范围
|
||||
*/
|
||||
const trackSimulationHistoryViewed = useCallback((history = {}) => {
|
||||
track(RETENTION_EVENTS.SIMULATION_HISTORY_VIEWED, {
|
||||
history_count: history.count || 0,
|
||||
filter_by: history.filterBy || 'all',
|
||||
date_range: history.dateRange || 'all',
|
||||
has_history: Boolean(history.count && history.count > 0),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '📜 Simulation History Viewed', {
|
||||
count: history.count,
|
||||
filterBy: history.filterBy,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪买入按钮点击
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} stock.code - 股票代码
|
||||
* @param {string} stock.name - 股票名称
|
||||
* @param {number} stock.price - 当前价格
|
||||
* @param {string} source - 来源 ('search' | 'holdings' | 'stock_detail')
|
||||
*/
|
||||
const trackBuyButtonClicked = useCallback((stock, source = 'search') => {
|
||||
if (!stock || !stock.code) {
|
||||
logger.warn('useTradingSimulationEvents', 'Stock object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Simulation Buy Button Clicked', {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
current_price: stock.price || 0,
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '🟢 Buy Button Clicked', {
|
||||
stockCode: stock.code,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪卖出按钮点击
|
||||
* @param {Object} holding - 持仓对象
|
||||
* @param {string} holding.stockCode - 股票代码
|
||||
* @param {string} holding.stockName - 股票名称
|
||||
* @param {number} holding.quantity - 持有数量
|
||||
* @param {number} holding.profitLoss - 盈亏金额
|
||||
* @param {string} source - 来源 ('holdings' | 'stock_detail')
|
||||
*/
|
||||
const trackSellButtonClicked = useCallback((holding, source = 'holdings') => {
|
||||
if (!holding || !holding.stockCode) {
|
||||
logger.warn('useTradingSimulationEvents', 'Holding object is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Simulation Sell Button Clicked', {
|
||||
stock_code: holding.stockCode,
|
||||
stock_name: holding.stockName || '',
|
||||
quantity: holding.quantity || 0,
|
||||
profit_loss: holding.profitLoss || 0,
|
||||
source,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '🔴 Sell Button Clicked', {
|
||||
stockCode: holding.stockCode,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪账户重置
|
||||
* @param {Object} beforeReset - 重置前的账户信息
|
||||
* @param {number} beforeReset.totalValue - 总资产
|
||||
* @param {number} beforeReset.profitLoss - 总盈亏
|
||||
*/
|
||||
const trackAccountReset = useCallback((beforeReset = {}) => {
|
||||
track('Simulation Account Reset', {
|
||||
total_value_before: beforeReset.totalValue || 0,
|
||||
profit_loss_before: beforeReset.profitLoss || 0,
|
||||
holdings_count_before: beforeReset.holdingsCount || 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '🔄 Account Reset', {
|
||||
totalValueBefore: beforeReset.totalValue,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪标签页切换
|
||||
* @param {string} tabName - 标签名称 ('trading' | 'holdings' | 'history')
|
||||
*/
|
||||
const trackTabClicked = useCallback((tabName) => {
|
||||
if (!tabName) {
|
||||
logger.warn('useTradingSimulationEvents', 'Tab name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
track('Simulation Tab Clicked', {
|
||||
tab_name: tabName,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
logger.debug('useTradingSimulationEvents', '📑 Tab Clicked', {
|
||||
tabName,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
// 搜索事件
|
||||
trackSimulationStockSearched,
|
||||
|
||||
// 交易事件
|
||||
trackSimulationOrderPlaced,
|
||||
trackBuyButtonClicked,
|
||||
trackSellButtonClicked,
|
||||
|
||||
// 持仓事件
|
||||
trackSimulationHoldingsViewed,
|
||||
trackHoldingClicked,
|
||||
|
||||
// 历史记录事件
|
||||
trackSimulationHistoryViewed,
|
||||
|
||||
// 账户管理事件
|
||||
trackAccountReset,
|
||||
|
||||
// UI交互事件
|
||||
trackTabClicked,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTradingSimulationEvents;
|
||||
@@ -49,6 +49,7 @@ import LineChart from '../../components/Charts/LineChart';
|
||||
|
||||
// 模拟盘账户管理 Hook
|
||||
import { useTradingAccount } from './hooks/useTradingAccount';
|
||||
import { useTradingSimulationEvents } from './hooks/useTradingSimulationEvents';
|
||||
|
||||
export default function TradingSimulation() {
|
||||
// ========== 1. 所有 Hooks 必须放在最顶部,不能有任何条件判断 ==========
|
||||
@@ -76,6 +77,15 @@ export default function TradingSimulation() {
|
||||
getAssetHistory
|
||||
} = useTradingAccount();
|
||||
|
||||
// 🎯 初始化模拟盘埋点Hook(传入账户信息)
|
||||
const tradingEvents = useTradingSimulationEvents({
|
||||
portfolio: account ? {
|
||||
totalValue: account.total_assets,
|
||||
availableCash: account.available_cash,
|
||||
holdingsCount: positions?.length || 0
|
||||
} : null
|
||||
});
|
||||
|
||||
// 所有的 useColorModeValue 也必须在顶部
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
@@ -271,9 +281,14 @@ export default function TradingSimulation() {
|
||||
</Box>
|
||||
|
||||
{/* 主要功能区域 - 放在上面 */}
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={(index) => {
|
||||
setActiveTab(index);
|
||||
// 🎯 追踪 Tab 切换
|
||||
const tabNames = ['trading', 'holdings', 'history', 'margin'];
|
||||
tradingEvents.trackTabClicked(tabNames[index]);
|
||||
}}
|
||||
variant="soft-rounded"
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
@@ -288,28 +303,31 @@ export default function TradingSimulation() {
|
||||
<TabPanels>
|
||||
{/* 交易面板 */}
|
||||
<TabPanel px={0}>
|
||||
<TradingPanel
|
||||
<TradingPanel
|
||||
account={account}
|
||||
onBuyStock={buyStock}
|
||||
onSellStock={sellStock}
|
||||
searchStocks={searchStocks}
|
||||
tradingEvents={tradingEvents}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* 我的持仓 */}
|
||||
<TabPanel px={0}>
|
||||
<PositionsList
|
||||
<PositionsList
|
||||
positions={positions}
|
||||
account={account}
|
||||
onSellStock={sellStock}
|
||||
tradingEvents={tradingEvents}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* 交易历史 */}
|
||||
<TabPanel px={0}>
|
||||
<TradingHistory
|
||||
<TradingHistory
|
||||
history={tradingHistory}
|
||||
onCancelOrder={cancelOrder}
|
||||
tradingEvents={tradingEvents}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
@@ -331,7 +349,7 @@ export default function TradingSimulation() {
|
||||
<Heading size="lg" mb={4} color={useColorModeValue('gray.700', 'white')}>
|
||||
📊 账户统计分析
|
||||
</Heading>
|
||||
<AccountOverview account={account} />
|
||||
<AccountOverview account={account} tradingEvents={tradingEvents} />
|
||||
</Box>
|
||||
|
||||
{/* 资产走势图表 - 只在有数据时显示 */}
|
||||
|
||||
@@ -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