Compare commits
57 Commits
aacbe5c31c
...
feature_lo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad21398e1c | ||
|
|
0e1cc11330 | ||
|
|
e9b54ce10d | ||
|
|
e5ab99bae6 | ||
|
|
8632e40c94 | ||
|
|
173b13bc70 | ||
|
|
02cd234def | ||
|
|
e3a953559f | ||
|
|
78e4b8f696 | ||
|
|
1cf6169370 | ||
|
|
e3721b22ff | ||
|
|
357b8bbdd7 | ||
|
|
c6a6444d9a | ||
|
|
c42a14aa8f | ||
|
|
cddd0e860e | ||
|
|
fbe3434521 | ||
|
|
bca2ad4f81 | ||
|
|
8f3af4ed07 | ||
|
|
fb76e442f7 | ||
|
|
6506cb222b | ||
|
|
542b20368e | ||
|
|
d456c3cd5f | ||
|
|
b221c2669c | ||
|
|
356f865f09 | ||
| 71df2b605b | |||
| 5892dc3156 | |||
|
|
e05ea154a2 | ||
| 8787d5ddb7 | |||
|
|
c33181a689 | ||
| 29f035b1cf | |||
| 513134f285 | |||
|
|
7da50aca40 | ||
|
|
72aae585d0 | ||
| 24c6c9e1c6 | |||
|
|
58254d3e8f | ||
|
|
760ce4d5e1 | ||
|
|
95c1eaf97b | ||
|
|
657c446594 | ||
|
|
10f519a764 | ||
|
|
f072256021 | ||
|
|
0e3bdc9b8c | ||
|
|
5e4c4e7cea | ||
|
|
31a7500388 | ||
|
|
03c113fe1b | ||
|
|
0f3bc06716 | ||
|
|
e568b5e05f | ||
| c5aaaabf17 | |||
| 9ede603c9f | |||
|
|
629c63f4ee | ||
|
|
d6bc2c7245 | ||
|
|
dc38199ae6 | ||
|
|
d93b5de319 | ||
|
|
199a54bc12 | ||
|
|
39feae87a6 | ||
|
|
a9dc1191bf | ||
|
|
227e1c9d15 | ||
|
|
b5cdceb92b |
@@ -10,7 +10,9 @@
|
||||
"Bash(npm cache clean --force)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm run start:mock)",
|
||||
"Bash(npm install fsevents@latest --save-optional --force)"
|
||||
"Bash(npm install fsevents@latest --save-optional --force)",
|
||||
"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
|
||||
BIN
__pycache__/app.cpython-310.pyc
Normal file
BIN
__pycache__/app.cpython-310.pyc
Normal file
Binary file not shown.
374
app.py
374
app.py
@@ -101,7 +101,7 @@ def get_trading_day_near_date(target_date):
|
||||
load_trading_days()
|
||||
|
||||
engine = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/stock?charset=utf8mb4",
|
||||
"mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4",
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
pool_recycle=3600,
|
||||
@@ -110,7 +110,7 @@ engine = create_engine(
|
||||
max_overflow=20
|
||||
)
|
||||
engine_med = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/med?charset=utf8mb4",
|
||||
"mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/med?charset=utf8mb4",
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
pool_recycle=3600,
|
||||
@@ -119,7 +119,7 @@ engine_med = create_engine(
|
||||
max_overflow=10
|
||||
)
|
||||
engine_2 = create_engine(
|
||||
"mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/valuefrontier?charset=utf8mb4",
|
||||
"mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/valuefrontier?charset=utf8mb4",
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
pool_recycle=3600,
|
||||
@@ -204,7 +204,7 @@ app.config['COMPRESS_MIMETYPES'] = [
|
||||
'application/javascript',
|
||||
'application/x-javascript'
|
||||
]
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@111.198.58.126:33060/stock?charset=utf8mb4'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
|
||||
'pool_size': 10,
|
||||
@@ -1897,7 +1897,7 @@ def send_verification_code():
|
||||
|
||||
@app.route('/api/auth/login-with-code', methods=['POST'])
|
||||
def login_with_verification_code():
|
||||
"""使用验证码登录"""
|
||||
"""使用验证码登录/注册(自动注册)"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
credential = data.get('credential') # 手机号或邮箱
|
||||
@@ -1932,13 +1932,47 @@ def login_with_verification_code():
|
||||
|
||||
# 验证码正确,查找用户
|
||||
user = None
|
||||
is_new_user = False
|
||||
|
||||
if login_type == 'phone':
|
||||
user = User.query.filter_by(phone=credential).first()
|
||||
if not user:
|
||||
# 自动注册新用户
|
||||
is_new_user = True
|
||||
# 生成唯一用户名
|
||||
base_username = f"user_{credential}"
|
||||
username = base_username
|
||||
counter = 1
|
||||
while User.query.filter_by(username=username).first():
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
# 创建新用户
|
||||
user = User(username=username, phone=credential)
|
||||
user.phone_confirmed = True
|
||||
user.email = f"{username}@valuefrontier.temp" # 临时邮箱
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
elif login_type == 'email':
|
||||
user = User.query.filter_by(email=credential).first()
|
||||
if not user:
|
||||
# 自动注册新用户
|
||||
is_new_user = True
|
||||
# 从邮箱生成用户名
|
||||
email_prefix = credential.split('@')[0]
|
||||
base_username = f"user_{email_prefix}"
|
||||
username = base_username
|
||||
counter = 1
|
||||
while User.query.filter_by(username=username).first():
|
||||
username = f"{base_username}_{counter}"
|
||||
counter += 1
|
||||
|
||||
if not user:
|
||||
return jsonify({'success': False, 'error': '用户不存在'}), 404
|
||||
# 创建新用户
|
||||
user = User(username=username, email=credential)
|
||||
user.email_confirmed = True
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# 清除验证码
|
||||
session.pop(session_key, None)
|
||||
@@ -1957,7 +1991,8 @@ def login_with_verification_code():
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '登录成功',
|
||||
'message': '注册成功' if is_new_user else '登录成功',
|
||||
'isNewUser': is_new_user,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
@@ -1969,62 +2004,10 @@ def login_with_verification_code():
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"验证码登录错误: {e}")
|
||||
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}")
|
||||
return jsonify({'success': False, 'error': '注册失败,请重试'}), 500
|
||||
print(f"验证码登录/注册错误: {e}")
|
||||
return jsonify({'success': False, 'error': '登录失败'}), 500
|
||||
|
||||
|
||||
def send_sms_code(phone, code, template_id):
|
||||
@@ -2504,9 +2487,13 @@ def get_wechat_qrcode():
|
||||
# 生成唯一state参数
|
||||
state = uuid.uuid4().hex
|
||||
|
||||
print(f"🆕 [QRCODE] 生成新的微信二维码, state={state[:8]}...")
|
||||
|
||||
# URL编码回调地址
|
||||
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
|
||||
|
||||
print(f"🔗 [QRCODE] 回调地址: {WECHAT_REDIRECT_URI}")
|
||||
|
||||
# 构建微信授权URL
|
||||
wechat_auth_url = (
|
||||
f"https://open.weixin.qq.com/connect/qrconnect?"
|
||||
@@ -2524,6 +2511,8 @@ def get_wechat_qrcode():
|
||||
'wechat_unionid': None
|
||||
}
|
||||
|
||||
print(f"✅ [QRCODE] session 已存储, 当前总数: {len(wechat_qr_sessions)}")
|
||||
|
||||
return jsonify({"code":0,
|
||||
"data":
|
||||
{
|
||||
@@ -2587,6 +2576,8 @@ def check_wechat_scan():
|
||||
del wechat_qr_sessions[session_id]
|
||||
return jsonify({'status': 'expired'}), 200
|
||||
|
||||
print(f"📡 [CHECK] session_id: {session_id[:8]}..., status: {session['status']}, user_info: {session.get('user_info')}")
|
||||
|
||||
return jsonify({
|
||||
'status': session['status'],
|
||||
'user_info': session.get('user_info'),
|
||||
@@ -2628,32 +2619,48 @@ 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 or not code or not state:
|
||||
print(f"❌ [CALLBACK] 参数错误: error={error}, has_code={bool(code)}, has_state={bool(state)}")
|
||||
return redirect('/auth/signin?error=wechat_auth_failed')
|
||||
|
||||
# 验证state
|
||||
if state not in wechat_qr_sessions:
|
||||
print(f"❌ [CALLBACK] state 不在 wechat_qr_sessions 中: {state[:8]}...")
|
||||
print(f" 当前 sessions: {list(wechat_qr_sessions.keys())}")
|
||||
return redirect('/auth/signin?error=session_expired')
|
||||
|
||||
session_data = wechat_qr_sessions[state]
|
||||
|
||||
print(f"✅ [CALLBACK] 找到 session_data, mode={session_data.get('mode')}")
|
||||
|
||||
# 检查过期
|
||||
if time.time() > session_data['expires']:
|
||||
print(f"❌ [CALLBACK] session 已过期")
|
||||
del wechat_qr_sessions[state]
|
||||
return redirect('/auth/signin?error=session_expired')
|
||||
|
||||
try:
|
||||
# 获取access_token
|
||||
print(f"🔑 [CALLBACK] 开始获取 access_token...")
|
||||
token_data = get_wechat_access_token(code)
|
||||
if not token_data:
|
||||
print(f"❌ [CALLBACK] 获取 access_token 失败")
|
||||
return redirect('/auth/signin?error=token_failed')
|
||||
|
||||
print(f"✅ [CALLBACK] 获取 access_token 成功, openid={token_data.get('openid', '')[:8]}...")
|
||||
|
||||
# 获取用户信息
|
||||
print(f"👤 [CALLBACK] 开始获取用户信息...")
|
||||
user_info = get_wechat_userinfo(token_data['access_token'], token_data['openid'])
|
||||
if not user_info:
|
||||
print(f"❌ [CALLBACK] 获取用户信息失败")
|
||||
return redirect('/auth/signin?error=userinfo_failed')
|
||||
|
||||
print(f"✅ [CALLBACK] 获取用户信息成功, nickname={user_info.get('nickname', 'N/A')}")
|
||||
|
||||
# 查找或创建用户 / 或处理绑定
|
||||
openid = token_data['openid']
|
||||
unionid = user_info.get('unionid') or token_data.get('unionid')
|
||||
@@ -2696,13 +2703,16 @@ def wechat_callback():
|
||||
return redirect('/home?bind=failed')
|
||||
|
||||
user = None
|
||||
is_new_user = False
|
||||
|
||||
if unionid:
|
||||
user = User.query.filter_by(wechat_union_id=unionid).first()
|
||||
if not user:
|
||||
user = User.query.filter_by(wechat_open_id=openid).first()
|
||||
|
||||
if not user:
|
||||
# 创建新用户
|
||||
# 创建新用户(自动注册)
|
||||
is_new_user = True
|
||||
# 先清理微信昵称
|
||||
raw_nickname = user_info.get('nickname', '微信用户')
|
||||
# 创建临时用户实例以使用清理方法
|
||||
@@ -2726,30 +2736,45 @@ def wechat_callback():
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# 更新最后登录时间
|
||||
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(仅登录/注册流程清理;绑定流程在上方已处理,不在此处清理)
|
||||
# 更新 wechat_qr_sessions 状态,供前端轮询检测
|
||||
print(f"🔍 [DEBUG] state={state}, state in wechat_qr_sessions: {state in wechat_qr_sessions}")
|
||||
if state in wechat_qr_sessions:
|
||||
# 仅当不是绑定流程,或没有模式信息时清理
|
||||
if not wechat_qr_sessions[state].get('mode'):
|
||||
del wechat_qr_sessions[state]
|
||||
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}")
|
||||
|
||||
# 直接跳转到首页
|
||||
return redirect('/home')
|
||||
# 返回一个简单的成功页面(前端轮询会检测到状态变化)
|
||||
return '''
|
||||
<html>
|
||||
<head><title>授权成功</title></head>
|
||||
<body>
|
||||
<h2>微信授权成功</h2>
|
||||
<p>请返回原页面继续操作...</p>
|
||||
<script>
|
||||
// 尝试关闭窗口(如果是弹窗的话)
|
||||
setTimeout(function() {
|
||||
window.close();
|
||||
}, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''', 200
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 微信登录失败: {e}")
|
||||
print(f"❌ [CALLBACK] 微信登录失败: {e}")
|
||||
import traceback
|
||||
print(f"❌ [CALLBACK] 错误堆栈:\n{traceback.format_exc()}")
|
||||
db.session.rollback()
|
||||
return redirect('/auth/signin?error=login_failed')
|
||||
|
||||
@@ -2764,16 +2789,16 @@ def login_with_wechat():
|
||||
return jsonify({'success': False, 'error': 'session_id不能为空'}), 400
|
||||
|
||||
# 验证session
|
||||
session = wechat_qr_sessions.get(session_id)
|
||||
if not session:
|
||||
wechat_session = wechat_qr_sessions.get(session_id)
|
||||
if not wechat_session:
|
||||
return jsonify({'success': False, 'error': '会话不存在或已过期'}), 400
|
||||
|
||||
# 检查session状态
|
||||
if session['status'] not in ['login_ready', 'register_ready']:
|
||||
if wechat_session['status'] not in ['login_success', 'register_success']:
|
||||
return jsonify({'success': False, 'error': '会话状态无效'}), 400
|
||||
|
||||
# 检查是否有用户信息
|
||||
user_info = session.get('user_info')
|
||||
user_info = wechat_session.get('user_info')
|
||||
if not user_info or not user_info.get('user_id'):
|
||||
return jsonify({'success': False, 'error': '用户信息不完整'}), 400
|
||||
|
||||
@@ -2785,18 +2810,33 @@ def login_with_wechat():
|
||||
# 更新最后登录时间
|
||||
user.update_last_seen()
|
||||
|
||||
# 清除session
|
||||
# 设置 Flask session
|
||||
session.permanent = True
|
||||
session['user_id'] = user.id
|
||||
session['username'] = user.username
|
||||
session['logged_in'] = True
|
||||
session['wechat_login'] = True # 标记是微信登录
|
||||
|
||||
# Flask-Login 登录
|
||||
login_user(user, remember=True)
|
||||
|
||||
# 判断是否为新用户
|
||||
is_new_user = user_info.get('is_new_user', False)
|
||||
|
||||
# 清除 wechat_qr_sessions
|
||||
del wechat_qr_sessions[session_id]
|
||||
|
||||
# 生成登录响应
|
||||
response_data = {
|
||||
'success': True,
|
||||
'message': '登录成功' if session['status'] == 'login_ready' else '注册并登录成功',
|
||||
'message': '注册成功' if is_new_user else '登录成功',
|
||||
'isNewUser': is_new_user,
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'nickname': user.nickname or user.username,
|
||||
'email': user.email,
|
||||
'phone': user.phone,
|
||||
'avatar_url': user.avatar_url,
|
||||
'has_wechat': True,
|
||||
'wechat_open_id': user.wechat_open_id,
|
||||
@@ -2821,61 +2861,6 @@ def login_with_wechat():
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/auth/register/wechat', methods=['POST'])
|
||||
def register_with_wechat():
|
||||
"""微信注册(保留用于特殊情况)"""
|
||||
data = request.get_json()
|
||||
session_id = data.get('session_id')
|
||||
username = data.get('username')
|
||||
password = data.get('password')
|
||||
|
||||
if not all([session_id, username, password]):
|
||||
return jsonify({'error': '所有字段都是必填的'}), 400
|
||||
|
||||
# 验证session
|
||||
session = wechat_qr_sessions.get(session_id)
|
||||
if not session:
|
||||
return jsonify({'error': '微信验证失败或状态无效'}), 400
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
return jsonify({'error': '用户名已存在'}), 400
|
||||
|
||||
# 检查微信OpenID是否已被其他用户使用
|
||||
wechat_openid = session.get('wechat_openid')
|
||||
wechat_unionid = session.get('wechat_unionid')
|
||||
|
||||
if wechat_unionid and User.query.filter_by(wechat_union_id=wechat_unionid).first():
|
||||
return jsonify({'error': '该微信号已被其他用户绑定'}), 400
|
||||
if User.query.filter_by(wechat_open_id=wechat_openid).first():
|
||||
return jsonify({'error': '该微信号已被其他用户绑定'}), 400
|
||||
|
||||
# 创建用户
|
||||
try:
|
||||
wechat_info = session['user_info']
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
# 使用清理后的昵称
|
||||
user.nickname = user._sanitize_nickname(wechat_info.get('nickname', username))
|
||||
user.avatar_url = wechat_info.get('avatar_url')
|
||||
user.wechat_open_id = wechat_openid
|
||||
user.wechat_union_id = wechat_unionid
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
# 清除session
|
||||
del wechat_qr_sessions[session_id]
|
||||
|
||||
return jsonify({
|
||||
'message': '注册成功',
|
||||
'user': user.to_dict()
|
||||
}), 201
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"WeChat register error: {e}")
|
||||
return jsonify({'error': '注册失败,请重试'}), 500
|
||||
|
||||
|
||||
@app.route('/api/account/wechat/unbind', methods=['POST'])
|
||||
def unbind_wechat_account():
|
||||
"""解绑当前登录用户的微信"""
|
||||
@@ -4576,8 +4561,8 @@ def get_stock_quotes():
|
||||
|
||||
def get_clickhouse_client():
|
||||
return Cclient(
|
||||
host='111.198.58.126',
|
||||
port=18778,
|
||||
host='222.128.1.157',
|
||||
port=18000,
|
||||
user='default',
|
||||
password='Zzl33818!',
|
||||
database='stock'
|
||||
@@ -7911,6 +7896,98 @@ def format_date(date_obj):
|
||||
return str(date_obj)
|
||||
|
||||
|
||||
def remove_cycles_from_sankey_flows(flows_data):
|
||||
"""
|
||||
移除Sankey图数据中的循环边,确保数据是DAG(有向无环图)
|
||||
使用拓扑排序算法检测循环,优先保留flow_ratio高的边
|
||||
|
||||
Args:
|
||||
flows_data: list of flow objects with 'source', 'target', 'flow_metrics' keys
|
||||
|
||||
Returns:
|
||||
list of flows without cycles
|
||||
"""
|
||||
if not flows_data:
|
||||
return flows_data
|
||||
|
||||
# 按flow_ratio降序排序,优先保留重要的边
|
||||
sorted_flows = sorted(
|
||||
flows_data,
|
||||
key=lambda x: x.get('flow_metrics', {}).get('flow_ratio', 0) or 0,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
# 构建图的邻接表和入度表
|
||||
def build_graph(flows):
|
||||
graph = {} # node -> list of successors
|
||||
in_degree = {} # node -> in-degree count
|
||||
all_nodes = set()
|
||||
|
||||
for flow in flows:
|
||||
source = flow['source']['node_name']
|
||||
target = flow['target']['node_name']
|
||||
all_nodes.add(source)
|
||||
all_nodes.add(target)
|
||||
|
||||
if source not in graph:
|
||||
graph[source] = []
|
||||
graph[source].append(target)
|
||||
|
||||
if target not in in_degree:
|
||||
in_degree[target] = 0
|
||||
in_degree[target] += 1
|
||||
|
||||
if source not in in_degree:
|
||||
in_degree[source] = 0
|
||||
|
||||
return graph, in_degree, all_nodes
|
||||
|
||||
# 使用Kahn算法检测是否有环
|
||||
def has_cycle(graph, in_degree, all_nodes):
|
||||
# 找到所有入度为0的节点
|
||||
queue = [node for node in all_nodes if in_degree.get(node, 0) == 0]
|
||||
visited_count = 0
|
||||
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
visited_count += 1
|
||||
|
||||
# 访问所有邻居
|
||||
for neighbor in graph.get(node, []):
|
||||
in_degree[neighbor] -= 1
|
||||
if in_degree[neighbor] == 0:
|
||||
queue.append(neighbor)
|
||||
|
||||
# 如果访问的节点数等于总节点数,说明没有环
|
||||
return visited_count < len(all_nodes)
|
||||
|
||||
# 逐个添加边,如果添加后产生环则跳过
|
||||
result_flows = []
|
||||
|
||||
for flow in sorted_flows:
|
||||
# 尝试添加这条边
|
||||
temp_flows = result_flows + [flow]
|
||||
|
||||
# 检查是否产生环
|
||||
graph, in_degree, all_nodes = build_graph(temp_flows)
|
||||
|
||||
# 复制in_degree用于检测(因为检测过程会修改它)
|
||||
in_degree_copy = in_degree.copy()
|
||||
|
||||
if not has_cycle(graph, in_degree_copy, all_nodes):
|
||||
# 没有产生环,可以添加
|
||||
result_flows.append(flow)
|
||||
else:
|
||||
# 产生环,跳过这条边
|
||||
print(f"Skipping edge that creates cycle: {flow['source']['node_name']} -> {flow['target']['node_name']}")
|
||||
|
||||
removed_count = len(flows_data) - len(result_flows)
|
||||
if removed_count > 0:
|
||||
print(f"Removed {removed_count} edges to eliminate cycles in Sankey diagram")
|
||||
|
||||
return result_flows
|
||||
|
||||
|
||||
def get_report_type(date_str):
|
||||
"""获取报告期类型"""
|
||||
if not date_str:
|
||||
@@ -10159,7 +10236,7 @@ def get_daily_top_concepts():
|
||||
limit = request.args.get('limit', 6, type=int)
|
||||
|
||||
# 构建概念中心API的URL
|
||||
concept_api_url = 'http://111.198.58.126:16801/search'
|
||||
concept_api_url = 'http://222.128.1.157:16801/search'
|
||||
|
||||
# 准备请求数据
|
||||
request_data = {
|
||||
@@ -10621,6 +10698,9 @@ def get_value_chain_analysis(company_code):
|
||||
}
|
||||
})
|
||||
|
||||
# 移除循环边,确保Sankey图数据是DAG(有向无环图)
|
||||
flows_data = remove_cycles_from_sankey_flows(flows_data)
|
||||
|
||||
# 统计各层级节点数量
|
||||
level_stats = {}
|
||||
for level_key, nodes in nodes_by_level.items():
|
||||
|
||||
15
package.json
15
package.json
@@ -43,6 +43,7 @@
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.281.0",
|
||||
"react": "18.3.1",
|
||||
"react-apexcharts": "^1.3.9",
|
||||
"react-big-calendar": "^0.33.2",
|
||||
@@ -92,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",
|
||||
@@ -104,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",
|
||||
@@ -118,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",
|
||||
|
||||
11
src/App.js
11
src/App.js
@@ -61,6 +61,10 @@ import NotificationTestTool from "components/NotificationTestTool";
|
||||
import ScrollToTop from "components/ScrollToTop";
|
||||
import { logger } from "utils/logger";
|
||||
|
||||
// PostHog Redux 集成
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { initializePostHog } from "store/slices/posthogSlice";
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
* 需要在 NotificationProvider 内部使用,所以单独提取
|
||||
@@ -108,6 +112,13 @@ function ConnectionStatusBarWrapper() {
|
||||
|
||||
function AppContent() {
|
||||
const { colorMode } = useColorMode();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 🎯 PostHog Redux 初始化
|
||||
useEffect(() => {
|
||||
dispatch(initializePostHog());
|
||||
logger.info('App', 'PostHog Redux 初始化已触发');
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||
|
||||
@@ -37,6 +37,7 @@ import VerificationCodeInput from './VerificationCodeInput';
|
||||
import WechatRegister from './WechatRegister';
|
||||
import { setCurrentUser } from '../../mocks/data/users';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useAuthEvents } from '../../hooks/useAuthEvents';
|
||||
|
||||
// 统一配置对象
|
||||
const AUTH_CONFIG = {
|
||||
@@ -86,6 +87,12 @@ export default function AuthFormContent() {
|
||||
|
||||
// 响应式布局配置
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'AuthFormContent',
|
||||
isMobile: isMobile
|
||||
});
|
||||
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
|
||||
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px,更紧凑
|
||||
|
||||
@@ -107,6 +114,16 @@ export default function AuthFormContent() {
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 追踪用户开始填写手机号 (判断用户选择了手机登录方式)
|
||||
if (name === 'phone' && value.length === 1 && !formData.phone) {
|
||||
authEvents.trackPhoneLoginInitiated(value);
|
||||
}
|
||||
|
||||
// 追踪验证码输入变化
|
||||
if (name === 'verificationCode') {
|
||||
authEvents.trackVerificationCodeInputChanged(value.length);
|
||||
}
|
||||
};
|
||||
|
||||
// 倒计时逻辑
|
||||
@@ -144,6 +161,10 @@ export default function AuthFormContent() {
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(credential)) {
|
||||
// 追踪手机号验证失败
|
||||
authEvents.trackPhoneNumberValidated(credential, false, 'invalid_format');
|
||||
authEvents.trackFormValidationError('phone', 'invalid_format', '请输入有效的手机号');
|
||||
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -152,6 +173,9 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪手机号验证通过
|
||||
authEvents.trackPhoneNumberValidated(credential, true);
|
||||
|
||||
try {
|
||||
setSendingCode(true);
|
||||
|
||||
@@ -187,6 +211,14 @@ export default function AuthFormContent() {
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// 追踪验证码发送成功 (或重发)
|
||||
const isResend = verificationCodeSent;
|
||||
if (isResend) {
|
||||
authEvents.trackVerificationCodeResent(credential, countdown > 0 ? 2 : 1);
|
||||
} else {
|
||||
authEvents.trackVerificationCodeSent(credential, config.api.purpose);
|
||||
}
|
||||
|
||||
// ❌ 移除成功 toast,静默处理
|
||||
logger.info('AuthFormContent', '验证码发送成功', {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7),
|
||||
@@ -204,6 +236,13 @@ export default function AuthFormContent() {
|
||||
throw new Error(data.error || '发送验证码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// 追踪验证码发送失败
|
||||
authEvents.trackVerificationCodeSendFailed(credential, error);
|
||||
authEvents.trackError('api', error.message || '发送验证码失败', {
|
||||
endpoint: '/api/auth/send-verification-code',
|
||||
phone_masked: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
});
|
||||
|
||||
logger.api.error('POST', '/api/auth/send-verification-code', error, {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
});
|
||||
@@ -256,6 +295,9 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪验证码提交
|
||||
authEvents.trackVerificationCodeSubmitted(phone);
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = {
|
||||
credential: phone.trim(), // 添加 trim() 防止空格
|
||||
@@ -310,6 +352,9 @@ export default function AuthFormContent() {
|
||||
// 更新session
|
||||
await checkSession();
|
||||
|
||||
// 追踪登录成功并识别用户
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser);
|
||||
|
||||
// ✅ 保留登录成功 toast(关键操作提示)
|
||||
toast({
|
||||
title: data.isNewUser ? '注册成功' : '登录成功',
|
||||
@@ -329,6 +374,8 @@ export default function AuthFormContent() {
|
||||
setTimeout(() => {
|
||||
setCurrentPhone(phone);
|
||||
setShowNicknamePrompt(true);
|
||||
// 追踪昵称设置引导显示
|
||||
authEvents.trackNicknamePromptShown(phone);
|
||||
}, config.features.successDelay);
|
||||
} else {
|
||||
// 已有用户,直接登录成功
|
||||
@@ -349,6 +396,15 @@ export default function AuthFormContent() {
|
||||
}
|
||||
} catch (error) {
|
||||
const { phone, verificationCode } = formData;
|
||||
|
||||
// 追踪登录失败
|
||||
const errorType = error.message.includes('网络') ? 'network' :
|
||||
error.message.includes('服务器') ? 'api' : 'validation';
|
||||
authEvents.trackLoginFailed('phone', errorType, error.message, {
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||||
has_verification_code: !!verificationCode
|
||||
});
|
||||
|
||||
logger.error('AuthFormContent', 'handleSubmit', error, {
|
||||
phone: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||||
hasVerificationCode: !!verificationCode
|
||||
@@ -376,6 +432,9 @@ export default function AuthFormContent() {
|
||||
|
||||
// 微信H5登录处理
|
||||
const handleWechatH5Login = async () => {
|
||||
// 追踪用户选择微信登录
|
||||
authEvents.trackWechatLoginInitiated('icon_button');
|
||||
|
||||
try {
|
||||
// 1. 构建回调URL
|
||||
const redirectUrl = `${window.location.origin}/home/wechat-callback`;
|
||||
@@ -396,11 +455,19 @@ export default function AuthFormContent() {
|
||||
throw new Error('获取授权链接失败');
|
||||
}
|
||||
|
||||
// 追踪微信H5跳转
|
||||
authEvents.trackWechatH5Redirect();
|
||||
|
||||
// 4. 延迟跳转,让用户看到提示
|
||||
setTimeout(() => {
|
||||
window.location.href = response.auth_url;
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
// 追踪跳转失败
|
||||
authEvents.trackError('api', error.message || '获取微信授权链接失败', {
|
||||
context: 'wechat_h5_redirect'
|
||||
});
|
||||
|
||||
logger.error('AuthFormContent', 'handleWechatH5Login', error);
|
||||
toast({
|
||||
title: "跳转失败",
|
||||
@@ -412,14 +479,17 @@ export default function AuthFormContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
// 组件挂载时追踪页面浏览
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// 追踪登录页面浏览
|
||||
authEvents.trackLoginPageViewed();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
}, [authEvents]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -479,6 +549,7 @@ export default function AuthFormContent() {
|
||||
color="blue.500"
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
onClick={authEvents.trackUserAgreementClicked}
|
||||
>
|
||||
《用户协议》
|
||||
</ChakraLink>
|
||||
@@ -491,6 +562,7 @@ export default function AuthFormContent() {
|
||||
color="blue.500"
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
onClick={authEvents.trackPrivacyPolicyClicked}
|
||||
>
|
||||
《隐私政策》
|
||||
</ChakraLink>
|
||||
@@ -518,8 +590,30 @@ export default function AuthFormContent() {
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">完善个人信息</AlertDialogHeader>
|
||||
<AlertDialogBody>您已成功注册!是否前往个人资料设置昵称和其他信息?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); }}>稍后再说</Button>
|
||||
<Button colorScheme="green" onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); setTimeout(() => { navigate('/home/profile'); }, 300); }} ml={3}>去设置</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptSkipped();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
}}
|
||||
>
|
||||
稍后再说
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="green"
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptAccepted();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
setTimeout(() => {
|
||||
navigate('/home/profile');
|
||||
}, 300);
|
||||
}}
|
||||
ml={3}
|
||||
>
|
||||
去设置
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
|
||||
@@ -15,7 +15,10 @@ import { FaQrcode } from "react-icons/fa";
|
||||
import { FiAlertCircle } from "react-icons/fi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService";
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { useAuthEvents } from "../../hooks/useAuthEvents";
|
||||
|
||||
// 配置常量
|
||||
const POLL_INTERVAL = 2000; // 轮询间隔:2秒
|
||||
@@ -45,6 +48,16 @@ const getStatusText = (status) => {
|
||||
};
|
||||
|
||||
export default function WechatRegister() {
|
||||
// 获取关闭弹窗方法
|
||||
const { closeModal } = useAuthModal();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'WechatRegister',
|
||||
isMobile: false // WechatRegister 只在桌面端显示
|
||||
});
|
||||
|
||||
// 状态管理
|
||||
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
|
||||
const [wechatSessionId, setWechatSessionId] = useState("");
|
||||
@@ -58,6 +71,7 @@ export default function WechatRegister() {
|
||||
const timeoutRef = useRef(null);
|
||||
const isMountedRef = useRef(true); // 追踪组件挂载状态
|
||||
const containerRef = useRef(null); // 容器DOM引用
|
||||
const sessionIdRef = useRef(null); // 存储最新的 sessionId,避免闭包陷阱
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
@@ -90,6 +104,7 @@ export default function WechatRegister() {
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
* 注意:不清理 sessionIdRef,因为 startPolling 时也会调用此函数
|
||||
*/
|
||||
const clearTimers = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
@@ -111,9 +126,20 @@ export default function WechatRegister() {
|
||||
*/
|
||||
const handleLoginSuccess = useCallback(async (sessionId, status) => {
|
||||
try {
|
||||
logger.info('WechatRegister', '开始调用登录接口', { sessionId: sessionId.substring(0, 8) + '...', status });
|
||||
|
||||
const response = await authService.loginWithWechat(sessionId);
|
||||
|
||||
logger.info('WechatRegister', '登录接口返回', { success: response?.success, hasUser: !!response?.user });
|
||||
|
||||
if (response?.success) {
|
||||
// 追踪微信登录成功
|
||||
authEvents.trackLoginSuccess(
|
||||
response.user,
|
||||
'wechat',
|
||||
response.isNewUser || false
|
||||
);
|
||||
|
||||
// Session cookie 会自动管理,不需要手动存储
|
||||
// 如果后端返回了 token,可以选择性存储(兼容旧方式)
|
||||
if (response.token) {
|
||||
@@ -124,32 +150,48 @@ export default function WechatRegister() {
|
||||
}
|
||||
|
||||
showSuccess(
|
||||
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "注册成功",
|
||||
"正在跳转..."
|
||||
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "欢迎回来!"
|
||||
);
|
||||
|
||||
// 延迟跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
navigate("/home");
|
||||
}, 1000);
|
||||
// 刷新 AuthContext 状态
|
||||
await refreshSession();
|
||||
|
||||
// 关闭认证弹窗,留在当前页面
|
||||
closeModal();
|
||||
} else {
|
||||
throw new Error(response?.error || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// 追踪微信登录失败
|
||||
authEvents.trackLoginFailed('wechat', 'api', error.message || '登录失败', {
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
status: status
|
||||
});
|
||||
|
||||
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
|
||||
showError("登录失败", error.message || "请重试");
|
||||
}
|
||||
}, [navigate, showSuccess, showError]);
|
||||
}, [showSuccess, showError, closeModal, refreshSession, authEvents]);
|
||||
|
||||
/**
|
||||
* 检查微信扫码状态
|
||||
* 使用 sessionIdRef.current 避免闭包陷阱
|
||||
*/
|
||||
const checkWechatStatus = useCallback(async () => {
|
||||
// 检查组件是否已卸载
|
||||
if (!isMountedRef.current || !wechatSessionId) return;
|
||||
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId
|
||||
if (!isMountedRef.current || !sessionIdRef.current) {
|
||||
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
|
||||
isMounted: isMountedRef.current,
|
||||
hasSessionId: !!sessionIdRef.current
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
|
||||
|
||||
try {
|
||||
const response = await authService.checkWechatStatus(wechatSessionId);
|
||||
const response = await authService.checkWechatStatus(currentSessionId);
|
||||
|
||||
// 安全检查:确保 response 存在且包含 status
|
||||
if (!response || typeof response.status === 'undefined') {
|
||||
@@ -158,32 +200,44 @@ export default function WechatRegister() {
|
||||
}
|
||||
|
||||
const { status } = response;
|
||||
logger.debug('WechatRegister', '微信状态', { status });
|
||||
|
||||
logger.debug('WechatRegister', '检测到微信状态', {
|
||||
sessionId: wechatSessionId.substring(0, 8) + '...',
|
||||
status,
|
||||
userInfo: response.user_info
|
||||
});
|
||||
|
||||
// 组件卸载后不再更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 追踪状态变化
|
||||
if (wechatStatus !== status) {
|
||||
authEvents.trackWechatStatusChanged(currentSessionId, wechatStatus, status);
|
||||
|
||||
// 特别追踪扫码事件
|
||||
if (status === WECHAT_STATUS.SCANNED) {
|
||||
authEvents.trackWechatQRScanned(currentSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
setWechatStatus(status);
|
||||
|
||||
// 处理成功状态
|
||||
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
|
||||
logger.info('WechatRegister', '检测到登录成功状态,停止轮询', { status });
|
||||
clearTimers(); // 停止轮询
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
|
||||
// 显示"扫码成功,登录中"提示
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "扫码成功",
|
||||
description: "正在登录,请稍候...",
|
||||
status: "info",
|
||||
duration: 2000,
|
||||
isClosable: false,
|
||||
});
|
||||
}
|
||||
|
||||
await handleLoginSuccess(wechatSessionId, status);
|
||||
await handleLoginSuccess(currentSessionId, status);
|
||||
}
|
||||
// 处理过期状态
|
||||
else if (status === WECHAT_STATUS.EXPIRED) {
|
||||
// 追踪二维码过期
|
||||
authEvents.trackWechatQRExpired(currentSessionId, QR_CODE_TIMEOUT / 1000);
|
||||
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "授权已过期",
|
||||
@@ -195,11 +249,12 @@ export default function WechatRegister() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: wechatSessionId });
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
|
||||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||||
// 但如果错误持续发生,停止轮询避免无限重试
|
||||
if (error.message.includes('网络连接失败')) {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "网络连接失败",
|
||||
@@ -211,12 +266,17 @@ export default function WechatRegister() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [wechatSessionId, handleLoginSuccess, clearTimers, toast]);
|
||||
}, [handleLoginSuccess, clearTimers, toast]);
|
||||
|
||||
/**
|
||||
* 启动轮询
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
logger.debug('WechatRegister', '启动轮询', {
|
||||
sessionId: sessionIdRef.current,
|
||||
interval: POLL_INTERVAL
|
||||
});
|
||||
|
||||
// 清理旧的定时器
|
||||
clearTimers();
|
||||
|
||||
@@ -227,7 +287,9 @@ export default function WechatRegister() {
|
||||
|
||||
// 设置超时
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
logger.debug('WechatRegister', '二维码超时');
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
setWechatStatus(WECHAT_STATUS.EXPIRED);
|
||||
}, QR_CODE_TIMEOUT);
|
||||
}, [checkWechatStatus, clearTimers]);
|
||||
@@ -239,6 +301,16 @@ export default function WechatRegister() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 追踪用户选择微信登录(首次或刷新)
|
||||
const isRefresh = Boolean(wechatSessionId);
|
||||
if (isRefresh) {
|
||||
const oldSessionId = wechatSessionId;
|
||||
authEvents.trackWechatLoginInitiated('qr_refresh');
|
||||
// 稍后会在成功时追踪刷新事件
|
||||
} else {
|
||||
authEvents.trackWechatLoginInitiated('qr_area');
|
||||
}
|
||||
|
||||
// 生产环境:调用真实 API
|
||||
const response = await authService.getWechatQRCode();
|
||||
|
||||
@@ -254,13 +326,32 @@ export default function WechatRegister() {
|
||||
throw new Error(response.message || '获取二维码失败');
|
||||
}
|
||||
|
||||
// 追踪二维码显示 (首次或刷新)
|
||||
if (isRefresh) {
|
||||
authEvents.trackWechatQRRefreshed(wechatSessionId, response.data.session_id);
|
||||
} else {
|
||||
authEvents.trackWechatQRDisplayed(response.data.session_id, response.data.auth_url);
|
||||
}
|
||||
|
||||
// 同时更新 ref 和 state,确保轮询能立即读取到最新值
|
||||
sessionIdRef.current = response.data.session_id;
|
||||
setWechatAuthUrl(response.data.auth_url);
|
||||
setWechatSessionId(response.data.session_id);
|
||||
setWechatStatus(WECHAT_STATUS.WAITING);
|
||||
|
||||
logger.debug('WechatRegister', '获取二维码成功', {
|
||||
sessionId: response.data.session_id,
|
||||
authUrl: response.data.auth_url
|
||||
});
|
||||
|
||||
// 启动轮询检查扫码状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
// 追踪获取二维码失败
|
||||
authEvents.trackError('api', error.message || '获取二维码失败', {
|
||||
context: 'get_wechat_qrcode'
|
||||
});
|
||||
|
||||
logger.error('WechatRegister', 'getWechatQRCode', error);
|
||||
if (isMountedRef.current) {
|
||||
showError("获取微信授权失败", error.message || "请稍后重试");
|
||||
@@ -270,7 +361,7 @@ export default function WechatRegister() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [startPolling, showError]);
|
||||
}, [startPolling, showError, wechatSessionId, authEvents]);
|
||||
|
||||
/**
|
||||
* 安全的按钮点击处理,确保所有错误都被捕获,防止被 ErrorBoundary 捕获
|
||||
@@ -293,43 +384,10 @@ export default function WechatRegister() {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
/**
|
||||
* 备用轮询机制 - 防止丢失状态
|
||||
* 每3秒检查一次,仅在获取到二维码URL且状态为waiting时执行
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 只在有auth_url、session_id且状态为waiting时启动备用轮询
|
||||
if (wechatAuthUrl && wechatSessionId && wechatStatus === WECHAT_STATUS.WAITING) {
|
||||
logger.debug('WechatRegister', '备用轮询:启动备用轮询机制');
|
||||
|
||||
backupPollIntervalRef.current = setInterval(() => {
|
||||
try {
|
||||
if (wechatStatus === WECHAT_STATUS.WAITING && isMountedRef.current) {
|
||||
logger.debug('WechatRegister', '备用轮询:检查微信状态');
|
||||
// 添加 .catch() 静默处理异步错误,防止被 ErrorBoundary 捕获
|
||||
checkWechatStatus().catch(error => {
|
||||
logger.warn('WechatRegister', '备用轮询检查失败(静默处理)', { error: error.message });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 捕获所有同步错误,防止被 ErrorBoundary 捕获
|
||||
logger.warn('WechatRegister', '备用轮询执行出错(静默处理)', { error: error.message });
|
||||
}
|
||||
}, BACKUP_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
// 清理备用轮询
|
||||
return () => {
|
||||
if (backupPollIntervalRef.current) {
|
||||
clearInterval(backupPollIntervalRef.current);
|
||||
backupPollIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [wechatAuthUrl, wechatSessionId, wechatStatus, checkWechatStatus]);
|
||||
|
||||
/**
|
||||
* 测量容器尺寸并计算缩放比例
|
||||
*/
|
||||
@@ -397,7 +455,7 @@ export default function WechatRegister() {
|
||||
textAlign="center"
|
||||
mb={3} // 12px底部间距
|
||||
>
|
||||
微信扫码
|
||||
微信登陆
|
||||
</Heading>
|
||||
|
||||
{/* ========== 二维码区域 ========== */}
|
||||
@@ -414,19 +472,26 @@ export default function WechatRegister() {
|
||||
bg="gray.50"
|
||||
boxShadow="sm" // ✅ 添加轻微阴影
|
||||
>
|
||||
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||
{wechatStatus !== WECHAT_STATUS.NONE ? (
|
||||
/* 已获取二维码:显示iframe */
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-20px)', // ✅ 裁剪顶部logo
|
||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||
transformOrigin: 'top left',
|
||||
marginLeft: '-5px'
|
||||
marginLeft: '-5px',
|
||||
pointerEvents: 'auto', // 允许点击 │ │
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
}}
|
||||
// 使用 onWheel 事件阻止滚动 │ │
|
||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Button,
|
||||
VStack,
|
||||
Container
|
||||
} from '@chakra-ui/react';
|
||||
// import {
|
||||
// Box,
|
||||
// Alert,
|
||||
// AlertIcon,
|
||||
// AlertTitle,
|
||||
// AlertDescription,
|
||||
// Button,
|
||||
// VStack,
|
||||
// Container
|
||||
// } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
@@ -40,66 +40,68 @@ class ErrorBoundary extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// 如果有错误,显示错误边界(所有环境)
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Container maxW="lg" py={20}>
|
||||
<VStack spacing={6}>
|
||||
<Alert status="error" borderRadius="lg" p={6}>
|
||||
<AlertIcon boxSize="24px" />
|
||||
<Box>
|
||||
<AlertTitle fontSize="lg" mb={2}>
|
||||
页面出现错误!
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{process.env.NODE_ENV === 'development'
|
||||
? '组件渲染时发生错误,请查看下方详情和控制台日志。'
|
||||
: '页面加载时发生了未预期的错误,请尝试刷新页面。'}
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
// 静默模式:捕获错误并记录日志(已在 componentDidCatch 中完成)
|
||||
// 但继续渲染子组件,不显示错误页面
|
||||
// 注意:如果组件因错误无法渲染,该区域可能显示为空白
|
||||
// // 如果有错误,显示错误边界(所有环境)
|
||||
// if (this.state.hasError) {
|
||||
// return (
|
||||
// <Container maxW="lg" py={20}>
|
||||
// <VStack spacing={6}>
|
||||
// <Alert status="error" borderRadius="lg" p={6}>
|
||||
// <AlertIcon boxSize="24px" />
|
||||
// <Box>
|
||||
// <AlertTitle fontSize="lg" mb={2}>
|
||||
// 页面出现错误!
|
||||
// </AlertTitle>
|
||||
// <AlertDescription>
|
||||
// {process.env.NODE_ENV === 'development'
|
||||
// ? '组件渲染时发生错误,请查看下方详情和控制台日志。'
|
||||
// : '页面加载时发生了未预期的错误,请尝试刷新页面。'}
|
||||
// </AlertDescription>
|
||||
// </Box>
|
||||
// </Alert>
|
||||
|
||||
{/* 开发环境显示详细错误信息 */}
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<Box
|
||||
w="100%"
|
||||
bg="red.50"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
fontSize="sm"
|
||||
overflow="auto"
|
||||
maxH="400px"
|
||||
border="1px"
|
||||
borderColor="red.200"
|
||||
>
|
||||
<Box fontWeight="bold" mb={2} color="red.700">错误详情:</Box>
|
||||
<Box as="pre" whiteSpace="pre-wrap" color="red.900" fontSize="xs">
|
||||
<Box fontWeight="bold" mb={1}>{this.state.error.name}: {this.state.error.message}</Box>
|
||||
{this.state.error.stack && (
|
||||
<Box mt={2} color="gray.700">{this.state.error.stack}</Box>
|
||||
)}
|
||||
{this.state.errorInfo && this.state.errorInfo.componentStack && (
|
||||
<>
|
||||
<Box fontWeight="bold" mt={3} mb={1} color="red.700">组件堆栈:</Box>
|
||||
<Box color="gray.700">{this.state.errorInfo.componentStack}</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新加载页面
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
// {/* 开发环境显示详细错误信息 */}
|
||||
// {process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
// <Box
|
||||
// w="100%"
|
||||
// bg="red.50"
|
||||
// p={4}
|
||||
// borderRadius="lg"
|
||||
// fontSize="sm"
|
||||
// overflow="auto"
|
||||
// maxH="400px"
|
||||
// border="1px"
|
||||
// borderColor="red.200"
|
||||
// >
|
||||
// <Box fontWeight="bold" mb={2} color="red.700">错误详情:</Box>
|
||||
// <Box as="pre" whiteSpace="pre-wrap" color="red.900" fontSize="xs">
|
||||
// <Box fontWeight="bold" mb={1}>{this.state.error.name}: {this.state.error.message}</Box>
|
||||
// {this.state.error.stack && (
|
||||
// <Box mt={2} color="gray.700">{this.state.error.stack}</Box>
|
||||
// )}
|
||||
// {this.state.errorInfo && this.state.errorInfo.componentStack && (
|
||||
// <>
|
||||
// <Box fontWeight="bold" mt={3} mb={1} color="red.700">组件堆栈:</Box>
|
||||
// <Box color="gray.700">{this.state.errorInfo.componentStack}</Box>
|
||||
// </>
|
||||
// )}
|
||||
// </Box>
|
||||
// </Box>
|
||||
// )}
|
||||
|
||||
// <Button
|
||||
// colorScheme="blue"
|
||||
// size="lg"
|
||||
// onClick={() => window.location.reload()}
|
||||
// >
|
||||
// 重新加载页面
|
||||
// </Button>
|
||||
// </VStack>
|
||||
// </Container>
|
||||
// );
|
||||
// }
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }}
|
||||
|
||||
83
src/components/PostHogProvider.js
Normal file
83
src/components/PostHogProvider.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/components/PostHogProvider.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { initPostHog } from '../lib/posthog';
|
||||
import { usePageTracking } from '../hooks/usePageTracking';
|
||||
|
||||
/**
|
||||
* PostHog Provider Component
|
||||
* Initializes PostHog SDK and provides automatic page view tracking
|
||||
*
|
||||
* Usage:
|
||||
* <PostHogProvider>
|
||||
* <App />
|
||||
* </PostHogProvider>
|
||||
*/
|
||||
export const PostHogProvider = ({ children }) => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Initialize PostHog once when component mounts
|
||||
useEffect(() => {
|
||||
// Only run in browser
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Initialize PostHog
|
||||
initPostHog();
|
||||
setIsInitialized(true);
|
||||
|
||||
// Log initialization
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHogProvider initialized');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Automatically track page views
|
||||
usePageTracking({
|
||||
enabled: isInitialized,
|
||||
getProperties: (location) => {
|
||||
// Add custom properties based on route
|
||||
const properties = {};
|
||||
|
||||
// Identify page type based on path
|
||||
if (location.pathname === '/home' || location.pathname === '/home/') {
|
||||
properties.page_type = 'landing';
|
||||
} else if (location.pathname.startsWith('/home/center')) {
|
||||
properties.page_type = 'dashboard';
|
||||
} else if (location.pathname.startsWith('/auth/')) {
|
||||
properties.page_type = 'auth';
|
||||
} else if (location.pathname.startsWith('/community')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'community';
|
||||
} else if (location.pathname.startsWith('/concepts')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'concepts';
|
||||
} else if (location.pathname.startsWith('/stocks')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'stocks';
|
||||
} else if (location.pathname.startsWith('/limit-analyse')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'limit_analyse';
|
||||
} else if (location.pathname.startsWith('/trading-simulation')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'trading_simulation';
|
||||
} else if (location.pathname.startsWith('/company')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'company';
|
||||
} else if (location.pathname.startsWith('/event-detail')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'event';
|
||||
}
|
||||
|
||||
return properties;
|
||||
},
|
||||
});
|
||||
|
||||
// Don't render children until PostHog is initialized
|
||||
// This prevents tracking events before SDK is ready
|
||||
if (!isInitialized) {
|
||||
return children; // Or return a loading spinner
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default PostHogProvider;
|
||||
@@ -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: '订阅已激活,正在跳转...',
|
||||
|
||||
@@ -212,59 +212,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 注册方法
|
||||
const register = async (username, email, password) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('email', email);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await fetch(`/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '注册失败');
|
||||
}
|
||||
|
||||
// 注册成功后自动登录
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿!",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// ⚡ 注册成功后显示欢迎引导(延迟2秒)
|
||||
setTimeout(() => {
|
||||
showWelcomeGuide();
|
||||
}, 2000);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'register', error);
|
||||
|
||||
// ❌ 移除错误 toast,静默失败
|
||||
return { success: false, error: error.message };
|
||||
} finally{
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 手机号注册
|
||||
const registerWithPhone = async (phone, code, username, password) => {
|
||||
@@ -475,7 +422,6 @@ export const AuthProvider = ({ children }) => {
|
||||
isLoading,
|
||||
updateUser,
|
||||
login,
|
||||
register,
|
||||
registerWithPhone,
|
||||
registerWithEmail,
|
||||
sendSmsCode,
|
||||
|
||||
@@ -572,14 +572,10 @@ export const NotificationProvider = ({ children }) => {
|
||||
// 连接到 Socket 服务
|
||||
useEffect(() => {
|
||||
logger.info('NotificationContext', 'Initializing socket connection...');
|
||||
console.log(`%c[NotificationContext] Initializing socket (type: ${SOCKET_TYPE})`, 'color: #673AB7; font-weight: bold;');
|
||||
|
||||
// 连接 socket
|
||||
socket.connect();
|
||||
|
||||
// 获取并保存最大重连次数
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
// ✅ 第一步: 注册所有事件监听器
|
||||
console.log('%c[NotificationContext] Step 1: Registering event listeners...', 'color: #673AB7;');
|
||||
|
||||
// 监听连接状态
|
||||
socket.on('connect', () => {
|
||||
@@ -587,6 +583,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
setIsConnected(true);
|
||||
setReconnectAttempt(0);
|
||||
logger.info('NotificationContext', 'Socket connected', { wasDisconnected });
|
||||
console.log('%c[NotificationContext] ✅ Received connect event, updating state to connected', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// 如果之前断开过,显示 RECONNECTED 状态2秒后自动消失
|
||||
if (wasDisconnected) {
|
||||
@@ -683,6 +680,18 @@ export const NotificationProvider = ({ children }) => {
|
||||
addNotification(data);
|
||||
});
|
||||
|
||||
console.log('%c[NotificationContext] ✅ All event listeners registered', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// ✅ 第二步: 获取最大重连次数
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
|
||||
// ✅ 第三步: 调用 socket.connect()
|
||||
console.log('%c[NotificationContext] Step 2: Calling socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
socket.connect();
|
||||
console.log('%c[NotificationContext] socket.connect() completed', 'color: #673AB7;');
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
logger.info('NotificationContext', 'Cleaning up socket connection');
|
||||
@@ -700,7 +709,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
socket.off('system_notification');
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [adaptEventToNotification, connectionStatus, toast]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, []); // ✅ 空依赖数组,确保只执行一次,避免 React 严格模式重复执行
|
||||
|
||||
// ==================== 智能自动重试 ====================
|
||||
|
||||
|
||||
463
src/hooks/useAuthEvents.js
Normal file
463
src/hooks/useAuthEvents.js
Normal file
@@ -0,0 +1,463 @@
|
||||
// src/hooks/useAuthEvents.js
|
||||
// 认证事件追踪 Hook
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { usePostHogTrack, usePostHogUser } from './usePostHogRedux';
|
||||
import { ACTIVATION_EVENTS } from '../lib/constants';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 认证事件追踪 Hook
|
||||
* 提供登录/注册流程中所有关键节点的事件追踪功能
|
||||
*
|
||||
* 用法示例:
|
||||
*
|
||||
* ```jsx
|
||||
* import { useAuthEvents } from 'hooks/useAuthEvents';
|
||||
*
|
||||
* function AuthComponent() {
|
||||
* const {
|
||||
* trackLoginPageViewed,
|
||||
* trackPhoneLoginInitiated,
|
||||
* trackVerificationCodeSent,
|
||||
* trackLoginSuccess
|
||||
* } = useAuthEvents();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* trackLoginPageViewed();
|
||||
* }, [trackLoginPageViewed]);
|
||||
*
|
||||
* const handlePhoneFocus = () => {
|
||||
* trackPhoneLoginInitiated(formData.phone);
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.component - 组件名称 ('AuthFormContent' | 'WechatRegister')
|
||||
* @param {boolean} options.isMobile - 是否为移动设备
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
const { identify } = usePostHogUser();
|
||||
|
||||
// 通用事件属性
|
||||
const getBaseProperties = useCallback(() => ({
|
||||
component,
|
||||
device: isMobile ? 'mobile' : 'desktop',
|
||||
timestamp: new Date().toISOString(),
|
||||
}), [component, isMobile]);
|
||||
|
||||
// ==================== 页面浏览事件 ====================
|
||||
|
||||
/**
|
||||
* 追踪登录页面浏览
|
||||
*/
|
||||
const trackLoginPageViewed = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '📄 Login Page Viewed', { component });
|
||||
}, [track, getBaseProperties, component]);
|
||||
|
||||
// ==================== 登录方式选择 ====================
|
||||
|
||||
/**
|
||||
* 追踪用户开始手机号登录
|
||||
* @param {string} phone - 手机号(可选,用于判断是否已填写)
|
||||
*/
|
||||
const trackPhoneLoginInitiated = useCallback((phone = '') => {
|
||||
track(ACTIVATION_EVENTS.PHONE_LOGIN_INITIATED, {
|
||||
...getBaseProperties(),
|
||||
has_phone: Boolean(phone),
|
||||
});
|
||||
logger.debug('useAuthEvents', '📱 Phone Login Initiated', { hasPhone: Boolean(phone) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户选择微信登录
|
||||
* @param {string} source - 触发来源 ('qr_area' | 'icon_button' | 'h5_redirect')
|
||||
*/
|
||||
const trackWechatLoginInitiated = useCallback((source = 'qr_area') => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_LOGIN_INITIATED, {
|
||||
...getBaseProperties(),
|
||||
source,
|
||||
});
|
||||
logger.debug('useAuthEvents', '💬 WeChat Login Initiated', { source });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 手机验证码流程 ====================
|
||||
|
||||
/**
|
||||
* 追踪验证码发送成功
|
||||
* @param {string} phone - 手机号
|
||||
* @param {string} purpose - 发送目的 ('login' | 'register')
|
||||
*/
|
||||
const trackVerificationCodeSent = useCallback((phone, purpose = 'login') => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_SENT, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
purpose,
|
||||
});
|
||||
logger.debug('useAuthEvents', '✉️ Verification Code Sent', { phone: phone?.substring(0, 3) + '****', purpose });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪验证码发送失败
|
||||
* @param {string} phone - 手机号
|
||||
* @param {Error|string} error - 错误对象或错误消息
|
||||
*/
|
||||
const trackVerificationCodeSendFailed = useCallback((phone, error) => {
|
||||
const errorMessage = typeof error === 'string' ? error : error?.message || 'Unknown error';
|
||||
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_SEND_FAILED, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
error_message: errorMessage,
|
||||
error_type: 'send_code_failed',
|
||||
});
|
||||
logger.debug('useAuthEvents', '❌ Verification Code Send Failed', { error: errorMessage });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户输入验证码
|
||||
* @param {number} codeLength - 当前输入的验证码长度
|
||||
*/
|
||||
const trackVerificationCodeInputChanged = useCallback((codeLength) => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_INPUT_CHANGED, {
|
||||
...getBaseProperties(),
|
||||
code_length: codeLength,
|
||||
is_complete: codeLength >= 6,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⌨️ Verification Code Input Changed', { codeLength });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪重新发送验证码
|
||||
* @param {string} phone - 手机号
|
||||
* @param {number} attemptCount - 第几次重发(可选)
|
||||
*/
|
||||
const trackVerificationCodeResent = useCallback((phone, attemptCount = 1) => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_RESENT, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
attempt_count: attemptCount,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 Verification Code Resent', { attempt: attemptCount });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪手机号验证结果
|
||||
* @param {string} phone - 手机号
|
||||
* @param {boolean} isValid - 是否有效
|
||||
* @param {string} errorType - 错误类型(可选)
|
||||
*/
|
||||
const trackPhoneNumberValidated = useCallback((phone, isValid, errorType = '') => {
|
||||
track(ACTIVATION_EVENTS.PHONE_NUMBER_VALIDATED, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
is_valid: isValid,
|
||||
error_type: errorType,
|
||||
});
|
||||
logger.debug('useAuthEvents', '✓ Phone Number Validated', { isValid, errorType });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪验证码提交
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
const trackVerificationCodeSubmitted = useCallback((phone) => {
|
||||
track(ACTIVATION_EVENTS.VERIFICATION_CODE_SUBMITTED, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
});
|
||||
logger.debug('useAuthEvents', '📤 Verification Code Submitted');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 微信登录流程 ====================
|
||||
|
||||
/**
|
||||
* 追踪微信二维码显示
|
||||
* @param {string} sessionId - 会话ID
|
||||
* @param {string} authUrl - 授权URL
|
||||
*/
|
||||
const trackWechatQRDisplayed = useCallback((sessionId, authUrl = '') => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_DISPLAYED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
has_auth_url: Boolean(authUrl),
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔲 WeChat QR Code Displayed', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪微信二维码被扫描
|
||||
* @param {string} sessionId - 会话ID
|
||||
*/
|
||||
const trackWechatQRScanned = useCallback((sessionId) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_SCANNED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '📱 WeChat QR Code Scanned', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪微信二维码过期
|
||||
* @param {string} sessionId - 会话ID
|
||||
* @param {number} timeElapsed - 经过时间(秒)
|
||||
*/
|
||||
const trackWechatQRExpired = useCallback((sessionId, timeElapsed = 0) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_EXPIRED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
time_elapsed: timeElapsed,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⏰ WeChat QR Code Expired', { sessionId: sessionId?.substring(0, 8), timeElapsed });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪刷新微信二维码
|
||||
* @param {string} oldSessionId - 旧会话ID
|
||||
* @param {string} newSessionId - 新会话ID
|
||||
*/
|
||||
const trackWechatQRRefreshed = useCallback((oldSessionId, newSessionId) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_QR_REFRESHED, {
|
||||
...getBaseProperties(),
|
||||
old_session_id: oldSessionId?.substring(0, 8) + '...',
|
||||
new_session_id: newSessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat QR Code Refreshed');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪微信登录状态变化
|
||||
* @param {string} sessionId - 会话ID
|
||||
* @param {string} oldStatus - 旧状态
|
||||
* @param {string} newStatus - 新状态
|
||||
*/
|
||||
const trackWechatStatusChanged = useCallback((sessionId, oldStatus, newStatus) => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_STATUS_CHANGED, {
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
old_status: oldStatus,
|
||||
new_status: newStatus,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat Status Changed', { oldStatus, newStatus });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪移动端跳转微信H5授权
|
||||
*/
|
||||
const trackWechatH5Redirect = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_H5_REDIRECT, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '🔗 WeChat H5 Redirect');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 登录/注册结果 ====================
|
||||
|
||||
/**
|
||||
* 追踪登录成功并识别用户
|
||||
* @param {Object} user - 用户对象
|
||||
* @param {string} loginMethod - 登录方式 ('wechat' | 'phone')
|
||||
* @param {boolean} isNewUser - 是否为新注册用户
|
||||
*/
|
||||
const trackLoginSuccess = useCallback((user, loginMethod, isNewUser = false) => {
|
||||
// 追踪登录成功事件
|
||||
const eventName = isNewUser ? ACTIVATION_EVENTS.USER_SIGNED_UP : ACTIVATION_EVENTS.USER_LOGGED_IN;
|
||||
|
||||
track(eventName, {
|
||||
...getBaseProperties(),
|
||||
user_id: user.id,
|
||||
login_method: loginMethod,
|
||||
is_new_user: isNewUser,
|
||||
has_nickname: Boolean(user.nickname),
|
||||
has_email: Boolean(user.email),
|
||||
has_wechat: Boolean(user.wechat_open_id),
|
||||
});
|
||||
|
||||
// 识别用户(关联 PostHog 用户)
|
||||
identify(user.id.toString(), {
|
||||
phone: user.phone,
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
login_method: loginMethod,
|
||||
is_new_user: isNewUser,
|
||||
registration_date: user.created_at,
|
||||
last_login: new Date().toISOString(),
|
||||
has_wechat: Boolean(user.wechat_open_id),
|
||||
wechat_open_id: user.wechat_open_id,
|
||||
wechat_union_id: user.wechat_union_id,
|
||||
});
|
||||
|
||||
logger.debug('useAuthEvents', `✅ ${isNewUser ? 'User Signed Up' : 'User Logged In'}`, {
|
||||
userId: user.id,
|
||||
method: loginMethod,
|
||||
isNewUser,
|
||||
});
|
||||
}, [track, identify, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪登录失败
|
||||
* @param {string} loginMethod - 登录方式 ('wechat' | 'phone')
|
||||
* @param {string} errorType - 错误类型
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @param {Object} context - 额外上下文信息
|
||||
*/
|
||||
const trackLoginFailed = useCallback((loginMethod, errorType, errorMessage, context = {}) => {
|
||||
track(ACTIVATION_EVENTS.LOGIN_FAILED, {
|
||||
...getBaseProperties(),
|
||||
login_method: loginMethod,
|
||||
error_type: errorType,
|
||||
error_message: errorMessage,
|
||||
...context,
|
||||
});
|
||||
logger.debug('useAuthEvents', '❌ Login Failed', { method: loginMethod, errorType, errorMessage });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 用户行为细节 ====================
|
||||
|
||||
/**
|
||||
* 追踪表单字段聚焦
|
||||
* @param {string} fieldName - 字段名称 ('phone' | 'verificationCode')
|
||||
*/
|
||||
const trackFormFocused = useCallback((fieldName) => {
|
||||
track(ACTIVATION_EVENTS.AUTH_FORM_FOCUSED, {
|
||||
...getBaseProperties(),
|
||||
field_name: fieldName,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🎯 Form Field Focused', { fieldName });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪表单验证错误
|
||||
* @param {string} fieldName - 字段名称
|
||||
* @param {string} errorType - 错误类型
|
||||
* @param {string} errorMessage - 错误消息
|
||||
*/
|
||||
const trackFormValidationError = useCallback((fieldName, errorType, errorMessage) => {
|
||||
track(ACTIVATION_EVENTS.AUTH_FORM_VALIDATION_ERROR, {
|
||||
...getBaseProperties(),
|
||||
field_name: fieldName,
|
||||
error_type: errorType,
|
||||
error_message: errorMessage,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⚠️ Form Validation Error', { fieldName, errorType });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪昵称设置引导弹窗显示
|
||||
* @param {string} phone - 手机号
|
||||
*/
|
||||
const trackNicknamePromptShown = useCallback((phone) => {
|
||||
track(ACTIVATION_EVENTS.NICKNAME_PROMPT_SHOWN, {
|
||||
...getBaseProperties(),
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : '',
|
||||
});
|
||||
logger.debug('useAuthEvents', '💬 Nickname Prompt Shown');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户接受设置昵称
|
||||
*/
|
||||
const trackNicknamePromptAccepted = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.NICKNAME_PROMPT_ACCEPTED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '✅ Nickname Prompt Accepted');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户跳过设置昵称
|
||||
*/
|
||||
const trackNicknamePromptSkipped = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.NICKNAME_PROMPT_SKIPPED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '⏭️ Nickname Prompt Skipped');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户点击用户协议链接
|
||||
*/
|
||||
const trackUserAgreementClicked = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.USER_AGREEMENT_LINK_CLICKED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '📄 User Agreement Link Clicked');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
* 追踪用户点击隐私政策链接
|
||||
*/
|
||||
const trackPrivacyPolicyClicked = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.PRIVACY_POLICY_LINK_CLICKED, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '📄 Privacy Policy Link Clicked');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 错误追踪 ====================
|
||||
|
||||
/**
|
||||
* 追踪通用错误
|
||||
* @param {string} errorType - 错误类型 ('network' | 'api' | 'validation' | 'session')
|
||||
* @param {string} errorMessage - 错误消息
|
||||
* @param {Object} context - 错误上下文
|
||||
*/
|
||||
const trackError = useCallback((errorType, errorMessage, context = {}) => {
|
||||
const eventMap = {
|
||||
network: ACTIVATION_EVENTS.NETWORK_ERROR_OCCURRED,
|
||||
api: ACTIVATION_EVENTS.API_ERROR_OCCURRED,
|
||||
session: ACTIVATION_EVENTS.SESSION_EXPIRED,
|
||||
default: ACTIVATION_EVENTS.LOGIN_ERROR_OCCURRED,
|
||||
};
|
||||
|
||||
const eventName = eventMap[errorType] || eventMap.default;
|
||||
|
||||
track(eventName, {
|
||||
...getBaseProperties(),
|
||||
error_type: errorType,
|
||||
error_message: errorMessage,
|
||||
...context,
|
||||
});
|
||||
logger.error('useAuthEvents', `❌ ${errorType} Error`, { errorMessage, context });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 返回接口 ====================
|
||||
|
||||
return {
|
||||
// 页面浏览
|
||||
trackLoginPageViewed,
|
||||
|
||||
// 登录方式选择
|
||||
trackPhoneLoginInitiated,
|
||||
trackWechatLoginInitiated,
|
||||
|
||||
// 手机验证码流程
|
||||
trackVerificationCodeSent,
|
||||
trackVerificationCodeSendFailed,
|
||||
trackVerificationCodeInputChanged,
|
||||
trackVerificationCodeResent,
|
||||
trackPhoneNumberValidated,
|
||||
trackVerificationCodeSubmitted,
|
||||
|
||||
// 微信登录流程
|
||||
trackWechatQRDisplayed,
|
||||
trackWechatQRScanned,
|
||||
trackWechatQRExpired,
|
||||
trackWechatQRRefreshed,
|
||||
trackWechatStatusChanged,
|
||||
trackWechatH5Redirect,
|
||||
|
||||
// 登录/注册结果
|
||||
trackLoginSuccess,
|
||||
trackLoginFailed,
|
||||
|
||||
// 用户行为
|
||||
trackFormFocused,
|
||||
trackFormValidationError,
|
||||
trackNicknamePromptShown,
|
||||
trackNicknamePromptAccepted,
|
||||
trackNicknamePromptSkipped,
|
||||
trackUserAgreementClicked,
|
||||
trackPrivacyPolicyClicked,
|
||||
|
||||
// 错误追踪
|
||||
trackError,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAuthEvents;
|
||||
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;
|
||||
@@ -22,7 +22,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { socketService } from '../services/socketService';
|
||||
import socket from '../services/socket';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const useEventNotifications = (options = {}) => {
|
||||
@@ -80,26 +80,31 @@ export const useEventNotifications = (options = {}) => {
|
||||
};
|
||||
|
||||
// 监听连接事件(必须在connect之前设置,否则可能错过事件)
|
||||
socketService.on('connect', handleConnect);
|
||||
socketService.on('disconnect', handleDisconnect);
|
||||
socketService.on('connect_error', handleConnectError);
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('disconnect', handleDisconnect);
|
||||
socket.on('connect_error', handleConnectError);
|
||||
|
||||
// 连接 WebSocket
|
||||
console.log('[useEventNotifications DEBUG] 准备连接 WebSocket...');
|
||||
logger.info('useEventNotifications', 'Initializing WebSocket connection');
|
||||
|
||||
// 先检查是否已经连接
|
||||
const alreadyConnected = socketService.isConnected();
|
||||
const alreadyConnected = socket.connected || false;
|
||||
console.log('[useEventNotifications DEBUG] 当前连接状态:', alreadyConnected);
|
||||
logger.info('useEventNotifications', 'Pre-connection check', { isConnected: alreadyConnected });
|
||||
|
||||
if (alreadyConnected) {
|
||||
// 如果已经连接,直接更新状态
|
||||
console.log('[useEventNotifications DEBUG] Socket已连接,直接更新状态');
|
||||
logger.info('useEventNotifications', 'Socket already connected, updating state immediately');
|
||||
setIsConnected(true);
|
||||
// 验证状态更新
|
||||
setTimeout(() => {
|
||||
console.log('[useEventNotifications DEBUG] 1秒后验证状态更新 - isConnected应该为true');
|
||||
}, 1000);
|
||||
} else {
|
||||
// 否则建立新连接
|
||||
socketService.connect();
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
// 新事件处理函数 - 使用 ref 中的回调
|
||||
@@ -131,21 +136,28 @@ export const useEventNotifications = (options = {}) => {
|
||||
console.log('[useEventNotifications DEBUG] importance:', importance);
|
||||
console.log('[useEventNotifications DEBUG] enabled:', enabled);
|
||||
|
||||
socketService.subscribeToEvents({
|
||||
eventType,
|
||||
importance,
|
||||
onNewEvent: handleNewEvent,
|
||||
onSubscribed: (data) => {
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 订阅成功回调 ==========');
|
||||
console.log('[useEventNotifications DEBUG] 订阅数据:', data);
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅成功处理完成 ==========\n');
|
||||
},
|
||||
});
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅请求已发送 ==========\n');
|
||||
// 检查 socket 是否有 subscribeToEvents 方法(mockSocketService 和 socketService 都有)
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType,
|
||||
importance,
|
||||
onNewEvent: handleNewEvent,
|
||||
onSubscribed: (data) => {
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 订阅成功回调 ==========');
|
||||
console.log('[useEventNotifications DEBUG] 订阅数据:', data);
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅成功处理完成 ==========\n');
|
||||
},
|
||||
});
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅请求已发送 ==========\n');
|
||||
} else {
|
||||
console.warn('[useEventNotifications] socket.subscribeToEvents 方法不存在');
|
||||
}
|
||||
|
||||
// 保存取消订阅函数
|
||||
unsubscribeRef.current = () => {
|
||||
socketService.unsubscribeFromEvents({ eventType });
|
||||
if (socket.unsubscribeFromEvents) {
|
||||
socket.unsubscribeFromEvents({ eventType });
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
@@ -160,18 +172,25 @@ export const useEventNotifications = (options = {}) => {
|
||||
|
||||
// 移除监听器
|
||||
console.log('[useEventNotifications DEBUG] 移除事件监听器...');
|
||||
socketService.off('connect', handleConnect);
|
||||
socketService.off('disconnect', handleDisconnect);
|
||||
socketService.off('connect_error', handleConnectError);
|
||||
|
||||
// 断开连接
|
||||
console.log('[useEventNotifications DEBUG] 断开 WebSocket 连接...');
|
||||
socketService.disconnect();
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('disconnect', handleDisconnect);
|
||||
socket.off('connect_error', handleConnectError);
|
||||
|
||||
// 注意:不断开连接,因为 socket 是全局共享的
|
||||
// 由 NotificationContext 统一管理连接生命周期
|
||||
console.log('[useEventNotifications DEBUG] ========== 清理完成 ==========\n');
|
||||
};
|
||||
}, [eventType, importance, enabled]); // 移除 onNewEvent 依赖
|
||||
|
||||
// 监控 isConnected 状态变化(调试用)
|
||||
useEffect(() => {
|
||||
console.log('[useEventNotifications DEBUG] ========== isConnected 状态变化 ==========');
|
||||
console.log('[useEventNotifications DEBUG] isConnected:', isConnected);
|
||||
console.log('[useEventNotifications DEBUG] ===========================================');
|
||||
}, [isConnected]);
|
||||
|
||||
console.log('[useEventNotifications DEBUG] Hook返回值 - isConnected:', isConnected);
|
||||
|
||||
return {
|
||||
newEvent, // 最新收到的事件
|
||||
isConnected, // WebSocket 连接状态
|
||||
|
||||
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;
|
||||
55
src/hooks/usePageTracking.js
Normal file
55
src/hooks/usePageTracking.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// src/hooks/usePageTracking.js
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
/**
|
||||
* Custom hook for automatic page view tracking with PostHog
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} options.enabled - Whether tracking is enabled
|
||||
* @param {Function} options.getProperties - Function to get custom properties for each page view
|
||||
*/
|
||||
export const usePageTracking = ({ enabled = true, getProperties } = {}) => {
|
||||
const location = useLocation();
|
||||
const previousPathRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
// Get the current path
|
||||
const currentPath = location.pathname + location.search;
|
||||
|
||||
// Skip if it's the same page (prevents duplicate tracking)
|
||||
if (previousPathRef.current === currentPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the previous path
|
||||
previousPathRef.current = currentPath;
|
||||
|
||||
// Get custom properties if function provided
|
||||
const customProperties = getProperties ? getProperties(location) : {};
|
||||
|
||||
// Track page view with PostHog
|
||||
if (posthog && posthog.__loaded) {
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: window.location.href,
|
||||
path: location.pathname,
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
...customProperties,
|
||||
});
|
||||
|
||||
// Log in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📊 PostHog $pageview:', {
|
||||
path: location.pathname,
|
||||
...customProperties,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [location, enabled, getProperties]);
|
||||
};
|
||||
|
||||
export default usePageTracking;
|
||||
101
src/hooks/usePostHog.js
Normal file
101
src/hooks/usePostHog.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/hooks/usePostHog.js
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
getPostHog,
|
||||
trackEvent,
|
||||
trackPageView,
|
||||
identifyUser,
|
||||
setUserProperties,
|
||||
resetUser,
|
||||
optIn,
|
||||
optOut,
|
||||
hasOptedOut,
|
||||
getFeatureFlag,
|
||||
isFeatureEnabled,
|
||||
} from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* Custom hook to access PostHog functionality
|
||||
* Provides convenient methods for tracking events and managing user sessions
|
||||
*
|
||||
* @returns {object} PostHog methods
|
||||
*/
|
||||
export const usePostHog = () => {
|
||||
// Get PostHog instance
|
||||
const posthog = getPostHog();
|
||||
|
||||
// Track custom event
|
||||
const track = useCallback((eventName, properties = {}) => {
|
||||
trackEvent(eventName, properties);
|
||||
}, []);
|
||||
|
||||
// Track page view
|
||||
const trackPage = useCallback((pagePath, properties = {}) => {
|
||||
trackPageView(pagePath, properties);
|
||||
}, []);
|
||||
|
||||
// Identify user
|
||||
const identify = useCallback((userId, userProperties = {}) => {
|
||||
identifyUser(userId, userProperties);
|
||||
}, []);
|
||||
|
||||
// Set user properties
|
||||
const setProperties = useCallback((properties) => {
|
||||
setUserProperties(properties);
|
||||
}, []);
|
||||
|
||||
// Reset user session (logout)
|
||||
const reset = useCallback(() => {
|
||||
resetUser();
|
||||
}, []);
|
||||
|
||||
// Opt out of tracking
|
||||
const optOutTracking = useCallback(() => {
|
||||
optOut();
|
||||
}, []);
|
||||
|
||||
// Opt in to tracking
|
||||
const optInTracking = useCallback(() => {
|
||||
optIn();
|
||||
}, []);
|
||||
|
||||
// Check if user has opted out
|
||||
const isOptedOut = useCallback(() => {
|
||||
return hasOptedOut();
|
||||
}, []);
|
||||
|
||||
// Get feature flag value
|
||||
const getFlag = useCallback((flagKey, defaultValue = false) => {
|
||||
return getFeatureFlag(flagKey, defaultValue);
|
||||
}, []);
|
||||
|
||||
// Check if feature is enabled
|
||||
const isEnabled = useCallback((flagKey) => {
|
||||
return isFeatureEnabled(flagKey);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Core PostHog instance
|
||||
posthog,
|
||||
|
||||
// Tracking methods
|
||||
track,
|
||||
trackPage,
|
||||
|
||||
// User management
|
||||
identify,
|
||||
setProperties,
|
||||
reset,
|
||||
|
||||
// Privacy controls
|
||||
optOut: optOutTracking,
|
||||
optIn: optInTracking,
|
||||
isOptedOut,
|
||||
|
||||
// Feature flags
|
||||
getFlag,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePostHog;
|
||||
272
src/hooks/usePostHogRedux.js
Normal file
272
src/hooks/usePostHogRedux.js
Normal file
@@ -0,0 +1,272 @@
|
||||
// src/hooks/usePostHogRedux.js
|
||||
import { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
trackEvent,
|
||||
identifyUser,
|
||||
resetUser,
|
||||
optIn,
|
||||
optOut,
|
||||
selectPostHog,
|
||||
selectIsInitialized,
|
||||
selectUser,
|
||||
selectFeatureFlags,
|
||||
selectFeatureFlag,
|
||||
selectIsOptedOut,
|
||||
selectStats,
|
||||
flushCachedEvents,
|
||||
} from '../store/slices/posthogSlice';
|
||||
import { trackPageView } from '../lib/posthog';
|
||||
|
||||
/**
|
||||
* PostHog Redux Hook
|
||||
* 提供便捷的 PostHog 功能访问接口
|
||||
*
|
||||
* 用法示例:
|
||||
*
|
||||
* ```jsx
|
||||
* import { usePostHogRedux } from 'hooks/usePostHogRedux';
|
||||
* import { RETENTION_EVENTS } from 'lib/constants';
|
||||
*
|
||||
* function MyComponent() {
|
||||
* const { track, identify, user, isInitialized } = usePostHogRedux();
|
||||
*
|
||||
* const handleClick = () => {
|
||||
* track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
* article_id: '123',
|
||||
* article_title: '标题',
|
||||
* });
|
||||
* };
|
||||
*
|
||||
* if (!isInitialized) {
|
||||
* return <div>正在加载...</div>;
|
||||
* }
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <button onClick={handleClick}>点击追踪</button>
|
||||
* {user && <p>当前用户: {user.userId}</p>}
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const usePostHogRedux = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Selectors
|
||||
const posthog = useSelector(selectPostHog);
|
||||
const isInitialized = useSelector(selectIsInitialized);
|
||||
const user = useSelector(selectUser);
|
||||
const featureFlags = useSelector(selectFeatureFlags);
|
||||
const stats = useSelector(selectStats);
|
||||
|
||||
// ==================== 追踪事件 ====================
|
||||
|
||||
/**
|
||||
* 追踪自定义事件
|
||||
* @param {string} eventName - 事件名称(建议使用 constants.js 中的常量)
|
||||
* @param {object} properties - 事件属性
|
||||
*/
|
||||
const track = useCallback(
|
||||
(eventName, properties = {}) => {
|
||||
dispatch(trackEvent({ eventName, properties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪页面浏览
|
||||
* @param {string} pagePath - 页面路径
|
||||
* @param {object} properties - 页面属性
|
||||
*/
|
||||
const trackPage = useCallback(
|
||||
(pagePath, properties = {}) => {
|
||||
trackPageView(pagePath, properties);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
/**
|
||||
* 识别用户(登录后调用)
|
||||
* @param {string} userId - 用户 ID
|
||||
* @param {object} userProperties - 用户属性
|
||||
*/
|
||||
const identify = useCallback(
|
||||
(userId, userProperties = {}) => {
|
||||
dispatch(identifyUser({ userId, userProperties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
/**
|
||||
* 重置用户会话(登出时调用)
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
dispatch(resetUser());
|
||||
}, [dispatch]);
|
||||
|
||||
// ==================== 隐私控制 ====================
|
||||
|
||||
/**
|
||||
* 用户选择退出追踪
|
||||
*/
|
||||
const optOutTracking = useCallback(() => {
|
||||
dispatch(optOut());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 用户选择加入追踪
|
||||
*/
|
||||
const optInTracking = useCallback(() => {
|
||||
dispatch(optIn());
|
||||
}, [dispatch]);
|
||||
|
||||
/**
|
||||
* 检查用户是否已退出追踪
|
||||
*/
|
||||
const isOptedOut = selectIsOptedOut();
|
||||
|
||||
// ==================== Feature Flags ====================
|
||||
|
||||
/**
|
||||
* 获取特定 Feature Flag 的值
|
||||
* @param {string} flagKey - Flag 键名
|
||||
* @returns {any} Flag 值
|
||||
*/
|
||||
const getFlag = useCallback(
|
||||
(flagKey) => {
|
||||
return selectFeatureFlag(flagKey)({ posthog });
|
||||
},
|
||||
[posthog]
|
||||
);
|
||||
|
||||
/**
|
||||
* 检查 Feature Flag 是否启用
|
||||
* @param {string} flagKey - Flag 键名
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isEnabled = useCallback(
|
||||
(flagKey) => {
|
||||
const value = getFlag(flagKey);
|
||||
return Boolean(value);
|
||||
},
|
||||
[getFlag]
|
||||
);
|
||||
|
||||
// ==================== 离线事件管理 ====================
|
||||
|
||||
/**
|
||||
* 刷新缓存的离线事件
|
||||
*/
|
||||
const flushEvents = useCallback(() => {
|
||||
dispatch(flushCachedEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// ==================== 返回接口 ====================
|
||||
|
||||
return {
|
||||
// 状态
|
||||
isInitialized,
|
||||
user,
|
||||
featureFlags,
|
||||
stats,
|
||||
posthog, // 完整的 PostHog 状态
|
||||
|
||||
// 追踪方法
|
||||
track,
|
||||
trackPage,
|
||||
|
||||
// 用户管理
|
||||
identify,
|
||||
reset,
|
||||
|
||||
// 隐私控制
|
||||
optOut: optOutTracking,
|
||||
optIn: optInTracking,
|
||||
isOptedOut,
|
||||
|
||||
// Feature Flags
|
||||
getFlag,
|
||||
isEnabled,
|
||||
|
||||
// 离线事件
|
||||
flushEvents,
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 便捷 Hooks ====================
|
||||
|
||||
/**
|
||||
* 仅获取追踪功能的 Hook(性能优化)
|
||||
*/
|
||||
export const usePostHogTrack = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const track = useCallback(
|
||||
(eventName, properties = {}) => {
|
||||
dispatch(trackEvent({ eventName, properties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return { track };
|
||||
};
|
||||
|
||||
/**
|
||||
* 仅获取 Feature Flags 的 Hook(性能优化)
|
||||
*/
|
||||
export const usePostHogFlags = () => {
|
||||
const featureFlags = useSelector(selectFeatureFlags);
|
||||
const posthog = useSelector(selectPostHog);
|
||||
|
||||
const getFlag = useCallback(
|
||||
(flagKey) => {
|
||||
return selectFeatureFlag(flagKey)({ posthog });
|
||||
},
|
||||
[posthog]
|
||||
);
|
||||
|
||||
const isEnabled = useCallback(
|
||||
(flagKey) => {
|
||||
const value = getFlag(flagKey);
|
||||
return Boolean(value);
|
||||
},
|
||||
[getFlag]
|
||||
);
|
||||
|
||||
return {
|
||||
featureFlags,
|
||||
getFlag,
|
||||
isEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取用户信息的 Hook(性能优化)
|
||||
*/
|
||||
export const usePostHogUser = () => {
|
||||
const user = useSelector(selectUser);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const identify = useCallback(
|
||||
(userId, userProperties = {}) => {
|
||||
dispatch(identifyUser({ userId, userProperties }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
dispatch(resetUser());
|
||||
}, [dispatch]);
|
||||
|
||||
return {
|
||||
user,
|
||||
identify,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePostHogRedux;
|
||||
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;
|
||||
381
src/lib/constants.js
Normal file
381
src/lib/constants.js
Normal file
@@ -0,0 +1,381 @@
|
||||
// src/lib/constants.js
|
||||
// PostHog Event Names and Constants
|
||||
// Organized by AARRR Framework (Acquisition, Activation, Retention, Referral, Revenue)
|
||||
|
||||
// ============================================================================
|
||||
// ACQUISITION (获客) - Landing page, marketing website events
|
||||
// ============================================================================
|
||||
export const ACQUISITION_EVENTS = {
|
||||
// Landing page
|
||||
LANDING_PAGE_VIEWED: 'Landing Page Viewed',
|
||||
CTA_BUTTON_CLICKED: 'CTA Button Clicked',
|
||||
FEATURE_CARD_VIEWED: 'Feature Card Viewed',
|
||||
FEATURE_VIDEO_PLAYED: 'Feature Video Played',
|
||||
|
||||
// Pricing page
|
||||
PRICING_PAGE_VIEWED: 'Pricing Page Viewed',
|
||||
PRICING_PLAN_VIEWED: 'Pricing Plan Viewed',
|
||||
PRICING_PLAN_SELECTED: 'Pricing Plan Selected',
|
||||
|
||||
// How to use page
|
||||
HOW_TO_USE_PAGE_VIEWED: 'How To Use Page Viewed',
|
||||
TUTORIAL_STEP_VIEWED: 'Tutorial Step Viewed',
|
||||
|
||||
// Roadmap page
|
||||
ROADMAP_PAGE_VIEWED: 'Roadmap Page Viewed',
|
||||
ROADMAP_ITEM_CLICKED: 'Roadmap Item Clicked',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ACTIVATION (激活) - Sign up, login, onboarding
|
||||
// ============================================================================
|
||||
export const ACTIVATION_EVENTS = {
|
||||
// Auth pages
|
||||
LOGIN_PAGE_VIEWED: 'Login Page Viewed',
|
||||
SIGNUP_PAGE_VIEWED: 'Signup Page Viewed',
|
||||
|
||||
// Login method selection
|
||||
PHONE_LOGIN_INITIATED: 'Phone Login Initiated', // 用户开始填写手机号
|
||||
WECHAT_LOGIN_INITIATED: 'WeChat Login Initiated', // 用户选择微信登录
|
||||
|
||||
// Phone verification code flow
|
||||
VERIFICATION_CODE_SENT: 'Verification Code Sent',
|
||||
VERIFICATION_CODE_SEND_FAILED: 'Verification Code Send Failed',
|
||||
VERIFICATION_CODE_INPUT_CHANGED: 'Verification Code Input Changed',
|
||||
VERIFICATION_CODE_RESENT: 'Verification Code Resent',
|
||||
VERIFICATION_CODE_SUBMITTED: 'Verification Code Submitted',
|
||||
PHONE_NUMBER_VALIDATED: 'Phone Number Validated',
|
||||
|
||||
// WeChat login flow
|
||||
WECHAT_QR_DISPLAYED: 'WeChat QR Code Displayed',
|
||||
WECHAT_QR_SCANNED: 'WeChat QR Code Scanned',
|
||||
WECHAT_QR_EXPIRED: 'WeChat QR Code Expired',
|
||||
WECHAT_QR_REFRESHED: 'WeChat QR Code Refreshed',
|
||||
WECHAT_STATUS_CHANGED: 'WeChat Status Changed',
|
||||
WECHAT_H5_REDIRECT: 'WeChat H5 Redirect', // 移动端跳转微信H5
|
||||
|
||||
// Login/Signup results
|
||||
USER_LOGGED_IN: 'User Logged In',
|
||||
USER_SIGNED_UP: 'User Signed Up',
|
||||
LOGIN_FAILED: 'Login Failed',
|
||||
SIGNUP_FAILED: 'Signup Failed',
|
||||
|
||||
// User behavior details
|
||||
AUTH_FORM_FOCUSED: 'Auth Form Field Focused',
|
||||
AUTH_FORM_VALIDATION_ERROR: 'Auth Form Validation Error',
|
||||
NICKNAME_PROMPT_SHOWN: 'Nickname Prompt Shown',
|
||||
NICKNAME_PROMPT_ACCEPTED: 'Nickname Prompt Accepted',
|
||||
NICKNAME_PROMPT_SKIPPED: 'Nickname Prompt Skipped',
|
||||
USER_AGREEMENT_LINK_CLICKED: 'User Agreement Link Clicked',
|
||||
PRIVACY_POLICY_LINK_CLICKED: 'Privacy Policy Link Clicked',
|
||||
|
||||
// Error tracking
|
||||
LOGIN_ERROR_OCCURRED: 'Login Error Occurred',
|
||||
NETWORK_ERROR_OCCURRED: 'Network Error Occurred',
|
||||
SESSION_EXPIRED: 'Session Expired',
|
||||
API_ERROR_OCCURRED: 'API Error Occurred',
|
||||
|
||||
// Onboarding
|
||||
ONBOARDING_STARTED: 'Onboarding Started',
|
||||
ONBOARDING_STEP_COMPLETED: 'Onboarding Step Completed',
|
||||
ONBOARDING_COMPLETED: 'Onboarding Completed',
|
||||
ONBOARDING_SKIPPED: 'Onboarding Skipped',
|
||||
|
||||
// User agreement (deprecated, use link clicked events instead)
|
||||
USER_AGREEMENT_VIEWED: 'User Agreement Viewed',
|
||||
USER_AGREEMENT_ACCEPTED: 'User Agreement Accepted',
|
||||
PRIVACY_POLICY_VIEWED: 'Privacy Policy Viewed',
|
||||
PRIVACY_POLICY_ACCEPTED: 'Privacy Policy Accepted',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RETENTION (留存) - Core product usage, feature engagement
|
||||
// ============================================================================
|
||||
export const RETENTION_EVENTS = {
|
||||
// Dashboard
|
||||
DASHBOARD_VIEWED: 'Dashboard Viewed',
|
||||
DASHBOARD_CENTER_VIEWED: 'Dashboard Center Viewed',
|
||||
FUNCTION_CARD_CLICKED: 'Function Card Clicked', // Core功能卡片点击
|
||||
|
||||
// Navigation
|
||||
TOP_NAV_CLICKED: 'Top Navigation Clicked',
|
||||
SIDEBAR_MENU_CLICKED: 'Sidebar Menu Clicked',
|
||||
MENU_ITEM_CLICKED: 'Menu Item Clicked',
|
||||
BREADCRUMB_CLICKED: 'Breadcrumb Clicked',
|
||||
|
||||
// Search
|
||||
SEARCH_INITIATED: 'Search Initiated',
|
||||
SEARCH_QUERY_SUBMITTED: 'Search Query Submitted',
|
||||
SEARCH_RESULT_CLICKED: 'Search Result Clicked',
|
||||
SEARCH_NO_RESULTS: 'Search No Results',
|
||||
SEARCH_FILTER_APPLIED: 'Search Filter Applied',
|
||||
|
||||
// News/Community (新闻催化分析)
|
||||
COMMUNITY_PAGE_VIEWED: 'Community Page Viewed',
|
||||
NEWS_LIST_VIEWED: 'News List Viewed',
|
||||
NEWS_ARTICLE_CLICKED: 'News Article Clicked',
|
||||
NEWS_DETAIL_OPENED: 'News Detail Opened',
|
||||
NEWS_TAB_CLICKED: 'News Tab Clicked', // 相关标的, 相关概念, etc.
|
||||
NEWS_FILTER_APPLIED: 'News Filter Applied',
|
||||
NEWS_SORTED: 'News Sorted',
|
||||
|
||||
// Concept Center (概念中心)
|
||||
CONCEPT_PAGE_VIEWED: 'Concept Page Viewed',
|
||||
CONCEPT_LIST_VIEWED: 'Concept List Viewed',
|
||||
CONCEPT_CLICKED: 'Concept Clicked',
|
||||
CONCEPT_DETAIL_VIEWED: 'Concept Detail Viewed',
|
||||
CONCEPT_STOCK_CLICKED: 'Concept Stock Clicked',
|
||||
|
||||
// Stock Center (个股中心)
|
||||
STOCK_OVERVIEW_VIEWED: 'Stock Overview Page Viewed',
|
||||
STOCK_LIST_VIEWED: 'Stock List Viewed',
|
||||
STOCK_SEARCHED: 'Stock Searched',
|
||||
STOCK_CLICKED: 'Stock Clicked',
|
||||
STOCK_DETAIL_VIEWED: 'Stock Detail Viewed',
|
||||
STOCK_TAB_CLICKED: 'Stock Tab Clicked', // 公司概览, 股票行情, 财务全景, 盈利预测
|
||||
|
||||
// Company Details
|
||||
COMPANY_OVERVIEW_VIEWED: 'Company Overview Viewed',
|
||||
COMPANY_FINANCIALS_VIEWED: 'Company Financials Viewed',
|
||||
COMPANY_FORECAST_VIEWED: 'Company Forecast Viewed',
|
||||
COMPANY_MARKET_DATA_VIEWED: 'Company Market Data Viewed',
|
||||
|
||||
// Limit Analysis (涨停分析)
|
||||
LIMIT_ANALYSE_PAGE_VIEWED: 'Limit Analyse Page Viewed',
|
||||
LIMIT_BOARD_CLICKED: 'Limit Board Clicked',
|
||||
LIMIT_SECTOR_EXPANDED: 'Limit Sector Expanded',
|
||||
LIMIT_SECTOR_ANALYSIS_VIEWED: 'Limit Sector Analysis Viewed',
|
||||
LIMIT_STOCK_CLICKED: 'Limit Stock Clicked',
|
||||
|
||||
// Trading Simulation (模拟盘交易)
|
||||
TRADING_SIMULATION_ENTERED: 'Trading Simulation Entered',
|
||||
SIMULATION_ORDER_PLACED: 'Simulation Order Placed',
|
||||
SIMULATION_HOLDINGS_VIEWED: 'Simulation Holdings Viewed',
|
||||
SIMULATION_HISTORY_VIEWED: 'Simulation History Viewed',
|
||||
SIMULATION_STOCK_SEARCHED: 'Simulation Stock Searched',
|
||||
|
||||
// Event Details
|
||||
EVENT_DETAIL_VIEWED: 'Event Detail Viewed',
|
||||
EVENT_ANALYSIS_VIEWED: 'Event Analysis Viewed',
|
||||
EVENT_TIMELINE_CLICKED: 'Event Timeline Clicked',
|
||||
|
||||
// Profile & Settings
|
||||
PROFILE_PAGE_VIEWED: 'Profile Page Viewed',
|
||||
PROFILE_UPDATED: 'Profile Updated',
|
||||
SETTINGS_PAGE_VIEWED: 'Settings Page Viewed',
|
||||
SETTINGS_CHANGED: 'Settings Changed',
|
||||
|
||||
// Subscription Management
|
||||
SUBSCRIPTION_PAGE_VIEWED: 'Subscription Page Viewed',
|
||||
UPGRADE_PLAN_CLICKED: 'Upgrade Plan Clicked',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// REFERRAL (推荐) - Sharing, inviting
|
||||
// ============================================================================
|
||||
export const REFERRAL_EVENTS = {
|
||||
// Sharing
|
||||
SHARE_BUTTON_CLICKED: 'Share Button Clicked',
|
||||
CONTENT_SHARED: 'Content Shared',
|
||||
SHARE_LINK_GENERATED: 'Share Link Generated',
|
||||
SHARE_MODAL_OPENED: 'Share Modal Opened',
|
||||
SHARE_MODAL_CLOSED: 'Share Modal Closed',
|
||||
|
||||
// Referral
|
||||
REFERRAL_PAGE_VIEWED: 'Referral Page Viewed',
|
||||
REFERRAL_LINK_COPIED: 'Referral Link Copied',
|
||||
REFERRAL_INVITE_SENT: 'Referral Invite Sent',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// REVENUE (收入) - Payment, subscription, monetization
|
||||
// ============================================================================
|
||||
export const REVENUE_EVENTS = {
|
||||
// Paywall
|
||||
PAYWALL_SHOWN: 'Paywall Shown',
|
||||
PAYWALL_DISMISSED: 'Paywall Dismissed',
|
||||
PAYWALL_UPGRADE_CLICKED: 'Paywall Upgrade Clicked',
|
||||
|
||||
// Payment
|
||||
PAYMENT_PAGE_VIEWED: 'Payment Page Viewed',
|
||||
PAYMENT_METHOD_SELECTED: 'Payment Method Selected',
|
||||
PAYMENT_INITIATED: 'Payment Initiated',
|
||||
PAYMENT_SUCCESSFUL: 'Payment Successful',
|
||||
PAYMENT_FAILED: 'Payment Failed',
|
||||
|
||||
// Subscription
|
||||
SUBSCRIPTION_CREATED: 'Subscription Created',
|
||||
SUBSCRIPTION_RENEWED: 'Subscription Renewed',
|
||||
SUBSCRIPTION_UPGRADED: 'Subscription Upgraded',
|
||||
SUBSCRIPTION_DOWNGRADED: 'Subscription Downgraded',
|
||||
SUBSCRIPTION_CANCELLED: 'Subscription Cancelled',
|
||||
SUBSCRIPTION_EXPIRED: 'Subscription Expired',
|
||||
|
||||
// Refund
|
||||
REFUND_REQUESTED: 'Refund Requested',
|
||||
REFUND_PROCESSED: 'Refund Processed',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SPECIAL EVENTS (特殊事件) - Errors, performance, chatbot
|
||||
// ============================================================================
|
||||
export const SPECIAL_EVENTS = {
|
||||
// Errors
|
||||
ERROR_OCCURRED: 'Error Occurred',
|
||||
API_ERROR: 'API Error',
|
||||
NOT_FOUND_404: '404 Not Found',
|
||||
|
||||
// Performance
|
||||
PAGE_LOAD_TIME: 'Page Load Time',
|
||||
API_RESPONSE_TIME: 'API Response Time',
|
||||
|
||||
// Chatbot (Dify)
|
||||
CHATBOT_OPENED: 'Chatbot Opened',
|
||||
CHATBOT_CLOSED: 'Chatbot Closed',
|
||||
CHATBOT_MESSAGE_SENT: 'Chatbot Message Sent',
|
||||
CHATBOT_MESSAGE_RECEIVED: 'Chatbot Message Received',
|
||||
CHATBOT_FEEDBACK_PROVIDED: 'Chatbot Feedback Provided',
|
||||
|
||||
// Scroll depth
|
||||
SCROLL_DEPTH_25: 'Scroll Depth 25%',
|
||||
SCROLL_DEPTH_50: 'Scroll Depth 50%',
|
||||
SCROLL_DEPTH_75: 'Scroll Depth 75%',
|
||||
SCROLL_DEPTH_100: 'Scroll Depth 100%',
|
||||
|
||||
// Session
|
||||
SESSION_STARTED: 'Session Started',
|
||||
SESSION_ENDED: 'Session Ended',
|
||||
USER_IDLE: 'User Idle',
|
||||
USER_RETURNED: 'User Returned',
|
||||
|
||||
// Logout
|
||||
USER_LOGGED_OUT: 'User Logged Out',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// USER PROPERTIES (用户属性)
|
||||
// ============================================================================
|
||||
export const USER_PROPERTIES = {
|
||||
// Identity
|
||||
EMAIL: 'email',
|
||||
USERNAME: 'username',
|
||||
USER_ID: 'user_id',
|
||||
PHONE: 'phone',
|
||||
|
||||
// Subscription
|
||||
SUBSCRIPTION_TIER: 'subscription_tier', // 'free', 'pro', 'enterprise'
|
||||
SUBSCRIPTION_STATUS: 'subscription_status', // 'active', 'expired', 'cancelled'
|
||||
SUBSCRIPTION_START_DATE: 'subscription_start_date',
|
||||
SUBSCRIPTION_END_DATE: 'subscription_end_date',
|
||||
|
||||
// Engagement
|
||||
REGISTRATION_DATE: 'registration_date',
|
||||
LAST_LOGIN: 'last_login',
|
||||
LOGIN_COUNT: 'login_count',
|
||||
DAYS_SINCE_REGISTRATION: 'days_since_registration',
|
||||
LIFETIME_VALUE: 'lifetime_value',
|
||||
|
||||
// Preferences
|
||||
PREFERRED_LANGUAGE: 'preferred_language',
|
||||
THEME_PREFERENCE: 'theme_preference', // 'light', 'dark'
|
||||
NOTIFICATION_ENABLED: 'notification_enabled',
|
||||
|
||||
// Attribution
|
||||
UTM_SOURCE: 'utm_source',
|
||||
UTM_MEDIUM: 'utm_medium',
|
||||
UTM_CAMPAIGN: 'utm_campaign',
|
||||
REFERRER: 'referrer',
|
||||
|
||||
// Behavioral
|
||||
FAVORITE_FEATURES: 'favorite_features',
|
||||
MOST_VISITED_PAGES: 'most_visited_pages',
|
||||
TOTAL_SESSIONS: 'total_sessions',
|
||||
AVERAGE_SESSION_DURATION: 'average_session_duration',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SUBSCRIPTION TIERS (订阅等级)
|
||||
// ============================================================================
|
||||
export const SUBSCRIPTION_TIERS = {
|
||||
FREE: 'free',
|
||||
PRO: 'pro',
|
||||
ENTERPRISE: 'enterprise',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PAGE TYPES (页面类型)
|
||||
// ============================================================================
|
||||
export const PAGE_TYPES = {
|
||||
LANDING: 'landing',
|
||||
DASHBOARD: 'dashboard',
|
||||
FEATURE: 'feature',
|
||||
DETAIL: 'detail',
|
||||
AUTH: 'auth',
|
||||
SETTINGS: 'settings',
|
||||
PAYMENT: 'payment',
|
||||
ERROR: 'error',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// CONTENT TYPES (内容类型)
|
||||
// ============================================================================
|
||||
export const CONTENT_TYPES = {
|
||||
NEWS: 'news',
|
||||
STOCK: 'stock',
|
||||
CONCEPT: 'concept',
|
||||
ANALYSIS: 'analysis',
|
||||
EVENT: 'event',
|
||||
COMPANY: 'company',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SHARE CHANNELS (分享渠道)
|
||||
// ============================================================================
|
||||
export const SHARE_CHANNELS = {
|
||||
WECHAT: 'wechat',
|
||||
LINK: 'link',
|
||||
QRCODE: 'qrcode',
|
||||
EMAIL: 'email',
|
||||
COPY: 'copy',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// LOGIN METHODS (登录方式)
|
||||
// ============================================================================
|
||||
export const LOGIN_METHODS = {
|
||||
WECHAT: 'wechat',
|
||||
EMAIL: 'email',
|
||||
PHONE: 'phone',
|
||||
USERNAME: 'username',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PAYMENT METHODS (支付方式)
|
||||
// ============================================================================
|
||||
export const PAYMENT_METHODS = {
|
||||
WECHAT_PAY: 'wechat_pay',
|
||||
ALIPAY: 'alipay',
|
||||
CREDIT_CARD: 'credit_card',
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper function to get all events
|
||||
// ============================================================================
|
||||
export const getAllEvents = () => {
|
||||
return {
|
||||
...ACQUISITION_EVENTS,
|
||||
...ACTIVATION_EVENTS,
|
||||
...RETENTION_EVENTS,
|
||||
...REFERRAL_EVENTS,
|
||||
...REVENUE_EVENTS,
|
||||
...SPECIAL_EVENTS,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper function to validate event name
|
||||
// ============================================================================
|
||||
export const isValidEvent = (eventName) => {
|
||||
const allEvents = getAllEvents();
|
||||
return Object.values(allEvents).includes(eventName);
|
||||
};
|
||||
271
src/lib/posthog.js
Normal file
271
src/lib/posthog.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// src/lib/posthog.js
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
/**
|
||||
* Initialize PostHog SDK
|
||||
* Should be called once when the app starts
|
||||
*/
|
||||
export const initPostHog = () => {
|
||||
// Only run in browser environment
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const apiKey = process.env.REACT_APP_POSTHOG_KEY;
|
||||
const apiHost = process.env.REACT_APP_POSTHOG_HOST || 'https://app.posthog.com';
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('⚠️ PostHog API key not found. Analytics will be disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
|
||||
// Pageview tracking - manual control for better accuracy
|
||||
capture_pageview: false, // We'll manually capture with custom properties
|
||||
capture_pageleave: true, // Auto-capture when user leaves page
|
||||
|
||||
// Session Recording Configuration
|
||||
session_recording: {
|
||||
enabled: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
|
||||
|
||||
// Privacy: Mask sensitive input fields
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
'data-sensitive': true, // Custom attribute for sensitive fields
|
||||
},
|
||||
|
||||
// Record canvas for charts/graphs
|
||||
recordCanvas: true,
|
||||
|
||||
// Network payload capture (useful for debugging API issues)
|
||||
networkPayloadCapture: {
|
||||
recordHeaders: true,
|
||||
recordBody: true,
|
||||
// Don't record sensitive endpoints
|
||||
urlBlocklist: [
|
||||
'/api/auth/session',
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/payment',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Performance optimization
|
||||
batch_size: 10, // Send events in batches of 10
|
||||
batch_interval_ms: 3000, // Or every 3 seconds
|
||||
|
||||
// Privacy settings
|
||||
respect_dnt: true, // Respect Do Not Track browser setting
|
||||
persistence: 'localStorage+cookie', // Use both for reliability
|
||||
|
||||
// Feature flags (for A/B testing)
|
||||
bootstrap: {
|
||||
featureFlags: {},
|
||||
},
|
||||
|
||||
// Autocapture settings
|
||||
autocapture: {
|
||||
// Automatically capture clicks on buttons, links, etc.
|
||||
dom_event_allowlist: ['click', 'submit', 'change'],
|
||||
|
||||
// Capture additional element properties
|
||||
capture_copied_text: false, // Don't capture copied text (privacy)
|
||||
},
|
||||
|
||||
// Development debugging
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHog initialized successfully');
|
||||
posthogInstance.debug(); // Enable debug mode in development
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log('📊 PostHog Analytics initialized');
|
||||
} catch (error) {
|
||||
console.error('❌ PostHog initialization failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get PostHog instance
|
||||
* @returns {object} PostHog instance
|
||||
*/
|
||||
export const getPostHog = () => {
|
||||
return posthog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Identify user with PostHog
|
||||
* Call this after successful login/registration
|
||||
*
|
||||
* @param {string} userId - Unique user identifier
|
||||
* @param {object} userProperties - User properties (email, name, subscription_tier, etc.)
|
||||
*/
|
||||
export const identifyUser = (userId, userProperties = {}) => {
|
||||
if (!userId) {
|
||||
console.warn('⚠️ Cannot identify user: userId is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
posthog.identify(userId, {
|
||||
email: userProperties.email,
|
||||
username: userProperties.username,
|
||||
subscription_tier: userProperties.subscription_tier || 'free',
|
||||
role: userProperties.role,
|
||||
registration_date: userProperties.registration_date,
|
||||
last_login: new Date().toISOString(),
|
||||
...userProperties,
|
||||
});
|
||||
|
||||
console.log('👤 User identified:', userId);
|
||||
} catch (error) {
|
||||
console.error('❌ User identification failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update user properties
|
||||
* Use this to update user attributes without re-identifying
|
||||
*
|
||||
* @param {object} properties - Properties to update
|
||||
*/
|
||||
export const setUserProperties = (properties) => {
|
||||
try {
|
||||
posthog.people.set(properties);
|
||||
console.log('📝 User properties updated');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update user properties:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track custom event
|
||||
*
|
||||
* @param {string} eventName - Name of the event
|
||||
* @param {object} properties - Event properties
|
||||
*/
|
||||
export const trackEvent = (eventName, properties = {}) => {
|
||||
try {
|
||||
posthog.capture(eventName, {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📍 Event tracked:', eventName, properties);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Event tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*
|
||||
* @param {string} pagePath - Current page path
|
||||
* @param {object} properties - Additional properties
|
||||
*/
|
||||
export const trackPageView = (pagePath, properties = {}) => {
|
||||
try {
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: window.location.href,
|
||||
page_path: pagePath,
|
||||
page_title: document.title,
|
||||
referrer: document.referrer,
|
||||
...properties,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📄 Page view tracked:', pagePath);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Page view tracking failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reset user session
|
||||
* Call this on logout
|
||||
*/
|
||||
export const resetUser = () => {
|
||||
try {
|
||||
posthog.reset();
|
||||
console.log('🔄 User session reset');
|
||||
} catch (error) {
|
||||
console.error('❌ Session reset failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* User opt-out from tracking
|
||||
*/
|
||||
export const optOut = () => {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
console.log('🚫 User opted out of tracking');
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-out failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* User opt-in to tracking
|
||||
*/
|
||||
export const optIn = () => {
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
console.log('✅ User opted in to tracking');
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-in failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if user has opted out
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const hasOptedOut = () => {
|
||||
try {
|
||||
return posthog.has_opted_out_capturing();
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to check opt-out status:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get feature flag value
|
||||
* @param {string} flagKey - Feature flag key
|
||||
* @param {any} defaultValue - Default value if flag not found
|
||||
* @returns {any} Feature flag value
|
||||
*/
|
||||
export const getFeatureFlag = (flagKey, defaultValue = false) => {
|
||||
try {
|
||||
return posthog.getFeatureFlag(flagKey) || defaultValue;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get feature flag:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if feature flag is enabled
|
||||
* @param {string} flagKey - Feature flag key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isFeatureEnabled = (flagKey) => {
|
||||
try {
|
||||
return posthog.isFeatureEnabled(flagKey);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to check feature flag:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default posthog;
|
||||
@@ -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) {
|
||||
|
||||
535
src/mocks/data/company.js
Normal file
535
src/mocks/data/company.js
Normal file
@@ -0,0 +1,535 @@
|
||||
// src/mocks/data/company.js
|
||||
// 公司相关的 Mock 数据
|
||||
|
||||
// 平安银行 (000001) 的完整数据
|
||||
export const PINGAN_BANK_DATA = {
|
||||
stockCode: '000001',
|
||||
stockName: '平安银行',
|
||||
|
||||
// 基本信息
|
||||
basicInfo: {
|
||||
code: '000001',
|
||||
name: '平安银行',
|
||||
english_name: 'Ping An Bank Co., Ltd.',
|
||||
registered_capital: 1940642.3, // 万元
|
||||
registered_capital_unit: '万元',
|
||||
legal_representative: '谢永林',
|
||||
general_manager: '谢永林',
|
||||
secretary: '周强',
|
||||
registered_address: '深圳市深南东路5047号',
|
||||
office_address: '深圳市深南东路5047号',
|
||||
zipcode: '518001',
|
||||
phone: '0755-82080387',
|
||||
fax: '0755-82080386',
|
||||
email: 'ir@bank.pingan.com',
|
||||
website: 'http://bank.pingan.com',
|
||||
business_scope: '吸收公众存款;发放短期、中期和长期贷款;办理国内外结算;办理票据承兑与贴现;发行金融债券;代理发行、代理兑付、承销政府债券;买卖政府债券、金融债券;从事同业拆借;买卖、代理买卖外汇;从事银行卡业务;提供信用证服务及担保;代理收付款项及代理保险业务;提供保管箱服务;经有关监管机构批准的其他业务。',
|
||||
employees: 36542,
|
||||
introduction: '平安银行股份有限公司是中国平安保险(集团)股份有限公司控股的一家跨区域经营的股份制商业银行,为中国大陆12家全国性股份制商业银行之一。注册资本为人民币51.2335亿元,总资产近1.37万亿元,总部位于深圳。平安银行拥有全国性银行经营资质,主要经营商业银行业务。',
|
||||
list_date: '1991-04-03',
|
||||
establish_date: '1987-12-22',
|
||||
province: '广东省',
|
||||
city: '深圳市',
|
||||
industry: '银行',
|
||||
main_business: '商业银行业务',
|
||||
},
|
||||
|
||||
// 实际控制人信息
|
||||
actualControl: {
|
||||
controller_name: '中国平安保险(集团)股份有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 52.38,
|
||||
control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司',
|
||||
is_listed: true,
|
||||
change_date: '2023-12-31',
|
||||
remark: '中国平安通过直接和间接方式控股平安银行',
|
||||
},
|
||||
|
||||
// 股权集中度
|
||||
concentration: {
|
||||
top1_ratio: 52.38,
|
||||
top3_ratio: 58.42,
|
||||
top5_ratio: 60.15,
|
||||
top10_ratio: 63.28,
|
||||
update_date: '2024-09-30',
|
||||
concentration_level: '高度集中',
|
||||
herfindahl_index: 0.2845,
|
||||
},
|
||||
|
||||
// 高管信息
|
||||
management: [
|
||||
{
|
||||
name: '谢永林',
|
||||
position: '董事长、执行董事、行长',
|
||||
gender: '男',
|
||||
age: 56,
|
||||
education: '硕士',
|
||||
appointment_date: '2019-01-01',
|
||||
annual_compensation: 723.8,
|
||||
shareholding: 0,
|
||||
background: '中国平安保险(集团)股份有限公司副总经理兼首席保险业务执行官'
|
||||
},
|
||||
{
|
||||
name: '周强',
|
||||
position: '执行董事、副行长、董事会秘书',
|
||||
gender: '男',
|
||||
age: 54,
|
||||
education: '硕士',
|
||||
appointment_date: '2016-06-01',
|
||||
annual_compensation: 542.3,
|
||||
shareholding: 0.002,
|
||||
background: '历任平安银行深圳分行行长'
|
||||
},
|
||||
{
|
||||
name: '郭世邦',
|
||||
position: '执行董事、副行长、首席财务官',
|
||||
gender: '男',
|
||||
age: 52,
|
||||
education: '博士',
|
||||
appointment_date: '2018-03-01',
|
||||
annual_compensation: 498.6,
|
||||
shareholding: 0.001,
|
||||
background: '历任中国平安集团财务负责人'
|
||||
},
|
||||
{
|
||||
name: '蔡新发',
|
||||
position: '副行长、首席风险官',
|
||||
gender: '男',
|
||||
age: 51,
|
||||
education: '硕士',
|
||||
appointment_date: '2017-05-01',
|
||||
annual_compensation: 467.2,
|
||||
shareholding: 0.0008,
|
||||
background: '历任平安银行风险管理部总经理'
|
||||
},
|
||||
{
|
||||
name: '项有志',
|
||||
position: '副行长、首席信息官',
|
||||
gender: '男',
|
||||
age: 49,
|
||||
education: '硕士',
|
||||
appointment_date: '2019-09-01',
|
||||
annual_compensation: 425.1,
|
||||
shareholding: 0,
|
||||
background: '历任中国平安科技公司总经理'
|
||||
}
|
||||
],
|
||||
|
||||
// 十大流通股东
|
||||
topCirculationShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业' },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业' },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司' },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金' },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司' },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人' }
|
||||
],
|
||||
|
||||
// 十大股东(与流通股东相同,因为平安银行全流通)
|
||||
topShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业', is_restricted: false },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业', is_restricted: false },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司', is_restricted: false },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司', is_restricted: false },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人', is_restricted: false }
|
||||
],
|
||||
|
||||
// 分支机构
|
||||
branches: [
|
||||
{ name: '北京分行', address: '北京市朝阳区建国路88号SOHO现代城', phone: '010-85806888', type: '一级分行', establish_date: '2007-03-15' },
|
||||
{ name: '上海分行', address: '上海市浦东新区陆家嘴环路1366号', phone: '021-38637777', type: '一级分行', establish_date: '2007-05-20' },
|
||||
{ name: '广州分行', address: '广州市天河区珠江新城珠江东路32号', phone: '020-38390888', type: '一级分行', establish_date: '2007-06-10' },
|
||||
{ name: '深圳分行', address: '深圳市福田区益田路5033号', phone: '0755-82538888', type: '一级分行', establish_date: '1995-01-01' },
|
||||
{ name: '杭州分行', address: '杭州市江干区钱江路1366号', phone: '0571-87028888', type: '一级分行', establish_date: '2008-09-12' },
|
||||
{ name: '成都分行', address: '成都市武侯区人民南路四段13号', phone: '028-85266888', type: '一级分行', establish_date: '2009-04-25' },
|
||||
{ name: '南京分行', address: '南京市建邺区江东中路359号', phone: '025-86625888', type: '一级分行', establish_date: '2010-06-30' },
|
||||
{ name: '武汉分行', address: '武汉市江汉区建设大道568号', phone: '027-85712888', type: '一级分行', establish_date: '2011-08-15' },
|
||||
{ name: '西安分行', address: '西安市高新区唐延路35号', phone: '029-88313888', type: '一级分行', establish_date: '2012-10-20' },
|
||||
{ name: '天津分行', address: '天津市和平区南京路189号', phone: '022-23399888', type: '一级分行', establish_date: '2013-03-18' }
|
||||
],
|
||||
|
||||
// 公告列表
|
||||
announcements: [
|
||||
{
|
||||
title: '平安银行股份有限公司2024年第三季度报告',
|
||||
publish_date: '2024-10-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年前三季度实现营业收入1245.6亿元,同比增长8.2%;净利润402.3亿元,同比增长12.5%',
|
||||
url: '/announcement/detail/ann_20241028_001'
|
||||
},
|
||||
{
|
||||
title: '关于召开2024年第一次临时股东大会的通知',
|
||||
publish_date: '2024-10-15',
|
||||
type: '临时公告',
|
||||
summary: '定于2024年11月5日召开2024年第一次临时股东大会,审议关于调整董事会成员等议案',
|
||||
url: '/announcement/detail/ann_20241015_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司关于完成注册资本变更登记的公告',
|
||||
publish_date: '2024-09-20',
|
||||
type: '临时公告',
|
||||
summary: '公司已完成注册资本由人民币194.06亿元变更为194.06亿元的工商变更登记手续',
|
||||
url: '/announcement/detail/ann_20240920_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司2024年半年度报告',
|
||||
publish_date: '2024-08-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年上半年实现营业收入828.5亿元,同比增长7.8%;净利润265.4亿元,同比增长11.2%',
|
||||
url: '/announcement/detail/ann_20240828_001'
|
||||
},
|
||||
{
|
||||
title: '关于2024年上半年利润分配预案的公告',
|
||||
publish_date: '2024-08-20',
|
||||
type: '分配方案',
|
||||
summary: '拟以总股本194.06亿股为基数,向全体股东每10股派发现金红利2.8元(含税)',
|
||||
url: '/announcement/detail/ann_20240820_001'
|
||||
}
|
||||
],
|
||||
|
||||
// 披露时间表
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第四季度报告', planned_date: '2025-01-31', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
{ report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' },
|
||||
{ report_type: '2024年第一季度报告', planned_date: '2024-04-30', status: '已披露' }
|
||||
],
|
||||
|
||||
// 综合分析
|
||||
comprehensiveAnalysis: {
|
||||
overview: {
|
||||
company_name: '平安银行股份有限公司',
|
||||
stock_code: '000001',
|
||||
industry: '银行',
|
||||
established_date: '1987-12-22',
|
||||
listing_date: '1991-04-03',
|
||||
total_assets: 50245.6, // 亿元
|
||||
net_assets: 3256.8,
|
||||
registered_capital: 194.06,
|
||||
employee_count: 36542
|
||||
},
|
||||
financial_highlights: {
|
||||
revenue: 1623.5,
|
||||
revenue_growth: 8.5,
|
||||
net_profit: 528.6,
|
||||
profit_growth: 12.3,
|
||||
roe: 16.23,
|
||||
roa: 1.05,
|
||||
asset_quality_ratio: 1.02,
|
||||
capital_adequacy_ratio: 13.45,
|
||||
core_tier1_ratio: 10.82
|
||||
},
|
||||
business_structure: [
|
||||
{ business: '对公业务', revenue: 685.4, ratio: 42.2, growth: 6.8 },
|
||||
{ business: '零售业务', revenue: 812.3, ratio: 50.1, growth: 11.2 },
|
||||
{ business: '金融市场业务', revenue: 125.8, ratio: 7.7, growth: 3.5 }
|
||||
],
|
||||
competitive_advantages: [
|
||||
'背靠中国平安集团,综合金融优势明显',
|
||||
'零售业务转型成效显著,客户基础雄厚',
|
||||
'金融科技创新能力强,数字化银行建设领先',
|
||||
'风险管理体系完善,资产质量稳定',
|
||||
'管理团队经验丰富,执行力强'
|
||||
],
|
||||
risk_factors: [
|
||||
'宏观经济下行压力影响信贷质量',
|
||||
'利率市场化导致息差收窄',
|
||||
'金融监管趋严,合规成本上升',
|
||||
'同业竞争激烈,市场份额面临挑战',
|
||||
'金融科技发展带来的技术和运营风险'
|
||||
],
|
||||
development_strategy: '坚持"科技引领、零售突破、对公做精"战略,加快数字化转型,提升综合金融服务能力',
|
||||
analyst_rating: {
|
||||
buy: 18,
|
||||
hold: 12,
|
||||
sell: 2,
|
||||
target_price: 15.8,
|
||||
current_price: 13.2
|
||||
}
|
||||
},
|
||||
|
||||
// 价值链分析
|
||||
valueChainAnalysis: {
|
||||
upstream: [
|
||||
{ name: '央行及监管机构', relationship: '政策与监管', importance: '高', description: '接受货币政策调控和监管指导' },
|
||||
{ name: '同业资金市场', relationship: '资金来源', importance: '高', description: '开展同业拆借、债券回购等业务' },
|
||||
{ name: '金融科技公司', relationship: '技术支持', importance: '中', description: '提供金融科技解决方案和技术服务' }
|
||||
],
|
||||
core_business: {
|
||||
deposit_business: { scale: 33256.8, market_share: 2.8, growth_rate: 9.2 },
|
||||
loan_business: { scale: 28945.3, market_share: 2.5, growth_rate: 12.5 },
|
||||
intermediary_business: { scale: 425.6, market_share: 3.2, growth_rate: 15.8 },
|
||||
digital_banking: { user_count: 11256, app_mau: 4235, growth_rate: 28.5 }
|
||||
},
|
||||
downstream: [
|
||||
{ name: '个人客户', scale: '1.12亿户', contribution: '50.1%', description: '零售银行业务主体' },
|
||||
{ name: '企业客户', scale: '85.6万户', contribution: '42.2%', description: '对公业务主体' },
|
||||
{ name: '政府机构', scale: '2.3万户', contribution: '7.7%', description: '公共事业及政府业务' }
|
||||
],
|
||||
ecosystem_partners: [
|
||||
{ name: '中国平安集团', type: '关联方', cooperation: '综合金融服务、客户共享' },
|
||||
{ name: '平安科技', type: '科技支持', cooperation: '金融科技研发、系统建设' },
|
||||
{ name: '平安普惠', type: '业务协同', cooperation: '普惠金融、小微贷款' },
|
||||
{ name: '平安证券', type: '业务协同', cooperation: '投资银行、资产管理' }
|
||||
]
|
||||
},
|
||||
|
||||
// 关键因素时间线
|
||||
keyFactorsTimeline: [
|
||||
{
|
||||
date: '2024-10-28',
|
||||
event: '发布2024年三季报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '前三季度净利润同比增长12.5%,超市场预期',
|
||||
change: '+5.2%'
|
||||
},
|
||||
{
|
||||
date: '2024-09-15',
|
||||
event: '推出AI智能客服系统',
|
||||
type: '科技创新',
|
||||
importance: 'medium',
|
||||
impact: '提升客户服务效率,降低运营成本',
|
||||
change: '+2.1%'
|
||||
},
|
||||
{
|
||||
date: '2024-08-28',
|
||||
event: '发布2024年中报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '上半年净利润增长11.2%,资产质量保持稳定',
|
||||
change: '+3.8%'
|
||||
},
|
||||
{
|
||||
date: '2024-07-20',
|
||||
event: '获批设立理财子公司',
|
||||
type: '业务拓展',
|
||||
importance: 'high',
|
||||
impact: '完善财富管理业务布局,拓展收入来源',
|
||||
change: '+4.5%'
|
||||
},
|
||||
{
|
||||
date: '2024-06-10',
|
||||
event: '完成300亿元二级资本债发行',
|
||||
type: '融资事件',
|
||||
importance: 'medium',
|
||||
impact: '补充资本实力,支持业务扩张',
|
||||
change: '+1.8%'
|
||||
},
|
||||
{
|
||||
date: '2024-04-30',
|
||||
event: '发布2024年一季报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '一季度净利润增长10.8%,开门红表现优异',
|
||||
change: '+4.2%'
|
||||
},
|
||||
{
|
||||
date: '2024-03-15',
|
||||
event: '零售客户突破1.1亿户',
|
||||
type: '业务里程碑',
|
||||
importance: 'medium',
|
||||
impact: '零售转型成效显著,客户基础进一步夯实',
|
||||
change: '+2.5%'
|
||||
},
|
||||
{
|
||||
date: '2024-01-20',
|
||||
event: '获评"2023年度最佳零售银行"',
|
||||
type: '荣誉奖项',
|
||||
importance: 'low',
|
||||
impact: '品牌影响力提升',
|
||||
change: '+0.8%'
|
||||
}
|
||||
],
|
||||
|
||||
// 盈利预测报告
|
||||
forecastReport: {
|
||||
// 营收与利润趋势
|
||||
income_profit_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
income: [116524, 134632, 148956, 162350, 175280, 189450, 204120], // 营业总收入(百万元)
|
||||
profit: [34562, 39845, 43218, 52860, 58420, 64680, 71250] // 归母净利润(百万元)
|
||||
},
|
||||
// 增长率分析
|
||||
growth_bars: {
|
||||
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
revenue_growth_pct: [15.5, 10.6, 8.9, 8.0, 8.1, 7.7] // 营收增长率(%)
|
||||
},
|
||||
// EPS趋势
|
||||
eps_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
eps: [1.78, 2.05, 2.23, 2.72, 3.01, 3.33, 3.67] // EPS(稀释,元/股)
|
||||
},
|
||||
// PE与PEG分析
|
||||
pe_peg_axes: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
pe: [7.4, 6.9, 7.2, 4.9, 4.4, 4.0, 3.6], // PE(倍)
|
||||
peg: [0.48, 0.65, 0.81, 0.55, 0.55, 0.49, 0.47] // PEG
|
||||
},
|
||||
// 详细数据表格
|
||||
detail_table: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
rows: [
|
||||
{ '指标': '营业总收入(百万元)', '2020': 116524, '2021': 134632, '2022': 148956, '2023': 162350, '2024E': 175280, '2025E': 189450, '2026E': 204120 },
|
||||
{ '指标': '营收增长率(%)', '2020': '-', '2021': 15.5, '2022': 10.6, '2023': 8.9, '2024E': 8.0, '2025E': 8.1, '2026E': 7.7 },
|
||||
{ '指标': '归母净利润(百万元)', '2020': 34562, '2021': 39845, '2022': 43218, '2023': 52860, '2024E': 58420, '2025E': 64680, '2026E': 71250 },
|
||||
{ '指标': '净利润增长率(%)', '2020': '-', '2021': 15.3, '2022': 8.5, '2023': 22.3, '2024E': 10.5, '2025E': 10.7, '2026E': 10.2 },
|
||||
{ '指标': 'EPS(稀释,元)', '2020': 1.78, '2021': 2.05, '2022': 2.23, '2023': 2.72, '2024E': 3.01, '2025E': 3.33, '2026E': 3.67 },
|
||||
{ '指标': 'ROE(%)', '2020': 14.2, '2021': 15.8, '2022': 15.5, '2023': 16.2, '2024E': 16.5, '2025E': 16.8, '2026E': 17.0 },
|
||||
{ '指标': '总资产(百万元)', '2020': 4512360, '2021': 4856230, '2022': 4923150, '2023': 5024560, '2024E': 5230480, '2025E': 5445200, '2026E': 5668340 },
|
||||
{ '指标': '净资产(百万元)', '2020': 293540, '2021': 312680, '2022': 318920, '2023': 325680, '2024E': 338560, '2025E': 352480, '2026E': 367820 },
|
||||
{ '指标': '资产负债率(%)', '2020': 93.5, '2021': 93.6, '2022': 93.5, '2023': 93.5, '2024E': 93.5, '2025E': 93.5, '2026E': 93.5 },
|
||||
{ '指标': 'PE(倍)', '2020': 7.4, '2021': 6.9, '2022': 7.2, '2023': 4.9, '2024E': 4.4, '2025E': 4.0, '2026E': 3.6 },
|
||||
{ '指标': 'PB(倍)', '2020': 1.05, '2021': 1.09, '2022': 1.12, '2023': 0.79, '2024E': 0.72, '2025E': 0.67, '2026E': 0.61 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 生成通用公司数据的工具函数
|
||||
export const generateCompanyData = (stockCode, stockName) => {
|
||||
// 如果是平安银行,直接返回详细数据
|
||||
if (stockCode === '000001') {
|
||||
return PINGAN_BANK_DATA;
|
||||
}
|
||||
|
||||
// 否则生成通用数据
|
||||
return {
|
||||
stockCode,
|
||||
stockName,
|
||||
basicInfo: {
|
||||
code: stockCode,
|
||||
name: stockName,
|
||||
registered_capital: Math.floor(Math.random() * 500000) + 10000,
|
||||
registered_capital_unit: '万元',
|
||||
legal_representative: '张三',
|
||||
general_manager: '李四',
|
||||
secretary: '王五',
|
||||
registered_address: '中国某省某市某区某路123号',
|
||||
office_address: '中国某省某市某区某路123号',
|
||||
phone: '021-12345678',
|
||||
email: 'ir@company.com',
|
||||
website: 'http://www.company.com',
|
||||
employees: Math.floor(Math.random() * 10000) + 1000,
|
||||
list_date: '2010-01-01',
|
||||
industry: '制造业',
|
||||
},
|
||||
actualControl: {
|
||||
controller_name: '某控股集团有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 35.5,
|
||||
control_chain: '某控股集团有限公司 -> ' + stockName,
|
||||
},
|
||||
concentration: {
|
||||
top1_ratio: 35.5,
|
||||
top3_ratio: 52.3,
|
||||
top5_ratio: 61.8,
|
||||
top10_ratio: 72.5,
|
||||
concentration_level: '适度集中',
|
||||
},
|
||||
management: [
|
||||
{ name: '张三', position: '董事长', gender: '男', age: 55, education: '硕士', annual_compensation: 320.5 },
|
||||
{ name: '李四', position: '总经理', gender: '男', age: 50, education: '硕士', annual_compensation: 280.3 },
|
||||
{ name: '王五', position: '董事会秘书', gender: '女', age: 45, education: '本科', annual_compensation: 180.2 },
|
||||
],
|
||||
topCirculationShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: (10 - i) * 0.8,
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: '企业'
|
||||
})),
|
||||
topShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: (10 - i) * 0.8,
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: '企业',
|
||||
is_restricted: false
|
||||
})),
|
||||
branches: [
|
||||
{ name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司' },
|
||||
{ name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司' },
|
||||
],
|
||||
announcements: [
|
||||
{ title: stockName + '2024年第三季度报告', publish_date: '2024-10-28', type: '定期报告', summary: '业绩稳步增长' },
|
||||
{ title: stockName + '2024年半年度报告', publish_date: '2024-08-28', type: '定期报告', summary: '经营情况良好' },
|
||||
],
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
],
|
||||
comprehensiveAnalysis: {
|
||||
overview: {
|
||||
company_name: stockName,
|
||||
stock_code: stockCode,
|
||||
industry: '制造业',
|
||||
total_assets: Math.floor(Math.random() * 10000) + 100,
|
||||
},
|
||||
financial_highlights: {
|
||||
revenue: Math.floor(Math.random() * 1000) + 50,
|
||||
revenue_growth: (Math.random() * 20 - 5).toFixed(2),
|
||||
net_profit: Math.floor(Math.random() * 100) + 10,
|
||||
profit_growth: (Math.random() * 20 - 5).toFixed(2),
|
||||
},
|
||||
competitive_advantages: ['技术领先', '品牌优势', '管理团队优秀'],
|
||||
risk_factors: ['市场竞争激烈', '原材料价格波动'],
|
||||
},
|
||||
valueChainAnalysis: {
|
||||
upstream: [
|
||||
{ name: '原材料供应商A', relationship: '供应商', importance: '高' },
|
||||
{ name: '原材料供应商B', relationship: '供应商', importance: '中' },
|
||||
],
|
||||
downstream: [
|
||||
{ name: '经销商网络', scale: '1000家', contribution: '60%' },
|
||||
{ name: '直营渠道', scale: '100家', contribution: '40%' },
|
||||
],
|
||||
},
|
||||
keyFactorsTimeline: [
|
||||
{ date: '2024-10-28', event: '发布三季报', type: '业绩公告', importance: 'high', impact: '业绩超预期' },
|
||||
{ date: '2024-08-28', event: '发布中报', type: '业绩公告', importance: 'high', impact: '业绩稳定增长' },
|
||||
],
|
||||
// 通用预测报告数据
|
||||
forecastReport: {
|
||||
income_profit_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
income: [5000, 5800, 6500, 7200, 7900, 8600, 9400],
|
||||
profit: [450, 520, 580, 650, 720, 800, 890]
|
||||
},
|
||||
growth_bars: {
|
||||
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
revenue_growth_pct: [16.0, 12.1, 10.8, 9.7, 8.9, 9.3]
|
||||
},
|
||||
eps_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
eps: [0.45, 0.52, 0.58, 0.65, 0.72, 0.80, 0.89]
|
||||
},
|
||||
pe_peg_axes: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
pe: [22.2, 19.2, 17.2, 15.4, 13.9, 12.5, 11.2],
|
||||
peg: [1.39, 1.59, 1.59, 1.42, 1.43, 1.40, 1.20]
|
||||
},
|
||||
detail_table: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
rows: [
|
||||
{ '指标': '营业总收入(百万元)', '2020': 5000, '2021': 5800, '2022': 6500, '2023': 7200, '2024E': 7900, '2025E': 8600, '2026E': 9400 },
|
||||
{ '指标': '营收增长率(%)', '2020': '-', '2021': 16.0, '2022': 12.1, '2023': 10.8, '2024E': 9.7, '2025E': 8.9, '2026E': 9.3 },
|
||||
{ '指标': '归母净利润(百万元)', '2020': 450, '2021': 520, '2022': 580, '2023': 650, '2024E': 720, '2025E': 800, '2026E': 890 },
|
||||
{ '指标': 'EPS(稀释,元)', '2020': 0.45, '2021': 0.52, '2022': 0.58, '2023': 0.65, '2024E': 0.72, '2025E': 0.80, '2026E': 0.89 },
|
||||
{ '指标': 'ROE(%)', '2020': 12.5, '2021': 13.2, '2022': 13.8, '2023': 14.2, '2024E': 14.5, '2025E': 14.8, '2026E': 15.0 },
|
||||
{ '指标': 'PE(倍)', '2020': 22.2, '2021': 19.2, '2022': 17.2, '2023': 15.4, '2024E': 13.9, '2025E': 12.5, '2026E': 11.2 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
139
src/mocks/data/financial.js
Normal file
139
src/mocks/data/financial.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// src/mocks/data/financial.js
|
||||
// 财务数据相关的 Mock 数据
|
||||
|
||||
// 生成财务数据
|
||||
export const generateFinancialData = (stockCode) => {
|
||||
const periods = ['2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31'];
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
|
||||
// 股票基本信息
|
||||
stockInfo: {
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
industry: stockCode === '000001' ? '银行' : '制造业',
|
||||
list_date: '1991-04-03',
|
||||
market: 'SZ'
|
||||
},
|
||||
|
||||
// 资产负债表
|
||||
balanceSheet: periods.map((period, i) => ({
|
||||
period,
|
||||
total_assets: 5024560 - i * 50000, // 百万元
|
||||
total_liabilities: 4698880 - i * 48000,
|
||||
shareholders_equity: 325680 - i * 2000,
|
||||
current_assets: 2512300 - i * 25000,
|
||||
non_current_assets: 2512260 - i * 25000,
|
||||
current_liabilities: 3456780 - i * 35000,
|
||||
non_current_liabilities: 1242100 - i * 13000
|
||||
})),
|
||||
|
||||
// 利润表
|
||||
incomeStatement: periods.map((period, i) => ({
|
||||
period,
|
||||
revenue: 162350 - i * 4000, // 百万元
|
||||
operating_cost: 45620 - i * 1200,
|
||||
gross_profit: 116730 - i * 2800,
|
||||
operating_profit: 68450 - i * 1500,
|
||||
net_profit: 52860 - i * 1200,
|
||||
eps: 2.72 - i * 0.06
|
||||
})),
|
||||
|
||||
// 现金流量表
|
||||
cashflow: periods.map((period, i) => ({
|
||||
period,
|
||||
operating_cashflow: 125600 - i * 3000, // 百万元
|
||||
investing_cashflow: -45300 - i * 1000,
|
||||
financing_cashflow: -38200 + i * 500,
|
||||
net_cashflow: 42100 - i * 1500,
|
||||
cash_ending: 456780 - i * 10000
|
||||
})),
|
||||
|
||||
// 财务指标
|
||||
financialMetrics: periods.map((period, i) => ({
|
||||
period,
|
||||
roe: 16.23 - i * 0.3, // %
|
||||
roa: 1.05 - i * 0.02,
|
||||
gross_margin: 71.92 - i * 0.5,
|
||||
net_margin: 32.56 - i * 0.3,
|
||||
current_ratio: 0.73 + i * 0.01,
|
||||
quick_ratio: 0.71 + i * 0.01,
|
||||
debt_ratio: 93.52 + i * 0.05,
|
||||
asset_turnover: 0.41 - i * 0.01,
|
||||
inventory_turnover: 0, // 银行无库存
|
||||
receivable_turnover: 0 // 银行特殊
|
||||
})),
|
||||
|
||||
// 主营业务
|
||||
mainBusiness: {
|
||||
by_product: [
|
||||
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
|
||||
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
|
||||
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
|
||||
],
|
||||
by_region: [
|
||||
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
|
||||
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
|
||||
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
|
||||
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
|
||||
]
|
||||
},
|
||||
|
||||
// 业绩预告
|
||||
forecast: {
|
||||
period: '2024',
|
||||
forecast_net_profit_min: 580000, // 百万元
|
||||
forecast_net_profit_max: 620000,
|
||||
yoy_growth_min: 10.0, // %
|
||||
yoy_growth_max: 17.0,
|
||||
forecast_type: '预增',
|
||||
reason: '受益于零售业务快速增长及资产质量改善,预计全年业绩保持稳定增长',
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
|
||||
// 行业排名
|
||||
industryRank: {
|
||||
industry: '银行',
|
||||
total_companies: 42,
|
||||
rankings: [
|
||||
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
|
||||
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
|
||||
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
|
||||
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
|
||||
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
|
||||
]
|
||||
},
|
||||
|
||||
// 期间对比
|
||||
periodComparison: {
|
||||
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
|
||||
metrics: [
|
||||
{
|
||||
name: '营业收入',
|
||||
unit: '百万元',
|
||||
values: [41500, 40800, 40200, 40850],
|
||||
yoy: [8.2, 7.8, 8.5, 9.2]
|
||||
},
|
||||
{
|
||||
name: '净利润',
|
||||
unit: '百万元',
|
||||
values: [13420, 13180, 13050, 13210],
|
||||
yoy: [12.5, 11.2, 10.8, 12.3]
|
||||
},
|
||||
{
|
||||
name: 'ROE',
|
||||
unit: '%',
|
||||
values: [16.23, 15.98, 15.75, 16.02],
|
||||
yoy: [1.2, 0.8, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
name: 'EPS',
|
||||
unit: '元',
|
||||
values: [0.69, 0.68, 0.67, 0.68],
|
||||
yoy: [12.3, 11.5, 10.5, 12.0]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
};
|
||||
150
src/mocks/data/market.js
Normal file
150
src/mocks/data/market.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// src/mocks/data/market.js
|
||||
// 市场行情相关的 Mock 数据
|
||||
|
||||
// 生成市场数据
|
||||
export const generateMarketData = (stockCode) => {
|
||||
const basePrice = 13.50; // 基准价格(平安银行约13.5元)
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
|
||||
// 成交数据 - 必须包含K线所需的字段
|
||||
tradeData: {
|
||||
success: true,
|
||||
data: Array(30).fill(null).map((_, i) => {
|
||||
const open = basePrice + (Math.random() - 0.5) * 0.5;
|
||||
const close = basePrice + (Math.random() - 0.5) * 0.5;
|
||||
const high = Math.max(open, close) + Math.random() * 0.3;
|
||||
const low = Math.min(open, close) - Math.random() * 0.3;
|
||||
return {
|
||||
date: new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
open: parseFloat(open.toFixed(2)),
|
||||
close: parseFloat(close.toFixed(2)),
|
||||
high: parseFloat(high.toFixed(2)),
|
||||
low: parseFloat(low.toFixed(2)),
|
||||
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
|
||||
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
|
||||
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
|
||||
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
// 资金流向 - 融资融券数据数组
|
||||
fundingData: {
|
||||
success: true,
|
||||
data: Array(30).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
financing: {
|
||||
balance: Math.floor(Math.random() * 5000000000) + 10000000000, // 融资余额
|
||||
buy: Math.floor(Math.random() * 500000000) + 100000000, // 融资买入
|
||||
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
|
||||
},
|
||||
securities: {
|
||||
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额
|
||||
sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
|
||||
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
// 大单统计 - 包含 daily_stats 数组
|
||||
bigDealData: {
|
||||
success: true,
|
||||
data: [],
|
||||
daily_stats: Array(10).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
big_buy: Math.floor(Math.random() * 300000000) + 100000000,
|
||||
big_sell: Math.floor(Math.random() * 300000000) + 80000000,
|
||||
medium_buy: Math.floor(Math.random() * 200000000) + 60000000,
|
||||
medium_sell: Math.floor(Math.random() * 200000000) + 50000000,
|
||||
small_buy: Math.floor(Math.random() * 100000000) + 30000000,
|
||||
small_sell: Math.floor(Math.random() * 100000000) + 25000000
|
||||
}))
|
||||
},
|
||||
|
||||
// 异动分析 - 包含 grouped_data 数组
|
||||
unusualData: {
|
||||
success: true,
|
||||
data: [],
|
||||
grouped_data: Array(5).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
events: [
|
||||
{ time: '14:35:22', type: '快速拉升', change: '+2.3%', description: '5分钟内上涨2.3%' },
|
||||
{ time: '11:28:45', type: '大单买入', amount: '5680万', description: '单笔大单买入' },
|
||||
{ time: '10:15:30', type: '量比异动', ratio: '3.2', description: '量比达到3.2倍' }
|
||||
],
|
||||
count: 3
|
||||
}))
|
||||
},
|
||||
|
||||
// 股权质押
|
||||
pledgeData: {
|
||||
success: true,
|
||||
data: {
|
||||
total_pledged: 25.6, // 质押比例%
|
||||
major_shareholders: [
|
||||
{ name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 },
|
||||
{ name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 }
|
||||
],
|
||||
update_date: '2024-09-30'
|
||||
}
|
||||
},
|
||||
|
||||
// 市场摘要
|
||||
summaryData: {
|
||||
success: true,
|
||||
data: {
|
||||
current_price: basePrice,
|
||||
change: 0.25,
|
||||
change_pct: 1.89,
|
||||
open: 13.35,
|
||||
high: 13.68,
|
||||
low: 13.28,
|
||||
volume: 345678900,
|
||||
amount: 4678900000,
|
||||
turnover_rate: 1.78,
|
||||
pe_ratio: 4.96,
|
||||
pb_ratio: 0.72,
|
||||
total_market_cap: 262300000000,
|
||||
circulating_market_cap: 262300000000
|
||||
}
|
||||
},
|
||||
|
||||
// 涨停分析
|
||||
riseAnalysisData: {
|
||||
success: true,
|
||||
data: {
|
||||
is_limit_up: false,
|
||||
limit_up_price: basePrice * 1.10,
|
||||
current_price: basePrice,
|
||||
distance_to_limit: 8.92, // %
|
||||
consecutive_days: 0,
|
||||
reason: '',
|
||||
concept_tags: ['银行', '深圳国资', 'MSCI', '沪深300']
|
||||
}
|
||||
},
|
||||
|
||||
// 最新分时数据
|
||||
latestMinuteData: {
|
||||
success: true,
|
||||
data: Array(240).fill(null).map((_, i) => {
|
||||
const minute = 9 * 60 + 30 + i; // 从9:30开始
|
||||
const hour = Math.floor(minute / 60);
|
||||
const min = minute % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
||||
const randomChange = (Math.random() - 0.5) * 0.1;
|
||||
return {
|
||||
time,
|
||||
price: (basePrice + randomChange).toFixed(2),
|
||||
volume: Math.floor(Math.random() * 2000000) + 500000,
|
||||
avg_price: (basePrice + randomChange * 0.8).toFixed(2)
|
||||
};
|
||||
}),
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例股票',
|
||||
trade_date: new Date().toISOString().split('T')[0],
|
||||
type: 'minute'
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -136,7 +136,9 @@ export const authHandlers = [
|
||||
});
|
||||
|
||||
// 模拟微信授权 URL(实际是微信的 URL)
|
||||
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=mock&redirect_uri=&response_type=code&scope=snsapi_login&state=${sessionId}#wechat_redirect`;
|
||||
// 使用真实的微信 AppID 和真实的授权回调地址(必须与微信开放平台配置的域名一致)
|
||||
const mockRedirectUri = encodeURIComponent('http://valuefrontier.cn/api/auth/wechat/callback');
|
||||
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=wxa8d74c47041b5f87&redirect_uri=${mockRedirectUri}&response_type=code&scope=snsapi_login&state=${sessionId}#wechat_redirect`;
|
||||
|
||||
console.log('[Mock] 生成微信二维码:', { sessionId, authUrl });
|
||||
|
||||
@@ -147,16 +149,16 @@ export const authHandlers = [
|
||||
session.status = 'scanned';
|
||||
console.log(`[Mock] 模拟用户扫码: ${sessionId}`);
|
||||
|
||||
// 再过2秒自动确认登录
|
||||
// 再过5秒自动确认登录(延长时间让用户看到 scanned 状态)
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(sessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
session2.status = 'confirmed';
|
||||
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与后端保持一致
|
||||
session2.user = {
|
||||
id: 999,
|
||||
nickname: '微信用户',
|
||||
wechat_openid: 'mock_openid_' + sessionId,
|
||||
avatar_url: 'https://i.pravatar.cc/150?img=99',
|
||||
avatar_url: 'https://ui-avatars.com/api/?name=微信用户&size=150&background=4299e1&color=fff',
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
@@ -168,6 +170,7 @@ export const authHandlers = [
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
|
||||
console.log(`[Mock] 模拟用户确认登录: ${sessionId}`, session2.user);
|
||||
}
|
||||
}, 2000);
|
||||
@@ -185,7 +188,7 @@ export const authHandlers = [
|
||||
}),
|
||||
|
||||
// 4. 检查微信扫码状态
|
||||
http.post('/api/auth/wechat/check-status', async ({ request }) => {
|
||||
http.post('/api/auth/wechat/check', async ({ request }) => {
|
||||
await delay(200); // 轮询请求,延迟短一些
|
||||
|
||||
const body = await request.json();
|
||||
@@ -209,18 +212,16 @@ export const authHandlers = [
|
||||
|
||||
console.log('[Mock] 检查微信状态:', { session_id, status: session.status });
|
||||
|
||||
// ✅ 返回与后端真实 API 一致的扁平化数据结构
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
message: '成功',
|
||||
data: {
|
||||
status: session.status,
|
||||
user: session.user
|
||||
}
|
||||
status: session.status,
|
||||
user_info: session.user_info,
|
||||
expires_in: Math.floor((session.createdAt + 5 * 60 * 1000 - Date.now()) / 1000)
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 微信登录确认
|
||||
http.post('/api/auth/wechat/login', async ({ request }) => {
|
||||
http.post('/api/auth/login/wechat', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
@@ -228,7 +229,7 @@ export const authHandlers = [
|
||||
|
||||
const session = mockWechatSessions.get(session_id);
|
||||
|
||||
if (!session || session.status !== 'confirmed') {
|
||||
if (!session || session.status !== 'authorized') { // ✅ 使用 'authorized' 状态,与前端保持一致
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '微信登录未确认或已过期'
|
||||
@@ -386,12 +387,12 @@ if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(targetSessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
session2.status = 'confirmed';
|
||||
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与自动扫码流程保持一致
|
||||
session2.user = {
|
||||
id: 999,
|
||||
nickname: '微信测试用户',
|
||||
wechat_openid: 'mock_openid_' + targetSessionId,
|
||||
avatar_url: 'https://i.pravatar.cc/150?img=99',
|
||||
avatar_url: 'https://ui-avatars.com/api/?name=微信测试用户&size=150&background=4299e1&color=fff',
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
@@ -402,6 +403,7 @@ if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
|
||||
console.log(`[Mock API] ✅ 模拟确认登录: ${targetSessionId}`, session2.user);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
215
src/mocks/handlers/company.js
Normal file
215
src/mocks/handlers/company.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// src/mocks/handlers/company.js
|
||||
// 公司相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { PINGAN_BANK_DATA, generateCompanyData } from '../data/company';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 获取公司数据的辅助函数
|
||||
const getCompanyData = (stockCode) => {
|
||||
return stockCode === '000001' ? PINGAN_BANK_DATA : generateCompanyData(stockCode, '示例公司');
|
||||
};
|
||||
|
||||
export const companyHandlers = [
|
||||
// 1. 综合分析
|
||||
http.get('/api/company/comprehensive-analysis/:stockCode', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.comprehensiveAnalysis
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 价值链分析
|
||||
http.get('/api/company/value-chain-analysis/:stockCode', async ({ params }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.valueChainAnalysis
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 关键因素时间线
|
||||
http.get('/api/company/key-factors-timeline/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
timeline: data.keyFactorsTimeline,
|
||||
total: data.keyFactorsTimeline.length
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 基本信息
|
||||
http.get('/api/stock/:stockCode/basic-info', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.basicInfo
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 实际控制人
|
||||
http.get('/api/stock/:stockCode/actual-control', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.actualControl
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 股权集中度
|
||||
http.get('/api/stock/:stockCode/concentration', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.concentration
|
||||
});
|
||||
}),
|
||||
|
||||
// 7. 高管信息
|
||||
http.get('/api/stock/:stockCode/management', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const activeOnly = url.searchParams.get('active_only') === 'true';
|
||||
|
||||
let management = data.management || [];
|
||||
|
||||
// 如果需要只返回在职高管(mock 数据中默认都是在职)
|
||||
if (activeOnly) {
|
||||
management = management.filter(m => m.status !== 'resigned');
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: management // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 8. 十大流通股东
|
||||
http.get('/api/stock/:stockCode/top-circulation-shareholders', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
||||
|
||||
const shareholders = (data.topCirculationShareholders || []).slice(0, limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: shareholders // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 9. 十大股东
|
||||
http.get('/api/stock/:stockCode/top-shareholders', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
||||
|
||||
const shareholders = (data.topShareholders || []).slice(0, limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: shareholders // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 10. 分支机构
|
||||
http.get('/api/stock/:stockCode/branches', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.branches || [] // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 11. 公告列表
|
||||
http.get('/api/stock/:stockCode/announcements', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const type = url.searchParams.get('type');
|
||||
|
||||
let announcements = data.announcements || [];
|
||||
|
||||
// 类型筛选
|
||||
if (type) {
|
||||
announcements = announcements.filter(a => a.type === type);
|
||||
}
|
||||
|
||||
// 分页
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const paginatedAnnouncements = announcements.slice(start, end);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: paginatedAnnouncements // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 12. 披露时间表
|
||||
http.get('/api/stock/:stockCode/disclosure-schedule', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.disclosureSchedule || [] // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 13. 盈利预测报告
|
||||
http.get('/api/stock/:stockCode/forecast-report', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.forecastReport || null
|
||||
});
|
||||
}),
|
||||
];
|
||||
121
src/mocks/handlers/financial.js
Normal file
121
src/mocks/handlers/financial.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// src/mocks/handlers/financial.js
|
||||
// 财务数据相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { generateFinancialData } from '../data/financial';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const financialHandlers = [
|
||||
// 1. 股票基本信息
|
||||
http.get('/api/financial/stock-info/:stockCode', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.stockInfo
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 资产负债表
|
||||
http.get('/api/financial/balance-sheet/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.balanceSheet.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 利润表
|
||||
http.get('/api/financial/income-statement/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.incomeStatement.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 现金流量表
|
||||
http.get('/api/financial/cashflow/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.cashflow.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 财务指标
|
||||
http.get('/api/financial/financial-metrics/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.financialMetrics.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 主营业务
|
||||
http.get('/api/financial/main-business/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.mainBusiness
|
||||
});
|
||||
}),
|
||||
|
||||
// 7. 业绩预告
|
||||
http.get('/api/financial/forecast/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.forecast
|
||||
});
|
||||
}),
|
||||
|
||||
// 8. 行业排名
|
||||
http.get('/api/financial/industry-rank/:stockCode', async ({ params }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.industryRank
|
||||
});
|
||||
}),
|
||||
|
||||
// 9. 期间对比
|
||||
http.get('/api/financial/comparison/:stockCode', async ({ params }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.periodComparison
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -9,6 +9,9 @@ import { paymentHandlers } from './payment';
|
||||
import { industryHandlers } from './industry';
|
||||
import { conceptHandlers } from './concept';
|
||||
import { stockHandlers } from './stock';
|
||||
import { companyHandlers } from './company';
|
||||
import { marketHandlers } from './market';
|
||||
import { financialHandlers } from './financial';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
@@ -22,5 +25,8 @@ export const handlers = [
|
||||
...industryHandlers,
|
||||
...conceptHandlers,
|
||||
...stockHandlers,
|
||||
...companyHandlers,
|
||||
...marketHandlers,
|
||||
...financialHandlers,
|
||||
// ...userHandlers,
|
||||
];
|
||||
|
||||
74
src/mocks/handlers/market.js
Normal file
74
src/mocks/handlers/market.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// src/mocks/handlers/market.js
|
||||
// 市场行情相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { generateMarketData } from '../data/market';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const marketHandlers = [
|
||||
// 1. 成交数据
|
||||
http.get('/api/market/trade/:stockCode', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.tradeData);
|
||||
}),
|
||||
|
||||
// 2. 资金流向
|
||||
http.get('/api/market/funding/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.fundingData);
|
||||
}),
|
||||
|
||||
// 3. 大单统计
|
||||
http.get('/api/market/bigdeal/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.bigDealData);
|
||||
}),
|
||||
|
||||
// 4. 异动分析
|
||||
http.get('/api/market/unusual/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.unusualData);
|
||||
}),
|
||||
|
||||
// 5. 股权质押
|
||||
http.get('/api/market/pledge/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.pledgeData);
|
||||
}),
|
||||
|
||||
// 6. 市场摘要
|
||||
http.get('/api/market/summary/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.summaryData);
|
||||
}),
|
||||
|
||||
// 7. 涨停分析
|
||||
http.get('/api/market/rise-analysis/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.riseAnalysisData);
|
||||
}),
|
||||
|
||||
// 8. 最新分时数据
|
||||
http.get('/api/stock/:stockCode/latest-minute', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.latestMinuteData);
|
||||
}),
|
||||
];
|
||||
@@ -144,8 +144,8 @@ export const WECHAT_STATUS = {
|
||||
WAITING: 'waiting',
|
||||
SCANNED: 'scanned',
|
||||
AUTHORIZED: 'authorized',
|
||||
LOGIN_SUCCESS: 'login_success',
|
||||
REGISTER_SUCCESS: 'register_success',
|
||||
LOGIN_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
REGISTER_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
EXPIRED: 'expired',
|
||||
};
|
||||
|
||||
@@ -153,7 +153,7 @@ export const WECHAT_STATUS = {
|
||||
* 状态提示信息映射
|
||||
*/
|
||||
export const STATUS_MESSAGES = {
|
||||
[WECHAT_STATUS.WAITING]: '请使用微信扫码',
|
||||
[WECHAT_STATUS.WAITING]: '使用微信扫一扫登陆',
|
||||
[WECHAT_STATUS.SCANNED]: '扫码成功,请在手机上确认',
|
||||
[WECHAT_STATUS.AUTHORIZED]: '授权成功,正在登录...',
|
||||
[WECHAT_STATUS.EXPIRED]: '二维码已过期',
|
||||
|
||||
@@ -303,6 +303,7 @@ const mockFinancialNews = [
|
||||
class MockSocketService {
|
||||
constructor() {
|
||||
this.connected = false;
|
||||
this.connecting = false; // 新增:正在连接标志,防止重复连接
|
||||
this.listeners = new Map();
|
||||
this.intervals = [];
|
||||
this.messageQueue = [];
|
||||
@@ -325,18 +326,30 @@ class MockSocketService {
|
||||
* 连接到 mock socket
|
||||
*/
|
||||
connect() {
|
||||
// ✅ 防止重复连接
|
||||
if (this.connected) {
|
||||
logger.warn('mockSocketService', 'Already connected');
|
||||
console.log('%c[Mock Socket] Already connected, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connecting) {
|
||||
logger.warn('mockSocketService', 'Connection in progress');
|
||||
console.log('%c[Mock Socket] Connection already in progress, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
this.connecting = true; // 标记为连接中
|
||||
logger.info('mockSocketService', 'Connecting to mock socket service...');
|
||||
console.log('%c[Mock Socket] 🔌 Connecting...', 'color: #2196F3; font-weight: bold;');
|
||||
|
||||
// 模拟连接延迟
|
||||
setTimeout(() => {
|
||||
// 检查是否应该模拟连接失败
|
||||
if (this.failConnection) {
|
||||
this.connecting = false; // 清除连接中标志
|
||||
logger.warn('mockSocketService', 'Simulated connection failure');
|
||||
console.log('%c[Mock Socket] ❌ Connection failed (simulated)', 'color: #F44336; font-weight: bold;');
|
||||
|
||||
// 触发连接错误事件
|
||||
this.emit('connect_error', {
|
||||
@@ -351,6 +364,7 @@ class MockSocketService {
|
||||
|
||||
// 正常连接成功
|
||||
this.connected = true;
|
||||
this.connecting = false; // 清除连接中标志
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// 清除自定义重连定时器
|
||||
@@ -360,9 +374,15 @@ class MockSocketService {
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', 'Mock socket connected successfully');
|
||||
console.log('%c[Mock Socket] ✅ Connected successfully!', 'color: #4CAF50; font-weight: bold; font-size: 14px;');
|
||||
console.log(`%c[Mock Socket] Status: connected=${this.connected}, connecting=${this.connecting}`, 'color: #4CAF50;');
|
||||
|
||||
// 触发连接成功事件
|
||||
this.emit('connect', { timestamp: Date.now() });
|
||||
// ✅ 使用 setTimeout(0) 确保监听器已注册后再触发事件
|
||||
setTimeout(() => {
|
||||
console.log('%c[Mock Socket] Emitting connect event...', 'color: #9C27B0;');
|
||||
this.emit('connect', { timestamp: Date.now() });
|
||||
console.log('%c[Mock Socket] Connect event emitted', 'color: #9C27B0;');
|
||||
}, 0);
|
||||
|
||||
// 在连接后3秒发送欢迎消息
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -25,4 +25,73 @@ console.log(
|
||||
`color: ${useMock ? '#FF9800' : '#4CAF50'}; font-weight: bold; font-size: 12px;`
|
||||
);
|
||||
|
||||
// ========== 暴露调试 API 到全局 ==========
|
||||
if (typeof window !== 'undefined') {
|
||||
// 暴露 Socket 类型到全局
|
||||
window.SOCKET_TYPE = SOCKET_TYPE;
|
||||
|
||||
// 暴露调试 API
|
||||
window.__SOCKET_DEBUG__ = {
|
||||
// 获取当前连接状态
|
||||
getStatus: () => {
|
||||
const isConnected = socket.connected || false;
|
||||
return {
|
||||
type: SOCKET_TYPE,
|
||||
connected: isConnected,
|
||||
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
|
||||
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
|
||||
service: useMock ? 'mockSocketService' : 'socketService',
|
||||
};
|
||||
},
|
||||
|
||||
// 手动重连
|
||||
reconnect: () => {
|
||||
console.log('[Socket Debug] Manual reconnect triggered');
|
||||
if (socket.reconnect) {
|
||||
socket.reconnect();
|
||||
} else {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
}
|
||||
},
|
||||
|
||||
// 断开连接
|
||||
disconnect: () => {
|
||||
console.log('[Socket Debug] Manual disconnect triggered');
|
||||
socket.disconnect();
|
||||
},
|
||||
|
||||
// 连接
|
||||
connect: () => {
|
||||
console.log('[Socket Debug] Manual connect triggered');
|
||||
socket.connect();
|
||||
},
|
||||
|
||||
// 获取服务实例 (仅用于调试)
|
||||
getService: () => socket,
|
||||
|
||||
// 导出诊断信息
|
||||
exportDiagnostics: () => {
|
||||
const status = window.__SOCKET_DEBUG__.getStatus();
|
||||
const diagnostics = {
|
||||
...status,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
};
|
||||
console.log('[Socket Diagnostics]', diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
};
|
||||
|
||||
console.log(
|
||||
'%c[Socket Debug] Debug API available at window.__SOCKET_DEBUG__',
|
||||
'color: #2196F3; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
}
|
||||
|
||||
export default socket;
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
// src/store/index.js
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import communityDataReducer from './slices/communityDataSlice';
|
||||
import posthogReducer from './slices/posthogSlice';
|
||||
import posthogMiddleware from './middleware/posthogMiddleware';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
communityData: communityDataReducer
|
||||
communityData: communityDataReducer,
|
||||
posthog: posthogReducer, // ✅ PostHog Redux 状态管理
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
// 忽略这些 action types 的序列化检查
|
||||
ignoredActions: ['communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchHotEvents/fulfilled'],
|
||||
ignoredActions: [
|
||||
'communityData/fetchPopularKeywords/fulfilled',
|
||||
'communityData/fetchHotEvents/fulfilled',
|
||||
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪
|
||||
],
|
||||
},
|
||||
}),
|
||||
}).concat(posthogMiddleware), // ✅ PostHog 自动追踪中间件
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
281
src/store/middleware/posthogMiddleware.js
Normal file
281
src/store/middleware/posthogMiddleware.js
Normal file
@@ -0,0 +1,281 @@
|
||||
// src/store/middleware/posthogMiddleware.js
|
||||
import { trackPageView } from '../../lib/posthog';
|
||||
import { trackEvent } from '../slices/posthogSlice';
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
ACTIVATION_EVENTS,
|
||||
RETENTION_EVENTS,
|
||||
SPECIAL_EVENTS,
|
||||
REVENUE_EVENTS,
|
||||
} from '../../lib/constants';
|
||||
|
||||
// ==================== 自动追踪规则配置 ====================
|
||||
|
||||
/**
|
||||
* Action 到 PostHog 事件的映射
|
||||
* 当这些 Redux actions 被 dispatch 时,自动追踪对应的 PostHog 事件
|
||||
*/
|
||||
const ACTION_TO_EVENT_MAP = {
|
||||
// ==================== 登录/登出 ====================
|
||||
'auth/login/fulfilled': {
|
||||
event: ACTIVATION_EVENTS.USER_LOGGED_IN,
|
||||
getProperties: (action) => ({
|
||||
login_method: action.payload?.login_method || 'unknown',
|
||||
user_id: action.payload?.user?.id,
|
||||
}),
|
||||
},
|
||||
'auth/logout': {
|
||||
event: SPECIAL_EVENTS.USER_LOGGED_OUT,
|
||||
getProperties: () => ({}),
|
||||
},
|
||||
'auth/wechatLogin/fulfilled': {
|
||||
event: ACTIVATION_EVENTS.USER_LOGGED_IN,
|
||||
getProperties: (action) => ({
|
||||
login_method: 'wechat',
|
||||
user_id: action.payload?.user?.id,
|
||||
}),
|
||||
},
|
||||
|
||||
// ==================== Community/新闻模块 ====================
|
||||
'communityData/fetchHotEvents/fulfilled': {
|
||||
event: RETENTION_EVENTS.NEWS_LIST_VIEWED,
|
||||
getProperties: (action) => ({
|
||||
event_count: action.payload?.length || 0,
|
||||
source: 'community_page',
|
||||
}),
|
||||
},
|
||||
'communityData/fetchPopularKeywords/fulfilled': {
|
||||
event: RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED,
|
||||
getProperties: () => ({
|
||||
feature: 'popular_keywords',
|
||||
}),
|
||||
},
|
||||
|
||||
// ==================== 搜索 ====================
|
||||
'search/submit': {
|
||||
event: RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED,
|
||||
getProperties: (action) => ({
|
||||
query: action.payload?.query,
|
||||
category: action.payload?.category,
|
||||
}),
|
||||
},
|
||||
'search/filterApplied': {
|
||||
event: RETENTION_EVENTS.SEARCH_FILTER_APPLIED,
|
||||
getProperties: (action) => ({
|
||||
filter_type: action.payload?.filterType,
|
||||
filter_value: action.payload?.filterValue,
|
||||
}),
|
||||
},
|
||||
|
||||
// ==================== 支付/订阅 ====================
|
||||
'payment/initiated': {
|
||||
event: REVENUE_EVENTS.PAYMENT_INITIATED,
|
||||
getProperties: (action) => ({
|
||||
amount: action.payload?.amount,
|
||||
payment_method: action.payload?.method,
|
||||
subscription_tier: action.payload?.tier,
|
||||
}),
|
||||
},
|
||||
'payment/success': {
|
||||
event: REVENUE_EVENTS.PAYMENT_SUCCESSFUL,
|
||||
getProperties: (action) => ({
|
||||
amount: action.payload?.amount,
|
||||
transaction_id: action.payload?.transactionId,
|
||||
subscription_tier: action.payload?.tier,
|
||||
}),
|
||||
},
|
||||
'subscription/upgraded': {
|
||||
event: REVENUE_EVENTS.SUBSCRIPTION_UPGRADED,
|
||||
getProperties: (action) => ({
|
||||
from_tier: action.payload?.fromTier,
|
||||
to_tier: action.payload?.toTier,
|
||||
}),
|
||||
},
|
||||
|
||||
// ==================== 错误追踪 ====================
|
||||
'error/occurred': {
|
||||
event: SPECIAL_EVENTS.ERROR_OCCURRED,
|
||||
getProperties: (action) => ({
|
||||
error_type: action.payload?.errorType,
|
||||
error_message: action.payload?.message,
|
||||
stack_trace: action.payload?.stack,
|
||||
}),
|
||||
},
|
||||
'api/error': {
|
||||
event: SPECIAL_EVENTS.API_ERROR,
|
||||
getProperties: (action) => ({
|
||||
endpoint: action.payload?.endpoint,
|
||||
status_code: action.payload?.statusCode,
|
||||
error_message: action.payload?.message,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 页面路由追踪配置 ====================
|
||||
|
||||
/**
|
||||
* 路由变化的 action type(根据不同路由库调整)
|
||||
*/
|
||||
const LOCATION_CHANGE_ACTIONS = [
|
||||
'@@router/LOCATION_CHANGE', // Redux-first router
|
||||
'router/navigate', // 自定义路由 action
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据路径识别页面类型
|
||||
*/
|
||||
const getPageTypeFromPath = (pathname) => {
|
||||
if (pathname === '/home' || pathname === '/') {
|
||||
return { page_type: 'landing' };
|
||||
} else if (pathname.startsWith('/auth/')) {
|
||||
return { page_type: 'auth' };
|
||||
} else if (pathname.startsWith('/community')) {
|
||||
return { page_type: 'feature', feature_name: 'community' };
|
||||
} else if (pathname.startsWith('/concepts')) {
|
||||
return { page_type: 'feature', feature_name: 'concepts' };
|
||||
} else if (pathname.startsWith('/stocks')) {
|
||||
return { page_type: 'feature', feature_name: 'stocks' };
|
||||
} else if (pathname.startsWith('/limit-analyse')) {
|
||||
return { page_type: 'feature', feature_name: 'limit_analyse' };
|
||||
} else if (pathname.startsWith('/trading-simulation')) {
|
||||
return { page_type: 'feature', feature_name: 'trading_simulation' };
|
||||
} else if (pathname.startsWith('/company')) {
|
||||
return { page_type: 'detail', content_type: 'company' };
|
||||
} else if (pathname.startsWith('/event-detail')) {
|
||||
return { page_type: 'detail', content_type: 'event' };
|
||||
}
|
||||
return { page_type: 'other' };
|
||||
};
|
||||
|
||||
// ==================== 中间件实现 ====================
|
||||
|
||||
/**
|
||||
* PostHog Middleware
|
||||
* 自动拦截 Redux actions 并追踪对应的 PostHog 事件
|
||||
*/
|
||||
const posthogMiddleware = (store) => (next) => (action) => {
|
||||
// 先执行 action
|
||||
const result = next(action);
|
||||
|
||||
// 获取当前 PostHog 状态
|
||||
const state = store.getState();
|
||||
const posthogState = state.posthog;
|
||||
|
||||
// 如果 PostHog 未初始化,不追踪(事件会被缓存到 eventQueue)
|
||||
if (!posthogState?.isInitialized) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// ==================== 1. 自动追踪特定 actions ====================
|
||||
if (ACTION_TO_EVENT_MAP[action.type]) {
|
||||
const { event, getProperties } = ACTION_TO_EVENT_MAP[action.type];
|
||||
const properties = getProperties(action);
|
||||
|
||||
// 通过 dispatch 追踪事件(会走 Redux 状态管理)
|
||||
store.dispatch(trackEvent({ eventName: event, properties }));
|
||||
|
||||
logger.debug('PostHog Middleware', `自动追踪事件: ${event}`, properties);
|
||||
}
|
||||
|
||||
// ==================== 2. 路由变化追踪 ====================
|
||||
if (LOCATION_CHANGE_ACTIONS.includes(action.type)) {
|
||||
const location = action.payload?.location || action.payload;
|
||||
const pathname = location?.pathname || window.location.pathname;
|
||||
const search = location?.search || window.location.search;
|
||||
|
||||
// 识别页面类型
|
||||
const pageProperties = getPageTypeFromPath(pathname);
|
||||
|
||||
// 追踪页面浏览
|
||||
trackPageView(pathname, {
|
||||
...pageProperties,
|
||||
page_path: pathname,
|
||||
page_search: search,
|
||||
page_title: document.title,
|
||||
referrer: document.referrer,
|
||||
});
|
||||
|
||||
logger.debug('PostHog Middleware', `页面浏览追踪: ${pathname}`, pageProperties);
|
||||
}
|
||||
|
||||
// ==================== 3. 离线事件处理 ====================
|
||||
// 检测网络状态变化
|
||||
if (action.type === 'network/online') {
|
||||
// 恢复在线时,刷新缓存的事件
|
||||
const { eventQueue } = posthogState;
|
||||
if (eventQueue && eventQueue.length > 0) {
|
||||
logger.info('PostHog Middleware', `网络恢复,刷新 ${eventQueue.length} 个缓存事件`);
|
||||
// 这里可以 dispatch flushCachedEvents,但为了避免循环依赖,直接在 slice 中处理
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 4. 性能追踪(可选) ====================
|
||||
// 追踪耗时较长的 actions
|
||||
const startTime = action.meta?.startTime;
|
||||
if (startTime) {
|
||||
const duration = Date.now() - startTime;
|
||||
if (duration > 1000) {
|
||||
// 超过 1 秒的操作
|
||||
store.dispatch(trackEvent({
|
||||
eventName: SPECIAL_EVENTS.PAGE_LOAD_TIME,
|
||||
properties: {
|
||||
action_type: action.type,
|
||||
duration_ms: duration,
|
||||
is_slow: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PostHog Middleware', '追踪失败', error, { actionType: action.type });
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 创建带性能追踪的 action creator
|
||||
* 用法: dispatch(withTiming(someAction(payload)))
|
||||
*/
|
||||
export const withTiming = (action) => ({
|
||||
...action,
|
||||
meta: {
|
||||
...action.meta,
|
||||
startTime: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 手动触发页面浏览追踪
|
||||
* 用于非路由跳转的场景(如 Modal、Tab 切换)
|
||||
*/
|
||||
export const trackModalView = (modalName, properties = {}) => (dispatch) => {
|
||||
dispatch(trackEvent({
|
||||
eventName: '$pageview',
|
||||
properties: {
|
||||
modal_name: modalName,
|
||||
page_type: 'modal',
|
||||
...properties,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* 追踪 Tab 切换
|
||||
*/
|
||||
export const trackTabChange = (tabName, properties = {}) => (dispatch) => {
|
||||
dispatch(trackEvent({
|
||||
eventName: RETENTION_EVENTS.NEWS_TAB_CLICKED,
|
||||
properties: {
|
||||
tab_name: tabName,
|
||||
...properties,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// ==================== Export ====================
|
||||
|
||||
export default posthogMiddleware;
|
||||
@@ -145,7 +145,7 @@ export const fetchHotEvents = createAsyncThunk(
|
||||
try {
|
||||
return await fetchWithCache({
|
||||
cacheKey: CACHE_KEYS.HOT_EVENTS,
|
||||
fetchFn: () => eventService.getHotEvents({ days: 5, limit: 4 }),
|
||||
fetchFn: () => eventService.getHotEvents({ days: 5, limit: 20 }),
|
||||
getState,
|
||||
stateKey: 'hotEvents',
|
||||
forceRefresh
|
||||
|
||||
299
src/store/slices/posthogSlice.js
Normal file
299
src/store/slices/posthogSlice.js
Normal file
@@ -0,0 +1,299 @@
|
||||
// src/store/slices/posthogSlice.js
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import {
|
||||
initPostHog,
|
||||
identifyUser as posthogIdentifyUser,
|
||||
resetUser as posthogResetUser,
|
||||
trackEvent as posthogTrackEvent,
|
||||
getFeatureFlag as posthogGetFeatureFlag,
|
||||
optIn as posthogOptIn,
|
||||
optOut as posthogOptOut,
|
||||
hasOptedOut as posthogHasOptedOut
|
||||
} from '../../lib/posthog';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// ==================== Initial State ====================
|
||||
|
||||
const initialState = {
|
||||
// 初始化状态
|
||||
isInitialized: false,
|
||||
initError: null,
|
||||
|
||||
// 用户信息
|
||||
user: null,
|
||||
|
||||
// 事件队列(用于离线缓存)
|
||||
eventQueue: [],
|
||||
|
||||
// Feature Flags
|
||||
featureFlags: {},
|
||||
|
||||
// 配置
|
||||
config: {
|
||||
apiKey: process.env.REACT_APP_POSTHOG_KEY || null,
|
||||
apiHost: process.env.REACT_APP_POSTHOG_HOST || 'https://app.posthog.com',
|
||||
sessionRecording: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
|
||||
},
|
||||
|
||||
// 统计
|
||||
stats: {
|
||||
totalEvents: 0,
|
||||
lastEventTime: null,
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== Async Thunks ====================
|
||||
|
||||
/**
|
||||
* 初始化 PostHog SDK
|
||||
*/
|
||||
export const initializePostHog = createAsyncThunk(
|
||||
'posthog/initialize',
|
||||
async (_, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { config } = getState().posthog;
|
||||
|
||||
if (!config.apiKey) {
|
||||
logger.warn('PostHog', '未配置 API Key,分析功能将被禁用');
|
||||
return { isInitialized: false, warning: 'No API Key' };
|
||||
}
|
||||
|
||||
// 调用 PostHog SDK 初始化
|
||||
initPostHog();
|
||||
|
||||
logger.info('PostHog', 'Redux 初始化成功');
|
||||
|
||||
return { isInitialized: true };
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '初始化失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 识别用户
|
||||
*/
|
||||
export const identifyUser = createAsyncThunk(
|
||||
'posthog/identifyUser',
|
||||
async ({ userId, userProperties }, { rejectWithValue }) => {
|
||||
try {
|
||||
posthogIdentifyUser(userId, userProperties);
|
||||
logger.info('PostHog', '用户已识别', { userId });
|
||||
return { userId, userProperties };
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '用户识别失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 重置用户会话(登出)
|
||||
*/
|
||||
export const resetUser = createAsyncThunk(
|
||||
'posthog/resetUser',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
posthogResetUser();
|
||||
logger.info('PostHog', '用户会话已重置');
|
||||
return {};
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '重置用户会话失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 追踪事件
|
||||
*/
|
||||
export const trackEvent = createAsyncThunk(
|
||||
'posthog/trackEvent',
|
||||
async ({ eventName, properties = {} }, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { isInitialized } = getState().posthog;
|
||||
|
||||
if (!isInitialized) {
|
||||
logger.warn('PostHog', 'PostHog 未初始化,事件将被缓存', { eventName });
|
||||
return { eventName, properties, cached: true };
|
||||
}
|
||||
|
||||
posthogTrackEvent(eventName, properties);
|
||||
|
||||
return {
|
||||
eventName,
|
||||
properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
cached: false
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '追踪事件失败', error, { eventName });
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取所有 Feature Flags
|
||||
*/
|
||||
export const fetchFeatureFlags = createAsyncThunk(
|
||||
'posthog/fetchFeatureFlags',
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
// PostHog SDK 会在初始化时自动获取 feature flags
|
||||
// 这里只是读取缓存的值
|
||||
const flags = {};
|
||||
logger.info('PostHog', 'Feature Flags 已更新');
|
||||
return flags;
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '获取 Feature Flags 失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 刷新缓存的离线事件
|
||||
*/
|
||||
export const flushCachedEvents = createAsyncThunk(
|
||||
'posthog/flushCachedEvents',
|
||||
async (_, { getState, dispatch }) => {
|
||||
try {
|
||||
const { eventQueue, isInitialized } = getState().posthog;
|
||||
|
||||
if (!isInitialized || eventQueue.length === 0) {
|
||||
return { flushed: 0 };
|
||||
}
|
||||
|
||||
logger.info('PostHog', `刷新 ${eventQueue.length} 个缓存事件`);
|
||||
|
||||
// 批量发送缓存的事件
|
||||
for (const { eventName, properties } of eventQueue) {
|
||||
dispatch(trackEvent({ eventName, properties }));
|
||||
}
|
||||
|
||||
return { flushed: eventQueue.length };
|
||||
} catch (error) {
|
||||
logger.error('PostHog', '刷新缓存事件失败', error);
|
||||
return { flushed: 0, error: error.message };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== Slice ====================
|
||||
|
||||
const posthogSlice = createSlice({
|
||||
name: 'posthog',
|
||||
initialState,
|
||||
reducers: {
|
||||
// 设置 Feature Flag
|
||||
setFeatureFlag: (state, action) => {
|
||||
const { flagKey, value } = action.payload;
|
||||
state.featureFlags[flagKey] = value;
|
||||
},
|
||||
|
||||
// 清空事件队列
|
||||
clearEventQueue: (state) => {
|
||||
state.eventQueue = [];
|
||||
},
|
||||
|
||||
// 更新配置
|
||||
updateConfig: (state, action) => {
|
||||
state.config = { ...state.config, ...action.payload };
|
||||
},
|
||||
|
||||
// 用户 Opt-in
|
||||
optIn: (state) => {
|
||||
posthogOptIn();
|
||||
logger.info('PostHog', '用户已选择加入追踪');
|
||||
},
|
||||
|
||||
// 用户 Opt-out
|
||||
optOut: (state) => {
|
||||
posthogOptOut();
|
||||
logger.info('PostHog', '用户已选择退出追踪');
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
// 初始化
|
||||
builder.addCase(initializePostHog.fulfilled, (state, action) => {
|
||||
state.isInitialized = action.payload.isInitialized;
|
||||
state.initError = null;
|
||||
});
|
||||
builder.addCase(initializePostHog.rejected, (state, action) => {
|
||||
state.isInitialized = false;
|
||||
state.initError = action.payload;
|
||||
});
|
||||
|
||||
// 识别用户
|
||||
builder.addCase(identifyUser.fulfilled, (state, action) => {
|
||||
state.user = {
|
||||
userId: action.payload.userId,
|
||||
...action.payload.userProperties,
|
||||
};
|
||||
});
|
||||
|
||||
// 重置用户
|
||||
builder.addCase(resetUser.fulfilled, (state) => {
|
||||
state.user = null;
|
||||
state.featureFlags = {};
|
||||
});
|
||||
|
||||
// 追踪事件
|
||||
builder.addCase(trackEvent.fulfilled, (state, action) => {
|
||||
const { eventName, properties, timestamp, cached } = action.payload;
|
||||
|
||||
// 如果事件被缓存,添加到队列
|
||||
if (cached) {
|
||||
state.eventQueue.push({ eventName, properties, timestamp });
|
||||
} else {
|
||||
// 更新统计
|
||||
state.stats.totalEvents += 1;
|
||||
state.stats.lastEventTime = timestamp;
|
||||
}
|
||||
});
|
||||
|
||||
// 刷新缓存事件
|
||||
builder.addCase(flushCachedEvents.fulfilled, (state, action) => {
|
||||
if (action.payload.flushed > 0) {
|
||||
state.eventQueue = [];
|
||||
state.stats.totalEvents += action.payload.flushed;
|
||||
}
|
||||
});
|
||||
|
||||
// 获取 Feature Flags
|
||||
builder.addCase(fetchFeatureFlags.fulfilled, (state, action) => {
|
||||
state.featureFlags = action.payload;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== Actions ====================
|
||||
|
||||
export const {
|
||||
setFeatureFlag,
|
||||
clearEventQueue,
|
||||
updateConfig,
|
||||
optIn,
|
||||
optOut,
|
||||
} = posthogSlice.actions;
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectPostHog = (state) => state.posthog;
|
||||
export const selectIsInitialized = (state) => state.posthog.isInitialized;
|
||||
export const selectUser = (state) => state.posthog.user;
|
||||
export const selectFeatureFlags = (state) => state.posthog.featureFlags;
|
||||
export const selectEventQueue = (state) => state.posthog.eventQueue;
|
||||
export const selectStats = (state) => state.posthog.stats;
|
||||
|
||||
export const selectFeatureFlag = (flagKey) => (state) => {
|
||||
return state.posthog.featureFlags[flagKey] || posthogGetFeatureFlag(flagKey);
|
||||
};
|
||||
|
||||
export const selectIsOptedOut = () => posthogHasOptedOut();
|
||||
|
||||
// ==================== Export ====================
|
||||
|
||||
export default posthogSlice.reducer;
|
||||
@@ -3,6 +3,43 @@
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
// ========== 日志限流配置 ==========
|
||||
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次
|
||||
const recentLogs = new Map(); // 日志缓存,用于去重
|
||||
const MAX_CACHE_SIZE = 100; // 最大缓存数量
|
||||
|
||||
/**
|
||||
* 生成日志的唯一键
|
||||
*/
|
||||
function getLogKey(component, message) {
|
||||
return `${component}:${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该输出日志(限流检查)
|
||||
*/
|
||||
function shouldLog(component, message) {
|
||||
const key = getLogKey(component, message);
|
||||
const now = Date.now();
|
||||
const lastLog = recentLogs.get(key);
|
||||
|
||||
// 如果1秒内已经输出过相同日志,跳过
|
||||
if (lastLog && now - lastLog < LOG_THROTTLE_TIME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 记录日志时间
|
||||
recentLogs.set(key, now);
|
||||
|
||||
// 限制缓存大小,避免内存泄漏
|
||||
if (recentLogs.size > MAX_CACHE_SIZE) {
|
||||
const oldestKey = recentLogs.keys().next().value;
|
||||
recentLogs.delete(oldestKey);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一日志工具
|
||||
* 开发环境:输出详细日志
|
||||
@@ -20,7 +57,7 @@ export const logger = {
|
||||
* @param {object} data - 请求参数/body
|
||||
*/
|
||||
request: (method, url, data = null) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog('API', `${method} ${url}`)) {
|
||||
console.group(`🌐 API Request: ${method} ${url}`);
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
if (data) console.log('Data:', data);
|
||||
@@ -36,7 +73,7 @@ export const logger = {
|
||||
* @param {any} data - 响应数据
|
||||
*/
|
||||
response: (method, url, status, data) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog('API', `${method} ${url} ${status}`)) {
|
||||
console.group(`✅ API Response: ${method} ${url}`);
|
||||
console.log('Status:', status);
|
||||
console.log('Data:', data);
|
||||
@@ -53,6 +90,7 @@ export const logger = {
|
||||
* @param {object} requestData - 请求参数(可选)
|
||||
*/
|
||||
error: (method, url, error, requestData = null) => {
|
||||
// API 错误始终输出,不做限流
|
||||
console.group(`❌ API Error: ${method} ${url}`);
|
||||
console.error('Error:', error);
|
||||
console.error('Message:', error?.message || error);
|
||||
@@ -75,6 +113,7 @@ export const logger = {
|
||||
* @param {object} context - 上下文信息(可选)
|
||||
*/
|
||||
error: (component, method, error, context = {}) => {
|
||||
// 错误日志始终输出,不做限流
|
||||
console.group(`🔴 Error in ${component}.${method}`);
|
||||
console.error('Error:', error);
|
||||
console.error('Message:', error?.message || error);
|
||||
@@ -93,7 +132,7 @@ export const logger = {
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
warn: (component, message, data = {}) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
console.group(`⚠️ Warning: ${component}`);
|
||||
console.warn('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
@@ -111,7 +150,7 @@ export const logger = {
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
debug: (component, message, data = {}) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
console.group(`🐛 Debug: ${component}`);
|
||||
console.log('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
@@ -129,7 +168,7 @@ export const logger = {
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
info: (component, message, data = {}) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
console.group(`ℹ️ Info: ${component}`);
|
||||
console.log('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import EventListSection from './EventListSection';
|
||||
* @param {Array} popularKeywords - 热门关键词
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onSearch - 搜索回调
|
||||
* @param {Function} onSearchFocus - 搜索框获得焦点回调
|
||||
* @param {Function} onPageChange - 分页变化回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
@@ -35,15 +36,17 @@ const EventTimelineCard = forwardRef(({
|
||||
popularKeywords,
|
||||
lastUpdateTime,
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
onPageChange,
|
||||
onEventClick,
|
||||
onViewDetail
|
||||
onViewDetail,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
return (
|
||||
<Card ref={ref} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader>
|
||||
<EventTimelineHeader lastUpdateTime={lastUpdateTime} />
|
||||
@@ -55,6 +58,7 @@ const EventTimelineCard = forwardRef(({
|
||||
<Box mb={4}>
|
||||
<UnifiedSearchBox
|
||||
onSearch={onSearch}
|
||||
onSearchFocus={onSearchFocus}
|
||||
popularKeywords={popularKeywords}
|
||||
filters={filters}
|
||||
/>
|
||||
|
||||
@@ -23,7 +23,7 @@ const EventTimelineHeader = ({ lastUpdateTime }) => {
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>实时事件时间轴</Text>
|
||||
<Text>实时事件</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* Hot Events Section */
|
||||
.hot-events-section {
|
||||
padding: 24px 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -17,11 +16,76 @@
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Carousel */
|
||||
.carousel-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-counter {
|
||||
position: absolute;
|
||||
top: 8px; /* 容器内部顶部 */
|
||||
right: 48px; /* 避开右侧箭头 */
|
||||
z-index: 100; /* 确保在卡片和箭头上方 */
|
||||
background: rgba(24, 144, 255, 0.95);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none; /* 不阻挡鼠标事件 */
|
||||
}
|
||||
|
||||
.hot-events-carousel {
|
||||
padding: 0 40px; /* 增加左右padding为箭头留出空间 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hot-events-carousel .carousel-item {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* 自定义箭头样式 */
|
||||
.custom-carousel-arrow {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
.custom-carousel-arrow:hover {
|
||||
background: rgba(255, 255, 255, 1) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.custom-carousel-arrow:hover .anticon {
|
||||
color: #096dd9 !important;
|
||||
}
|
||||
|
||||
/* 箭头位置 */
|
||||
.hot-events-carousel .slick-prev.custom-carousel-arrow {
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
.hot-events-carousel .slick-next.custom-carousel-arrow {
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
/* 禁用状态 */
|
||||
.custom-carousel-arrow.slick-disabled {
|
||||
opacity: 0.3 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.hot-event-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hot-event-card:hover {
|
||||
@@ -29,11 +93,16 @@
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Cover image */
|
||||
/* Card body padding */
|
||||
.hot-event-card .ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Cover image - 高度减半 */
|
||||
.event-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -55,28 +124,53 @@
|
||||
|
||||
/* Card content */
|
||||
.event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.event-header .ant-tag {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
flex: 1;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 标题文字 - inline显示,可以换行 */
|
||||
.event-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 标签紧跟标题后面 */
|
||||
.event-tag {
|
||||
display: inline;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.event-tag .ant-tag {
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
transform: scale(0.9);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 详情描述 - 三行省略 */
|
||||
.event-description {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 4.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-footer {
|
||||
@@ -84,6 +178,7 @@
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.creator {
|
||||
@@ -93,6 +188,19 @@
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
/* 时间样式 - 年月日高亮 */
|
||||
.time {
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.time-date {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time-hour {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
// src/views/Community/components/HotEvents.js
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Badge, Tag, Empty } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, FireOutlined } from '@ant-design/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import './HotEvents.css';
|
||||
import defaultEventImage from '../../../assets/img/default-event.jpg'
|
||||
const HotEvents = ({ events }) => {
|
||||
|
||||
// 自定义箭头组件
|
||||
const CustomArrow = ({ className, style, onClick, direction }) => {
|
||||
const Icon = direction === 'left' ? LeftOutlined : RightOutlined;
|
||||
return (
|
||||
<div
|
||||
className={`${className} custom-carousel-arrow`}
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon style={{ fontSize: '20px', color: '#1890ff' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HotEvents = ({ events, onPageChange }) => {
|
||||
const navigate = useNavigate();
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
const renderPriceChange = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -39,18 +60,60 @@ const HotEvents = ({ events }) => {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = Math.ceil((events?.length || 0) / 4);
|
||||
|
||||
// Carousel 配置
|
||||
const carouselSettings = {
|
||||
dots: false, // 隐藏圆点导航
|
||||
infinite: true, // 始终启用无限循环,确保箭头显示
|
||||
speed: 500,
|
||||
slidesToShow: 4,
|
||||
slidesToScroll: 1,
|
||||
arrows: true, // 保留左右箭头
|
||||
prevArrow: <CustomArrow direction="left" />,
|
||||
nextArrow: <CustomArrow direction="right" />,
|
||||
autoplay: false,
|
||||
beforeChange: (_current, next) => {
|
||||
// 计算实际页码(考虑无限循环)
|
||||
const actualPage = next % totalPages;
|
||||
setCurrentSlide(actualPage);
|
||||
// 通知父组件页码变化
|
||||
if (onPageChange) {
|
||||
onPageChange(actualPage + 1, totalPages);
|
||||
}
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 1200,
|
||||
settings: {
|
||||
slidesToShow: 3,
|
||||
slidesToScroll: 1,
|
||||
}
|
||||
},
|
||||
{
|
||||
breakpoint: 992,
|
||||
settings: {
|
||||
slidesToShow: 2,
|
||||
slidesToScroll: 1,
|
||||
}
|
||||
},
|
||||
{
|
||||
breakpoint: 576,
|
||||
settings: {
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hot-events-section">
|
||||
<h2 className="section-title">
|
||||
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
|
||||
近期热点信息
|
||||
</h2>
|
||||
<p className="section-subtitle">展示最近5天内涨幅最高的事件,助您把握市场热点</p>
|
||||
|
||||
{events && events.length > 0 ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Carousel {...carouselSettings} className="hot-events-carousel">
|
||||
{events.map((event, index) => (
|
||||
<Col lg={6} md={12} sm={24} key={event.id}>
|
||||
<div key={event.id} className="carousel-item">
|
||||
<Card
|
||||
hoverable
|
||||
className="hot-event-card"
|
||||
@@ -75,33 +138,36 @@ const HotEvents = ({ events }) => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div className="event-header">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<p className="event-description">
|
||||
{event.description && event.description.length > 80
|
||||
? `${event.description.substring(0, 80)}...`
|
||||
: event.description}
|
||||
</p>
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="time">{moment(event.created_at).format('MM-DD HH:mm')}</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{/* Custom layout without Card.Meta */}
|
||||
<div className="event-header">
|
||||
<Tooltip title={event.title}>
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="event-tag">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tooltip title={event.description}>
|
||||
<div className="event-description">
|
||||
{event.description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="time">
|
||||
<span className="time-date">{moment(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
{' '}
|
||||
<span className="time-hour">{moment(event.created_at).format('HH:mm')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
</Carousel>
|
||||
) : (
|
||||
<Card>
|
||||
<Empty description="暂无热点信息" />
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// src/views/Community/components/HotEventsSection.js
|
||||
// 热点事件区域组件
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import HotEvents from './HotEvents';
|
||||
@@ -17,6 +19,14 @@ import HotEvents from './HotEvents';
|
||||
*/
|
||||
const HotEventsSection = ({ events }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page, total) => {
|
||||
setCurrentPage(page);
|
||||
setTotalPages(total);
|
||||
};
|
||||
|
||||
// 如果没有热点事件,不渲染组件
|
||||
if (!events || events.length === 0) {
|
||||
@@ -24,12 +34,28 @@ const HotEventsSection = ({ events }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Card mt={8} bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="md">🔥 热点事件</Heading>
|
||||
<Card mt={0} bg={cardBg}>
|
||||
<CardHeader pb={0} display="flex" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box>
|
||||
<Heading size="md">🔥 热点事件</Heading>
|
||||
<p className="section-subtitle" style={{paddingTop: '8px'}}>展示最近5天内涨幅最高的事件,助您把握市场热点</p>
|
||||
</Box>
|
||||
{/* 页码指示器 */}
|
||||
{totalPages > 1 && (
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
ml={4}
|
||||
>
|
||||
{currentPage} / {totalPages}
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<HotEvents events={events} />
|
||||
<CardBody py={0} px={4}>
|
||||
<HotEvents events={events} onPageChange={handlePageChange} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -480,9 +480,10 @@ export default function MidjourneyHeroSection() {
|
||||
minH="100vh"
|
||||
bg="linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, #000000 100%)"
|
||||
overflow="hidden"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{/* 粒子背景 */}
|
||||
<Box position="absolute" inset={0} zIndex={0}>
|
||||
<Box position="absolute" inset={0} zIndex={-1} pointerEvents="none">
|
||||
<Particles
|
||||
id="tsparticles"
|
||||
init={particlesInit}
|
||||
@@ -499,7 +500,7 @@ export default function MidjourneyHeroSection() {
|
||||
<DataStreams />
|
||||
|
||||
{/* 内容容器 */}
|
||||
<Container maxW="7xl" position="relative" zIndex={20} pt={20} pb={20}>
|
||||
<Container maxW="7xl" position="relative" zIndex={1} pt={20} pb={20}>
|
||||
<Grid templateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }} gap={12} alignItems="center" minH="70vh">
|
||||
|
||||
{/* 左侧文本内容 */}
|
||||
@@ -776,7 +777,7 @@ export default function MidjourneyHeroSection() {
|
||||
borderRadius="full"
|
||||
filter="blur(40px)"
|
||||
animation="pulse 4s ease-in-out infinite"
|
||||
animationDelay="2s"
|
||||
sx={{ animationDelay: '2s' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -793,7 +794,7 @@ export default function MidjourneyHeroSection() {
|
||||
right={0}
|
||||
h="128px"
|
||||
bgGradient="linear(to-t, black, transparent)"
|
||||
zIndex={10}
|
||||
zIndex={-1}
|
||||
/>
|
||||
|
||||
{/* 全局样式 */}
|
||||
|
||||
@@ -138,9 +138,9 @@ const PopularKeywords = ({ onKeywordClick, keywords: propKeywords }) => {
|
||||
</span>
|
||||
|
||||
{/* 所有标签 */}
|
||||
{keywords.map((item) => (
|
||||
{keywords.map((item, index) => (
|
||||
<Tag
|
||||
key={item.concept_id}
|
||||
key={item.concept_id || `keyword-${index}`}
|
||||
color={getTagColor(item.change_pct)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const { Option } = AntSelect;
|
||||
|
||||
const UnifiedSearchBox = ({
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
popularKeywords = [],
|
||||
filters = {}
|
||||
}) => {
|
||||
@@ -385,7 +386,7 @@ const UnifiedSearchBox = ({
|
||||
page: 1,
|
||||
|
||||
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
|
||||
q: overrides.q ?? filters.q ?? '',
|
||||
q: (overrides.q ?? filters.q) ?? '',
|
||||
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
|
||||
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
|
||||
|
||||
@@ -486,10 +487,8 @@ const UnifiedSearchBox = ({
|
||||
} else if (key === 'date_range') {
|
||||
// 清除日期范围
|
||||
setDateRange(null);
|
||||
setTimeout(() => {
|
||||
const params = buildFilterParams();
|
||||
triggerSearch(params);
|
||||
}, 50);
|
||||
const params = buildFilterParams({ date_range: '' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'importance') {
|
||||
// 重置重要性为默认值
|
||||
setImportance('all');
|
||||
@@ -521,9 +520,14 @@ const UnifiedSearchBox = ({
|
||||
onChange={handleInputChange}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleStockSelect}
|
||||
onFocus={onSearchFocus}
|
||||
options={stockOptions}
|
||||
placeholder="请输入股票代码/股票名称/相关话题"
|
||||
onPressEnter={handleMainSearch}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleMainSearch();
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
size="large"
|
||||
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
|
||||
|
||||
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;
|
||||
@@ -4,6 +4,8 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
|
||||
/**
|
||||
* 事件筛选逻辑 Hook
|
||||
@@ -15,6 +17,7 @@ import { logger } from '../../../utils/logger';
|
||||
*/
|
||||
export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { track } = usePostHogTrack(); // PostHog 追踪
|
||||
|
||||
// 筛选参数状态 - 初始化时从URL读取,之后只用本地状态
|
||||
const [filters, setFilters] = useState(() => {
|
||||
@@ -35,12 +38,68 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
oldFilters: filters,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 🎯 PostHog 追踪:搜索查询
|
||||
if (newFilters.q !== filters.q && newFilters.q) {
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query: newFilters.q,
|
||||
category: 'news',
|
||||
previous_query: filters.q || null,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:排序变化
|
||||
if (newFilters.sort !== filters.sort) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'sort',
|
||||
filter_value: newFilters.sort,
|
||||
previous_value: filters.sort,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:重要性筛选
|
||||
if (newFilters.importance !== filters.importance) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'importance',
|
||||
filter_value: newFilters.importance,
|
||||
previous_value: filters.importance,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:时间范围筛选
|
||||
if (newFilters.date_range !== filters.date_range && newFilters.date_range) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'date_range',
|
||||
filter_value: newFilters.date_range,
|
||||
previous_value: filters.date_range || null,
|
||||
});
|
||||
}
|
||||
|
||||
// 🎯 PostHog 追踪:行业筛选
|
||||
if (newFilters.industry_code !== filters.industry_code && newFilters.industry_code) {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'industry',
|
||||
filter_value: newFilters.industry_code,
|
||||
previous_value: filters.industry_code || null,
|
||||
});
|
||||
}
|
||||
|
||||
setFilters(newFilters);
|
||||
logger.debug('useEventFilters', '✅ setFilters 已调用 (React异步更新中...)');
|
||||
}, [filters]);
|
||||
}, [filters, track]);
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = useCallback((page) => {
|
||||
// 🎯 PostHog 追踪:翻页
|
||||
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
page,
|
||||
filters: {
|
||||
sort: filters.sort,
|
||||
importance: filters.importance,
|
||||
has_query: !!filters.q,
|
||||
},
|
||||
});
|
||||
|
||||
// 保持现有筛选条件,只更新页码
|
||||
updateFilters({ ...filters, page });
|
||||
|
||||
@@ -53,21 +112,37 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
});
|
||||
}, 100); // 延迟100ms,确保DOM更新
|
||||
}
|
||||
}, [filters, updateFilters, eventTimelineRef]);
|
||||
}, [filters, updateFilters, eventTimelineRef, track]);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((event) => {
|
||||
// 🎯 PostHog 追踪:新闻事件点击
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
event_id: event.id || event.event_id,
|
||||
event_title: event.title,
|
||||
importance: event.importance,
|
||||
source: 'community_page',
|
||||
has_stocks: !!(event.related_stocks && event.related_stocks.length > 0),
|
||||
has_concepts: !!(event.related_concepts && event.related_concepts.length > 0),
|
||||
});
|
||||
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
}, [onEventClick]);
|
||||
}, [onEventClick, track]);
|
||||
|
||||
// 处理查看详情
|
||||
const handleViewDetail = useCallback((eventId) => {
|
||||
// 🎯 PostHog 追踪:查看详情
|
||||
track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
event_id: eventId,
|
||||
source: 'community_page',
|
||||
});
|
||||
|
||||
if (navigate) {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
}
|
||||
}, [navigate]);
|
||||
}, [navigate, track]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
|
||||
@@ -210,8 +210,6 @@
|
||||
|
||||
/* 热点事件部分样式 */
|
||||
.hot-events-section {
|
||||
margin-top: 48px;
|
||||
padding: 32px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Community/index.js
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchPopularKeywords, fetchHotEvents } from '../../store/slices/communityDataSlice';
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
// 导入组件
|
||||
import MidjourneyHeroSection from './components/MidjourneyHeroSection';
|
||||
import EventTimelineCard from './components/EventTimelineCard';
|
||||
import HotEventsSection from './components/HotEventsSection';
|
||||
import EventModals from './components/EventModals';
|
||||
@@ -18,15 +17,19 @@ 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';
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../lib/constants';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
const Community = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { track } = usePostHogTrack(); // PostHog 追踪(保留用于兼容)
|
||||
|
||||
// Redux状态
|
||||
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
|
||||
@@ -45,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,
|
||||
@@ -60,6 +66,28 @@ const Community = () => {
|
||||
dispatch(fetchHotEvents());
|
||||
}, [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(() => {
|
||||
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(() => {
|
||||
if (showCommunityGuide) {
|
||||
@@ -72,38 +100,30 @@ const Community = () => {
|
||||
}
|
||||
}, [showCommunityGuide]); // 只在组件挂载时执行一次
|
||||
|
||||
// ⚡ 页面渲染完成后1秒,自动滚动到实时事件时间轴
|
||||
useEffect(() => {
|
||||
// 只在第一次数据加载完成后滚动
|
||||
if (!loading && !hasScrolledRef.current && eventTimelineRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
if (eventTimelineRef.current) {
|
||||
eventTimelineRef.current.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动动画
|
||||
block: 'start', // 元素顶部对齐视口顶部,标题正好可见
|
||||
inline: 'nearest' // 水平方向最小滚动
|
||||
});
|
||||
hasScrolledRef.current = true; // 标记已滚动
|
||||
logger.debug('Community', '页面渲染完成,自动滚动到实时事件时间轴(顶部对齐)');
|
||||
}
|
||||
}, 1000); // 渲染完成后延迟1秒
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
// ⚡ 滚动到实时事件区域(由搜索框聚焦触发)
|
||||
const scrollToTimeline = useCallback(() => {
|
||||
if (!hasScrolledRef.current && eventTimelineRef.current) {
|
||||
eventTimelineRef.current.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动动画
|
||||
block: 'start', // 元素顶部对齐视口顶部,标题正好可见
|
||||
inline: 'nearest' // 水平方向最小滚动
|
||||
});
|
||||
hasScrolledRef.current = true; // 标记已滚动
|
||||
logger.debug('Community', '用户触发搜索,滚动到实时事件时间轴');
|
||||
}
|
||||
}, [loading]); // 监听 loading 状态变化
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 导航栏已由 MainLayout 提供 */}
|
||||
|
||||
{/* Midjourney风格英雄区域 */}
|
||||
<MidjourneyHeroSection />
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Container maxW="container.xl" py={8}>
|
||||
{/* 实时事件时间轴卡片 */}
|
||||
<Container maxW="container.xl" pt={6} pb={8}>
|
||||
{/* 热点事件区域 */}
|
||||
<HotEventsSection events={hotEvents} />
|
||||
|
||||
{/* 实时事件 */}
|
||||
<EventTimelineCard
|
||||
ref={eventTimelineRef}
|
||||
mt={6}
|
||||
events={events}
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
@@ -111,13 +131,11 @@ const Community = () => {
|
||||
popularKeywords={popularKeywords}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
onSearch={updateFilters}
|
||||
onSearchFocus={scrollToTimeline}
|
||||
onPageChange={handlePageChange}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
|
||||
{/* 热点事件区域 */}
|
||||
<HotEventsSection events={hotEvents} />
|
||||
</Container>
|
||||
|
||||
{/* 事件弹窗 */}
|
||||
|
||||
@@ -473,6 +473,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
}
|
||||
];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(balanceSheet) || balanceSheet.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无资产负债表数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(balanceSheet.length, 6);
|
||||
const displayData = balanceSheet.slice(0, maxColumns);
|
||||
|
||||
@@ -707,6 +717,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
}
|
||||
];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(incomeStatement) || incomeStatement.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无利润表数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(incomeStatement.length, 6);
|
||||
const displayData = incomeStatement.slice(0, maxColumns);
|
||||
|
||||
@@ -866,6 +886,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
{ name: '自由现金流', key: 'free_cash_flow', path: 'key_metrics.free_cash_flow' },
|
||||
];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(cashflow) || cashflow.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无现金流量表数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(cashflow.length, 8);
|
||||
const displayData = cashflow.slice(0, maxColumns);
|
||||
|
||||
@@ -1069,6 +1099,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无财务指标数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(financialMetrics.length, 6);
|
||||
const displayData = financialMetrics.slice(0, maxColumns);
|
||||
const currentCategory = metricsCategories[selectedCategory];
|
||||
@@ -1426,8 +1466,9 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{industryRank.map((periodData, periodIdx) => (
|
||||
<Card key={periodIdx}>
|
||||
{Array.isArray(industryRank) && industryRank.length > 0 ? (
|
||||
industryRank.map((periodData, periodIdx) => (
|
||||
<Card key={periodIdx}>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">{periodData.report_type} 行业排名</Heading>
|
||||
@@ -1486,7 +1527,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Text textAlign="center" color="gray.500" py={8}>
|
||||
暂无行业排名数据
|
||||
</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
@@ -1738,7 +1788,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
|
||||
// 综合对比分析
|
||||
const ComparisonAnalysis = () => {
|
||||
if (!comparison || comparison.length === 0) return null;
|
||||
if (!Array.isArray(comparison) || comparison.length === 0) return null;
|
||||
|
||||
const revenueData = comparison.map(item => ({
|
||||
period: formatUtils.getReportType(item.period),
|
||||
|
||||
@@ -1471,7 +1471,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.textPrimary} fontSize="lg">
|
||||
{minuteData.data[0]?.open.toFixed(2)}
|
||||
{minuteData.data[0]?.open != null ? minuteData.data[0].open.toFixed(2) : '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
@@ -1485,13 +1485,15 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
color={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? theme.success : theme.danger}
|
||||
fontSize="lg"
|
||||
>
|
||||
{minuteData.data[minuteData.data.length - 1]?.close.toFixed(2)}
|
||||
{minuteData.data[minuteData.data.length - 1]?.close != null ? minuteData.data[minuteData.data.length - 1].close.toFixed(2) : '-'}
|
||||
</StatNumber>
|
||||
<StatHelpText fontSize="xs">
|
||||
<StatArrow
|
||||
type={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? 'increase' : 'decrease'}
|
||||
/>
|
||||
{Math.abs(((minuteData.data[minuteData.data.length - 1]?.close - minuteData.data[0]?.open) / minuteData.data[0]?.open * 100)).toFixed(2)}%
|
||||
{(minuteData.data[minuteData.data.length - 1]?.close != null && minuteData.data[0]?.open != null)
|
||||
? Math.abs(((minuteData.data[minuteData.data.length - 1].close - minuteData.data[0].open) / minuteData.data[0].open * 100)).toFixed(2)
|
||||
: '0.00'}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
<Stat>
|
||||
@@ -1502,7 +1504,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.success} fontSize="lg">
|
||||
{Math.max(...minuteData.data.map(item => item.high)).toFixed(2)}
|
||||
{(() => {
|
||||
const highs = minuteData.data.map(item => item.high).filter(h => h != null);
|
||||
return highs.length > 0 ? Math.max(...highs).toFixed(2) : '-';
|
||||
})()}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
@@ -1513,7 +1518,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.danger} fontSize="lg">
|
||||
{Math.min(...minuteData.data.map(item => item.low)).toFixed(2)}
|
||||
{(() => {
|
||||
const lows = minuteData.data.map(item => item.low).filter(l => l != null);
|
||||
return lows.length > 0 ? Math.min(...lows).toFixed(2) : '-';
|
||||
})()}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
@@ -1558,7 +1566,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
平均价格
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.textPrimary}>
|
||||
{(minuteData.data.reduce((sum, item) => sum + item.close, 0) / minuteData.data.length).toFixed(2)}
|
||||
{(() => {
|
||||
const closes = minuteData.data.map(item => item.close).filter(c => c != null);
|
||||
return closes.length > 0 ? (closes.reduce((sum, c) => sum + c, 0) / closes.length).toFixed(2) : '-';
|
||||
})()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -1744,7 +1755,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
成交额: {formatUtils.formatNumber(dayStats.total_amount)}万元
|
||||
</Badge>
|
||||
<Badge colorScheme="purple" fontSize="md">
|
||||
均价: {dayStats.avg_price.toFixed(2)}元
|
||||
均价: {dayStats.avg_price != null ? dayStats.avg_price.toFixed(2) : '-'}元
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
@@ -1766,23 +1777,23 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
{dayStats.deals.map((deal, i) => (
|
||||
<Tr key={i} _hover={{ bg: colorMode === 'light' ? 'rgba(43, 108, 176, 0.05)' : 'rgba(255, 215, 0, 0.1)' }}>
|
||||
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
|
||||
<Tooltip label={deal.buyer_dept} placement="top">
|
||||
<Text>{deal.buyer_dept}</Text>
|
||||
<Tooltip label={deal.buyer_dept || '-'} placement="top">
|
||||
<Text>{deal.buyer_dept || '-'}</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
|
||||
<Tooltip label={deal.seller_dept} placement="top">
|
||||
<Text>{deal.seller_dept}</Text>
|
||||
<Tooltip label={deal.seller_dept || '-'} placement="top">
|
||||
<Text>{deal.seller_dept || '-'}</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
|
||||
{deal.price.toFixed(2)}
|
||||
{deal.price != null ? deal.price.toFixed(2) : '-'}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
{deal.volume.toFixed(2)}
|
||||
{deal.volume != null ? deal.volume.toFixed(2) : '-'}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textSecondary} fontWeight="bold">
|
||||
{deal.amount.toFixed(2)}
|
||||
{deal.amount != null ? deal.amount.toFixed(2) : '-'}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
@@ -1845,22 +1856,26 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
买入前五
|
||||
</Text>
|
||||
<VStack spacing={1} align="stretch">
|
||||
{dayData.buyers.slice(0, 5).map((buyer, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{buyer.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.success} fontWeight="bold">
|
||||
{formatUtils.formatNumber(buyer.buy_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{dayData.buyers && dayData.buyers.length > 0 ? (
|
||||
dayData.buyers.slice(0, 5).map((buyer, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{buyer.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.success} fontWeight="bold">
|
||||
{formatUtils.formatNumber(buyer.buy_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
@@ -1869,22 +1884,26 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
卖出前五
|
||||
</Text>
|
||||
<VStack spacing={1} align="stretch">
|
||||
{dayData.sellers.slice(0, 5).map((seller, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{seller.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
|
||||
{formatUtils.formatNumber(seller.sell_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{dayData.sellers && dayData.sellers.length > 0 ? (
|
||||
dayData.sellers.slice(0, 5).map((seller, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{seller.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
|
||||
{formatUtils.formatNumber(seller.sell_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Grid>
|
||||
@@ -1948,19 +1967,27 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{pledgeData.map((item, idx) => (
|
||||
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
|
||||
<Td color={theme.textPrimary}>{item.end_date}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{formatUtils.formatNumber(item.total_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.total_shares, 0)}</Td>
|
||||
<Td isNumeric color={theme.warning} fontWeight="bold">
|
||||
{formatUtils.formatPercent(item.pledge_ratio)}
|
||||
{Array.isArray(pledgeData) && pledgeData.length > 0 ? (
|
||||
pledgeData.map((item, idx) => (
|
||||
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
|
||||
<Td color={theme.textPrimary}>{item.end_date}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{formatUtils.formatNumber(item.total_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.total_shares, 0)}</Td>
|
||||
<Td isNumeric color={theme.warning} fontWeight="bold">
|
||||
{formatUtils.formatPercent(item.pledge_ratio)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{item.pledge_count}</Td>
|
||||
</Tr>
|
||||
))
|
||||
) : (
|
||||
<Tr>
|
||||
<Td colSpan={7} textAlign="center" py={8}>
|
||||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{item.pledge_count}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
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,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useConceptTimelineEvents } from './hooks/useConceptTimelineEvents';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -64,6 +65,17 @@ const ConceptTimelineModal = ({
|
||||
conceptId
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
|
||||
// 🎯 PostHog 事件追踪
|
||||
const {
|
||||
trackDateToggled,
|
||||
trackNewsClicked,
|
||||
trackNewsDetailOpened,
|
||||
trackReportClicked,
|
||||
trackReportDetailOpened,
|
||||
trackModalClosed,
|
||||
} = useConceptTimelineEvents({ conceptName, conceptId, isOpen });
|
||||
|
||||
const [timelineData, setTimelineData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedDates, setExpandedDates] = useState({});
|
||||
@@ -318,6 +330,11 @@ const ConceptTimelineModal = ({
|
||||
|
||||
// 切换日期展开状态
|
||||
const toggleDateExpand = (date) => {
|
||||
const willExpand = !expandedDates[date];
|
||||
|
||||
// 🎯 追踪日期展开/折叠
|
||||
trackDateToggled(date, willExpand);
|
||||
|
||||
setExpandedDates(prev => ({
|
||||
...prev,
|
||||
[date]: !prev[date]
|
||||
@@ -728,6 +745,10 @@ const ConceptTimelineModal = ({
|
||||
leftIcon={<ViewIcon />}
|
||||
onClick={() => {
|
||||
if (event.type === 'news') {
|
||||
// 🎯 追踪新闻点击和详情打开
|
||||
trackNewsClicked(event, date);
|
||||
trackNewsDetailOpened(event);
|
||||
|
||||
setSelectedNews({
|
||||
title: event.title,
|
||||
content: event.content,
|
||||
@@ -737,6 +758,10 @@ const ConceptTimelineModal = ({
|
||||
});
|
||||
setIsNewsModalOpen(true);
|
||||
} else if (event.type === 'report') {
|
||||
// 🎯 追踪研报点击和详情打开
|
||||
trackReportClicked(event, date);
|
||||
trackReportDetailOpened(event);
|
||||
|
||||
setSelectedReport({
|
||||
title: event.title,
|
||||
content: event.content,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { useConceptStatsEvents } from '../hooks/useConceptStatsEvents';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
@@ -54,6 +55,15 @@ const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||||
? '/concept-api'
|
||||
: 'http://111.198.58.126:16801';
|
||||
|
||||
// 🎯 PostHog 事件追踪
|
||||
const {
|
||||
trackTabChanged,
|
||||
trackTimeRangeChanged,
|
||||
trackCustomDateRangeSet,
|
||||
trackRankItemClicked,
|
||||
trackDataRefreshed,
|
||||
} = useConceptStatsEvents();
|
||||
|
||||
const [statsData, setStatsData] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
@@ -180,10 +190,18 @@ const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||||
|
||||
setCustomEndDate(today.toISOString().split('T')[0]);
|
||||
setCustomStartDate(weekAgo.toISOString().split('T')[0]);
|
||||
|
||||
// 🎯 追踪切换到自定义范围
|
||||
trackTimeRangeChanged(0, true);
|
||||
} else {
|
||||
setUseCustomRange(false);
|
||||
setTimeRange(parseInt(newRange));
|
||||
fetchStatsData(parseInt(newRange));
|
||||
const days = parseInt(newRange);
|
||||
setTimeRange(days);
|
||||
|
||||
// 🎯 追踪时间范围变化
|
||||
trackTimeRangeChanged(days, false);
|
||||
|
||||
fetchStatsData(days);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,6 +217,10 @@ const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 追踪自定义日期范围设置
|
||||
trackCustomDateRangeSet(customStartDate, customEndDate);
|
||||
|
||||
fetchStatsData(null, customStartDate, customEndDate);
|
||||
}
|
||||
};
|
||||
@@ -848,7 +870,17 @@ const ConceptStatsPanel = ({ apiBaseUrl, onConceptClick }) => {
|
||||
|
||||
{/* 主内容卡片 */}
|
||||
<Box bg={bg} borderRadius="xl" border="1px" borderColor={borderColor} shadow="sm" overflow="hidden">
|
||||
<Tabs index={activeTab} onChange={setActiveTab} variant="unstyled" size="sm">
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={(index) => {
|
||||
const tabNames = ['涨幅榜', '跌幅榜', '活跃榜', '波动榜', '连涨榜'];
|
||||
// 🎯 追踪Tab切换
|
||||
trackTabChanged(index, tabNames[index]);
|
||||
setActiveTab(index);
|
||||
}}
|
||||
variant="unstyled"
|
||||
size="sm"
|
||||
>
|
||||
<TabList
|
||||
bg="gray.50"
|
||||
borderBottom="1px"
|
||||
|
||||
292
src/views/Concept/hooks/useConceptEvents.js
Normal file
292
src/views/Concept/hooks/useConceptEvents.js
Normal file
@@ -0,0 +1,292 @@
|
||||
// src/views/Concept/hooks/useConceptEvents.js
|
||||
// 概念中心页面事件追踪 Hook
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 概念中心事件追踪 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @returns {Object} 事件追踪处理函数集合
|
||||
*/
|
||||
export const useConceptEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.CONCEPT_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useConceptEvents', '📊 Concept Page Viewed');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念列表数据查看
|
||||
* @param {Array} concepts - 概念列表
|
||||
* @param {Object} filters - 当前筛选条件
|
||||
*/
|
||||
const trackConceptListViewed = useCallback((concepts, filters = {}) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_LIST_VIEWED, {
|
||||
concept_count: concepts.length,
|
||||
sort_by: filters.sortBy,
|
||||
view_mode: filters.viewMode,
|
||||
has_search_query: !!filters.searchQuery,
|
||||
selected_date: filters.selectedDate,
|
||||
page: filters.page,
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '📋 Concept List Viewed', {
|
||||
count: concepts.length,
|
||||
filters,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪搜索开始
|
||||
*/
|
||||
const trackSearchInitiated = useCallback(() => {
|
||||
track(RETENTION_EVENTS.SEARCH_INITIATED, {
|
||||
context: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🔍 Search Initiated');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪搜索查询提交
|
||||
* @param {string} query - 搜索查询词
|
||||
* @param {number} resultCount - 搜索结果数量
|
||||
*/
|
||||
const trackSearchQuerySubmitted = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
category: 'concept',
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'concept_center',
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('useConceptEvents', '🔍 Search Query Submitted', {
|
||||
query,
|
||||
resultCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪排序方式变化
|
||||
* @param {string} sortBy - 新的排序方式
|
||||
* @param {string} previousSortBy - 之前的排序方式
|
||||
*/
|
||||
const trackSortChanged = useCallback((sortBy, previousSortBy = null) => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'sort',
|
||||
filter_value: sortBy,
|
||||
previous_value: previousSortBy,
|
||||
context: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🔄 Sort Changed', {
|
||||
sortBy,
|
||||
previousSortBy,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪视图模式切换
|
||||
* @param {string} viewMode - 新的视图模式 (grid/list)
|
||||
* @param {string} previousViewMode - 之前的视图模式
|
||||
*/
|
||||
const trackViewModeChanged = useCallback((viewMode, previousViewMode = null) => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'view_mode',
|
||||
filter_value: viewMode,
|
||||
previous_value: previousViewMode,
|
||||
context: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '👁️ View Mode Changed', {
|
||||
viewMode,
|
||||
previousViewMode,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪日期选择变化
|
||||
* @param {string} newDate - 新选择的日期
|
||||
* @param {string} previousDate - 之前的日期
|
||||
* @param {string} selectionMethod - 选择方式 (today/yesterday/week_ago/month_ago/custom)
|
||||
*/
|
||||
const trackDateChanged = useCallback((newDate, previousDate = null, selectionMethod = 'custom') => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'date',
|
||||
filter_value: newDate,
|
||||
previous_value: previousDate,
|
||||
selection_method: selectionMethod,
|
||||
context: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '📅 Date Changed', {
|
||||
newDate,
|
||||
previousDate,
|
||||
selectionMethod,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪分页变化
|
||||
* @param {number} page - 新的页码
|
||||
* @param {Object} filters - 当前筛选条件
|
||||
*/
|
||||
const trackPageChanged = useCallback((page, filters = {}) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_LIST_VIEWED, {
|
||||
page,
|
||||
sort_by: filters.sortBy,
|
||||
view_mode: filters.viewMode,
|
||||
has_search_query: !!filters.searchQuery,
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '📄 Page Changed', { page, filters });
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念卡片点击
|
||||
* @param {Object} concept - 概念对象
|
||||
* @param {number} position - 在列表中的位置
|
||||
* @param {string} source - 来源 (list/stats_panel)
|
||||
*/
|
||||
const trackConceptClicked = useCallback((concept, position = 0, source = 'list') => {
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_name: concept.concept_name || concept.name,
|
||||
concept_code: concept.concept_code || concept.code,
|
||||
change_percent: concept.change_pct || concept.change_percent,
|
||||
stock_count: concept.stock_count,
|
||||
position,
|
||||
source,
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🎯 Concept Clicked', {
|
||||
concept: concept.concept_name || concept.name,
|
||||
position,
|
||||
source,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念下的股票标签点击
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} conceptName - 所属概念名称
|
||||
*/
|
||||
const trackConceptStockClicked = useCallback((stock, conceptName) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_STOCK_CLICKED, {
|
||||
stock_code: stock.code || stock.stock_code,
|
||||
stock_name: stock.name || stock.stock_name,
|
||||
concept_name: conceptName,
|
||||
source: 'concept_center_tag',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🏷️ Concept Stock Tag Clicked', {
|
||||
stock: stock.code || stock.stock_code,
|
||||
concept: conceptName,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念详情查看(时间轴Modal)
|
||||
* @param {string} conceptName - 概念名称
|
||||
* @param {string} conceptId - 概念ID
|
||||
*/
|
||||
const trackConceptDetailViewed = useCallback((conceptName, conceptId) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_DETAIL_VIEWED, {
|
||||
concept_name: conceptName,
|
||||
concept_id: conceptId,
|
||||
source: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '📊 Concept Detail Viewed', {
|
||||
conceptName,
|
||||
conceptId,
|
||||
});
|
||||
}, [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: 'concept_center_modal',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '👁️ Stock Detail Modal Opened', {
|
||||
stockCode,
|
||||
stockName,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪付费墙展示
|
||||
* @param {string} feature - 需要付费的功能
|
||||
* @param {string} requiredTier - 需要的订阅等级
|
||||
*/
|
||||
const trackPaywallShown = useCallback((feature, requiredTier = 'pro') => {
|
||||
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
|
||||
feature,
|
||||
required_tier: requiredTier,
|
||||
page: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '🔒 Paywall Shown', {
|
||||
feature,
|
||||
requiredTier,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪升级按钮点击
|
||||
* @param {string} feature - 触发升级的功能
|
||||
* @param {string} targetTier - 目标订阅等级
|
||||
*/
|
||||
const trackUpgradeClicked = useCallback((feature, targetTier = 'pro') => {
|
||||
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
|
||||
feature,
|
||||
target_tier: targetTier,
|
||||
source_page: 'concept_center',
|
||||
});
|
||||
|
||||
logger.debug('useConceptEvents', '⬆️ Upgrade Button Clicked', {
|
||||
feature,
|
||||
targetTier,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
trackConceptListViewed,
|
||||
trackSearchInitiated,
|
||||
trackSearchQuerySubmitted,
|
||||
trackSortChanged,
|
||||
trackViewModeChanged,
|
||||
trackDateChanged,
|
||||
trackPageChanged,
|
||||
trackConceptClicked,
|
||||
trackConceptStockClicked,
|
||||
trackConceptDetailViewed,
|
||||
trackStockDetailViewed,
|
||||
trackPaywallShown,
|
||||
trackUpgradeClicked,
|
||||
};
|
||||
};
|
||||
BIN
src/views/Concept/hooks/useConceptStatsEvents.js
Normal file
BIN
src/views/Concept/hooks/useConceptStatsEvents.js
Normal file
Binary file not shown.
BIN
src/views/Concept/hooks/useConceptTimelineEvents.js
Normal file
BIN
src/views/Concept/hooks/useConceptTimelineEvents.js
Normal file
Binary file not shown.
@@ -90,6 +90,8 @@ import { useSubscription } from '../../hooks/useSubscription';
|
||||
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
|
||||
// 导入市场服务
|
||||
import { marketService } from '../../services/marketService';
|
||||
// 导入 PostHog 追踪 Hook
|
||||
import { useConceptEvents } from './hooks/useConceptEvents';
|
||||
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? '/concept-api'
|
||||
@@ -129,6 +131,18 @@ const ConceptCenter = () => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
// 🎯 PostHog 事件追踪
|
||||
const {
|
||||
trackConceptSearched,
|
||||
trackFilterApplied,
|
||||
trackConceptClicked,
|
||||
trackConceptStocksViewed,
|
||||
trackConceptStockClicked,
|
||||
trackConceptTimelineViewed,
|
||||
trackPageChange,
|
||||
trackViewModeChanged,
|
||||
} = useConceptEvents({ navigate });
|
||||
|
||||
// 订阅权限管理
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||
@@ -192,6 +206,9 @@ const ConceptCenter = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 追踪历史时间轴查看
|
||||
trackConceptTimelineViewed(conceptName, conceptId);
|
||||
|
||||
setSelectedConceptForContent(conceptName);
|
||||
setSelectedConceptId(conceptId);
|
||||
setIsTimelineModalOpen(true);
|
||||
@@ -318,8 +335,14 @@ const ConceptCenter = () => {
|
||||
setSortBy('change_pct');
|
||||
}
|
||||
|
||||
// 🎯 追踪搜索查询(在fetchConcepts后追踪结果数量)
|
||||
updateUrlParams({ q: searchQuery, page: 1, sort: newSortBy });
|
||||
fetchConcepts(searchQuery, 1, selectedDate, newSortBy);
|
||||
fetchConcepts(searchQuery, 1, selectedDate, newSortBy).then(() => {
|
||||
if (searchQuery && searchQuery.trim() !== '') {
|
||||
// 使用当前 concepts.length 作为结果数量
|
||||
setTimeout(() => trackConceptSearched(searchQuery, concepts.length), 100);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 处理Enter键搜索
|
||||
@@ -331,6 +354,11 @@ const ConceptCenter = () => {
|
||||
|
||||
// 处理排序变化
|
||||
const handleSortChange = (value) => {
|
||||
const previousSort = sortBy;
|
||||
|
||||
// 🎯 追踪排序变化
|
||||
trackFilterApplied('sort', value, previousSort);
|
||||
|
||||
setSortBy(value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ sort: value, page: 1 });
|
||||
@@ -340,6 +368,11 @@ const ConceptCenter = () => {
|
||||
// 处理日期变化
|
||||
const handleDateChange = (e) => {
|
||||
const date = new Date(e.target.value);
|
||||
const previousDate = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||||
|
||||
// 🎯 追踪日期变化
|
||||
trackFilterApplied('date', e.target.value, previousDate);
|
||||
|
||||
setSelectedDate(date);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ date: e.target.value, page: 1 });
|
||||
@@ -359,6 +392,9 @@ const ConceptCenter = () => {
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page) => {
|
||||
// 🎯 追踪翻页
|
||||
trackPageChange(page, { sort: sortBy, q: searchQuery, date: selectedDate?.toISOString().split('T')[0] });
|
||||
|
||||
setCurrentPage(page);
|
||||
updateUrlParams({ page });
|
||||
fetchConcepts(searchQuery, page, selectedDate, sortBy);
|
||||
@@ -366,7 +402,12 @@ const ConceptCenter = () => {
|
||||
};
|
||||
|
||||
// 处理概念点击
|
||||
const handleConceptClick = (conceptId, conceptName) => {
|
||||
const handleConceptClick = (conceptId, conceptName, concept = null, position = 0) => {
|
||||
// 🎯 追踪概念点击
|
||||
if (concept) {
|
||||
trackConceptClicked(concept, position);
|
||||
}
|
||||
|
||||
const htmlPath = `https://valuefrontier.cn/htmls/${encodeURIComponent(conceptName)}.html`;
|
||||
window.open(htmlPath, '_blank');
|
||||
};
|
||||
@@ -433,6 +474,9 @@ const ConceptCenter = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 追踪查看个股
|
||||
trackConceptStocksViewed(concept.concept, concept.stocks?.length || 0);
|
||||
|
||||
setSelectedConceptStocks(concept.stocks || []);
|
||||
setSelectedConceptName(concept.concept);
|
||||
setStockMarketData({}); // 清空之前的数据
|
||||
@@ -649,7 +693,7 @@ const ConceptCenter = () => {
|
||||
}, []);
|
||||
|
||||
// 概念卡片组件 - 优化版
|
||||
const ConceptCard = ({ concept }) => {
|
||||
const ConceptCard = ({ concept, position = 0 }) => {
|
||||
const changePercent = concept.price_info?.avg_change_pct;
|
||||
const changeColor = getChangeColor(changePercent);
|
||||
const hasChange = changePercent !== null && changePercent !== undefined;
|
||||
@@ -657,7 +701,7 @@ const ConceptCenter = () => {
|
||||
return (
|
||||
<Card
|
||||
cursor="pointer"
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept)}
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept, concept, position)}
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
@@ -857,7 +901,7 @@ const ConceptCenter = () => {
|
||||
};
|
||||
|
||||
// 概念列表项组件 - 列表视图
|
||||
const ConceptListItem = ({ concept }) => {
|
||||
const ConceptListItem = ({ concept, position = 0 }) => {
|
||||
const changePercent = concept.price_info?.avg_change_pct;
|
||||
const changeColor = getChangeColor(changePercent);
|
||||
const hasChange = changePercent !== null && changePercent !== undefined;
|
||||
@@ -865,7 +909,7 @@ const ConceptCenter = () => {
|
||||
return (
|
||||
<Card
|
||||
cursor="pointer"
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept)}
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept, concept, position)}
|
||||
bg="white"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
@@ -1361,7 +1405,12 @@ const ConceptCenter = () => {
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
<IconButton
|
||||
icon={<FaThLarge />}
|
||||
onClick={() => setViewMode('grid')}
|
||||
onClick={() => {
|
||||
if (viewMode !== 'grid') {
|
||||
trackViewModeChanged('grid', viewMode);
|
||||
setViewMode('grid');
|
||||
}
|
||||
}}
|
||||
bg={viewMode === 'grid' ? 'purple.500' : 'transparent'}
|
||||
color={viewMode === 'grid' ? 'white' : 'purple.500'}
|
||||
borderColor="purple.500"
|
||||
@@ -1370,7 +1419,12 @@ const ConceptCenter = () => {
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FaList />}
|
||||
onClick={() => setViewMode('list')}
|
||||
onClick={() => {
|
||||
if (viewMode !== 'list') {
|
||||
trackViewModeChanged('list', viewMode);
|
||||
setViewMode('list');
|
||||
}
|
||||
}}
|
||||
bg={viewMode === 'list' ? 'purple.500' : 'transparent'}
|
||||
color={viewMode === 'list' ? 'white' : 'purple.500'}
|
||||
borderColor="purple.500"
|
||||
@@ -1404,16 +1458,16 @@ const ConceptCenter = () => {
|
||||
<>
|
||||
{viewMode === 'grid' ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6} className="concept-grid">
|
||||
{concepts.map((concept) => (
|
||||
{concepts.map((concept, index) => (
|
||||
<Box key={concept.concept_id} className="concept-item" role="group">
|
||||
<ConceptCard concept={concept} />
|
||||
<ConceptCard concept={concept} position={index} />
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<VStack spacing={4} align="stretch" className="concept-list">
|
||||
{concepts.map((concept) => (
|
||||
<ConceptListItem key={concept.concept_id} concept={concept} />
|
||||
{concepts.map((concept, index) => (
|
||||
<ConceptListItem key={concept.concept_id} concept={concept} position={index} />
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Home/HomePage.js - 专业投资分析平台
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -20,10 +20,14 @@ import { useNavigate } from 'react-router-dom';
|
||||
import heroBg from '../../assets/img/BackgroundCard1.png';
|
||||
import '../../styles/home-animations.css';
|
||||
import { logger } from '../../utils/logger';
|
||||
import MidjourneyHeroSection from '../Community/components/MidjourneyHeroSection';
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
import { ACQUISITION_EVENTS } from '../../lib/constants';
|
||||
|
||||
export default function HomePage() {
|
||||
const { user, isAuthenticated } = useAuth(); // ⚡ 移除 isLoading,不再依赖它
|
||||
const navigate = useNavigate();
|
||||
const { track } = usePostHogTrack(); // PostHog 追踪
|
||||
const [imageLoaded, setImageLoaded] = React.useState(false);
|
||||
|
||||
// 响应式配置
|
||||
@@ -33,7 +37,6 @@ export default function HomePage() {
|
||||
const heroTextSize = useBreakpointValue({ base: 'md', md: 'lg', lg: 'xl' });
|
||||
const containerPx = useBreakpointValue({ base: 10, md: 10, lg: 10 });
|
||||
const showDecorations = useBreakpointValue({ base: false, md: true });
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 保留原有的调试信息
|
||||
useEffect(() => {
|
||||
@@ -46,15 +49,24 @@ export default function HomePage() {
|
||||
});
|
||||
}, [user?.id, isAuthenticated]); // 只依赖 user.id,避免无限循环
|
||||
|
||||
// 🎯 PostHog 追踪:页面浏览
|
||||
useEffect(() => {
|
||||
track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
is_authenticated: isAuthenticated,
|
||||
user_id: user?.id || null,
|
||||
});
|
||||
}, [track, isAuthenticated, user?.id]);
|
||||
|
||||
// 核心功能配置 - 5个主要功能
|
||||
const coreFeatures = [
|
||||
{
|
||||
id: 'news-catalyst',
|
||||
title: '新闻催化分析',
|
||||
title: '新闻中心',
|
||||
description: '实时新闻事件分析,捕捉市场催化因子',
|
||||
icon: '📊',
|
||||
color: 'yellow',
|
||||
url: 'https://valuefrontier.cn/community',
|
||||
url: '/community',
|
||||
badge: '核心',
|
||||
featured: true
|
||||
},
|
||||
@@ -64,7 +76,7 @@ export default function HomePage() {
|
||||
description: '热门概念与主题投资分析追踪',
|
||||
icon: '🎯',
|
||||
color: 'purple',
|
||||
url: 'https://valuefrontier.cn/concepts',
|
||||
url: '/concepts',
|
||||
badge: '热门'
|
||||
},
|
||||
{
|
||||
@@ -73,7 +85,7 @@ export default function HomePage() {
|
||||
description: '全面的个股基本面信息整合',
|
||||
icon: '📈',
|
||||
color: 'blue',
|
||||
url: 'https://valuefrontier.cn/stocks',
|
||||
url: '/stocks',
|
||||
badge: '全面'
|
||||
},
|
||||
{
|
||||
@@ -82,7 +94,7 @@ export default function HomePage() {
|
||||
description: '涨停板数据深度分析与规律挖掘',
|
||||
icon: '🚀',
|
||||
color: 'green',
|
||||
url: 'https://valuefrontier.cn/limit-analyse',
|
||||
url: '/limit-analyse',
|
||||
badge: '精准'
|
||||
},
|
||||
{
|
||||
@@ -91,7 +103,7 @@ export default function HomePage() {
|
||||
description: '个股全方位分析与投资决策支持',
|
||||
icon: '🧭',
|
||||
color: 'orange',
|
||||
url: 'https://valuefrontier.cn/company?scode=688256',
|
||||
url: '/company?scode=688256',
|
||||
badge: '专业'
|
||||
},
|
||||
{
|
||||
@@ -105,25 +117,26 @@ export default function HomePage() {
|
||||
}
|
||||
];
|
||||
|
||||
// 个人中心配置
|
||||
// const personalCenter = {
|
||||
// title: '个人中心',
|
||||
// description: '账户管理与个人设置',
|
||||
// icon: '👤',
|
||||
// color: 'gray',
|
||||
// url: 'https://valuefrontier.cn/home/center'
|
||||
// };
|
||||
|
||||
// @TODO 如何区分内部链接和外部链接?
|
||||
const handleProductClick = (url) => {
|
||||
if (url.startsWith('http')) {
|
||||
const handleProductClick = useCallback((feature) => {
|
||||
// 🎯 PostHog 追踪:功能卡片点击
|
||||
track(ACQUISITION_EVENTS.FEATURE_CARD_CLICKED, {
|
||||
feature_id: feature.id,
|
||||
feature_title: feature.title,
|
||||
feature_url: feature.url,
|
||||
is_featured: feature.featured || false,
|
||||
link_type: feature.url.startsWith('http') ? 'external' : 'internal',
|
||||
});
|
||||
|
||||
// 原有导航逻辑
|
||||
if (feature.url.startsWith('http')) {
|
||||
// 外部链接,直接打开
|
||||
window.open(url, '_blank');
|
||||
window.open(feature.url, '_blank');
|
||||
} else {
|
||||
// 内部路由
|
||||
navigate(url);
|
||||
navigate(feature.url);
|
||||
}
|
||||
};
|
||||
}, [track, navigate]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -201,7 +214,7 @@ export default function HomePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Container maxW="7xl" position="relative" zIndex={2} px={containerPx}>
|
||||
<Container maxW="7xl" position="relative" zIndex={30} px={containerPx}>
|
||||
<VStack spacing={{ base: 8, md: 12, lg: 16 }} align="stretch" minH={heroHeight} justify="center">
|
||||
{/* 主标题区域 */}
|
||||
<VStack spacing={{ base: 4, md: 5, lg: 6 }} textAlign="center" pt={{ base: 4, md: 6, lg: 8 }}>
|
||||
@@ -224,7 +237,7 @@ export default function HomePage() {
|
||||
<Box pb={{ base: 8, md: 12 }}>
|
||||
<VStack spacing={{ base: 6, md: 8 }}>
|
||||
|
||||
{/* 新闻催化分析 - 突出显示 */}
|
||||
{/* 新闻中心 - 突出显示 */}
|
||||
<Card
|
||||
bg="transparent"
|
||||
border="2px solid"
|
||||
@@ -246,108 +259,77 @@ export default function HomePage() {
|
||||
}}
|
||||
>
|
||||
<CardBody p={{ base: 6, md: 8 }} position="relative" zIndex={1}>
|
||||
{isMobile ? (
|
||||
/* 移动端:垂直布局 */
|
||||
<VStack spacing={4} align="stretch">
|
||||
<HStack spacing={4}>
|
||||
<Box
|
||||
p={3}
|
||||
borderRadius="lg"
|
||||
bg="yellow.400"
|
||||
color="black"
|
||||
>
|
||||
<Text fontSize="2xl">{coreFeatures[0].icon}</Text>
|
||||
</Box>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Heading size="lg" color="white">
|
||||
{/* 响应式布局:移动端纵向,桌面端横向 */}
|
||||
<Flex
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
align={{ base: 'stretch', md: 'center' }}
|
||||
justify={{ base: 'flex-start', md: 'space-between' }}
|
||||
gap={{ base: 4, md: 6 }}
|
||||
>
|
||||
<Flex align="center" gap={{ base: 4, md: 6 }} flex={1}>
|
||||
<Box
|
||||
p={{ base: 3, md: 4 }}
|
||||
borderRadius={{ base: 'lg', md: 'xl' }}
|
||||
bg="yellow.400"
|
||||
color="black"
|
||||
>
|
||||
<Text fontSize={{ base: '2xl', md: '3xl' }}>{coreFeatures[0].icon}</Text>
|
||||
</Box>
|
||||
<VStack align="start" spacing={{ base: 1, md: 2 }} flex={1}>
|
||||
<HStack>
|
||||
<Heading size={{ base: 'lg', md: 'xl' }} color="white">
|
||||
{coreFeatures[0].title}
|
||||
</Heading>
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize="xs">
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize={{ base: 'xs', md: 'sm' }}>
|
||||
{coreFeatures[0].badge}
|
||||
</Badge>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Text color="whiteAlpha.800" fontSize="md" lineHeight="tall">
|
||||
{coreFeatures[0].description}
|
||||
</Text>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
size="md"
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
onClick={() => handleProductClick(coreFeatures[0].url)}
|
||||
minH="44px"
|
||||
>
|
||||
进入功能 →
|
||||
</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
/* 桌面端:横向布局 */
|
||||
<Flex align="center" justify="space-between">
|
||||
<HStack spacing={6}>
|
||||
<Box
|
||||
p={4}
|
||||
borderRadius="xl"
|
||||
bg="yellow.400"
|
||||
color="black"
|
||||
>
|
||||
<Text fontSize="3xl">{coreFeatures[0].icon}</Text>
|
||||
</Box>
|
||||
<VStack align="start" spacing={2}>
|
||||
<HStack>
|
||||
<Heading size="xl" color="white">
|
||||
{coreFeatures[0].title}
|
||||
</Heading>
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize="sm">
|
||||
{coreFeatures[0].badge}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text color="whiteAlpha.800" fontSize="lg" maxW="md">
|
||||
{coreFeatures[0].description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
size="lg"
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
onClick={() => handleProductClick(coreFeatures[0].url)}
|
||||
>
|
||||
进入功能 →
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text color="whiteAlpha.800" fontSize={{ base: 'md', md: 'lg' }} maxW={{ md: 'md' }} lineHeight="tall">
|
||||
{coreFeatures[0].description}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
)}
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
size={{ base: 'md', md: 'lg' }}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
w={{ base: '100%', md: 'auto' }}
|
||||
onClick={() => handleProductClick(coreFeatures[0])}
|
||||
minH="44px"
|
||||
flexShrink={0}
|
||||
>
|
||||
进入功能 →
|
||||
</Button>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 其他5个功能 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={{ base: 4, md: 5, lg: 6 }} w="100%">
|
||||
{coreFeatures.slice(1).map((feature) => (
|
||||
<Card
|
||||
key={feature.id}
|
||||
bg="whiteAlpha.100"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
borderRadius={{ base: 'xl', md: '2xl' }}
|
||||
cursor="pointer"
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: '2xl'
|
||||
}}
|
||||
_active={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-2px)'
|
||||
}}
|
||||
onClick={() => handleProductClick(feature.url)}
|
||||
minH={{ base: 'auto', md: '180px' }}
|
||||
>
|
||||
<Card
|
||||
key={feature.id}
|
||||
bg="whiteAlpha.100"
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
borderRadius={{ base: 'xl', md: '2xl' }}
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: '2xl'
|
||||
}}
|
||||
_active={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-2px)'
|
||||
}}
|
||||
onClick={() => handleProductClick(feature)}
|
||||
minH={{ base: 'auto', md: '180px' }}
|
||||
>
|
||||
<CardBody p={{ base: 5, md: 6 }}>
|
||||
<VStack spacing={{ base: 3, md: 4 }} align="start" h="100%">
|
||||
<HStack>
|
||||
@@ -383,7 +365,7 @@ export default function HomePage() {
|
||||
minH="44px"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleProductClick(feature.url);
|
||||
handleProductClick(feature);
|
||||
}}
|
||||
>
|
||||
使用
|
||||
@@ -395,6 +377,10 @@ export default function HomePage() {
|
||||
</SimpleGrid>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Midjourney风格英雄区域 */}
|
||||
<MidjourneyHeroSection />
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
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() {
|
||||
@@ -62,6 +63,21 @@ export default function LimitAnalyse() {
|
||||
|
||||
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 +142,9 @@ export default function LimitAnalyse() {
|
||||
if (data.success) {
|
||||
setDailyData(data.data);
|
||||
|
||||
// 🎯 追踪每日统计数据查看
|
||||
trackDailyStatsViewed(data.data, date);
|
||||
|
||||
// 获取词云数据
|
||||
fetchWordCloudData(date);
|
||||
|
||||
@@ -169,14 +188,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`, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
236
src/views/StockOverview/hooks/useStockOverviewEvents.js
Normal file
236
src/views/StockOverview/hooks/useStockOverviewEvents.js
Normal file
@@ -0,0 +1,236 @@
|
||||
// src/views/StockOverview/hooks/useStockOverviewEvents.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 useStockOverviewEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.STOCK_OVERVIEW_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useStockOverviewEvents', '📊 Stock Overview Page Viewed');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪市场统计数据查看
|
||||
* @param {Object} stats - 市场统计数据
|
||||
*/
|
||||
const trackMarketStatsViewed = useCallback((stats) => {
|
||||
if (!stats) return;
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_LIST_VIEWED, {
|
||||
total_market_cap: stats.total_market_cap,
|
||||
total_volume: stats.total_volume,
|
||||
rising_stocks: stats.rising_count,
|
||||
falling_stocks: stats.falling_count,
|
||||
data_date: stats.date,
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '📈 Market Statistics Viewed', stats);
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪股票搜索开始
|
||||
*/
|
||||
const trackSearchInitiated = useCallback(() => {
|
||||
track(RETENTION_EVENTS.SEARCH_INITIATED, {
|
||||
context: 'stock_overview',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🔍 Search Initiated');
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪股票搜索查询
|
||||
* @param {string} query - 搜索查询词
|
||||
* @param {number} resultCount - 搜索结果数量
|
||||
*/
|
||||
const trackStockSearched = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_SEARCHED, {
|
||||
query,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'stock_overview',
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🔍 Stock Searched', {
|
||||
query,
|
||||
resultCount,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪搜索结果点击
|
||||
* @param {Object} stock - 被点击的股票对象
|
||||
* @param {number} position - 在搜索结果中的位置
|
||||
*/
|
||||
const trackSearchResultClicked = useCallback((stock, position = 0) => {
|
||||
track(RETENTION_EVENTS.SEARCH_RESULT_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
exchange: stock.exchange,
|
||||
position,
|
||||
context: 'stock_overview',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🎯 Search Result Clicked', {
|
||||
stock: stock.code,
|
||||
position,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念卡片点击
|
||||
* @param {Object} concept - 概念对象
|
||||
* @param {number} rank - 在列表中的排名
|
||||
*/
|
||||
const trackConceptClicked = useCallback((concept, rank = 0) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_name: concept.name,
|
||||
concept_code: concept.code,
|
||||
change_percent: concept.change_percent,
|
||||
stock_count: concept.stock_count,
|
||||
rank,
|
||||
source: 'daily_hot_concepts',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🔥 Concept Clicked', {
|
||||
concept: concept.name,
|
||||
rank,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪概念下的股票标签点击
|
||||
* @param {Object} stock - 股票对象
|
||||
* @param {string} conceptName - 所属概念名称
|
||||
*/
|
||||
const trackConceptStockClicked = useCallback((stock, conceptName) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
concept_name: conceptName,
|
||||
source: 'daily_hot_concepts_tag',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🏷️ Concept Stock Tag Clicked', {
|
||||
stock: stock.code,
|
||||
concept: conceptName,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪热力图中股票点击
|
||||
* @param {Object} stock - 被点击的股票对象
|
||||
* @param {string} marketCapRange - 市值区间
|
||||
*/
|
||||
const trackHeatmapStockClicked = useCallback((stock, marketCapRange = '') => {
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
change_percent: stock.change_percent,
|
||||
market_cap_range: marketCapRange,
|
||||
source: 'market_heatmap',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '📊 Heatmap Stock Clicked', {
|
||||
stock: stock.code,
|
||||
marketCapRange,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
/**
|
||||
* 追踪股票详情查看
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} source - 来源(search/concept/heatmap)
|
||||
*/
|
||||
const trackStockDetailViewed = useCallback((stockCode, source = 'unknown') => {
|
||||
track(RETENTION_EVENTS.STOCK_DETAIL_VIEWED, {
|
||||
stock_code: stockCode,
|
||||
source: `stock_overview_${source}`,
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '👁️ Stock Detail Viewed', {
|
||||
stockCode,
|
||||
source,
|
||||
});
|
||||
|
||||
// 导航到公司详情页
|
||||
if (navigate) {
|
||||
navigate(`/company/${stockCode}`);
|
||||
}
|
||||
}, [track, navigate]);
|
||||
|
||||
/**
|
||||
* 追踪概念详情查看
|
||||
* @param {string} conceptCode - 概念代码
|
||||
*/
|
||||
const trackConceptDetailViewed = useCallback((conceptCode) => {
|
||||
track(RETENTION_EVENTS.CONCEPT_DETAIL_VIEWED, {
|
||||
concept_code: conceptCode,
|
||||
source: 'stock_overview_daily_hot',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '🎯 Concept Detail Viewed', {
|
||||
conceptCode,
|
||||
});
|
||||
|
||||
// 导航到概念详情页
|
||||
if (navigate) {
|
||||
navigate(`/concept-detail/${conceptCode}`);
|
||||
}
|
||||
}, [track, navigate]);
|
||||
|
||||
/**
|
||||
* 追踪日期选择变化
|
||||
* @param {string} newDate - 新选择的日期
|
||||
* @param {string} previousDate - 之前的日期
|
||||
*/
|
||||
const trackDateChanged = useCallback((newDate, previousDate = null) => {
|
||||
track(RETENTION_EVENTS.SEARCH_FILTER_APPLIED, {
|
||||
filter_type: 'date',
|
||||
filter_value: newDate,
|
||||
previous_value: previousDate,
|
||||
context: 'stock_overview',
|
||||
});
|
||||
|
||||
logger.debug('useStockOverviewEvents', '📅 Date Changed', {
|
||||
newDate,
|
||||
previousDate,
|
||||
});
|
||||
}, [track]);
|
||||
|
||||
return {
|
||||
trackMarketStatsViewed,
|
||||
trackSearchInitiated,
|
||||
trackStockSearched,
|
||||
trackSearchResultClicked,
|
||||
trackConceptClicked,
|
||||
trackConceptStockClicked,
|
||||
trackHeatmapStockClicked,
|
||||
trackStockDetailViewed,
|
||||
trackConceptDetailViewed,
|
||||
trackDateChanged,
|
||||
};
|
||||
};
|
||||
@@ -61,6 +61,7 @@ import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
||||
// Navigation bar now provided by MainLayout
|
||||
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
|
||||
@@ -83,6 +84,20 @@ const StockOverview = () => {
|
||||
const heatmapRef = useRef(null);
|
||||
const heatmapChart = useRef(null);
|
||||
|
||||
// 🎯 事件追踪 Hook
|
||||
const {
|
||||
trackMarketStatsViewed,
|
||||
trackSearchInitiated,
|
||||
trackStockSearched,
|
||||
trackSearchResultClicked,
|
||||
trackConceptClicked,
|
||||
trackConceptStockClicked,
|
||||
trackHeatmapStockClicked,
|
||||
trackStockDetailViewed,
|
||||
trackConceptDetailViewed,
|
||||
trackDateChanged,
|
||||
} = useStockOverviewEvents({ navigate });
|
||||
|
||||
// 状态管理
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
@@ -141,11 +156,18 @@ const StockOverview = () => {
|
||||
});
|
||||
|
||||
if (data.success) {
|
||||
setSearchResults(data.data || []);
|
||||
const results = data.data || [];
|
||||
setSearchResults(results);
|
||||
setShowResults(true);
|
||||
|
||||
// 🎯 追踪搜索查询
|
||||
trackStockSearched(query, results.length);
|
||||
} else {
|
||||
logger.warn('StockOverview', '搜索失败', data.error || '请稍后重试', { query });
|
||||
// ❌ 移除搜索失败 toast(非关键操作)
|
||||
|
||||
// 🎯 追踪搜索无结果
|
||||
trackStockSearched(query, 0);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('StockOverview', 'searchStocks', error, { query });
|
||||
@@ -219,18 +241,23 @@ const StockOverview = () => {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setMarketStats(prevStats => ({
|
||||
const newStats = {
|
||||
...data.summary,
|
||||
// 保留之前从 heatmap 接口获取的上涨/下跌家数
|
||||
rising_count: prevStats?.rising_count,
|
||||
falling_count: prevStats?.falling_count
|
||||
}));
|
||||
falling_count: prevStats?.falling_count,
|
||||
date: data.trade_date
|
||||
};
|
||||
setMarketStats(newStats);
|
||||
setAvailableDates(data.available_dates || []);
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
logger.debug('StockOverview', '市场统计数据加载成功', {
|
||||
date: data.trade_date,
|
||||
availableDatesCount: data.available_dates?.length || 0
|
||||
});
|
||||
|
||||
// 🎯 追踪市场统计数据查看
|
||||
trackMarketStatsViewed(newStats);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('StockOverview', 'fetchMarketStats', error, { date });
|
||||
@@ -403,6 +430,16 @@ const StockOverview = () => {
|
||||
heatmapChart.current.on('click', function(params) {
|
||||
// 只有点击个股(有code的节点)才跳转
|
||||
if (params.data && params.data.code && !params.data.children) {
|
||||
const stock = {
|
||||
code: params.data.code,
|
||||
name: params.data.name,
|
||||
change_percent: params.data.change
|
||||
};
|
||||
const marketCapRange = getMarketCapRange(params.data.value);
|
||||
|
||||
// 🎯 追踪热力图股票点击
|
||||
trackHeatmapStockClicked(stock, marketCapRange);
|
||||
|
||||
navigate(`/company?scode=${params.data.code}`);
|
||||
}
|
||||
});
|
||||
@@ -412,7 +449,7 @@ const StockOverview = () => {
|
||||
});
|
||||
// ❌ 移除热力图渲染失败 toast(非关键操作)
|
||||
}
|
||||
}, [colorMode, goldColor, navigate]); // ✅ 移除 toast 依赖
|
||||
}, [colorMode, goldColor, navigate, trackHeatmapStockClicked]); // ✅ 添加追踪函数依赖
|
||||
|
||||
// 获取市值区间
|
||||
const getMarketCapRange = (cap) => {
|
||||
@@ -427,6 +464,12 @@ const StockOverview = () => {
|
||||
const handleSearchChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setSearchQuery(value);
|
||||
|
||||
// 🎯 追踪搜索开始(首次输入时)
|
||||
if (value && !searchQuery) {
|
||||
trackSearchInitiated();
|
||||
}
|
||||
|
||||
debounceSearch(value);
|
||||
};
|
||||
|
||||
@@ -438,19 +481,30 @@ const StockOverview = () => {
|
||||
};
|
||||
|
||||
// 选择股票
|
||||
const handleSelectStock = (stock) => {
|
||||
const handleSelectStock = (stock, index = 0) => {
|
||||
// 🎯 追踪搜索结果点击
|
||||
trackSearchResultClicked(stock, index);
|
||||
|
||||
navigate(`/company?scode=${stock.stock_code}`);
|
||||
handleClearSearch();
|
||||
};
|
||||
|
||||
// 查看概念详情(模仿概念中心:打开对应HTML页)
|
||||
const handleConceptClick = (conceptId, conceptName) => {
|
||||
const htmlPath = `/htmls/${conceptName}.html`;
|
||||
const handleConceptClick = (concept, rank = 0) => {
|
||||
// 🎯 追踪概念点击
|
||||
trackConceptClicked(concept, rank);
|
||||
|
||||
const htmlPath = `/htmls/${concept.concept_name}.html`;
|
||||
window.open(htmlPath, '_blank');
|
||||
};
|
||||
|
||||
// 处理日期选择
|
||||
const handleDateChange = (date) => {
|
||||
const previousDate = selectedDate;
|
||||
|
||||
// 🎯 追踪日期变化
|
||||
trackDateChanged(date, previousDate);
|
||||
|
||||
setSelectedDate(date);
|
||||
setIsCalendarOpen(false);
|
||||
// 重新获取数据
|
||||
@@ -661,7 +715,7 @@ const StockOverview = () => {
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
_hover={{ bg: hoverBg }}
|
||||
onClick={() => handleSelectStock(stock)}
|
||||
onClick={() => handleSelectStock(stock, index)}
|
||||
borderBottomWidth={index < searchResults.length - 1 ? "1px" : "0"}
|
||||
borderColor={borderColor}
|
||||
>
|
||||
@@ -880,7 +934,7 @@ const StockOverview = () => {
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
cursor="pointer"
|
||||
onClick={() => handleConceptClick(concept.concept_id, concept.concept_name)}
|
||||
onClick={() => handleConceptClick(concept, index)}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
>
|
||||
@@ -951,6 +1005,13 @@ const StockOverview = () => {
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 🎯 追踪概念下的股票标签点击
|
||||
trackConceptStockClicked({
|
||||
code: stock.stock_code,
|
||||
name: stock.stock_name
|
||||
}, concept.concept_name);
|
||||
|
||||
navigate(`/company?scode=${stock.stock_code}`);
|
||||
}}
|
||||
>
|
||||
@@ -969,7 +1030,7 @@ const StockOverview = () => {
|
||||
rightIcon={<FaChevronRight />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleConceptClick(concept.concept_id, concept.concept_name);
|
||||
handleConceptClick(concept, index);
|
||||
}}
|
||||
>
|
||||
查看详情
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 资产走势图表 - 只在有数据时显示 */}
|
||||
|
||||
Reference in New Issue
Block a user