Compare commits

...

111 Commits

Author SHA1 Message Date
02d5311005 update pay function 2025-12-01 14:28:46 +08:00
7fa3d26470 update pay function 2025-12-01 14:16:11 +08:00
21eb1783e9 update pay function 2025-12-01 14:01:14 +08:00
ec31801ccd update pay function 2025-12-01 07:48:03 +08:00
ff9c68295b update pay function 2025-11-30 23:58:06 +08:00
a72978c200 update pay function 2025-11-30 23:39:48 +08:00
2c4f5152e4 update pay function 2025-11-30 22:54:15 +08:00
846e66fecb update pay function 2025-11-30 22:51:24 +08:00
ef6c58b247 update pay function 2025-11-30 21:45:18 +08:00
b753d29dbf update pay function 2025-11-30 21:14:27 +08:00
455e1c1d32 update pay function 2025-11-30 18:55:35 +08:00
7b65cac358 update pay function 2025-11-30 18:45:36 +08:00
8843c81d8b update pay function 2025-11-30 18:31:13 +08:00
6763151c57 update pay function 2025-11-30 17:41:55 +08:00
9d9d3430b7 update pay function 2025-11-30 17:18:05 +08:00
25c3d9d828 update pay function 2025-11-30 17:06:34 +08:00
41368f82a7 update pay function 2025-11-30 16:39:24 +08:00
608ac4a962 update pay function 2025-11-30 16:33:34 +08:00
5a24cb9eec update pay function 2025-11-30 16:16:48 +08:00
33a3c16421 update pay function 2025-11-30 15:36:20 +08:00
2f8388ba41 update pay function 2025-11-30 13:57:39 +08:00
4127e4c816 update pay function 2025-11-30 13:47:47 +08:00
05aa0c89f0 update pay function 2025-11-30 13:38:29 +08:00
14ab2f62f3 update pay function 2025-11-30 09:15:24 +08:00
fc738dc639 update pay function 2025-11-29 18:43:43 +08:00
059275d1a2 update pay function 2025-11-29 18:28:32 +08:00
d14be2081d update pay function 2025-11-29 14:07:55 +08:00
1676d69917 update pay function 2025-11-29 13:47:18 +08:00
20b3d624f0 update pay function 2025-11-29 10:05:57 +08:00
34323cc63d update pay function 2025-11-29 09:42:41 +08:00
42fdb7d754 update pay function 2025-11-29 08:16:41 +08:00
5526705254 update pay function 2025-11-28 17:57:10 +08:00
f6e8d673a8 update pay function 2025-11-28 17:00:02 +08:00
547424fff6 update pay function 2025-11-28 16:51:28 +08:00
ec2978026a update pay function 2025-11-28 16:32:27 +08:00
250d585b87 update pay function 2025-11-28 16:08:31 +08:00
8cf2850660 update pay function 2025-11-28 15:32:03 +08:00
9b7a221315 update pay function 2025-11-28 14:49:16 +08:00
18f8f75116 update pay function 2025-11-28 14:09:47 +08:00
56a7ca7eb3 update pay function 2025-11-28 14:00:36 +08:00
c1937b9e31 update pay function 2025-11-28 12:37:01 +08:00
9c5900c7f5 update pay function 2025-11-28 12:27:30 +08:00
007de2d76d update pay function 2025-11-28 09:45:36 +08:00
49656e6e88 update pay function 2025-11-28 09:17:44 +08:00
bc6e993dec update pay function 2025-11-28 08:59:36 +08:00
72a490c789 update pay function 2025-11-28 08:52:09 +08:00
zdl
b88bfebcef Merge branch 'feature_2025/251121_h5UI' into feature_2025/251117_pref
* feature_2025/251121_h5UI:
  feat: 传导练UI调整
  fix: UI调试
  fix: 调整相关概念卡片UI
  fix: 文案调整
  fix: AI合成h5换行,pc一行,评论标题上方margin去掉
  fix: 调整AI合成UI
  fix: 分时图UI调整
  fix:事件详情弹窗UI
  fix:调整客服UI
  fix: 事件详情弹窗UI调整
  fix: 事件详情弹窗UI调整 重要性h5不展示 事件列表卡片间距调整
  fix: h5 去掉通知弹窗引导
  fix: 关注按钮UI调整
2025-11-28 07:15:11 +08:00
zdl
cf4fdf6a68 feat: 传导练UI调整 2025-11-28 07:14:52 +08:00
zdl
34338373cd fix: UI调试 2025-11-27 18:27:44 +08:00
zdl
589e1c20f9 fix: 调整相关概念卡片UI 2025-11-27 17:22:49 +08:00
zdl
60e9a40a1f fix: 文案调整 2025-11-27 17:03:35 +08:00
zdl
b8b24643fe fix: AI合成h5换行,pc一行,评论标题上方margin去掉 2025-11-27 16:55:25 +08:00
zdl
e9e9ec9051 fix: 调整AI合成UI 2025-11-27 16:40:35 +08:00
zdl
5b0e420770 fix: 分时图UI调整 2025-11-27 16:20:15 +08:00
zdl
93f43054fd fix:事件详情弹窗UI 2025-11-27 15:35:48 +08:00
zdl
101d042b0e fix:调整客服UI 2025-11-27 15:31:07 +08:00
zdl
a1aa6718e6 fix: 事件详情弹窗UI调整 2025-11-27 15:08:14 +08:00
zdl
753727c1c0 fix: 事件详情弹窗UI调整
重要性h5不展示
事件列表卡片间距调整
2025-11-27 14:40:38 +08:00
zdl
afc92ee583 fix: h5 去掉通知弹窗引导 2025-11-27 13:37:01 +08:00
900aff17df update pay function 2025-11-27 11:28:57 +08:00
zdl
d825e4fe59 fix: 关注按钮UI调整 2025-11-27 11:19:20 +08:00
zdl
62cf0a6c7d feat: 修改小程序跳转链接 2025-11-27 10:46:14 +08:00
zdl
805d446775 feat: 调整搜索框UI 2025-11-26 19:33:00 +08:00
zdl
24ddfcd4b5 feat: 新增:H5 时左右 padding 改为 8px 2025-11-26 19:31:12 +08:00
zdl
a90158239b feat: 模式切花移动到标题恻,通知UI调整 2025-11-26 19:11:33 +08:00
zdl
a8d4245595 pref: 文案调整 2025-11-26 17:49:39 +08:00
zdl
5aedde7528 feat:H5 移动端已隐藏整个顶部控制栏 2025-11-26 16:51:52 +08:00
zdl
f5f89a1c72 feat:箭头绝对定位
移除左右 padding
隐藏重复箭头
2025-11-26 16:50:46 +08:00
zdl
e0b7f8c59d feat: 调整事件列表h5模式调整 2025-11-26 16:44:53 +08:00
zdl
d22d75e761 pref: h5 分页UI调整 2025-11-26 16:35:49 +08:00
zdl
30fc156474 fix: 移动端事件中心事件列表添加时间 2025-11-26 16:23:28 +08:00
zdl
572665199a feat: 删除事件中心页面不再显示桌面通知提示横幅 2025-11-26 16:18:15 +08:00
zdl
a2831c82a8 feat: 移动端不显示政策标签 2025-11-26 16:02:59 +08:00
zdl
217551b6ab feat: H5 移动端将隐藏"开启通知"组件,桌面端保持正常显示 2025-11-26 16:01:58 +08:00
zdl
022271947a fix: 移动端抽屉菜单不再显示深色模式切换按钮 2025-11-26 15:47:26 +08:00
zdl
cd6ffdbe68 fix: 修复hooks报错 2025-11-26 15:45:46 +08:00
zdl
9df725b748 feat: 精简日志 2025-11-26 15:34:11 +08:00
zdl
64f8914951 feat: logger.js - 添加日志级别控制 2025-11-26 15:30:31 +08:00
zdl
506e5a448c feat: 本地优先启动服务拦截 2025-11-26 15:23:37 +08:00
zdl
e277352133 src/contexts/NotificationContext.js
- 添加 selectIsMobile 导入 在 NotificationProvider 组件开头添加移动端检测 移动端返回空壳 Provider
  - 桌面端保持原有完整功能
  移除 ConnectionStatusBar 组件和 ConnectionStatusBarWrapper(所有端)
  - 移除了不再使用的 useNotification、useLocation、logger 导入
  - 添加了 Redux selectIsMobile 检测
  - 移动端不渲染 NotificationContainer
2025-11-26 15:15:20 +08:00
zdl
87437ed229 feat: 增加 wechat_login=success 参数处理 2025-11-26 14:52:49 +08:00
zdl
037471d880 feat: 修复 Mock 路径从 h5-auth-url → h5-auth 2025-11-26 14:52:05 +08:00
zdl
0c482bc72c feat: 回调处理增加 H5 模式判断,重定向到前端回调页 2025-11-26 14:51:51 +08:00
zdl
4aebb3bf4b feat: 调整导航栏高度 2025-11-26 14:10:09 +08:00
zdl
ed241bd9c5 pref: 导航选中高亮 2025-11-26 14:01:58 +08:00
zdl
e6ede81c78 feat: 修复动态 reducer 注入导致的运行时错误 2025-11-26 13:59:26 +08:00
zdl
a0b688da80 feat: 移除 PerformanceMonitor 调试日志 2025-11-26 13:42:42 +08:00
zdl
6bd09b797d feat: PostHog 加载策略优化计划
目标

     改进 PostHog 延迟加载策略,平衡首屏性能和数据完整性:
     1. 使用 requestIdleCallback 替代固定 2 秒延迟
     2. 保留关键事件(first_visit)的同步追踪,确保数据不丢失
2025-11-26 13:41:09 +08:00
zdl
9c532b5f18 pref: 删除微信登陆日志 2025-11-26 13:38:26 +08:00
zdl
1d1d6c8169 pref: P0: PostHog 延迟加载 - 完成
P0: HeroPanel 懒加载 -  完成
 P0/P1: Charts/FullCalendar 懒加载 -  已通过路由懒加载隔离,无需额外处理
删除空的 CSS 文件
2025-11-26 13:33:58 +08:00
zdl
3507cfe9f7 pref: 删除调试工具 2025-11-26 13:16:30 +08:00
zdl
cc520893f8 fix: 添加 wechatStatusRef 用于跟踪最新状态
使用 wechatStatusRef.current 替代 wechatStatus
添加 AUTHORIZED 状态处理逻辑
添加 useEffect 同步 wechatStatusRef
2025-11-26 13:07:46 +08:00
zdl
dabedc1c0b feat: 之前的防重复逻辑 !subscriptionInfo.type 永远为 false(因为初始值是 free),导致订阅 API 从不被调用 2025-11-26 11:49:12 +08:00
zdl
7b4c4be7bf pref:点击手机登陆后日志优化 2025-11-26 11:43:16 +08:00
zdl
7a2c73f3ca :pref: 首屏优化 2025-11-26 11:30:12 +08:00
zdl
105a0b02ea fix:移除日志 2025-11-26 11:17:03 +08:00
zdl
d8a4c20565 fix: Login Page Viewed 事件仅在模态框首次打开时触发 1 次
- 验证码倒计时期间不再重复触发
     - 不影响其他事件追踪功能\
2025-11-26 11:15:04 +08:00
zdl
5f959fb44f feat: 添加 认证检查 和 首页渲染 的性能标记 2025-11-26 11:10:50 +08:00
zdl
ee78e00d3b feat: 添加设备检测功能 2025-11-26 11:03:13 +08:00
zdl
2fcc341213 feat: 修复通知初始化问题 2025-11-26 10:55:38 +08:00
zdl
1090a2fc67 feat: 客服接口mock添加 2025-11-26 10:55:18 +08:00
zdl
77f3949fe2 feat: 首页添加性能监控 2025-11-26 10:54:57 +08:00
zdl
742ab337dc feat: 更新测试用例 2025-11-26 10:54:20 +08:00
zdl
d2b6904a4a pref: 删除无用组件 2025-11-26 10:40:14 +08:00
zdl
789a6229a7 feat: 添加设备类型 2025-11-26 10:39:33 +08:00
zdl
6886a649f5 perf: Socket 连接异步化,使用 requestIdleCallback 不阻塞首屏
- NotificationContext: 将 Socket 初始化包裹在 requestIdleCallback 中
- 设置 3 秒超时保护,确保连接不会被无限延迟
- 不支持 requestIdleCallback 的浏览器自动降级到 setTimeout(0)
- socket/index.js: 移除模块加载时的 console.log,减少首屏阻塞感知
- 所有页面的首屏渲染都不再被 Socket 连接阻塞

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:34:45 +08:00
zdl
581e874b0d fix:修复类型提示错误 2025-11-26 10:11:02 +08:00
zdl
b23ed93020 Merge branch 'feature_2025/251117_pref' into feature_2025/251121_h5UI
* feature_2025/251117_pref:
  update pay function
  update pay function
  update pay function
2025-11-26 09:59:01 +08:00
zdl
84f70f3329 pref: 权限校验中 - 显示占位骨架,不显示登录按钮或用户菜单,/home页面添加骨架屏逻辑 2025-11-26 09:57:20 +08:00
zdl
601b06d79e fix: 修复 AgentChat hooks 中的 logger 调用 2025-11-26 09:47:54 +08:00
zdl
0818a7bff7 fix: 修复 logger 函数签名问题 2025-11-26 09:44:21 +08:00
158 changed files with 17782 additions and 5943 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

523
app.py
View File

@@ -3475,6 +3475,46 @@ def get_wechat_qrcode():
}}), 200
@app.route('/api/auth/wechat/h5-auth', methods=['POST'])
def get_wechat_h5_auth_url():
"""
获取微信 H5 网页授权 URL
用于手机浏览器跳转微信 App 授权
"""
data = request.get_json() or {}
frontend_redirect = data.get('redirect_url', '/home')
# 生成唯一 state
state = uuid.uuid4().hex
# 编码回调地址
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
# 构建授权 URL使用 snsapi_userinfo 获取用户信息,仅限微信内 H5 使用)
auth_url = (
f"https://open.weixin.qq.com/connect/oauth2/authorize?"
f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}"
f"&response_type=code&scope=snsapi_userinfo&state={state}"
"#wechat_redirect"
)
# 存储 session 信息
wechat_qr_sessions[state] = {
'status': 'waiting',
'expires': time.time() + 300,
'mode': 'h5', # 标记为 H5 模式
'frontend_redirect': frontend_redirect,
'user_info': None,
'wechat_openid': None,
'wechat_unionid': None
}
return jsonify({
'auth_url': auth_url,
'state': state
}), 200
@app.route('/api/account/wechat/qrcode', methods=['GET'])
def get_wechat_bind_qrcode():
"""发起微信绑定二维码,会话标记为绑定模式"""
@@ -3714,14 +3754,23 @@ def wechat_callback():
# 更新微信session状态供前端轮询检测
if state in wechat_qr_sessions:
session_item = wechat_qr_sessions[state]
# 仅处理登录/注册流程,不处理绑定流程
if not session_item.get('mode'):
# 更新状态和用户信息
mode = session_item.get('mode')
# H5 模式:重定向到前端回调页面
if mode == 'h5':
frontend_redirect = session_item.get('frontend_redirect', '/home/wechat-callback')
# 清理 session
del wechat_qr_sessions[state]
print(f"✅ H5 微信登录成功,重定向到: {frontend_redirect}")
return redirect(f"{frontend_redirect}?wechat_login=success")
# PC 扫码模式:更新状态供前端轮询
if not mode:
session_item['status'] = 'register_ready' if is_new_user else 'login_ready'
session_item['user_info'] = {'user_id': user.id}
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
# 直接跳转到首页
# PC 模式直接跳转到首页
return redirect('/home')
except Exception as e:
@@ -4560,20 +4609,10 @@ def get_my_following_future_events():
)
events = []
# 所有返回的事件都是已关注的
following_ids = set(future_event_ids)
for row in result:
event_data = {
'id': row.data_id,
'title': row.title,
'type': row.type,
'calendar_time': row.calendar_time.isoformat(),
'star': row.star,
'former': row.former,
'forecast': row.forecast,
'fact': row.fact,
'is_following': True, # 这些都是已关注的
'related_stocks': parse_json_field(row.related_stocks),
'concepts': parse_json_field(row.concepts)
}
event_data = process_future_event_row(row, following_ids)
events.append(event_data)
return jsonify({'success': True, 'data': events})
@@ -5379,31 +5418,26 @@ def get_related_stocks(event_id):
stocks_data = []
for stock in stocks:
if stock.retrieved_sources is not None:
stocks_data.append({
'id': stock.id,
'stock_code': stock.stock_code,
'stock_name': stock.stock_name,
'sector': stock.sector,
'relation_desc': {"data":stock.retrieved_sources},
'retrieved_sources': stock.retrieved_sources,
'correlation': stock.correlation,
'momentum': stock.momentum,
'created_at': stock.created_at.isoformat() if stock.created_at else None,
'updated_at': stock.updated_at.isoformat() if stock.updated_at else None
})
# 处理 relation_desc只有当 retrieved_sources 是数组时才使用新格式
if stock.retrieved_sources is not None and isinstance(stock.retrieved_sources, list):
# retrieved_sources 是有效数组,使用新格式
relation_desc_value = {"data": stock.retrieved_sources}
else:
stocks_data.append({
'id': stock.id,
'stock_code': stock.stock_code,
'stock_name': stock.stock_name,
'sector': stock.sector,
'relation_desc': stock.relation_desc,
'correlation': stock.correlation,
'momentum': stock.momentum,
'created_at': stock.created_at.isoformat() if stock.created_at else None,
'updated_at': stock.updated_at.isoformat() if stock.updated_at else None
})
# retrieved_sources 不是数组(可能是 {"raw": "..."} 等异常格式),回退到原始文本
relation_desc_value = stock.relation_desc
stocks_data.append({
'id': stock.id,
'stock_code': stock.stock_code,
'stock_name': stock.stock_name,
'sector': stock.sector,
'relation_desc': relation_desc_value,
'retrieved_sources': stock.retrieved_sources,
'correlation': stock.correlation,
'momentum': stock.momentum,
'created_at': stock.created_at.isoformat() if stock.created_at else None,
'updated_at': stock.updated_at.isoformat() if stock.updated_at else None
})
return jsonify({
'success': True,
@@ -5779,18 +5813,29 @@ def get_stock_quotes():
current_time = datetime.now()
client = get_clickhouse_client()
# Get stock names from MySQL
# Get stock names from MySQL(批量查询优化)
stock_names = {}
with engine.connect() as conn:
for code in codes:
codez = code.split('.')[0]
# 提取不带后缀的股票代码
base_codes = list(set([code.split('.')[0] for code in codes]))
if base_codes:
# 批量查询所有股票名称
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
params = {f'code{i}': code for i, code in enumerate(base_codes)}
result = conn.execute(text(
"SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code"
), {"code": codez}).fetchone()
if result:
stock_names[code] = result[0]
else:
stock_names[code] = f"股票{codez}"
f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})"
), params).fetchall()
# 构建代码到名称的映射
base_name_map = {row[0]: row[1] for row in result}
# 为每个完整代码(带后缀)分配名称
for code in codes:
base_code = code.split('.')[0]
if base_code in base_name_map:
stock_names[code] = base_name_map[base_code]
else:
stock_names[code] = f"股票{base_code}"
def get_trading_day_and_times(event_datetime):
event_date = event_datetime.date()
@@ -5862,65 +5907,111 @@ def get_stock_quotes():
})
results = {}
print(f"处理股票代码: {codes}, 交易日: {trading_day}, 时间范围: {start_datetime} - {end_datetime}")
print(f"批量处理 {len(codes)} 只股票: {codes[:5]}{'...' if len(codes) > 5 else ''}, 交易日: {trading_day}, 时间范围: {start_datetime} - {end_datetime}")
for code in codes:
try:
print(f"正在查询股票 {code} 的价格数据...")
# Get the first price and last price for the trading period
data = client.execute("""
WITH first_price AS (SELECT close
FROM stock_minute
WHERE code = %(code)s
AND timestamp >= %(start)s
AND timestamp <= %(end)s
ORDER BY timestamp
LIMIT 1
),
last_price AS (
SELECT close
FROM stock_minute
WHERE code = %(code)s
AND timestamp >= %(start)s
AND timestamp <= %(end)s
ORDER BY timestamp DESC
LIMIT 1
)
SELECT last_price.close as last_price,
(last_price.close - first_price.close) / first_price.close * 100 as change
FROM last_price
CROSS JOIN first_price
WHERE EXISTS (SELECT 1 FROM first_price)
AND EXISTS (SELECT 1 FROM last_price)
""", {
'code': code,
'start': start_datetime,
'end': end_datetime
})
# ==================== 性能优化:批量查询所有股票数据 ====================
# 使用 IN 子句一次查询所有股票,避免逐只循环查询
try:
# 批量查询价格和涨跌幅数据(使用窗口函数)
batch_price_query = """
WITH first_prices AS (
SELECT
code,
close as first_price,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp ASC) as rn
FROM stock_minute
WHERE code IN %(codes)s
AND timestamp >= %(start)s
AND timestamp <= %(end)s
),
last_prices AS (
SELECT
code,
close as last_price,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn
FROM stock_minute
WHERE code IN %(codes)s
AND timestamp >= %(start)s
AND timestamp <= %(end)s
)
SELECT
fp.code,
lp.last_price,
(lp.last_price - fp.first_price) / fp.first_price * 100 as change_pct
FROM first_prices fp
INNER JOIN last_prices lp ON fp.code = lp.code
WHERE fp.rn = 1 AND lp.rn = 1
"""
print(f"股票 {code} 查询结果: {data}")
if data and data[0] and data[0][0] is not None:
price = float(data[0][0]) if data[0][0] is not None else None
change = float(data[0][1]) if data[0][1] is not None else None
batch_data = client.execute(batch_price_query, {
'codes': codes,
'start': start_datetime,
'end': end_datetime
})
print(f"批量查询返回 {len(batch_data)} 条价格数据")
# 解析批量查询结果
price_data_map = {}
for row in batch_data:
code = row[0]
last_price = float(row[1]) if row[1] is not None else None
change_pct = float(row[2]) if row[2] is not None else None
price_data_map[code] = {
'price': last_price,
'change': change_pct
}
# 组装结果(所有股票)
for code in codes:
price_info = price_data_map.get(code)
if price_info:
results[code] = {
'price': price,
'change': change,
'price': price_info['price'],
'change': price_info['change'],
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
}
else:
# 批量查询没有返回的股票
results[code] = {
'price': None,
'change': None,
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
}
except Exception as e:
print(f"Error processing stock {code}: {e}")
results[code] = {
'price': None,
'change': None,
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
}
except Exception as e:
print(f"批量查询 ClickHouse 失败: {e},回退到逐只查询")
# 降级方案:逐只股票查询(保持向后兼容)
for code in codes:
try:
data = client.execute("""
WITH first_price AS (
SELECT close FROM stock_minute
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
ORDER BY timestamp LIMIT 1
),
last_price AS (
SELECT close FROM stock_minute
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
ORDER BY timestamp DESC LIMIT 1
)
SELECT last_price.close as last_price,
(last_price.close - first_price.close) / first_price.close * 100 as change
FROM last_price CROSS JOIN first_price
WHERE EXISTS (SELECT 1 FROM first_price) AND EXISTS (SELECT 1 FROM last_price)
""", {'code': code, 'start': start_datetime, 'end': end_datetime})
if data and data[0] and data[0][0] is not None:
results[code] = {
'price': float(data[0][0]) if data[0][0] is not None else None,
'change': float(data[0][1]) if data[0][1] is not None else None,
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
}
else:
results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')}
except Exception as inner_e:
print(f"Error processing stock {code}: {inner_e}")
results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')}
# 返回标准格式
return jsonify({'success': True, 'data': results})
@@ -6045,17 +6136,9 @@ def account_calendar_events():
future_events = []
if future_event_ids:
# 使用 SELECT * 以便获取所有字段(包括新字段)
base_sql = """
SELECT data_id, \
title, \
type, \
calendar_time, \
star, \
former, \
forecast, \
fact, \
related_stocks, \
concepts
SELECT *
FROM future_events
WHERE data_id IN :event_ids \
"""
@@ -6073,12 +6156,24 @@ def account_calendar_events():
result = db.session.execute(text(base_sql), params)
for row in result:
# related_stocks 形如 [[code,name,reason,score], ...]
rs = parse_json_field(row.related_stocks)
# 使用新字段回退逻辑获取 former
former_value = get_future_event_field(row, 'second_modified_text', 'former')
# 获取 related_stocks优先使用 best_matches
best_matches = getattr(row, 'best_matches', None) if hasattr(row, 'best_matches') else None
if best_matches and str(best_matches).strip():
rs = parse_best_matches(best_matches)
else:
rs = parse_json_field(getattr(row, 'related_stocks', None))
# 生成股票标签列表
stock_tags = []
try:
for it in rs:
if isinstance(it, (list, tuple)) and len(it) >= 2:
if isinstance(it, dict):
# 新结构
stock_tags.append(f"{it.get('code', '')} {it.get('name', '')}")
elif isinstance(it, (list, tuple)) and len(it) >= 2:
stock_tags.append(f"{it[0]} {it[1]}")
elif isinstance(it, str):
stock_tags.append(it)
@@ -6091,7 +6186,7 @@ def account_calendar_events():
'event_date': (row.calendar_time.date().isoformat() if row.calendar_time else None),
'type': 'future_event',
'importance': int(row.star) if getattr(row, 'star', None) is not None else 3,
'description': row.former or '',
'description': former_value or '',
'stocks': stock_tags,
'is_following': True,
'source': 'future'
@@ -7499,47 +7594,8 @@ def get_calendar_events():
user_following_ids = {f.future_event_id for f in follows}
for row in result:
event_data = {
'id': row.data_id,
'title': row.title,
'type': row.type,
'calendar_time': row.calendar_time.isoformat(),
'star': row.star,
'former': row.former,
'forecast': row.forecast,
'fact': row.fact,
'is_following': row.data_id in user_following_ids
}
# 解析相关股票和概念
if row.related_stocks:
try:
if isinstance(row.related_stocks, str):
if row.related_stocks.startswith('['):
event_data['related_stocks'] = json.loads(row.related_stocks)
else:
event_data['related_stocks'] = row.related_stocks.split(',')
else:
event_data['related_stocks'] = row.related_stocks
except:
event_data['related_stocks'] = []
else:
event_data['related_stocks'] = []
if row.concepts:
try:
if isinstance(row.concepts, str):
if row.concepts.startswith('['):
event_data['concepts'] = json.loads(row.concepts)
else:
event_data['concepts'] = row.concepts.split(',')
else:
event_data['concepts'] = row.concepts
except:
event_data['concepts'] = []
else:
event_data['concepts'] = []
# 使用统一的处理函数,支持新字段回退和 best_matches 解析
event_data = process_future_event_row(row, user_following_ids)
events.append(event_data)
return jsonify({
@@ -7565,28 +7621,18 @@ def get_calendar_event_detail(event_id):
'error': 'Event not found'
}), 404
event_data = {
'id': result.data_id,
'title': result.title,
'type': result.type,
'calendar_time': result.calendar_time.isoformat(),
'star': result.star,
'former': result.former,
'forecast': result.forecast,
'fact': result.fact,
'related_stocks': parse_json_field(result.related_stocks),
'concepts': parse_json_field(result.concepts)
}
# 检查当前用户是否关注了该未来事件
user_following_ids = set()
if 'user_id' in session:
is_following = FutureEventFollow.query.filter_by(
user_id=session['user_id'],
future_event_id=event_id
).first() is not None
event_data['is_following'] = is_following
else:
event_data['is_following'] = False
if is_following:
user_following_ids.add(event_id)
# 使用统一的处理函数,支持新字段回退和 best_matches 解析
event_data = process_future_event_row(result, user_following_ids)
return jsonify({
'success': True,
@@ -7678,6 +7724,147 @@ def parse_json_field(field_value):
return []
def get_future_event_field(row, new_field, old_field):
"""
获取 future_events 表字段值,支持新旧字段回退
如果新字段存在且不为空,使用新字段;否则使用旧字段
"""
new_value = getattr(row, new_field, None) if hasattr(row, new_field) else None
old_value = getattr(row, old_field, None) if hasattr(row, old_field) else None
# 如果新字段有值(不为空字符串),使用新字段
if new_value is not None and str(new_value).strip():
return new_value
return old_value
def parse_best_matches(best_matches_value):
"""
解析新的 best_matches 数据结构(含研报引用信息)
新结构示例:
[
{
"stock_code": "300451.SZ",
"company_name": "创业慧康",
"original_description": "核心标的,医疗信息化...",
"best_report_title": "报告标题",
"best_report_author": "作者",
"best_report_sentences": "相关内容",
"best_report_match_score": "",
"best_report_match_ratio": 0.9285714285714286,
"best_report_declare_date": "2023-04-25T00:00:00",
"total_reports": 9,
"high_score_reports": 6
},
...
]
返回统一格式的股票列表,兼容旧格式
"""
if not best_matches_value:
return []
try:
# 解析 JSON
if isinstance(best_matches_value, str):
data = json.loads(best_matches_value)
else:
data = best_matches_value
if not isinstance(data, list):
return []
result = []
for item in data:
if isinstance(item, dict):
# 新结构:包含研报信息的字典
stock_info = {
'code': item.get('stock_code', ''),
'name': item.get('company_name', ''),
'description': item.get('original_description', ''),
'score': item.get('best_report_match_ratio', 0),
# 研报引用信息
'report': {
'title': item.get('best_report_title', ''),
'author': item.get('best_report_author', ''),
'sentences': item.get('best_report_sentences', ''),
'match_score': item.get('best_report_match_score', ''),
'match_ratio': item.get('best_report_match_ratio', 0),
'declare_date': item.get('best_report_declare_date', ''),
'total_reports': item.get('total_reports', 0),
'high_score_reports': item.get('high_score_reports', 0)
} if item.get('best_report_title') else None
}
result.append(stock_info)
elif isinstance(item, (list, tuple)) and len(item) >= 2:
# 旧结构:[code, name, description, score]
result.append({
'code': item[0],
'name': item[1],
'description': item[2] if len(item) > 2 else '',
'score': item[3] if len(item) > 3 else 0,
'report': None
})
return result
except Exception as e:
print(f"parse_best_matches error: {e}")
return []
def process_future_event_row(row, user_following_ids=None):
"""
统一处理 future_events 表的行数据
支持新字段回退和 best_matches 解析
"""
if user_following_ids is None:
user_following_ids = set()
# 获取字段值,支持新旧回退
# second_modified_text -> former
# second_modified_text.1 -> forecast (MySQL 中用反引号)
former_value = get_future_event_field(row, 'second_modified_text', 'former')
# 处理 second_modified_text.1 字段(特殊字段名)
forecast_new = None
if hasattr(row, 'second_modified_text.1'):
forecast_new = getattr(row, 'second_modified_text.1', None)
# 尝试其他可能的属性名
for attr_name in ['second_modified_text.1', 'second_modified_text_1']:
if hasattr(row, attr_name):
val = getattr(row, attr_name, None)
if val and str(val).strip():
forecast_new = val
break
forecast_value = forecast_new if (forecast_new and str(forecast_new).strip()) else getattr(row, 'forecast', None)
# best_matches -> related_stocks
best_matches = getattr(row, 'best_matches', None) if hasattr(row, 'best_matches') else None
if best_matches and str(best_matches).strip():
related_stocks = parse_best_matches(best_matches)
else:
related_stocks = parse_json_field(getattr(row, 'related_stocks', None))
# 构建事件数据
event_data = {
'id': row.data_id,
'title': row.title,
'type': getattr(row, 'type', None),
'calendar_time': row.calendar_time.isoformat() if row.calendar_time else None,
'star': row.star,
'former': former_value,
'forecast': forecast_value,
'fact': getattr(row, 'fact', None),
'is_following': row.data_id in user_following_ids,
'related_stocks': related_stocks,
'concepts': parse_json_field(getattr(row, 'concepts', None)),
'update_time': getattr(row, 'update_time', None).isoformat() if getattr(row, 'update_time', None) else None
}
return event_data
# ==================== 行业API ====================
@app.route('/api/classifications', methods=['GET'])
def get_classifications():

381
app_vx.py
View File

@@ -1,9 +1,24 @@
# ===================== Gevent Monkey Patch (必须在最开头) =====================
# 检测是否通过 gevent/gunicorn 运行,如果是则打 monkey patch
import os
import sys
# 检查环境变量或命令行参数判断是否需要 gevent
_USE_GEVENT = os.environ.get('USE_GEVENT', 'false').lower() == 'true'
if _USE_GEVENT or 'gevent' in sys.modules:
try:
from gevent import monkey
monkey.patch_all()
print("✅ Gevent monkey patch 已应用")
except ImportError:
print("⚠️ Gevent 未安装,跳过 monkey patch")
# ===================== Gevent Monkey Patch 结束 =====================
import csv
import logging
import random
import re
import math
import os
import secrets
import string
@@ -74,20 +89,28 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(level
logger = logging.getLogger(__name__)
# ===================== ClickHouse 连接池实现 =====================
# ===================== ClickHouse 连接池实现(增强版)=====================
import threading as _threading
import atexit
class ClickHouseConnectionPool:
"""
ClickHouse 连接池
ClickHouse 连接池(增强版)
- 支持连接复用,避免频繁创建/销毁连接
- 支持连接超时和健康检查
- 支持自动重
- 支持连接最大存活时间(防止僵尸连接)
- 支持后台自动清理过期连接
- 支持自动重连和重试
- 线程安全
"""
def __init__(self, host, port, user, password, database,
pool_size=10, max_overflow=10,
connection_timeout=10, query_timeout=30,
health_check_interval=60):
health_check_interval=60,
max_connection_lifetime=300, # 新增:连接最大存活时间(秒)
cleanup_interval=60, # 新增:清理间隔(秒)
max_retries=3): # 新增:最大重试次数
"""
初始化连接池
@@ -102,6 +125,9 @@ class ClickHouseConnectionPool:
connection_timeout: 获取连接超时时间(秒)
query_timeout: 查询超时时间(秒)
health_check_interval: 健康检查间隔(秒)
max_connection_lifetime: 连接最大存活时间(秒),超过后自动关闭重建
cleanup_interval: 后台清理线程间隔(秒)
max_retries: 查询失败时的最大重试次数
"""
self.host = host
self.port = port
@@ -113,6 +139,9 @@ class ClickHouseConnectionPool:
self.connection_timeout = connection_timeout
self.query_timeout = query_timeout
self.health_check_interval = health_check_interval
self.max_connection_lifetime = max_connection_lifetime
self.cleanup_interval = cleanup_interval
self.max_retries = max_retries
# 连接池队列
self._pool = Queue(maxsize=pool_size + max_overflow)
@@ -124,25 +153,118 @@ class ClickHouseConnectionPool:
self._last_used = {}
# 连接创建时间记录
self._created_at = {}
# 是否已关闭
self._closed = False
# 清理线程
self._cleanup_thread = None
self._cleanup_stop_event = _threading.Event()
# 初始化核心连接
self._init_pool()
logger.info(f"ClickHouse 连接池初始化完成: pool_size={pool_size}, max_overflow={max_overflow}")
# 初始化核心连接(延迟初始化,首次使用时创建)
self._initialized = False
# 清理线程也延迟启动(避免 fork 前启动线程导致问题)
self._cleanup_thread_started = False
logger.info(f"ClickHouse 连接池配置完成: pool_size={pool_size}, max_overflow={max_overflow}, "
f"max_lifetime={max_connection_lifetime}s")
# 注册退出时清理
atexit.register(self.close_all)
def _start_cleanup_thread(self):
"""启动后台清理线程(延迟启动,首次使用连接池时调用)"""
if self._cleanup_thread_started or self._closed:
return
with self._lock:
if self._cleanup_thread_started or self._closed:
return
def cleanup_worker():
while not self._cleanup_stop_event.wait(self.cleanup_interval):
if self._closed:
break
try:
self._cleanup_expired_connections()
except Exception as e:
logger.error(f"清理连接时出错: {e}")
self._cleanup_thread = _threading.Thread(target=cleanup_worker, daemon=True, name="CH-Pool-Cleanup")
self._cleanup_thread.start()
self._cleanup_thread_started = True
logger.debug("后台清理线程已启动")
def _cleanup_expired_connections(self):
"""清理过期连接"""
current_time = time.time()
cleaned_count = 0
# 创建临时列表存放需要保留的连接
valid_connections = []
# 从池中取出所有连接进行检查
while True:
try:
conn = self._pool.get_nowait()
conn_id = id(conn)
created_at = self._created_at.get(conn_id, current_time)
last_used = self._last_used.get(conn_id, current_time)
# 检查是否过期
lifetime = current_time - created_at
idle_time = current_time - last_used
if lifetime > self.max_connection_lifetime:
# 连接存活时间过长,关闭
logger.debug(f"连接 {conn_id} 存活时间 {lifetime:.0f}s 超过限制,关闭")
self._close_connection(conn)
cleaned_count += 1
elif idle_time > self.health_check_interval * 3:
# 长时间空闲,进行健康检查
if not self._check_connection_health(conn):
self._close_connection(conn)
cleaned_count += 1
else:
valid_connections.append(conn)
else:
valid_connections.append(conn)
except Empty:
break
# 将有效连接放回池中
for conn in valid_connections:
try:
self._pool.put_nowait(conn)
except Full:
self._close_connection(conn)
if cleaned_count > 0:
logger.info(f"清理了 {cleaned_count} 个过期连接,当前活跃连接数: {self._active_connections}")
def _init_pool(self):
"""初始化连接池,预创建部分核心连接(非阻塞)"""
# 只预创建 2 个连接,其余按需创建
init_count = min(2, self.pool_size)
for i in range(init_count):
try:
conn = self._create_connection()
if conn:
self._pool.put(conn)
logger.info(f"预创建 ClickHouse 连接 {i+1}/{init_count} 成功")
except Exception as e:
logger.warning(f"预创建 ClickHouse 连接失败 ({i+1}/{init_count}): {e}")
# 预创建失败不阻塞启动,后续按需创建
break
if self._initialized:
return
with self._lock:
if self._initialized:
return
# 启动清理线程(延迟到首次使用时启动)
self._start_cleanup_thread()
# 预创建 1 个连接,其余按需创建
init_count = min(1, self.pool_size)
for i in range(init_count):
try:
conn = self._create_connection()
if conn:
self._pool.put(conn)
logger.info(f"预创建 ClickHouse 连接 {i+1}/{init_count} 成功")
except Exception as e:
logger.warning(f"预创建 ClickHouse 连接失败 ({i+1}/{init_count}): {e}")
# 预创建失败不阻塞启动,后续按需创建
break
self._initialized = True
def _create_connection(self):
"""创建新的 ClickHouse 连接"""
@@ -162,8 +284,9 @@ class ClickHouseConnectionPool:
}
)
conn_id = id(client)
self._created_at[conn_id] = time.time()
self._last_used[conn_id] = time.time()
current_time = time.time()
self._created_at[conn_id] = current_time
self._last_used[conn_id] = current_time
with self._lock:
self._active_connections += 1
logger.debug(f"创建新的 ClickHouse 连接: {conn_id}")
@@ -173,15 +296,23 @@ class ClickHouseConnectionPool:
raise
def _check_connection_health(self, conn):
"""检查连接健康状态"""
"""检查连接健康状态(带超时保护)"""
try:
conn_id = id(conn)
last_used = self._last_used.get(conn_id, 0)
created_at = self._created_at.get(conn_id, 0)
current_time = time.time()
# 检查连接是否存活时间过长
if current_time - created_at > self.max_connection_lifetime:
logger.debug(f"连接 {conn_id} 超过最大存活时间,标记为不健康")
return False
# 如果连接长时间未使用,进行健康检查
if time.time() - last_used > self.health_check_interval:
if current_time - last_used > self.health_check_interval:
# 执行简单查询检查连接
conn.execute("SELECT 1")
self._last_used[conn_id] = current_time
logger.debug(f"连接 {conn_id} 健康检查通过")
return True
@@ -191,9 +322,14 @@ class ClickHouseConnectionPool:
def _close_connection(self, conn):
"""关闭连接"""
if conn is None:
return
try:
conn_id = id(conn)
conn.disconnect()
try:
conn.disconnect()
except:
pass # 忽略断开连接时的错误
self._last_used.pop(conn_id, None)
self._created_at.pop(conn_id, None)
with self._lock:
@@ -216,34 +352,48 @@ class ClickHouseConnectionPool:
TimeoutError: 获取连接超时
Exception: 创建连接失败
"""
if self._closed:
raise RuntimeError("连接池已关闭")
# 延迟初始化
if not self._initialized:
self._init_pool()
timeout = timeout or self.connection_timeout
start_time = time.time()
# 首先尝试从池中获取连接
try:
conn = self._pool.get(block=True, timeout=timeout)
while True:
elapsed = time.time() - start_time
if elapsed >= timeout:
raise TimeoutError(f"获取 ClickHouse 连接超时 (timeout={timeout}s)")
# 检查连接健康状态
if self._check_connection_health(conn):
self._last_used[id(conn)] = time.time()
return conn
else:
# 连接不健康,关闭并创建新连接
self._close_connection(conn)
return self._create_connection()
remaining_timeout = timeout - elapsed
except Empty:
# 池中没有可用连接,检查是否可以创建新连接
with self._lock:
if self._active_connections < self.pool_size + self.max_overflow:
try:
return self._create_connection()
except Exception as e:
logger.error(f"创建溢出连接失败: {e}")
raise
# 首先尝试从池中获取连接
try:
conn = self._pool.get(block=True, timeout=min(remaining_timeout, 1.0))
# 已达到最大连接数,等待连接释放
logger.warning(f"连接池已满,等待连接释放... (当前连接数: {self._active_connections})")
raise TimeoutError(f"获取 ClickHouse 连接超时 (timeout={timeout}s)")
# 检查连接健康状态
if self._check_connection_health(conn):
self._last_used[id(conn)] = time.time()
return conn
else:
# 连接不健康,关闭并尝试获取新连接
self._close_connection(conn)
continue
except Empty:
# 池中没有可用连接,检查是否可以创建新连接
with self._lock:
if self._active_connections < self.pool_size + self.max_overflow:
try:
return self._create_connection()
except Exception as e:
logger.error(f"创建溢出连接失败: {e}")
# 不立即抛出异常,继续等待
# 短暂等待后重试
time.sleep(0.1)
def release_connection(self, conn):
"""
@@ -255,7 +405,19 @@ class ClickHouseConnectionPool:
if conn is None:
return
if self._closed:
self._close_connection(conn)
return
conn_id = id(conn)
created_at = self._created_at.get(conn_id, 0)
# 如果连接存活时间过长,直接关闭而不放回池中
if time.time() - created_at > self.max_connection_lifetime:
logger.debug(f"连接 {conn_id} 超过最大存活时间,关闭而不放回池中")
self._close_connection(conn)
return
self._last_used[conn_id] = time.time()
try:
@@ -296,7 +458,7 @@ class ClickHouseConnectionPool:
def execute(self, query, params=None, timeout=None):
"""
执行查询(自动管理连接)
执行查询(自动管理连接,带重试机制
Args:
query: SQL 查询语句
@@ -306,8 +468,21 @@ class ClickHouseConnectionPool:
Returns:
查询结果
"""
with self.connection(timeout) as conn:
return conn.execute(query, params)
last_error = None
for retry in range(self.max_retries):
try:
with self.connection(timeout) as conn:
return conn.execute(query, params)
except (TimeoutError, RuntimeError) as e:
# 这些错误不应该重试
raise
except Exception as e:
last_error = e
logger.warning(f"查询执行失败 (重试 {retry + 1}/{self.max_retries}): {e}")
if retry < self.max_retries - 1:
time.sleep(0.5 * (retry + 1)) # 递增等待时间
raise last_error
def get_pool_status(self):
"""获取连接池状态"""
@@ -316,11 +491,22 @@ class ClickHouseConnectionPool:
'max_overflow': self.max_overflow,
'active_connections': self._active_connections,
'available_connections': self._pool.qsize(),
'max_connections': self.pool_size + self.max_overflow
'max_connections': self.pool_size + self.max_overflow,
'max_connection_lifetime': self.max_connection_lifetime,
'initialized': self._initialized,
'closed': self._closed
}
def close_all(self):
"""关闭所有连接"""
self._closed = True
self._cleanup_stop_event.set() # 停止清理线程
# 等待清理线程结束
if self._cleanup_thread and self._cleanup_thread.is_alive():
self._cleanup_thread.join(timeout=2)
# 关闭所有池中的连接
while not self._pool.empty():
try:
conn = self._pool.get_nowait()
@@ -347,11 +533,14 @@ def _init_clickhouse_pool():
user='default',
password='Zzl33818!',
database='stock',
pool_size=5, # 减少预创建连接数
max_overflow=20, # 增加溢出连接数,总共支持 25 并发
connection_timeout=10, # 连接超时 10
query_timeout=30, # 查询超时 30 秒
health_check_interval=60 # 60 秒未使用的连接进行健康检查
pool_size=5, # 核心连接数
max_overflow=20, # 溢出连接数,总共支持 25 并发
connection_timeout=15, # 连接超时 15(增加容忍度)
query_timeout=60, # 查询超时 60 秒(给复杂查询更多时间)
health_check_interval=30, # 30 秒进行健康检查
max_connection_lifetime=300, # 连接最大存活 5 分钟(防止僵尸连接)
cleanup_interval=60, # 每 60 秒清理一次过期连接
max_retries=3 # 查询失败最多重试 3 次
)
return clickhouse_pool
# ===================== ClickHouse 连接池实现结束 =====================
@@ -6407,17 +6596,81 @@ def api_method_not_allowed(error):
return error
# 应用启动时自动初始化(兼容 Gunicorn 和直接运行)
_sywg_cache_initialized = False
def ensure_sywg_cache_initialized():
"""确保申银万国行业分类缓存已初始化(懒加载,首次请求时触发)"""
global _sywg_cache_initialized
if not _sywg_cache_initialized:
init_sywg_industry_cache()
_sywg_cache_initialized = True
@app.before_request
def before_request_init():
"""首次请求时初始化缓存"""
global _sywg_cache_initialized
if not _sywg_cache_initialized:
ensure_sywg_cache_initialized()
# ===================== 应用启动配置 =====================
# 生产环境推荐使用 Gunicorn 启动(见下方命令)
#
# 【启动方式 1】使用 Gunicorn + gevent推荐:
# USE_GEVENT=true gunicorn -w 4 -k gevent --worker-connections 1000 \
# -b 0.0.0.0:5002 --timeout 120 --graceful-timeout 30 \
# --certfile=/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem \
# --keyfile=/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem \
# app_vx:app
#
# 【启动方式 2】使用 Gunicorn + 多进程(不使用 gevent:
# gunicorn -w 4 -b 0.0.0.0:5002 --timeout 120 --graceful-timeout 30 \
# --certfile=/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem \
# --keyfile=/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem \
# app_vx:app
#
# 【启动方式 3】开发环境直接运行仅限本地调试:
# python app_vx.py
#
# ===================== 应用启动配置结束 =====================
if __name__ == '__main__':
# 初始化申银万国行业分类缓存
import argparse
parser = argparse.ArgumentParser(description='启动 Flask 应用')
parser.add_argument('--debug', action='store_true', help='启用调试模式(仅限开发环境)')
parser.add_argument('--port', type=int, default=5002, help='监听端口(默认 5002')
parser.add_argument('--no-ssl', action='store_true', help='禁用 SSL仅限开发环境')
args = parser.parse_args()
# 直接运行时,立即初始化缓存
with app.app_context():
init_sywg_industry_cache()
_sywg_cache_initialized = True
# 警告:生产环境不应使用 debug=True
if args.debug:
logger.warning("⚠️ 调试模式已启用,仅限开发环境使用!")
# SSL 配置
ssl_context = None
if not args.no_ssl:
cert_file = '/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem'
key_file = '/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem'
if os.path.exists(cert_file) and os.path.exists(key_file):
ssl_context = (cert_file, key_file)
else:
logger.warning("⚠️ SSL 证书文件不存在,将使用 HTTP 模式")
logger.info(f"🚀 启动 Flask 应用: port={args.port}, debug={args.debug}, ssl={'enabled' if ssl_context else 'disabled'}")
app.run(
host='0.0.0.0',
port=5002,
debug=True,
ssl_context=(
'/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem',
'/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem'
)
port=args.port,
debug=args.debug,
ssl_context=ssl_context,
threaded=True # 启用多线程处理请求
)

1096
concept_api_openapi.json Normal file

File diff suppressed because it is too large Load Diff

166
gunicorn_config.py Normal file
View File

@@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
"""
Gunicorn 配置文件 - app_vx.py 生产环境配置
使用方式:
# 方式1: 使用 gevent 异步模式(推荐,支持高并发)
gunicorn -c gunicorn_config.py -k gevent app_vx:app
# 方式2: 使用同步多进程模式(更稳定)
gunicorn -c gunicorn_config.py app_vx:app
# 方式3: 使用 systemd 管理(见文件末尾 systemd 配置示例)
"""
import os
import multiprocessing
# ==================== 基础配置 ====================
# 绑定地址和端口
bind = '0.0.0.0:5002'
# Worker 进程数(建议 2-4 个,不要太多以避免连接池竞争)
workers = 4
# Worker 类型 - 默认使用 sync 模式,更稳定
# 如果需要 gevent在命令行添加 -k gevent
worker_class = 'sync'
# 每个 worker 处理的最大请求数,超过后重启(防止内存泄漏)
max_requests = 5000
max_requests_jitter = 500 # 随机抖动,避免所有 worker 同时重启
# ==================== 超时配置 ====================
# Worker 超时时间(秒),超过后 worker 会被杀死重启
timeout = 120
# 优雅关闭超时时间(秒)
graceful_timeout = 30
# 保持连接超时时间(秒)
keepalive = 5
# ==================== SSL 配置 ====================
# SSL 证书路径(生产环境需要配置)
cert_file = '/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem'
key_file = '/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem'
if os.path.exists(cert_file) and os.path.exists(key_file):
certfile = cert_file
keyfile = key_file
# ==================== 日志配置 ====================
# 访问日志文件路径(- 表示输出到 stdout
accesslog = '-'
# 错误日志文件路径(- 表示输出到 stderr
errorlog = '-'
# 日志级别debug, info, warning, error, critical
loglevel = 'info'
# 访问日志格式
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
# ==================== 进程管理 ====================
# 是否在后台运行daemon 模式)
daemon = False
# PID 文件路径
pidfile = '/tmp/gunicorn_app_vx.pid'
# 进程名称
proc_name = 'app_vx'
# ==================== 预加载配置 ====================
# 是否预加载应用代码
# 重要:设为 False 以确保每个 worker 有独立的连接池实例
# 否则多个 worker 共享同一个连接池会导致竞争和超时
preload_app = False
# ==================== Hook 函数 ====================
def on_starting(server):
"""服务器启动时调用"""
print(f"Gunicorn 服务器正在启动...")
print(f" Workers: {server.app.cfg.workers}")
print(f" Worker Class: {server.app.cfg.worker_class}")
print(f" Bind: {server.app.cfg.bind}")
def when_ready(server):
"""服务准备就绪时调用"""
print("Gunicorn 服务准备就绪!")
print("注意: 缓存将在首次请求时懒加载初始化")
def on_reload(server):
"""服务器重载时调用"""
print("Gunicorn 服务器正在重载...")
def worker_int(worker):
"""Worker 收到 INT 或 QUIT 信号时调用"""
print(f"Worker {worker.pid} 收到中断信号")
def worker_abort(worker):
"""Worker 收到 SIGABRT 信号时调用(超时)"""
print(f"Worker {worker.pid} 超时被终止")
def post_fork(server, worker):
"""Worker 进程 fork 之后调用"""
print(f"Worker {worker.pid} 已启动")
def worker_exit(server, worker):
"""Worker 退出时调用"""
print(f"Worker {worker.pid} 已退出")
def on_exit(server):
"""服务器退出时调用"""
print("Gunicorn 服务器已关闭")
# ==================== systemd 配置示例 ====================
"""
将以下内容保存为 /etc/systemd/system/app_vx.service:
[Unit]
Description=Gunicorn instance to serve app_vx
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/path/to/vf_react
Environment="PATH=/path/to/venv/bin"
Environment="USE_GEVENT=true"
ExecStart=/path/to/venv/bin/gunicorn -c gunicorn_config.py app_vx:app
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=mixed
TimeoutStopSec=5
PrivateTmp=true
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
启用服务:
sudo systemctl daemon-reload
sudo systemctl enable app_vx
sudo systemctl start app_vx
sudo systemctl status app_vx
查看日志:
sudo journalctl -u app_vx -f
"""

View File

@@ -781,3 +781,252 @@ async def remove_favorite_event(user_id: str, event_id: int) -> Dict[str, Any]:
return {"success": True, "message": "删除自选事件成功"}
else:
return {"success": False, "message": "未找到该自选事件"}
# ==================== ClickHouse 分钟频数据查询 ====================
from clickhouse_driver import Client as ClickHouseClient
# ClickHouse 连接配置
CLICKHOUSE_CONFIG = {
'host': '222.128.1.157',
'port': 18000,
'user': 'default',
'password': 'Zzl33818!',
'database': 'stock'
}
# ClickHouse 客户端(懒加载)
_clickhouse_client = None
def get_clickhouse_client():
"""获取 ClickHouse 客户端(单例模式)"""
global _clickhouse_client
if _clickhouse_client is None:
_clickhouse_client = ClickHouseClient(
host=CLICKHOUSE_CONFIG['host'],
port=CLICKHOUSE_CONFIG['port'],
user=CLICKHOUSE_CONFIG['user'],
password=CLICKHOUSE_CONFIG['password'],
database=CLICKHOUSE_CONFIG['database']
)
logger.info("ClickHouse client created")
return _clickhouse_client
async def get_stock_minute_data(
code: str,
start_time: Optional[str] = None,
end_time: Optional[str] = None,
limit: int = 240
) -> List[Dict[str, Any]]:
"""
获取股票分钟频数据
Args:
code: 股票代码(例如:'600519''600519.SH'
start_time: 开始时间格式YYYY-MM-DD HH:MM:SS 或 YYYY-MM-DD
end_time: 结束时间格式YYYY-MM-DD HH:MM:SS 或 YYYY-MM-DD
limit: 返回条数默认240一个交易日的分钟数据
Returns:
分钟频数据列表
"""
try:
client = get_clickhouse_client()
# 标准化股票代码ClickHouse 分钟数据使用带后缀格式
# 6开头 -> .SH (上海), 0/3开头 -> .SZ (深圳), 其他 -> .BJ (北京)
if '.' in code:
# 已经有后缀,直接使用
stock_code = code
else:
# 需要添加后缀
if code.startswith('6'):
stock_code = f"{code}.SH"
elif code.startswith('0') or code.startswith('3'):
stock_code = f"{code}.SZ"
else:
stock_code = f"{code}.BJ"
# 构建查询 - 使用字符串格式化ClickHouse 参数化语法兼容性问题)
query = f"""
SELECT
code,
timestamp,
open,
high,
low,
close,
volume,
amt
FROM stock_minute
WHERE code = '{stock_code}'
"""
if start_time:
query += f" AND timestamp >= '{start_time}'"
if end_time:
query += f" AND timestamp <= '{end_time}'"
query += f" ORDER BY timestamp DESC LIMIT {limit}"
# 执行查询
result = client.execute(query, with_column_types=True)
rows = result[0]
columns = [col[0] for col in result[1]]
# 转换为字典列表
data = []
for row in rows:
record = {}
for i, col in enumerate(columns):
value = row[i]
# 处理 datetime 类型
if hasattr(value, 'isoformat'):
record[col] = value.isoformat()
else:
record[col] = value
data.append(record)
logger.info(f"[ClickHouse] 查询分钟数据: code={stock_code}, 返回 {len(data)} 条记录")
return data
except Exception as e:
logger.error(f"[ClickHouse] 查询分钟数据失败: {e}", exc_info=True)
return []
async def get_stock_minute_aggregation(
code: str,
date: str,
interval: int = 5
) -> List[Dict[str, Any]]:
"""
获取股票分钟频数据的聚合(按指定分钟间隔)
Args:
code: 股票代码
date: 日期格式YYYY-MM-DD
interval: 聚合间隔分钟默认5分钟
Returns:
聚合后的K线数据
"""
try:
client = get_clickhouse_client()
# 标准化股票代码
stock_code = code.split('.')[0] if '.' in code else code
# 使用 ClickHouse 的时间函数进行聚合
query = f"""
SELECT
code,
toStartOfInterval(timestamp, INTERVAL {interval} MINUTE) as interval_start,
argMin(open, timestamp) as open,
max(high) as high,
min(low) as low,
argMax(close, timestamp) as close,
sum(volume) as volume,
sum(amt) as amt
FROM stock_minute
WHERE code = %(code)s
AND toDate(timestamp) = %(date)s
GROUP BY code, interval_start
ORDER BY interval_start
"""
params = {'code': stock_code, 'date': date}
result = client.execute(query, params, with_column_types=True)
rows = result[0]
columns = [col[0] for col in result[1]]
data = []
for row in rows:
record = {}
for i, col in enumerate(columns):
value = row[i]
if hasattr(value, 'isoformat'):
record[col] = value.isoformat()
else:
record[col] = value
data.append(record)
logger.info(f"[ClickHouse] 聚合分钟数据: code={stock_code}, date={date}, interval={interval}min, 返回 {len(data)} 条记录")
return data
except Exception as e:
logger.error(f"[ClickHouse] 聚合分钟数据失败: {e}", exc_info=True)
return []
async def get_stock_intraday_statistics(
code: str,
date: str
) -> Dict[str, Any]:
"""
获取股票日内统计数据
Args:
code: 股票代码
date: 日期格式YYYY-MM-DD
Returns:
日内统计数据(开盘价、最高价、最低价、收盘价、成交量、成交额、波动率等)
"""
try:
client = get_clickhouse_client()
stock_code = code.split('.')[0] if '.' in code else code
query = """
SELECT
code,
toDate(timestamp) as trade_date,
argMin(open, timestamp) as open,
max(high) as high,
min(low) as low,
argMax(close, timestamp) as close,
sum(volume) as total_volume,
sum(amt) as total_amount,
count(*) as data_points,
min(timestamp) as first_time,
max(timestamp) as last_time,
(max(high) - min(low)) / min(low) * 100 as intraday_range_pct,
stddevPop(close) as price_volatility
FROM stock_minute
WHERE code = %(code)s
AND toDate(timestamp) = %(date)s
GROUP BY code, trade_date
"""
params = {'code': stock_code, 'date': date}
result = client.execute(query, params, with_column_types=True)
if not result[0]:
return {"success": False, "error": f"未找到 {stock_code}{date} 的分钟数据"}
row = result[0][0]
columns = [col[0] for col in result[1]]
data = {}
for i, col in enumerate(columns):
value = row[i]
if hasattr(value, 'isoformat'):
data[col] = value.isoformat()
else:
data[col] = float(value) if isinstance(value, (int, float)) else value
logger.info(f"[ClickHouse] 日内统计: code={stock_code}, date={date}")
return {"success": True, "data": data}
except Exception as e:
logger.error(f"[ClickHouse] 日内统计失败: {e}", exc_info=True)
return {"success": False, "error": str(e)}

View File

@@ -69,6 +69,8 @@ class ESClient:
},
"plan": {"type": "text"}, # 执行计划(仅 assistant
"steps": {"type": "text"}, # 执行步骤(仅 assistant
"session_title": {"type": "text"}, # 会话标题/概述(新增)
"is_first_message": {"type": "boolean"}, # 是否是会话首条消息(新增)
"timestamp": {"type": "date"}, # 时间戳
"created_at": {"type": "date"}, # 创建时间
}
@@ -105,6 +107,8 @@ class ESClient:
message: str,
plan: Optional[str] = None,
steps: Optional[str] = None,
session_title: Optional[str] = None,
is_first_message: bool = False,
) -> str:
"""
保存聊天消息
@@ -118,6 +122,8 @@ class ESClient:
message: 消息内容
plan: 执行计划(可选)
steps: 执行步骤(可选)
session_title: 会话标题(可选,通常在首条消息时设置)
is_first_message: 是否是会话首条消息
Returns:
文档ID
@@ -136,6 +142,8 @@ class ESClient:
"message_embedding": embedding if embedding else None,
"plan": plan,
"steps": steps,
"session_title": session_title,
"is_first_message": is_first_message,
"timestamp": datetime.now(),
"created_at": datetime.now(),
}
@@ -157,10 +165,10 @@ class ESClient:
limit: 返回数量
Returns:
会话列表每个会话包含session_id, last_message, last_timestamp
会话列表每个会话包含session_id, title, last_message, last_timestamp
"""
try:
# 聚合查询:按 session_id 分组,获取每个会话的最后一条消息
# 聚合查询:按 session_id 分组,获取每个会话的最后一条消息和标题
query = {
"query": {
"term": {"user_id": user_id}
@@ -180,7 +188,15 @@ class ESClient:
"top_hits": {
"size": 1,
"sort": [{"timestamp": {"order": "desc"}}],
"_source": ["message", "timestamp", "message_type"]
"_source": ["message", "timestamp", "message_type", "session_title"]
}
},
# 获取首条消息(包含标题)
"first_message": {
"top_hits": {
"size": 1,
"sort": [{"timestamp": {"order": "asc"}}],
"_source": ["session_title", "message"]
}
}
}
@@ -193,11 +209,21 @@ class ESClient:
sessions = []
for bucket in result["aggregations"]["sessions"]["buckets"]:
session_data = bucket["last_message_content"]["hits"]["hits"][0]["_source"]
last_msg = bucket["last_message_content"]["hits"]["hits"][0]["_source"]
first_msg = bucket["first_message"]["hits"]["hits"][0]["_source"]
# 优先使用 session_title否则使用首条消息的前30字符
title = (
last_msg.get("session_title") or
first_msg.get("session_title") or
first_msg.get("message", "")[:30]
)
sessions.append({
"session_id": bucket["key"],
"last_message": session_data["message"],
"last_timestamp": session_data["timestamp"],
"title": title,
"last_message": last_msg["message"],
"last_timestamp": last_msg["timestamp"],
"message_count": bucket["doc_count"],
})

2780
mcp_quant.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,7 @@
"react-to-print": "^3.0.3",
"react-tsparticles": "^2.12.2",
"recharts": "^3.1.2",
"remark-gfm": "^4.0.1",
"sass": "^1.49.9",
"socket.io-client": "^4.7.4",
"styled-components": "^5.3.11",

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 795 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1017 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 KiB

2089
report_zt_api.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -27,17 +27,66 @@ import { PerformancePanel } from './components/PerformancePanel';
import { useGlobalErrorHandler } from './hooks/useGlobalErrorHandler';
// Redux
import { initializePostHog } from './store/slices/posthogSlice';
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
// import { initializePostHog } from './store/slices/posthogSlice';
import { updateScreenSize } from './store/slices/deviceSlice';
import { injectReducer } from './store';
// Utils
import { logger } from './utils/logger';
import { performanceMonitor } from './utils/performanceMonitor';
// PostHog 追踪
import { trackEvent, trackEventAsync } from '@lib/posthog';
// PostHog 延迟加载:移除同步导入
// import { trackEvent, trackEventAsync } from '@lib/posthog';
// Contexts
import { useAuth } from '@contexts/AuthContext';
// ⚡ PostHog 延迟加载模块(动态导入后缓存)
let posthogModule = null;
let posthogSliceModule = null;
/**
* ⚡ 延迟加载 PostHog 模块
* 返回 { trackEvent, trackEventAsync, initializePostHog, posthogReducer }
*/
const loadPostHogModules = async () => {
if (posthogModule && posthogSliceModule) {
return { posthogModule, posthogSliceModule };
}
try {
const [posthog, posthogSlice] = await Promise.all([
import('@lib/posthog'),
import('./store/slices/posthogSlice'),
]);
posthogModule = posthog;
posthogSliceModule = posthogSlice;
return { posthogModule, posthogSliceModule };
} catch (error) {
logger.error('App', 'PostHog 模块加载失败', error);
return null;
}
};
/**
* ⚡ 异步追踪事件(延迟加载 PostHog 后调用)
* @param {string} eventName - 事件名称
* @param {object} properties - 事件属性
*/
const trackEventLazy = async (eventName, properties = {}) => {
// 等待模块加载完成
if (!posthogModule) {
const modules = await loadPostHogModules();
if (!modules) return;
}
// 使用异步追踪,不阻塞主线程
posthogModule.trackEventAsync(eventName, properties);
};
/**
* AppContent - 应用核心内容
* 负责 PostHog 初始化和渲染路由
@@ -51,28 +100,98 @@ function AppContent() {
const pageEnterTimeRef = useRef(Date.now());
const currentPathRef = useRef(location.pathname);
// 🎯 PostHog Redux 初始化
// 🎯 PostHog 空闲时加载 + Redux 初始化(首屏不加载 ~180KB
useEffect(() => {
dispatch(initializePostHog());
logger.info('App', 'PostHog Redux 初始化已触发');
const initPostHogRedux = async () => {
try {
const modules = await loadPostHogModules();
if (!modules) return;
const { posthogSliceModule } = modules;
// 动态注入 PostHog reducer
injectReducer('posthog', posthogSliceModule.default);
// 初始化 PostHog
dispatch(posthogSliceModule.initializePostHog());
// ⚡ 刷新注入前缓存的事件(避免丢失)
const pendingEvents = posthogSliceModule.flushPendingEventsBeforeInjection();
if (pendingEvents.length > 0) {
logger.info('App', `刷新 ${pendingEvents.length} 个注入前缓存的事件`);
pendingEvents.forEach(({ eventName, properties }) => {
posthogModule.trackEventAsync(eventName, properties);
});
}
logger.info('App', 'PostHog 模块空闲时加载完成Redux 初始化已触发');
} catch (error) {
logger.error('App', 'PostHog 加载失败', error);
}
};
// ⚡ 使用 requestIdleCallback 在浏览器空闲时加载,最长等待 3 秒
if ('requestIdleCallback' in window) {
const idleId = requestIdleCallback(initPostHogRedux, { timeout: 3000 });
return () => cancelIdleCallback(idleId);
} else {
// 降级Safari 等不支持 requestIdleCallback 的浏览器使用 setTimeout
const timer = setTimeout(initPostHogRedux, 1000);
return () => clearTimeout(timer);
}
}, [dispatch]);
// ✅ 首次访问追踪
// ⚡ 性能监控:标记 React 初始化完成
useEffect(() => {
performanceMonitor.mark('react-ready');
}, []);
// 📱 设备检测:监听窗口尺寸变化
useEffect(() => {
let resizeTimer;
const handleResize = () => {
// 防抖:避免频繁触发
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
dispatch(updateScreenSize());
}, 150);
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleResize);
return () => {
clearTimeout(resizeTimer);
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleResize);
};
}, [dispatch]);
// ✅ 首次访问追踪(🔴 关键事件:立即加载模块,确保数据不丢失)
useEffect(() => {
const hasVisited = localStorage.getItem('has_visited');
if (!hasVisited) {
const urlParams = new URLSearchParams(location.search);
// ⚡ 使用异步追踪,不阻塞页面渲染
trackEventAsync('first_visit', {
const eventData = {
referrer: document.referrer || 'direct',
utm_source: urlParams.get('utm_source'),
utm_medium: urlParams.get('utm_medium'),
utm_campaign: urlParams.get('utm_campaign'),
landing_page: location.pathname,
timestamp: new Date().toISOString()
});
};
// 🔴 关键事件:立即加载 PostHog 模块并同步追踪(不使用 trackEventLazy
// 确保首次访问数据不会因用户快速离开而丢失
(async () => {
const modules = await loadPostHogModules();
if (modules) {
// 使用同步追踪trackEvent而非异步追踪trackEventAsync
modules.posthogModule.trackEvent('first_visit', eventData);
logger.info('App', '首次访问事件已同步追踪', eventData);
}
})();
localStorage.setItem('has_visited', 'true');
}
@@ -87,8 +206,8 @@ function AppContent() {
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
if (duration > 1) {
// ⚡ 使用异步追踪,不阻塞页面切换
trackEventAsync('page_view_duration', {
// ⚡ 使用延迟加载的异步追踪,不阻塞页面切换
trackEventLazy('page_view_duration', {
path: currentPathRef.current,
duration_seconds: duration,
is_authenticated: isAuthenticated,

View File

@@ -15,6 +15,10 @@
import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
// ⚡ 模块级变量:防止 React StrictMode 双重初始化
let widgetInitialized = false;
let idleCallbackId = null;
const BytedeskWidget = ({
config,
autoLoad = true,
@@ -27,110 +31,151 @@ const BytedeskWidget = ({
useEffect(() => {
// 如果不自动加载或配置未设置,跳过
if (!autoLoad || !config) {
if (!config) {
console.warn('[Bytedesk] 配置未设置,客服组件未加载');
}
return;
}
console.log('[Bytedesk] 开始加载客服Widget...', config);
// ⚡ 防止重复初始化React StrictMode 会双重调用 useEffect
if (widgetInitialized) {
return;
}
// 加载Bytedesk Widget脚本
const script = document.createElement('script');
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
script.async = true;
script.id = 'bytedesk-web-script';
// ⚡ 使用 requestIdleCallback 延迟加载,不阻塞首屏
const loadWidget = () => {
// 再次检查,防止竞态条件
if (widgetInitialized) return;
widgetInitialized = true;
script.onload = () => {
console.log('[Bytedesk] Widget脚本加载成功');
// 检查脚本是否已存在
if (document.getElementById('bytedesk-web-script')) {
return;
}
try {
if (window.BytedeskWeb) {
console.log('[Bytedesk] 初始化Widget');
const bytedesk = new window.BytedeskWeb(config);
bytedesk.init();
// 加载Bytedesk Widget脚本
const script = document.createElement('script');
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
script.async = true;
script.id = 'bytedesk-web-script';
widgetRef.current = bytedesk;
console.log('[Bytedesk] Widget初始化成功');
script.onload = () => {
try {
if (window.BytedeskWeb) {
const bytedesk = new window.BytedeskWeb(config);
bytedesk.init();
widgetRef.current = bytedesk;
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能
// Bytedesk SDK 内部的 /stomp WebSocket 连接失败不影响核心客服功能
// SDK 会自动降级使用 HTTP 轮询
const originalConsoleError = console.error;
console.error = function(...args) {
const errorMsg = args.join(' ');
// 忽略 /stomp 和 STOMP 相关错误
if (errorMsg.includes('/stomp') ||
errorMsg.includes('stomp onWebSocketError') ||
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) {
return; // 不输出日志
// ⚡ H5 端样式适配:使用 MutationObserver 立即应用样式(避免闪烁
const isMobile = window.innerWidth <= 768;
const applyBytedeskStyles = () => {
const allElements = document.querySelectorAll('body > div');
allElements.forEach(el => {
const style = window.getComputedStyle(el);
// 检查是否是右下角固定定位的元素Bytedesk 按钮)
if (style.position === 'fixed' && style.right && style.bottom) {
const rightVal = parseInt(style.right);
const bottomVal = parseInt(style.bottom);
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
// H5 端设置按钮尺寸为 48x48只执行一次
if (isMobile && !el.dataset.bytedeskStyled) {
el.dataset.bytedeskStyled = 'true';
const button = el.querySelector('button');
if (button) {
button.style.width = '48px';
button.style.height = '48px';
button.style.minWidth = '48px';
button.style.minHeight = '48px';
}
}
// 提示框 3 秒后隐藏(查找白色气泡框)
const children = el.querySelectorAll('div');
children.forEach(child => {
if (child.dataset.bytedeskTooltip) return; // 已处理过
const childStyle = window.getComputedStyle(child);
// 白色背景的提示框
if (childStyle.backgroundColor === 'rgb(255, 255, 255)') {
child.dataset.bytedeskTooltip = 'true';
setTimeout(() => {
child.style.transition = 'opacity 0.3s';
child.style.opacity = '0';
setTimeout(() => child.remove(), 300);
}, 3000);
}
});
}
}
});
};
// 立即执行一次
applyBytedeskStyles();
// 监听 DOM 变化,新元素出现时立即应用样式
const observer = new MutationObserver(applyBytedeskStyles);
observer.observe(document.body, { childList: true, subtree: true });
// 5 秒后停止监听(避免性能问题)
setTimeout(() => observer.disconnect(), 5000);
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能)
const originalConsoleError = console.error;
console.error = function(...args) {
const errorMsg = args.join(' ');
if (errorMsg.includes('/stomp') ||
errorMsg.includes('stomp onWebSocketError') ||
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) {
return;
}
originalConsoleError.apply(console, args);
};
if (onLoad) {
onLoad(bytedesk);
}
originalConsoleError.apply(console, args);
};
if (onLoad) {
onLoad(bytedesk);
} else {
throw new Error('BytedeskWeb对象未定义');
}
} catch (error) {
console.error('[Bytedesk] 初始化失败:', error);
if (onError) {
onError(error);
}
} else {
throw new Error('BytedeskWeb对象未定义');
}
} catch (error) {
console.error('[Bytedesk] Widget初始化失败:', error);
};
script.onerror = (error) => {
console.error('[Bytedesk] 脚本加载失败:', error);
widgetInitialized = false; // 允许重试
if (onError) {
onError(error);
}
}
};
document.body.appendChild(script);
scriptRef.current = script;
};
script.onerror = (error) => {
console.error('[Bytedesk] Widget脚本加载失败:', error);
if (onError) {
onError(error);
}
};
// ⚡ 使用 requestIdleCallback 在浏览器空闲时加载
if ('requestIdleCallback' in window) {
idleCallbackId = requestIdleCallback(loadWidget, { timeout: 3000 });
} else {
// 降级:使用 setTimeout
idleCallbackId = setTimeout(loadWidget, 100);
}
// 添加脚本到页面
document.body.appendChild(script);
scriptRef.current = script;
// 清理函数 - 增强错误处理,防止 React 18 StrictMode 双重清理报错
// 清理函数
return () => {
console.log('[Bytedesk] 清理Widget');
// 移除脚本
try {
if (scriptRef.current && scriptRef.current.parentNode) {
scriptRef.current.parentNode.removeChild(scriptRef.current);
// 取消待执行的 idle callback
if (idleCallbackId) {
if ('cancelIdleCallback' in window) {
cancelIdleCallback(idleCallbackId);
} else {
clearTimeout(idleCallbackId);
}
scriptRef.current = null;
} catch (error) {
console.warn('[Bytedesk] 移除脚本失败(可能已被移除):', error.message);
idleCallbackId = null;
}
// 移除Widget DOM元素
try {
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
widgetElements.forEach(el => {
try {
if (el && el.parentNode && el.parentNode.contains(el)) {
el.parentNode.removeChild(el);
}
} catch (err) {
// 忽略单个元素移除失败(可能已被移除)
}
});
} catch (error) {
console.warn('[Bytedesk] 清理Widget DOM元素失败:', error.message);
}
// 清理全局对象
try {
if (window.BytedeskWeb) {
delete window.BytedeskWeb;
}
} catch (error) {
console.warn('[Bytedesk] 清理全局对象失败:', error.message);
}
// ⚠️ 不重置 widgetInitialized保持单例
// 不清理 DOM因为客服 Widget 应该持久存在
};
}, [config, autoLoad, onLoad, onError]);

View File

@@ -216,12 +216,6 @@ export default function AuthFormContent() {
authEvents.trackVerificationCodeSent(credential, config.api.purpose);
}
// ❌ 移除成功 toast静默处理
logger.info('AuthFormContent', '验证码发送成功', {
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7),
dev_code: data.dev_code
});
// ✅ 开发环境下在控制台显示验证码
if (data.dev_code) {
console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
@@ -328,16 +322,6 @@ export default function AuthFormContent() {
}
if (response.ok && data.success) {
// ⚡ Mock 模式:先在前端侧写入 localStorage确保时序正确
if (process.env.REACT_APP_ENABLE_MOCK === 'true' && data.user) {
setCurrentUser(data.user);
logger.debug('AuthFormContent', '前端侧设置当前用户(Mock模式)', {
userId: data.user?.id,
phone: data.user?.phone,
mockMode: true
});
}
// 更新session
await checkSession();
@@ -476,7 +460,8 @@ export default function AuthFormContent() {
return () => {
isMountedRef.current = false;
};
}, [authEvents]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅在挂载时执行一次,避免 countdown 倒计时导致重复触发
return (
<>

View File

@@ -74,6 +74,7 @@ export default function WechatRegister() {
const isMountedRef = useRef(true); // 追踪组件挂载状态
const containerRef = useRef(null); // 容器DOM引用
const sessionIdRef = useRef(null); // 存储最新的 sessionId避免闭包陷阱
const wechatStatusRef = useRef(WECHAT_STATUS.NONE); // 存储最新的 wechatStatus避免闭包陷阱
const navigate = useNavigate();
const toast = useToast();
@@ -128,12 +129,8 @@ 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(
@@ -182,40 +179,28 @@ export default function WechatRegister() {
const checkWechatStatus = useCallback(async () => {
// 检查组件是否已卸载,使用 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(currentSessionId);
// 安全检查:确保 response 存在且包含 status
if (!response || typeof response.status === 'undefined') {
logger.warn('WechatRegister', '微信状态检查返回无效数据', { response });
return;
}
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);
// 追踪状态变化(使用 ref 获取最新状态,避免闭包陷阱)
const previousStatus = wechatStatusRef.current;
if (previousStatus !== status) {
authEvents.trackWechatStatusChanged(currentSessionId, previousStatus, status);
// 特别追踪扫码事件
if (status === WECHAT_STATUS.SCANNED) {
@@ -227,7 +212,6 @@ export default function WechatRegister() {
// 处理成功状态
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
logger.info('WechatRegister', '检测到登录成功状态,停止轮询', { status });
clearTimers(); // 停止轮询
sessionIdRef.current = null; // 清理 sessionId
@@ -277,6 +261,12 @@ export default function WechatRegister() {
});
}
}
// 处理授权成功AUTHORIZED- 用户已在微信端确认授权,调用登录 API
else if (status === WECHAT_STATUS.AUTHORIZED) {
clearTimers();
sessionIdRef.current = null; // 清理 sessionId
await handleLoginSuccess(currentSessionId, status);
}
} catch (error) {
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
// 轮询过程中的错误不显示给用户,避免频繁提示
@@ -301,11 +291,6 @@ export default function WechatRegister() {
* 启动轮询
*/
const startPolling = useCallback(() => {
logger.debug('WechatRegister', '启动轮询', {
sessionId: sessionIdRef.current,
interval: POLL_INTERVAL
});
// 清理旧的定时器
clearTimers();
@@ -316,7 +301,6 @@ export default function WechatRegister() {
// 设置超时
timeoutRef.current = setTimeout(() => {
logger.debug('WechatRegister', '二维码超时');
clearTimers();
sessionIdRef.current = null; // 清理 sessionId
setWechatStatus(WECHAT_STATUS.EXPIRED);
@@ -368,11 +352,6 @@ export default function WechatRegister() {
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) {
@@ -404,6 +383,14 @@ export default function WechatRegister() {
}
}, [getWechatQRCode]);
/**
* 同步 wechatStatusRef 与 wechatStatus state
* 确保 checkWechatStatus 回调中能获取到最新状态
*/
useEffect(() => {
wechatStatusRef.current = wechatStatus;
}, [wechatStatus]);
/**
* 组件卸载时清理定时器和标记组件状态
*/

View File

@@ -1,40 +1,52 @@
import { Link } from "react-router-dom";
import { svgs } from "./svgs";
import React from 'react';
import { Link } from 'react-router-dom';
import { svgs } from './svgs';
const Button = ({
className,
href,
onClick,
children,
px,
white,
interface ButtonProps {
className?: string;
href?: string;
onClick?: () => void;
children?: React.ReactNode;
px?: string;
white?: boolean;
isPrimary?: boolean;
isSecondary?: boolean;
}
const Button: React.FC<ButtonProps> = ({
className,
href,
onClick,
children,
px,
white,
}) => {
const classes = `button relative inline-flex items-center justify-center h-11 ${
px || "px-7"
} ${white ? "text-n-8" : "text-n-1"} transition-colors hover:text-color-1 ${
className || ""
}`;
const classes = `button relative inline-flex items-center justify-center h-11 ${
px || 'px-7'
} ${white ? 'text-n-8' : 'text-n-1'} transition-colors hover:text-color-1 ${
className || ''
}`;
const spanClasses = `relative z-10`;
const spanClasses = `relative z-10`;
return href ? (
href.startsWith("mailto:") ? (
<a href={href} className={classes}>
<span className={spanClasses}>{children}</span>
{svgs(white)}
</a>
) : (
<Link href={href} className={classes}>
<span className={spanClasses}>{children}</span>
{svgs(white)}
</Link>
)
return href ? (
href.startsWith('mailto:') ? (
<a href={href} className={classes}>
<span className={spanClasses}>{children}</span>
{svgs(white)}
</a>
) : (
<button className={classes} onClick={onClick}>
<span className={spanClasses}>{children}</span>
{svgs(white)}
</button>
);
<Link to={href} className={classes}>
<span className={spanClasses}>{children}</span>
{svgs(white)}
</Link>
)
) : (
<button className={classes} onClick={onClick}>
<span className={spanClasses}>{children}</span>
{svgs(white)}
</button>
);
};
export default Button;

View File

@@ -1,53 +0,0 @@
import React from "react";
import Link, { LinkProps } from "next/link";
type CommonProps = {
className?: string;
children?: React.ReactNode;
isPrimary?: boolean;
isSecondary?: boolean;
};
type ButtonAsButton = {
as?: "button";
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
type ButtonAsAnchor = {
as: "a";
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
type ButtonAsLink = {
as: "link";
} & LinkProps;
type ButtonProps = CommonProps &
(ButtonAsButton | ButtonAsAnchor | ButtonAsLink);
const Button: React.FC<ButtonProps> = ({
className,
children,
isPrimary,
isSecondary,
as = "button",
...props
}) => {
const isLink = as === "link";
const Component: React.ElementType = isLink ? Link : as;
return (
<Component
className={`relative inline-flex justify-center items-center h-10 px-3.5 rounded-lg text-title-5 cursor-pointer transition-all ${
isPrimary ? "bg-white text-black hover:bg-white/90" : ""
} ${
isSecondary
? "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] text-white after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none after:transition-colors hover:after:border-white"
: ""
} ${className || ""}`}
{...(isLink ? (props as LinkProps) : props)}
>
{children}
</Component>
);
};
export default Button;

View File

@@ -9,55 +9,80 @@ import * as echarts from 'echarts';
* ECharts 图表渲染组件
* @param {Object} option - ECharts 配置对象
* @param {number} height - 图表高度(默认 400px
* @param {string} variant - 主题变体: 'light' | 'dark' | 'auto' (默认 auto)
*/
export const EChartsRenderer = ({ option, height = 400 }) => {
export const EChartsRenderer = ({ option, height = 400, variant = 'auto' }) => {
const chartRef = useRef(null);
const chartInstance = useRef(null);
const bgColor = useColorModeValue('white', 'gray.800');
// 系统颜色模式
const systemBgColor = useColorModeValue('white', 'transparent');
const systemIsDark = useColorModeValue(false, true);
// 根据 variant 决定实际使用的模式
const isDarkMode = variant === 'dark' ? true : variant === 'light' ? false : systemIsDark;
const bgColor = variant === 'dark' ? 'transparent' : variant === 'light' ? 'white' : systemBgColor;
useEffect(() => {
if (!chartRef.current || !option) return;
// 初始化图表
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
if (!chartRef.current || !option) {
console.warn('[EChartsRenderer] Missing chartRef or option');
return;
}
// 设置默认主题配置
const defaultOption = {
backgroundColor: 'transparent',
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
...option,
};
// 延迟初始化,确保 DOM 已渲染
const timer = setTimeout(() => {
try {
// 如果已有实例,先销毁
if (chartInstance.current) {
chartInstance.current.dispose();
}
// 设置图表配置
chartInstance.current.setOption(defaultOption, true);
// 初始化图表
chartInstance.current = echarts.init(chartRef.current, isDarkMode ? 'dark' : null);
// 响应式调整大小
// 深色模式下的样式调整
const darkModeStyle = isDarkMode ? {
backgroundColor: 'transparent',
textStyle: { color: '#e5e7eb' },
} : {};
// 合并配置
const finalOption = {
backgroundColor: 'transparent',
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
...darkModeStyle,
...option,
};
// 设置配置
chartInstance.current.setOption(finalOption);
console.log('[EChartsRenderer] Chart rendered successfully');
} catch (error) {
console.error('[EChartsRenderer] Failed to render chart:', error);
}
}, 100);
// 窗口 resize 处理
const handleResize = () => {
chartInstance.current?.resize();
};
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timer);
window.removeEventListener('resize', handleResize);
// chartInstance.current?.dispose(); // 不要销毁,避免重新渲染时闪烁
if (chartInstance.current) {
chartInstance.current.dispose();
chartInstance.current = null;
}
};
}, [option]);
// 组件卸载时销毁图表
useEffect(() => {
return () => {
chartInstance.current?.dispose();
chartInstance.current = null;
};
}, []);
}, [option, isDarkMode]);
return (
<Box
@@ -66,7 +91,6 @@ export const EChartsRenderer = ({ option, height = 400 }) => {
height={`${height}px`}
bg={bgColor}
borderRadius="md"
boxShadow="sm"
/>
);
};

View File

@@ -1,52 +1,161 @@
// src/components/ChatBot/MarkdownWithCharts.js
// 支持 ECharts 图表的 Markdown 渲染组件
import React from 'react';
import { Box, Alert, AlertIcon, Text, VStack, Code } from '@chakra-ui/react';
import React, { useMemo } from 'react';
import { Box, Alert, AlertIcon, Text, VStack, Code, useColorModeValue, Table, Thead, Tbody, Tr, Th, Td, TableContainer } from '@chakra-ui/react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { EChartsRenderer } from './EChartsRenderer';
import { logger } from '@utils/logger';
/**
* 稳定的图表组件包装器
* 使用 useMemo 避免 option 对象引用变化导致的重复渲染
*/
const StableChart = React.memo(({ jsonString, height, variant }) => {
const chartOption = useMemo(() => {
try {
return JSON.parse(jsonString);
} catch (e) {
console.error('[StableChart] JSON parse error:', e);
return null;
}
}, [jsonString]);
if (!chartOption) {
return (
<Alert status="warning" borderRadius="md">
<AlertIcon />
<Text fontSize="sm">图表配置解析失败</Text>
</Alert>
);
}
return <EChartsRenderer option={chartOption} height={height} variant={variant} />;
});
/**
* 解析 Markdown 内容,提取 ECharts 代码块
* 支持处理:
* 1. 正常的换行符 \n
* 2. 转义的换行符 \\n后端 JSON 序列化产生)
* 3. 不完整的代码块LLM 输出被截断)
*
* @param {string} markdown - Markdown 文本
* @returns {Array} - 包含文本和图表的数组
*/
const parseMarkdownWithCharts = (markdown) => {
if (!markdown) return [];
let content = markdown;
// 处理转义的换行符(后端返回的 JSON 字符串可能包含 \\n
// 只处理代码块标记周围的换行符,不破坏 JSON 内部结构
// 将 ```echarts\\n 转换为 ```echarts\n
content = content.replace(/```echarts\\n/g, '```echarts\n');
// 将 \\n``` 转换为 \n```
content = content.replace(/\\n```/g, '\n```');
// 如果整个内容都是转义的换行符格式,进行全局替换
// 检测:如果内容中没有真正的换行符但有 \\n则进行全局替换
if (!content.includes('\n') && content.includes('\\n')) {
content = content.replace(/\\n/g, '\n');
}
const parts = [];
const echartsRegex = /```echarts\s*\n([\s\S]*?)```/g;
// 匹配 echarts 代码块的正则表达式
// 支持多种格式:
// 1. ```echarts\n{...}\n```
// 2. ```echarts\n{...}```(末尾无换行)
// 3. ```echarts {...}```(同一行开始,虽不推荐但兼容)
const echartsBlockRegex = /```echarts\s*\n?([\s\S]*?)```/g;
let lastIndex = 0;
let match;
while ((match = echartsRegex.exec(markdown)) !== null) {
// 匹配所有 echarts 代码块
while ((match = echartsBlockRegex.exec(content)) !== null) {
// 添加代码块前的文本
if (match.index > lastIndex) {
const textBefore = markdown.substring(lastIndex, match.index).trim();
const textBefore = content.substring(lastIndex, match.index).trim();
if (textBefore) {
parts.push({ type: 'text', content: textBefore });
}
}
// 添加 ECharts 配置
const chartConfig = match[1].trim();
parts.push({ type: 'chart', content: chartConfig });
// 提取 ECharts 配置内容
let chartConfig = match[1].trim();
// 处理 JSON 内部的转义换行符(恢复为真正的换行,便于后续解析)
// 注意:这里的 \\n 在 JSON 内部应该保持为 \n换行符不是字面量
if (chartConfig.includes('\\n')) {
chartConfig = chartConfig.replace(/\\n/g, '\n');
}
if (chartConfig.includes('\\t')) {
chartConfig = chartConfig.replace(/\\t/g, '\t');
}
if (chartConfig) {
parts.push({ type: 'chart', content: chartConfig });
}
lastIndex = match.index + match[0].length;
}
// 添加剩余文本
if (lastIndex < markdown.length) {
const textAfter = markdown.substring(lastIndex).trim();
if (textAfter) {
parts.push({ type: 'text', content: textAfter });
// 检查剩余内容
if (lastIndex < content.length) {
const remainingText = content.substring(lastIndex);
// 检查是否有不完整的 echarts 代码块(没有结束的 ```
const incompleteMatch = remainingText.match(/```echarts\s*\n?([\s\S]*?)$/);
if (incompleteMatch) {
// 提取不完整代码块之前的文本
const textBeforeIncomplete = remainingText.substring(0, incompleteMatch.index).trim();
if (textBeforeIncomplete) {
parts.push({ type: 'text', content: textBeforeIncomplete });
}
// 提取不完整的 echarts 内容
let incompleteChartConfig = incompleteMatch[1].trim();
// 同样处理转义换行符
if (incompleteChartConfig.includes('\\n')) {
incompleteChartConfig = incompleteChartConfig.replace(/\\n/g, '\n');
}
if (incompleteChartConfig) {
logger.warn('[MarkdownWithCharts] 检测到不完整的 echarts 代码块', {
contentPreview: incompleteChartConfig.substring(0, 100),
});
parts.push({ type: 'chart', content: incompleteChartConfig });
}
} else {
// 普通剩余文本
const textAfter = remainingText.trim();
if (textAfter) {
parts.push({ type: 'text', content: textAfter });
}
}
}
// 如果没有找到图表,返回整个 markdown 作为文本
// 如果没有找到任何部分,返回整个 markdown 作为文本
if (parts.length === 0) {
parts.push({ type: 'text', content: markdown });
parts.push({ type: 'text', content: content });
}
// 开发环境调试
if (process.env.NODE_ENV === 'development') {
const chartParts = parts.filter(p => p.type === 'chart');
if (chartParts.length > 0 || content.includes('echarts')) {
logger.info('[MarkdownWithCharts] 解析结果', {
inputLength: markdown?.length,
hasEchartsKeyword: content.includes('echarts'),
hasCodeBlock: content.includes('```'),
partsCount: parts.length,
partTypes: parts.map(p => p.type),
});
}
}
return parts;
@@ -55,10 +164,26 @@ const parseMarkdownWithCharts = (markdown) => {
/**
* 支持 ECharts 图表的 Markdown 渲染组件
* @param {string} content - Markdown 文本
* @param {string} variant - 主题变体: 'light' | 'dark' | 'auto' (默认 auto跟随系统)
*/
export const MarkdownWithCharts = ({ content }) => {
export const MarkdownWithCharts = ({ content, variant = 'auto' }) => {
const parts = parseMarkdownWithCharts(content);
// 系统颜色模式
const systemTextColor = useColorModeValue('gray.700', 'gray.100');
const systemHeadingColor = useColorModeValue('gray.800', 'gray.50');
const systemBlockquoteColor = useColorModeValue('gray.600', 'gray.300');
const systemCodeBg = useColorModeValue('gray.100', 'rgba(255, 255, 255, 0.1)');
// 根据 variant 选择颜色
const isDark = variant === 'dark';
const isLight = variant === 'light';
const textColor = isDark ? 'gray.100' : isLight ? 'gray.700' : systemTextColor;
const headingColor = isDark ? 'gray.50' : isLight ? 'gray.800' : systemHeadingColor;
const blockquoteColor = isDark ? 'gray.300' : isLight ? 'gray.600' : systemBlockquoteColor;
const codeBg = isDark ? 'rgba(255, 255, 255, 0.1)' : isLight ? 'gray.100' : systemCodeBg;
return (
<VStack align="stretch" spacing={4}>
{parts.map((part, index) => {
@@ -67,25 +192,26 @@ export const MarkdownWithCharts = ({ content }) => {
return (
<Box key={index}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// 自定义渲染样式
p: ({ children }) => (
<Text mb={2} fontSize="sm">
<Text mb={2} fontSize="sm" color={textColor}>
{children}
</Text>
),
h1: ({ children }) => (
<Text fontSize="xl" fontWeight="bold" mb={3}>
<Text fontSize="xl" fontWeight="bold" mb={3} color={headingColor}>
{children}
</Text>
),
h2: ({ children }) => (
<Text fontSize="lg" fontWeight="bold" mb={2}>
<Text fontSize="lg" fontWeight="bold" mb={2} color={headingColor}>
{children}
</Text>
),
h3: ({ children }) => (
<Text fontSize="md" fontWeight="bold" mb={2}>
<Text fontSize="md" fontWeight="bold" mb={2} color={headingColor}>
{children}
</Text>
),
@@ -100,20 +226,46 @@ export const MarkdownWithCharts = ({ content }) => {
</Box>
),
li: ({ children }) => (
<Box as="li" fontSize="sm" mb={1}>
<Box as="li" fontSize="sm" mb={1} color={textColor}>
{children}
</Box>
),
code: ({ inline, children }) =>
inline ? (
<Code fontSize="sm" px={1}>
// 处理代码块和行内代码
code: ({ node, inline, className, children, ...props }) => {
// 检查是否是代码块(通过父元素是否为 pre 或通过 className 判断)
const isCodeBlock = !inline && (className || (node?.position?.start?.line !== node?.position?.end?.line));
if (isCodeBlock) {
// 代码块样式
return (
<Code
display="block"
p={3}
borderRadius="md"
fontSize="sm"
whiteSpace="pre-wrap"
bg={codeBg}
overflowX="auto"
maxW="100%"
{...props}
>
{children}
</Code>
);
}
// 行内代码样式
return (
<Code fontSize="sm" px={1} bg={codeBg} {...props}>
{children}
</Code>
) : (
<Code display="block" p={3} borderRadius="md" fontSize="sm" whiteSpace="pre-wrap">
{children}
</Code>
),
);
},
// 处理 pre 元素,防止嵌套问题
pre: ({ children }) => (
<Box as="pre" my={2} overflow="hidden" borderRadius="md">
{children}
</Box>
),
blockquote: ({ children }) => (
<Box
borderLeftWidth="4px"
@@ -121,11 +273,60 @@ export const MarkdownWithCharts = ({ content }) => {
pl={4}
py={2}
fontStyle="italic"
color="gray.600"
color={blockquoteColor}
>
{children}
</Box>
),
// 表格渲染
table: ({ children }) => (
<TableContainer
mb={4}
borderRadius="md"
border="1px solid"
borderColor={isDark ? 'rgba(255, 255, 255, 0.1)' : 'gray.200'}
overflowX="auto"
>
<Table size="sm" variant="simple">
{children}
</Table>
</TableContainer>
),
thead: ({ children }) => (
<Thead bg={isDark ? 'rgba(255, 255, 255, 0.05)' : 'gray.50'}>
{children}
</Thead>
),
tbody: ({ children }) => <Tbody>{children}</Tbody>,
tr: ({ children }) => (
<Tr
_hover={{
bg: isDark ? 'rgba(255, 255, 255, 0.03)' : 'gray.50'
}}
>
{children}
</Tr>
),
th: ({ children }) => (
<Th
fontSize="xs"
color={headingColor}
borderColor={isDark ? 'rgba(255, 255, 255, 0.1)' : 'gray.200'}
py={2}
>
{children}
</Th>
),
td: ({ children }) => (
<Td
fontSize="sm"
color={textColor}
borderColor={isDark ? 'rgba(255, 255, 255, 0.1)' : 'gray.200'}
py={2}
>
{children}
</Td>
),
}}
>
{part.content}
@@ -134,34 +335,21 @@ export const MarkdownWithCharts = ({ content }) => {
);
} else if (part.type === 'chart') {
// 渲染 ECharts 图表
// 清理可能的残留字符
let cleanContent = part.content.trim();
cleanContent = cleanContent.replace(/```\s*$/g, '').trim();
// 调试日志
console.log('[MarkdownWithCharts] Rendering chart, content length:', cleanContent.length);
console.log('[MarkdownWithCharts] Content preview:', cleanContent.substring(0, 100));
// 验证 JSON 是否可以解析
try {
// 清理可能的 Markdown 残留符号
let cleanContent = part.content.trim();
// 移除可能的前后空白和不可见字符
cleanContent = cleanContent.replace(/^\s+|\s+$/g, '');
// 尝试解析 JSON
const chartOption = JSON.parse(cleanContent);
// 验证是否是有效的 ECharts 配置
if (!chartOption || typeof chartOption !== 'object') {
throw new Error('Invalid chart configuration: not an object');
}
return (
<Box key={index}>
<EChartsRenderer option={chartOption} height={350} />
</Box>
);
} catch (error) {
// 记录详细的错误信息
logger.error('解析 ECharts 配置失败', {
error: error.message,
contentLength: part.content.length,
contentPreview: part.content.substring(0, 200),
errorStack: error.stack
});
const testParse = JSON.parse(cleanContent);
console.log('[MarkdownWithCharts] JSON valid, has series:', !!testParse.series);
} catch (e) {
console.error('[MarkdownWithCharts] JSON parse error:', e.message);
console.log('[MarkdownWithCharts] Problematic content:', cleanContent.substring(0, 300));
return (
<Alert status="warning" key={index} borderRadius="md">
@@ -171,16 +359,29 @@ export const MarkdownWithCharts = ({ content }) => {
图表配置解析失败
</Text>
<Text fontSize="xs" color="gray.600">
错误: {error.message}
错误: {e.message}
</Text>
<Code fontSize="xs" maxW="100%" overflow="auto" whiteSpace="pre-wrap">
{part.content.substring(0, 300)}
{part.content.length > 300 ? '...' : ''}
{cleanContent.substring(0, 300)}
{cleanContent.length > 300 ? '...' : ''}
</Code>
</VStack>
</Alert>
);
}
return (
<Box
key={index}
w="100%"
minW="300px"
my={3}
borderRadius="md"
overflow="hidden"
>
<StableChart jsonString={cleanContent} height={350} variant={variant} />
</Box>
);
}
return null;
})}

View File

@@ -82,29 +82,9 @@ const CitedContent = ({
...containerStyle
}}
>
{/* AI 标识 - 固定在右上角 */}
{showAIBadge && (
<Tag
icon={<RobotOutlined />}
color="purple"
style={{
position: 'absolute',
top: 12,
right: 12,
margin: 0,
zIndex: 10,
fontSize: 12,
padding: '2px 8px'
}}
className="ai-badge-responsive"
>
AI合成
</Tag>
)}
{/* 标题栏 */}
{title && (
<div style={{ marginBottom: 12, paddingRight: 80 }}>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ fontSize: 14, color: finalTitleColor }}>
{title}
</Text>
@@ -112,10 +92,24 @@ const CitedContent = ({
)}
{/* 带引用的文本内容 */}
<div style={{
lineHeight: 1.8,
paddingRight: title ? 0 : (showAIBadge ? 80 : 0)
}}>
<div style={{ lineHeight: 1.8 }}>
{/* AI 标识 - 行内显示在文字前面 */}
{showAIBadge && (
<Tag
icon={<RobotOutlined />}
color="purple"
style={{
fontSize: 12,
padding: '2px 8px',
marginRight: 8,
verticalAlign: 'middle',
display: 'inline-flex',
}}
className="ai-badge-responsive"
>
AI合成
</Tag>
)}
{/* 前缀标签(如果有) */}
{prefix && (
<Text style={{

View File

@@ -1,20 +1,25 @@
import Image from "../Image";
import React from 'react';
import Image from '../Image';
const Generating = ({ className }) => (
<div
className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${
className || ""
} text-base`}
>
<Image
className="w-5 h-5 mr-4"
src="/images/loading.png"
width={20}
height={20}
alt="Loading"
/>
AI is generating|
</div>
interface GeneratingProps {
className?: string;
}
const Generating: React.FC<GeneratingProps> = ({ className }) => (
<div
className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${
className || ''
} text-base`}
>
<Image
className="w-5 h-5 mr-4"
src="/images/loading.png"
width={20}
height={20}
alt="Loading"
/>
AI is generating|
</div>
);
export default Generating;

View File

@@ -2,95 +2,48 @@
// 集中管理应用的全局组件
import React, { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useNotification } from '../contexts/NotificationContext';
import { logger } from '../utils/logger';
import { useSelector } from 'react-redux';
import { selectIsMobile } from '@/store/slices/deviceSlice';
// Global Components
import AuthModalManager from './Auth/AuthModalManager';
import NotificationContainer from './NotificationContainer';
import ConnectionStatusBar from './ConnectionStatusBar';
import ScrollToTop from './ScrollToTop';
// Bytedesk客服组件
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config';
/**
* ConnectionStatusBar 包装组件
* 需要在 NotificationProvider 内部使用,所以在这里包装
*/
function ConnectionStatusBarWrapper() {
const { connectionStatus, reconnectAttempt, maxReconnectAttempts, retryConnection } = useNotification();
const [isDismissed, setIsDismissed] = React.useState(false);
// 监听连接状态变化
React.useEffect(() => {
// 重连成功后,清除 dismissed 状态
if (connectionStatus === 'connected' && isDismissed) {
setIsDismissed(false);
// 从 localStorage 清除 dismissed 标记
localStorage.removeItem('connection_status_dismissed');
}
// 从 localStorage 恢复 dismissed 状态
if (connectionStatus !== 'connected' && !isDismissed) {
const dismissed = localStorage.getItem('connection_status_dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}
}, [connectionStatus, isDismissed]);
const handleClose = () => {
// 用户手动关闭,保存到 localStorage
setIsDismissed(true);
localStorage.setItem('connection_status_dismissed', 'true');
logger.info('App', 'Connection status bar dismissed by user');
};
return (
<ConnectionStatusBar
status={connectionStatus}
reconnectAttempt={reconnectAttempt}
maxReconnectAttempts={maxReconnectAttempts}
onRetry={retryConnection}
onClose={handleClose}
isDismissed={isDismissed}
/>
);
}
/**
* GlobalComponents - 全局组件容器
* 集中管理所有全局级别的组件,如弹窗、通知、状态栏等
*
* 包含的组件:
* - ConnectionStatusBarWrapper: Socket 连接状态条
* - ScrollToTop: 路由切换时自动滚动到顶部
* - AuthModalManager: 认证弹窗管理器
* - NotificationContainer: 通知容器
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
* - NotificationContainer: 通知容器(仅桌面端渲染)
* - BytedeskWidget: Bytedesk在线客服
*
* 注意:
* - ConnectionStatusBar 已移除(所有端)
* - NotificationContainer 在移动端不渲染(通知功能已在 NotificationContext 层禁用)
*/
export function GlobalComponents() {
const location = useLocation();
const isMobile = useSelector(selectIsMobile);
// ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载
const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []);
return (
<>
{/* Socket 连接状态条 */}
<ConnectionStatusBarWrapper />
{/* 路由切换时自动滚动到顶部 */}
<ScrollToTop />
{/* 认证弹窗管理器 */}
<AuthModalManager />
{/* 通知容器 */}
<NotificationContainer />
{/* 通知容器(仅桌面端渲染) */}
{!isMobile && <NotificationContainer />}
{/* Bytedesk在线客服 - 使用缓存的配置对象 */}
<BytedeskWidget

View File

@@ -1,17 +1,21 @@
import { useState } from "react";
import React, { useState } from 'react';
const Image = ({ className, ...props }) => {
const [loaded, setLoaded] = useState(false);
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
className?: string;
}
return (
<img
className={`inline-block align-top opacity-0 transition-opacity ${
loaded && "opacity-100"
} ${className}`}
onLoad={() => setLoaded(true)}
{...props}
/>
);
const Image: React.FC<ImageProps> = ({ className, ...props }) => {
const [loaded, setLoaded] = useState(false);
return (
<img
className={`inline-block align-top opacity-0 transition-opacity ${
loaded && 'opacity-100'
} ${className || ''}`}
onLoad={() => setLoaded(true)}
{...props}
/>
);
};
export default Image;

View File

@@ -1,73 +0,0 @@
import Section from "@/components/Section";
import Image from "@/components/Image";
import Button from "@/components/Button";
type JoinProps = {};
const Join = ({}: JoinProps) => (
<Section crosses>
<div className="container">
<div className="relative max-w-[43.125rem] mx-auto py-8 md:py-14 xl:py-0">
<div className="relative z-1 text-center">
<h1 className="h1 mb-6">
Be part of the future of{" "}
<span className="inline-block relative">
Brainwave
<Image
className="absolute top-full left-0 w-full"
src="/images/curve.png"
width={624}
height={28}
alt="Curve"
/>
</span>
</h1>
<p className="body-1 mb-8 text-n-4">
Unleash the power of AI within Brainwave. Upgrade your
productivity with Brainwave, the open AI chat app.
</p>
<Button href="/pricing" white>
Get started
</Button>
</div>
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[39.25rem] h-[39.25rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[30.625rem] h-[30.625rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[21.5rem] h-[21.5rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[13.75rem] h-[13.75rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
</div>
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 opacity-60 mix-blend-color-dodge pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
<Image
className="w-full"
src="/images/gradient.png"
width={942}
height={942}
alt="Gradient"
/>
</div>
</div>
</div>
</div>
<div className="absolute -top-[5.75rem] left-[18.5rem] -z-1 w-[19.8125rem] pointer-events-none lg:-top-15 lg:left-[5.5rem]">
<Image
className="w-full"
src="/images/join/shapes-1.svg"
width={317}
height={293}
alt="Shapes 1"
/>
</div>
<div className="absolute right-[15rem] -bottom-[7rem] -z-1 w-[28.1875rem] pointer-events-none lg:right-7 lg:-bottom-[5rem]">
<Image
className="w-full"
src="/images/join/shapes-2.svg"
width={451}
height={266}
alt="Shapes 2"
/>
</div>
</Section>
);
export default Join;

View File

@@ -1,73 +0,0 @@
import Section from "@/components/Section";
import Image from "@/components/Image";
import Button from "@/components/Button";
type JoinProps = {};
const Join = ({}: JoinProps) => (
<Section crosses>
<div className="container">
<div className="relative max-w-[43.125rem] mx-auto py-8 md:py-14 xl:py-0">
<div className="relative z-1 text-center">
<h1 className="h1 mb-6">
Be part of the future of{" "}
<span className="inline-block relative">
Brainwave
<Image
className="absolute top-full left-0 w-full"
src="/images/curve.png"
width={624}
height={28}
alt="Curve"
/>
</span>
</h1>
<p className="body-1 mb-8 text-n-4">
Unleash the power of AI within Brainwave. Upgrade your
productivity with Brainwave, the open AI chat app.
</p>
<Button href="/pricing" white>
Get started
</Button>
</div>
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[39.25rem] h-[39.25rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[30.625rem] h-[30.625rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[21.5rem] h-[21.5rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[13.75rem] h-[13.75rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
</div>
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 opacity-60 mix-blend-color-dodge pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
<Image
className="w-full"
src="/images/gradient.png"
width={942}
height={942}
alt="Gradient"
/>
</div>
</div>
</div>
</div>
<div className="absolute -top-[5.75rem] left-[18.5rem] -z-1 w-[19.8125rem] pointer-events-none lg:-top-15 lg:left-[5.5rem]">
<Image
className="w-full"
src="/images/join/shapes-1.svg"
width={317}
height={293}
alt="Shapes 1"
/>
</div>
<div className="absolute right-[15rem] -bottom-[7rem] -z-1 w-[28.1875rem] pointer-events-none lg:right-7 lg:-bottom-[5rem]">
<Image
className="w-full"
src="/images/join/shapes-2.svg"
width={451}
height={266}
alt="Shapes 2"
/>
</div>
</Section>
);
export default Join;

View File

@@ -1,53 +0,0 @@
import Image from "../Image";
const Logos = ({ className }) => (
<div className={className}>
<h5 className="tagline mb-6 text-center text-n-1/50">
Helping people create beautiful content at
</h5>
<ul className="flex">
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
</ul>
</div>
);
export default Logos;

View File

@@ -1,53 +0,0 @@
import Image from "../Image";
const Logos = ({ className }) => (
<div className={className}>
<h5 className="tagline mb-6 text-center text-n-1/50">
Helping people create beautiful content at
</h5>
<ul className="flex">
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
</ul>
</div>
);
export default Logos;

View File

@@ -18,10 +18,8 @@ import {
Link,
Divider,
Avatar,
useColorMode,
useColorModeValue
} from '@chakra-ui/react';
import { SunIcon, MoonIcon } from '@chakra-ui/icons';
import { useNavigate, useLocation } from 'react-router-dom';
/**
@@ -46,7 +44,6 @@ const MobileDrawer = memo(({
}) => {
const navigate = useNavigate();
const location = useLocation();
const { colorMode, toggleColorMode } = useColorMode();
const userBgColor = useColorModeValue('gray.50', 'whiteAlpha.100');
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
const emailTextColor = useColorModeValue('gray.500', 'gray.300');
@@ -82,17 +79,6 @@ const MobileDrawer = memo(({
</DrawerHeader>
<DrawerBody>
<VStack spacing={4} align="stretch">
{/* 移动端:日夜模式切换 */}
<Button
leftIcon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
variant="ghost"
justifyContent="flex-start"
onClick={toggleColorMode}
size="sm"
>
切换到{colorMode === 'light' ? '深色' : '浅色'}模式
</Button>
{/* 移动端用户信息 */}
{isAuthenticated && user && (
<>

View File

@@ -2,7 +2,7 @@
// Navbar 右侧功能区组件
import React, { memo } from 'react';
import { HStack, Spinner, IconButton, Box } from '@chakra-ui/react';
import { HStack, IconButton, Box } from '@chakra-ui/react';
import { HamburgerIcon } from '@chakra-ui/icons';
// import ThemeToggleButton from '../ThemeToggleButton'; // ❌ 已删除 - 不再支持深色模式切换
import LoginButton from '../LoginButton';
@@ -41,9 +41,15 @@ const NavbarActions = memo(({
}) => {
return (
<HStack spacing={{ base: 2, md: 4 }}>
{/* 显示加载状态 */}
{/* 权限校验中 - 显示占位骨架,不显示登录按钮或用户菜单 */}
{isLoading ? (
<Spinner size="sm" color="blue.500" />
<Box
w={{ base: '80px', md: '120px' }}
h="36px"
borderRadius="md"
bg="whiteAlpha.100"
opacity={0.6}
/>
) : isAuthenticated && user ? (
// 已登录状态 - 用户菜单 + 功能菜单排列
<HStack spacing={{ base: 2, md: 3 }}>

View File

@@ -57,13 +57,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
bg={isActive(['/community', '/concepts']) ? 'blue.50' : 'transparent'}
color={isActive(['/community', '/concepts']) ? 'blue.600' : 'inherit'}
rightIcon={<ChevronDownIcon color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'} />}
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
borderColor="white"
borderRadius="md"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={highFreqMenu.handleMouseEnter}
onMouseLeave={highFreqMenu.handleMouseLeave}
onClick={highFreqMenu.handleClick}
@@ -123,13 +124,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.50' : 'transparent'}
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'inherit'}
rightIcon={<ChevronDownIcon color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'} />}
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
borderBottom={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.100' : 'gray.50' }}
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
borderColor="white"
borderRadius="md"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={marketReviewMenu.handleMouseEnter}
onMouseLeave={marketReviewMenu.handleMouseLeave}
onClick={marketReviewMenu.handleClick}
@@ -198,13 +200,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
<MenuButton
as={Button}
variant="ghost"
rightIcon={<ChevronDownIcon />}
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'}
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'}
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'}
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'}
borderColor="blue.600"
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }}
rightIcon={<ChevronDownIcon color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'} />}
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
borderColor="white"
borderRadius="md"
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave}
onClick={agentCommunityMenu.handleClick}

View File

@@ -1,49 +0,0 @@
import Image from "../Image";
const Notification = ({ className, title }) => (
<div
className={`flex items-center p-4 pr-6 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl ${
className || ""
}`}
>
<div className="mr-5">
<Image
className="w-full rounded-xl"
src="/images/notification/image-1.png"
width={52}
height={52}
alt="Image"
/>
</div>
<div className="flex-1">
<h6 className="mb-1 font-semibold text-base">{title}</h6>
<div className="flex items-center justify-between">
<ul className="flex -m-0.5">
{[
"/images/notification/image-4.png",
"/images/notification/image-3.png",
"/images/notification/image-2.png",
].map((item, index) => (
<li
className={`flex w-6 h-6 border-2 border-[#2E2A41] rounded-full overflow-hidden ${
index !== 0 ? "-ml-2" : ""
}`}
key={index}
>
<Image
className="w-full"
src={item}
width={20}
height={20}
alt={item}
/>
</li>
))}
</ul>
<div className="body-2 text-[#6C7275]">1m ago</div>
</div>
</div>
</div>
);
export default Notification;

View File

@@ -1,49 +0,0 @@
import Image from "../Image";
const Notification = ({ className, title }) => (
<div
className={`flex items-center p-4 pr-6 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl ${
className || ""
}`}
>
<div className="mr-5">
<Image
className="w-full rounded-xl"
src="/images/notification/image-1.png"
width={52}
height={52}
alt="Image"
/>
</div>
<div className="flex-1">
<h6 className="mb-1 font-semibold text-base">{title}</h6>
<div className="flex items-center justify-between">
<ul className="flex -m-0.5">
{[
"/images/notification/image-4.png",
"/images/notification/image-3.png",
"/images/notification/image-2.png",
].map((item, index) => (
<li
className={`flex w-6 h-6 border-2 border-[#2E2A41] rounded-full overflow-hidden ${
index !== 0 ? "-ml-2" : ""
}`}
key={index}
>
<Image
className="w-full"
src={item}
width={20}
height={20}
alt={item}
/>
</li>
))}
</ul>
<div className="body-2 text-[#6C7275]">1m ago</div>
</div>
</div>
</div>
);
export default Notification;

View File

@@ -1,57 +1,67 @@
const Section = ({
className,
crosses,
crossesOffset,
customPaddings,
children,
import React from 'react';
interface SectionProps {
className?: string;
crosses?: boolean;
crossesOffset?: string;
customPaddings?: string;
children?: React.ReactNode;
}
const Section: React.FC<SectionProps> = ({
className,
crosses,
crossesOffset,
customPaddings,
children,
}) => (
<div
className={`relative ${
customPaddings ||
`py-10 lg:py-16 xl:py-20 ${crosses ? "lg:py-32 xl:py-40" : ""}`
} ${className || ""}`}
>
{children}
<div className="hidden absolute top-0 left-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:left-7.5 xl:left-10"></div>
<div className="hidden absolute top-0 right-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:right-7.5 xl:right-10"></div>
{crosses && (
<>
<div
className={`hidden absolute top-0 left-7.5 right-7.5 h-0.25 bg-stroke-1 ${
crossesOffset && crossesOffset
} pointer-events-none lg:block xl:left-10 right-10`}
></div>
<svg
className={`hidden absolute -top-[0.3125rem] left-[1.5625rem] ${
crossesOffset && crossesOffset
} pointer-events-none lg:block xl:left-[2.1875rem]`}
xmlns="http://www.w3.org/2000/svg"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
<svg
className={`hidden absolute -top-[0.3125rem] right-[1.5625rem] ${
crossesOffset && crossesOffset
} pointer-events-none lg:block xl:right-[2.1875rem]`}
xmlns="http://www.w3.org/2000/svg"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
</>
)}
</div>
<div
className={`relative ${
customPaddings ||
`py-10 lg:py-16 xl:py-20 ${crosses ? 'lg:py-32 xl:py-40' : ''}`
} ${className || ''}`}
>
{children}
<div className="hidden absolute top-0 left-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:left-7.5 xl:left-10"></div>
<div className="hidden absolute top-0 right-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:right-7.5 xl:right-10"></div>
{crosses && (
<>
<div
className={`hidden absolute top-0 left-7.5 right-7.5 h-0.25 bg-stroke-1 ${
crossesOffset && crossesOffset
} pointer-events-none lg:block xl:left-10 right-10`}
></div>
<svg
className={`hidden absolute -top-[0.3125rem] left-[1.5625rem] ${
crossesOffset && crossesOffset
} pointer-events-none lg:block xl:left-[2.1875rem]`}
xmlns="http://www.w3.org/2000/svg"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
<svg
className={`hidden absolute -top-[0.3125rem] right-[1.5625rem] ${
crossesOffset && crossesOffset
} pointer-events-none lg:block xl:right-[2.1875rem]`}
xmlns="http://www.w3.org/2000/svg"
width="11"
height="11"
fill="none"
>
<path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4"
/>
</svg>
</>
)}
</div>
);
export default Section;

View File

@@ -1,195 +0,0 @@
import Section from "@/components/Section";
import Generating from "@/components/Generating";
import Image from "@/components/Image";
import Heading from "@/components/Heading";
type ServicesProps = {
containerClassName?: string;
};
const Services = ({ containerClassName }: ServicesProps) => (
<Section>
<div className={`container ${containerClassName || ""}`}>
<Heading
title="Generative AI made for creators."
text="Brainwave unlocks the potential of AI-powered applications"
/>
<div className="relative">
<div className="relative z-1 flex items-center h-[38.75rem] mb-5 p-8 border border-n-1/10 rounded-3xl overflow-hidden lg:h-[38.75rem] lg:p-20 xl:h-[45.75rem]">
<div className="absolute top-0 left-0 w-full h-full pointer-events-none md:w-3/5 xl:w-auto">
<Image
className="w-full h-full object-cover md:object-right"
src="/images/services/service-1.png"
width={797}
height={733}
alt="Smartest AI"
/>
</div>
<div className="relative z-1 max-w-[17rem] ml-auto">
<h4 className="h4 mb-4">Smartest AI</h4>
<p className="bpdy-2 mb-[3.125rem] text-n-3">
Brainwave unlocks the potential of AI-powered
applications
</p>
<ul className="body-2">
{[
"Photo generating",
"Photo enhance",
"Seamless Integration",
].map((item, index) => (
<li
className="flex items-start py-4 border-t border-n-6"
key={index}
>
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<p className="ml-4">{item}</p>
</li>
))}
</ul>
</div>
<Generating className="absolute left-4 right-4 bottom-4 border border-n-1/10 lg:left-1/2 lg-right-auto lg:bottom-8 lg:-translate-x-1/2" />
</div>
<div className="relative z-1 grid gap-5 lg:grid-cols-2">
<div className="relative min-h-[38.75rem] border border-n-1/10 rounded-3xl overflow-hidden">
<div className="absolute inset-0">
<Image
className="w-full h-full object-cover"
src="/images/services/service-2.png"
width={630}
height={748}
alt="Smartest AI"
/>
</div>
<div className="absolute inset-0 flex flex-col justify-end p-8 bg-gradient-to-b from-n-8/0 to-n-8/90 lg:p-15">
<h4 className="h4 mb-4">Photo editing</h4>
<p className="body-2 text-n-3">
{`Automatically enhance your photos using our AI app's
photo editing feature. Try it now!`}
</p>
</div>
<div className="absolute top-8 right-8 max-w-[17.5rem] py-6 px-8 bg-black rounded-t-xl rounded-bl-xl font-code text-base lg:top-16 lg:right-[8.75rem] lg:max-w-[17.5rem]">
Hey Brainwave, enhance this photo
<svg
className="absolute left-full bottom-0"
xmlns="http://www.w3.org/2000/svg"
width="26"
height="37"
>
<path d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z" />
</svg>
</div>
</div>
<div className="p-4 bg-n-7 rounded-3xl overflow-hidden lg:min-h-[45.75rem]">
<div className="py-12 px-4 xl:px-8">
<h4 className="h4 mb-4">Video generation</h4>
<p className="body-2 mb-[2.25rem] text-n-3">
The worlds most powerful AI photo and video art
generation engine.What will you create?
</p>
<ul className="flex items-center justify-between">
{[
"/images/icons/recording-03.svg",
"/images/icons/recording-01.svg",
"/images/icons/disc-02.svg",
"/images/icons/chrome-cast.svg",
"/images/icons/sliders-04.svg",
].map((item, index) => (
<li
className={`flex items-center justify-center ${
index === 2
? "w-[3rem] h-[3rem] p-0.25 bg-conic-gradient rounded-2xl md:w-[4.5rem] md:h-[4.5rem]"
: "flex w-10 h-10 bg-n-6 rounded-2xl md:w-15 md:h-15"
}`}
key={index}
>
<div
className={
index === 2
? "flex items-center justify-center w-full h-full bg-n-7 rounded-[0.9375rem]"
: ""
}
>
<Image
src={item}
width={24}
height={24}
alt={item}
/>
</div>
</li>
))}
</ul>
</div>
<div className="relative h-[20.5rem] bg-n-8 rounded-xl overflow-hidden md:h-[25rem]">
<Image
className="w-full h-full object-cover"
src="/images/services/service-3.png"
width={517}
height={400}
alt="Smartest AI"
/>
<div className="absolute top-8 left-[3.125rem] w-full max-w-[14rem] pt-2.5 pr-2.5 pb-7 pl-5 bg-n-6 rounded-t-xl rounded-br-xl font-code text-base md:max-w-[17.5rem]">
Video generated!
<div className="absolute left-5 -bottom-[1.125rem] flex items-center justify-center w-[2.25rem] h-[2.25rem] bg-color-1 rounded-[0.75rem]">
<Image
src="/images/brainwave-symbol-white.svg"
width={26}
height={26}
alt="Brainwave"
/>
</div>
<div className="tagline absolute right-2.5 bottom-1 text-[0.625rem] text-n-3 uppercase">
just now
</div>
<svg
className="absolute right-full bottom-0 -scale-x-100"
xmlns="http://www.w3.org/2000/svg"
width="26"
height="37"
>
<path
className="fill-n-6"
d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z"
/>
</svg>
</div>
<div className="absolute left-0 bottom-0 w-full flex items-center p-6">
<svg
className="mr-3"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
>
<path
d="M8.006 2.802l.036.024 10.549 7.032.805.567c.227.183.494.437.648.808a2 2 0 0 1 0 1.532c-.154.371-.421.625-.648.808-.217.175-.5.364-.805.567L8.006 21.198l-.993.627c-.285.154-.676.331-1.132.303a2 2 0 0 1-1.476-.79c-.276-.365-.346-.788-.375-1.111S4 19.502 4 19.054V4.99v-.043l.029-1.174c.03-.323.1-.746.375-1.11a2 2 0 0 1 1.476-.79c.456-.027.847.149 1.132.304s.62.378.993.627z"
fill="#fff"
/>
</svg>
<div className="flex-1 bg-[#D9D9D9]">
<div className="w-1/2 h-0.5 bg-color-1"></div>
</div>
</div>
</div>
</div>
</div>
<div className="absolute top-0 -left-[10rem] w-[56.625rem] h-[56.625rem] opacity-50 mix-blend-color-dodge pointer-events-none">
<Image
className="absolute top-1/2 left-1/2 w-[79.5625rem] max-w-[79.5625rem] h-[88.5625rem] -translate-x-1/2 -translate-y-1/2"
src="/images/gradient.png"
width={1417}
height={1417}
alt="Gradient"
/>
</div>
</div>
</div>
</Section>
);
export default Services;

View File

@@ -1,195 +0,0 @@
import Section from "@/components/Section";
import Generating from "@/components/Generating";
import Image from "@/components/Image";
import Heading from "@/components/Heading";
type ServicesProps = {
containerClassName?: string;
};
const Services = ({ containerClassName }: ServicesProps) => (
<Section>
<div className={`container ${containerClassName || ""}`}>
<Heading
title="Generative AI made for creators."
text="Brainwave unlocks the potential of AI-powered applications"
/>
<div className="relative">
<div className="relative z-1 flex items-center h-[38.75rem] mb-5 p-8 border border-n-1/10 rounded-3xl overflow-hidden lg:h-[38.75rem] lg:p-20 xl:h-[45.75rem]">
<div className="absolute top-0 left-0 w-full h-full pointer-events-none md:w-3/5 xl:w-auto">
<Image
className="w-full h-full object-cover md:object-right"
src="/images/services/service-1.png"
width={797}
height={733}
alt="Smartest AI"
/>
</div>
<div className="relative z-1 max-w-[17rem] ml-auto">
<h4 className="h4 mb-4">Smartest AI</h4>
<p className="bpdy-2 mb-[3.125rem] text-n-3">
Brainwave unlocks the potential of AI-powered
applications
</p>
<ul className="body-2">
{[
"Photo generating",
"Photo enhance",
"Seamless Integration",
].map((item, index) => (
<li
className="flex items-start py-4 border-t border-n-6"
key={index}
>
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<p className="ml-4">{item}</p>
</li>
))}
</ul>
</div>
<Generating className="absolute left-4 right-4 bottom-4 border border-n-1/10 lg:left-1/2 lg-right-auto lg:bottom-8 lg:-translate-x-1/2" />
</div>
<div className="relative z-1 grid gap-5 lg:grid-cols-2">
<div className="relative min-h-[38.75rem] border border-n-1/10 rounded-3xl overflow-hidden">
<div className="absolute inset-0">
<Image
className="w-full h-full object-cover"
src="/images/services/service-2.png"
width={630}
height={748}
alt="Smartest AI"
/>
</div>
<div className="absolute inset-0 flex flex-col justify-end p-8 bg-gradient-to-b from-n-8/0 to-n-8/90 lg:p-15">
<h4 className="h4 mb-4">Photo editing</h4>
<p className="body-2 text-n-3">
{`Automatically enhance your photos using our AI app's
photo editing feature. Try it now!`}
</p>
</div>
<div className="absolute top-8 right-8 max-w-[17.5rem] py-6 px-8 bg-black rounded-t-xl rounded-bl-xl font-code text-base lg:top-16 lg:right-[8.75rem] lg:max-w-[17.5rem]">
Hey Brainwave, enhance this photo
<svg
className="absolute left-full bottom-0"
xmlns="http://www.w3.org/2000/svg"
width="26"
height="37"
>
<path d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z" />
</svg>
</div>
</div>
<div className="p-4 bg-n-7 rounded-3xl overflow-hidden lg:min-h-[45.75rem]">
<div className="py-12 px-4 xl:px-8">
<h4 className="h4 mb-4">Video generation</h4>
<p className="body-2 mb-[2.25rem] text-n-3">
The worlds most powerful AI photo and video art
generation engine.What will you create?
</p>
<ul className="flex items-center justify-between">
{[
"/images/icons/recording-03.svg",
"/images/icons/recording-01.svg",
"/images/icons/disc-02.svg",
"/images/icons/chrome-cast.svg",
"/images/icons/sliders-04.svg",
].map((item, index) => (
<li
className={`flex items-center justify-center ${
index === 2
? "w-[3rem] h-[3rem] p-0.25 bg-conic-gradient rounded-2xl md:w-[4.5rem] md:h-[4.5rem]"
: "flex w-10 h-10 bg-n-6 rounded-2xl md:w-15 md:h-15"
}`}
key={index}
>
<div
className={
index === 2
? "flex items-center justify-center w-full h-full bg-n-7 rounded-[0.9375rem]"
: ""
}
>
<Image
src={item}
width={24}
height={24}
alt={item}
/>
</div>
</li>
))}
</ul>
</div>
<div className="relative h-[20.5rem] bg-n-8 rounded-xl overflow-hidden md:h-[25rem]">
<Image
className="w-full h-full object-cover"
src="/images/services/service-3.png"
width={517}
height={400}
alt="Smartest AI"
/>
<div className="absolute top-8 left-[3.125rem] w-full max-w-[14rem] pt-2.5 pr-2.5 pb-7 pl-5 bg-n-6 rounded-t-xl rounded-br-xl font-code text-base md:max-w-[17.5rem]">
Video generated!
<div className="absolute left-5 -bottom-[1.125rem] flex items-center justify-center w-[2.25rem] h-[2.25rem] bg-color-1 rounded-[0.75rem]">
<Image
src="/images/brainwave-symbol-white.svg"
width={26}
height={26}
alt="Brainwave"
/>
</div>
<div className="tagline absolute right-2.5 bottom-1 text-[0.625rem] text-n-3 uppercase">
just now
</div>
<svg
className="absolute right-full bottom-0 -scale-x-100"
xmlns="http://www.w3.org/2000/svg"
width="26"
height="37"
>
<path
className="fill-n-6"
d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z"
/>
</svg>
</div>
<div className="absolute left-0 bottom-0 w-full flex items-center p-6">
<svg
className="mr-3"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
>
<path
d="M8.006 2.802l.036.024 10.549 7.032.805.567c.227.183.494.437.648.808a2 2 0 0 1 0 1.532c-.154.371-.421.625-.648.808-.217.175-.5.364-.805.567L8.006 21.198l-.993.627c-.285.154-.676.331-1.132.303a2 2 0 0 1-1.476-.79c-.276-.365-.346-.788-.375-1.111S4 19.502 4 19.054V4.99v-.043l.029-1.174c.03-.323.1-.746.375-1.11a2 2 0 0 1 1.476-.79c.456-.027.847.149 1.132.304s.62.378.993.627z"
fill="#fff"
/>
</svg>
<div className="flex-1 bg-[#D9D9D9]">
<div className="w-1/2 h-0.5 bg-color-1"></div>
</div>
</div>
</div>
</div>
</div>
<div className="absolute top-0 -left-[10rem] w-[56.625rem] h-[56.625rem] opacity-50 mix-blend-color-dodge pointer-events-none">
<Image
className="absolute top-1/2 left-1/2 w-[79.5625rem] max-w-[79.5625rem] h-[88.5625rem] -translate-x-1/2 -translate-y-1/2"
src="/images/gradient.png"
width={1417}
height={1417}
alt="Gradient"
/>
</div>
</div>
</div>
</Section>
);
export default Services;

View File

@@ -3,7 +3,6 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import * as echarts from 'echarts';
import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/**
* 股票信息
@@ -72,11 +71,6 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
setError(null);
try {
logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', {
stockCode: stock.stock_code,
eventTime,
});
const response = await stockService.getKlineData(
stock.stock_code,
'daily',
@@ -91,12 +85,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
console.log('[KLineChartModal] 数据条数:', response.data.length);
setData(response.data);
logger.info('KLineChartModal', 'loadData', 'K线数据加载成功', {
dataCount: response.data.length,
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
logger.error('KLineChartModal', 'loadData', err as Error);
setError(errorMsg);
} finally {
setLoading(false);

View File

@@ -33,9 +33,6 @@ import {
// 工具函数
import { createSubIndicators } from './utils';
// 日志
import { logger } from '@utils/logger';
// ==================== 组件 Props ====================
export interface StockChartKLineModalProps {
@@ -110,10 +107,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
const handleChartTypeChange = useCallback((e: RadioChangeEvent) => {
const newType = e.target.value as ChartType;
setChartType(newType);
logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', {
newType,
});
}, []);
/**
@@ -130,10 +123,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
// 先移除所有副图指标KLineChart 会自动移除)
// 然后创建新的指标
createSubIndicators(chart, values);
logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', {
indicators: values,
});
},
[chart]
);
@@ -143,7 +132,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
*/
const handleRefresh = useCallback(() => {
loadData();
logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据');
}, [loadData]);
// ==================== 计算属性 ====================

View File

@@ -18,7 +18,6 @@ import {
} from '@chakra-ui/react';
import * as echarts from 'echarts';
import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/**
* 股票信息
@@ -76,11 +75,6 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
setError(null);
try {
logger.debug('TimelineChartModal', 'loadData', '开始加载分时图数据', {
stockCode: stock.stock_code,
eventTime,
});
const response = await stockService.getKlineData(
stock.stock_code,
'timeline',
@@ -95,12 +89,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
console.log('[TimelineChartModal] 数据条数:', response.data.length);
setData(response.data);
logger.info('TimelineChartModal', 'loadData', '分时图数据加载成功', {
dataCount: response.data.length,
});
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
logger.error('TimelineChartModal', 'loadData', err as Error);
setError(errorMsg);
} finally {
setLoading(false);

View File

@@ -13,7 +13,6 @@ import {
createEventHighlightOverlay,
removeAllEventMarkers,
} from '../utils/eventMarkerUtils';
import { logger } from '@utils/logger';
export interface UseEventMarkerOptions {
/** KLineChart 实例 */
@@ -77,10 +76,6 @@ export const useEventMarker = (
const createMarker = useCallback(
(time: string, label: string, color?: string) => {
if (!chart || !data || data.length === 0) {
logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', {
hasChart: !!chart,
dataLength: data?.length || 0,
});
return;
}
@@ -93,9 +88,6 @@ export const useEventMarker = (
const overlay = createEventMarkerOverlay(eventMarker, data);
if (!overlay) {
logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', {
eventMarker,
});
return;
}
@@ -103,9 +95,6 @@ export const useEventMarker = (
const id = chart.createOverlay(overlay);
if (!id || (Array.isArray(id) && id.length === 0)) {
logger.warn('useEventMarker', 'createMarker', '标记添加失败', {
overlay,
});
return;
}
@@ -118,23 +107,9 @@ export const useEventMarker = (
const highlightResult = chart.createOverlay(highlightOverlay);
const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult;
setHighlightId(actualHighlightId as string);
logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', {
highlightId: actualHighlightId,
});
}
logger.info('useEventMarker', 'createMarker', '事件标记创建成功', {
markerId: actualId,
label,
time,
chartId: chart.id,
});
} catch (err) {
logger.error('useEventMarker', 'createMarker', err as Error, {
time,
label,
});
// 忽略创建标记时的错误
}
},
[chart, data]
@@ -150,26 +125,17 @@ export const useEventMarker = (
try {
if (markerId) {
chart.removeOverlay(markerId);
chart.removeOverlay({ id: markerId });
}
if (highlightId) {
chart.removeOverlay(highlightId);
chart.removeOverlay({ id: highlightId });
}
setMarker(null);
setMarkerId(null);
setHighlightId(null);
logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', {
markerId,
highlightId,
chartId: chart.id,
});
} catch (err) {
logger.error('useEventMarker', 'removeMarker', err as Error, {
markerId,
highlightId,
});
// 忽略移除标记时的错误
}
}, [chart, markerId, highlightId]);
@@ -186,12 +152,8 @@ export const useEventMarker = (
setMarker(null);
setMarkerId(null);
setHighlightId(null);
logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', {
chartId: chart.id,
});
} catch (err) {
logger.error('useEventMarker', 'removeAllMarkers', err as Error);
// 忽略移除所有标记时的错误
}
}, [chart]);
@@ -216,10 +178,10 @@ export const useEventMarker = (
if (chart) {
try {
if (markerId) {
chart.removeOverlay(markerId);
chart.removeOverlay({ id: markerId });
}
if (highlightId) {
chart.removeOverlay(highlightId);
chart.removeOverlay({ id: highlightId });
}
} catch (err) {
// 忽略清理时的错误

View File

@@ -10,7 +10,6 @@ import type { Chart } from 'klinecharts';
// import { useColorMode } from '@chakra-ui/react'; // ❌ 已移除深色模式支持
import { getTheme, getTimelineTheme } from '../config/klineTheme';
import { CHART_INIT_OPTIONS } from '../config';
import { logger } from '@utils/logger';
import { avgPriceIndicator } from '../indicators/avgPriceIndicator';
export interface UseKLineChartOptions {
@@ -65,11 +64,9 @@ export const useKLineChart = (
// 全局注册自定义均价线指标(只执行一次)
useEffect(() => {
try {
registerIndicator(avgPriceIndicator);
logger.debug('useKLineChart', '✅ 自定义均价线指标(AVG)注册成功');
registerIndicator(avgPriceIndicator as any);
} catch (err) {
// 如果已注册会报错,忽略即可
logger.debug('useKLineChart', 'AVG指标已注册或注册失败', err);
}
}, []);
@@ -78,16 +75,10 @@ export const useKLineChart = (
// 图表初始化函数
const initChart = (): boolean => {
if (!chartRef.current) {
logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId });
return false;
}
try {
logger.debug('useKLineChart', 'init', '开始初始化图表', {
containerId,
height,
colorMode,
});
// 初始化图表实例KLineChart 10.0 API
// ✅ 根据 chartType 选择主题
@@ -112,29 +103,16 @@ export const useKLineChart = (
// ✅ 新增:创建成交量指标窗格
try {
const volumePaneId = chartInstance.createIndicator('VOL', false, {
chartInstance.createIndicator('VOL', false, {
height: 100, // 固定高度 100px约占整体的 20-25%
});
logger.debug('useKLineChart', 'init', '成交量窗格创建成功', {
volumePaneId,
});
} catch (err) {
logger.warn('useKLineChart', 'init', '成交量窗格创建失败', {
error: err,
});
// 不阻塞主流程,继续执行
}
logger.info('useKLineChart', 'init', '✅ 图表初始化成功', {
containerId,
chartId: chartInstance.id,
});
return true;
} catch (err) {
const error = err as Error;
logger.error('useKLineChart', 'init', error, { containerId });
setError(error);
setIsInitialized(false);
return false;
@@ -146,11 +124,6 @@ export const useKLineChart = (
// 成功,直接返回清理函数
return () => {
if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
containerId,
chartId: chartInstanceRef.current.id,
});
dispose(chartInstanceRef.current);
chartInstanceRef.current = null;
setChartInstance(null); // ✅ 新增:清空 state
@@ -161,7 +134,6 @@ export const useKLineChart = (
// 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载)
const timer = setTimeout(() => {
logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId });
initChart();
}, 50);
@@ -169,11 +141,6 @@ export const useKLineChart = (
return () => {
clearTimeout(timer);
if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
containerId,
chartId: chartInstanceRef.current.id,
});
dispose(chartInstanceRef.current);
chartInstanceRef.current = null;
setChartInstance(null); // ✅ 新增:清空 state
@@ -195,14 +162,8 @@ export const useKLineChart = (
? getTimelineTheme(colorMode)
: getTheme(colorMode);
chartInstanceRef.current.setStyles(newTheme);
logger.debug('useKLineChart', 'updateTheme', '更新图表主题', {
colorMode,
chartType,
chartId: chartInstanceRef.current.id,
});
} catch (err) {
logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode, chartType });
// 忽略主题更新错误
}
}, [colorMode, chartType, isInitialized]);
@@ -215,7 +176,6 @@ export const useKLineChart = (
const handleResize = () => {
if (chartInstanceRef.current) {
chartInstanceRef.current.resize();
logger.debug('useKLineChart', 'resize', '调整图表大小');
}
};

View File

@@ -8,7 +8,6 @@ import { useEffect, useState, useCallback } from 'react';
import type { Chart } from 'klinecharts';
import type { ChartType, KLineDataPoint, RawDataPoint } from '../types';
import { processChartData } from '../utils/dataAdapter';
import { logger } from '@utils/logger';
import { stockService } from '@services/eventService';
import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
@@ -78,7 +77,6 @@ export const useKLineData = (
*/
const loadData = useCallback(async () => {
if (!stockCode) {
logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType });
return;
}
@@ -86,11 +84,6 @@ export const useKLineData = (
setError(null);
try {
logger.debug('useKLineData', 'loadData', '开始加载数据', {
stockCode,
chartType,
eventTime,
});
// 1. 先检查缓存
const cacheKey = getCacheKey(stockCode, eventTime, chartType);
@@ -125,19 +118,8 @@ export const useKLineData = (
const processedData = processChartData(rawDataList, chartType, eventTime);
setData(processedData);
logger.info('useKLineData', 'loadData', '数据加载成功', {
stockCode,
chartType,
rawCount: rawDataList.length,
processedCount: processedData.length,
});
} catch (err) {
const error = err as Error;
logger.error('useKLineData', 'loadData', error, {
stockCode,
chartType,
});
setError(error);
setData([]);
setRawData([]);
@@ -207,9 +189,7 @@ export const useKLineData = (
(chart as any).setOffsetRightDistance(50);
}
} catch (err) {
logger.error('useKLineData', 'updateChartData', err as Error, {
step: '调整可见范围失败',
});
// 忽略调整可见范围时的错误
}
}, 100); // 延迟 100ms 确保数据已加载和渲染
@@ -259,14 +239,8 @@ export const useKLineData = (
}, 200); // 延迟 200ms确保均价线创建完成后再添加
}
logger.debug(
'useKLineData',
`updateChartData - ${stockCode} (${chartType}) - ${klineData.length}条数据加载成功`
);
} catch (err) {
logger.error('useKLineData', 'updateChartData', err as Error, {
dataCount: klineData.length,
});
// 忽略更新图表数据时的错误
}
},
[chart, stockCode, chartType]
@@ -279,11 +253,6 @@ export const useKLineData = (
(newData: KLineDataPoint[]) => {
setData(newData);
updateChartData(newData);
logger.debug(
'useKLineData',
`updateData - ${stockCode} (${chartType}) - ${newData.length}条数据手动更新`
);
},
[updateChartData]
);
@@ -298,7 +267,6 @@ export const useKLineData = (
if (chart) {
chart.resetData();
logger.debug('useKLineData', `clearData - chartId: ${(chart as any).id}`);
}
}, [chart]);

View File

@@ -5,17 +5,18 @@
* 计算公式:累计成交额 / 累计成交量
*/
import type { Indicator, KLineData } from 'klinecharts';
import type { KLineData } from 'klinecharts';
export const avgPriceIndicator: Indicator = {
// 使用部分类型定义,因为 Indicator 类型很复杂
export const avgPriceIndicator = {
name: 'AVG',
shortName: 'AVG',
calcParams: [],
calcParams: [] as number[],
shouldOhlc: false, // 不显示 OHLC 信息
shouldFormatBigNumber: false,
precision: 2,
minValue: null,
maxValue: null,
minValue: null as number | null,
maxValue: null as number | null,
figures: [
{
@@ -61,33 +62,27 @@ export const avgPriceIndicator: Indicator = {
},
/**
* Tooltip 格式化(显示均价 + 涨跌幅)
* 自定义 Tooltip 数据源
* 符合 IndicatorTooltipData 接口要求
*/
createTooltipDataSource: ({ kLineData, indicator, defaultStyles }: any) => {
if (!indicator?.avg) {
return {
title: { text: '均价', color: defaultStyles.tooltip.text.color },
value: { text: '--', color: '#FF9800' },
};
}
const avgPrice = indicator.avg;
const prevClose = kLineData?.prev_close;
// 计算均价涨跌幅
let changeText = `¥${avgPrice.toFixed(2)}`;
if (prevClose && prevClose > 0) {
const changePercent = ((avgPrice - prevClose) / prevClose * 100).toFixed(2);
const changeValue = (avgPrice - prevClose).toFixed(2);
changeText = `¥${avgPrice.toFixed(2)} (${changeValue}, ${changePercent}%)`;
}
const avgValue = kLineData?.avg;
const lineColor = defaultStyles?.lines?.[0]?.color || '#FF9800';
return {
title: { text: '均价', color: defaultStyles.tooltip.text.color },
value: {
text: changeText,
color: '#FF9800',
},
name: 'AVG',
calcParamsText: '',
features: [] as any[],
legends: [
{
title: { text: '均价: ', color: lineColor },
value: {
text: avgValue !== undefined ? avgValue.toFixed(2) : '--',
color: lineColor,
},
},
],
};
},
};

View File

@@ -4,8 +4,7 @@
* 包含图表初始化、技术指标管理等通用逻辑
*/
import type { Chart } from 'klinecharts';
import { logger } from '@utils/logger';
import type { Chart, ActionType } from 'klinecharts';
/**
* 安全地执行图表操作(捕获异常)
@@ -21,7 +20,6 @@ export const safeChartOperation = <T>(
try {
return fn();
} catch (error) {
logger.error('chartUtils', operation, error as Error);
return null;
}
};
@@ -50,13 +48,6 @@ export const createIndicator = (
isStack
);
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
indicatorName,
params,
isStack,
indicatorId,
});
return indicatorId;
});
};
@@ -69,8 +60,11 @@ export const createIndicator = (
*/
export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
safeChartOperation('removeIndicator', () => {
chart.removeIndicator(indicatorId);
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId });
if (indicatorId) {
chart.removeIndicator({ id: indicatorId });
} else {
chart.removeIndicator({});
}
});
};
@@ -94,11 +88,6 @@ export const createSubIndicators = (
}
});
logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', {
indicators,
createdIds: ids,
});
return ids;
};
@@ -130,10 +119,6 @@ export const setChartZoom = (chart: Chart, zoom: number): void => {
},
});
logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', {
zoom,
newBarSpace,
});
});
};
@@ -147,8 +132,6 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
safeChartOperation('scrollToTimestamp', () => {
// KLineChart 10.0: 使用 scrollToTimestamp 方法
chart.scrollToTimestamp(timestamp);
logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp });
});
};
@@ -160,7 +143,6 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
export const resizeChart = (chart: Chart): void => {
safeChartOperation('resizeChart', () => {
chart.resize();
logger.debug('chartUtils', 'resizeChart', '调整图表大小');
});
};
@@ -194,7 +176,6 @@ export const getVisibleRange = (chart: Chart): { from: number; to: number } | nu
export const clearChartData = (chart: Chart): void => {
safeChartOperation('clearChartData', () => {
chart.resetData();
logger.debug('chartUtils', 'clearChartData', '清空图表数据');
});
};
@@ -213,11 +194,6 @@ export const exportChartImage = (
// KLineChart 10.0: 使用 getConvertPictureUrl 方法
const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff');
logger.debug('chartUtils', 'exportChartImage', '导出图表图片', {
includeOverlay,
hasData: !!imageData,
});
return imageData;
});
};
@@ -235,8 +211,6 @@ export const toggleCrosshair = (chart: Chart, show: boolean): void => {
show,
},
});
logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show });
});
};
@@ -253,8 +227,6 @@ export const toggleGrid = (chart: Chart, show: boolean): void => {
show,
},
});
logger.debug('chartUtils', 'toggleGrid', '切换网格', { show });
});
};
@@ -267,12 +239,11 @@ export const toggleGrid = (chart: Chart, show: boolean): void => {
*/
export const subscribeChartEvent = (
chart: Chart,
eventName: string,
eventName: ActionType,
handler: (...args: any[]) => void
): void => {
safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
chart.subscribeAction(eventName, handler);
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
});
};
@@ -285,11 +256,10 @@ export const subscribeChartEvent = (
*/
export const unsubscribeChartEvent = (
chart: Chart,
eventName: string,
eventName: ActionType,
handler: (...args: any[]) => void
): void => {
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
chart.unsubscribeAction(eventName, handler);
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
});
};

View File

@@ -6,7 +6,6 @@
import dayjs from 'dayjs';
import type { KLineDataPoint, RawDataPoint, ChartType } from '../types';
import { logger } from '@utils/logger';
/**
* 将后端原始数据转换为 KLineChart 标准格式
@@ -22,7 +21,6 @@ export const convertToKLineData = (
eventTime?: string
): KLineDataPoint[] => {
if (!rawData || !Array.isArray(rawData) || rawData.length === 0) {
logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType });
return [];
}
@@ -37,15 +35,11 @@ export const convertToKLineData = (
low: Number(item.low) || 0,
close: Number(item.close) || 0,
volume: Number(item.volume) || 0,
turnover: item.turnover ? Number(item.turnover) : undefined,
turnover: (item as any).turnover ? Number((item as any).turnover) : undefined,
prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线)
};
});
} catch (error) {
logger.error('dataAdapter', 'convertToKLineData', error as Error, {
chartType,
dataLength: rawData.length,
});
return [];
}
};
@@ -90,7 +84,6 @@ const parseTimestamp = (
}
// 默认返回当前时间(避免图表崩溃)
logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item });
return Date.now();
};
@@ -109,7 +102,6 @@ const parseTimelineTimestamp = (time: string, eventTime: string): number => {
const eventDate = dayjs(eventTime).startOf('day');
return eventDate.hour(hours).minute(minutes).second(0).valueOf();
} catch (error) {
logger.error('dataAdapter', 'parseTimelineTimestamp', error as Error, { time, eventTime });
return dayjs(eventTime).valueOf();
}
};
@@ -126,19 +118,16 @@ export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] =
return data.filter((item) => {
// 移除价格为 0 或负数的数据
if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item });
return false;
}
// 移除 high < low 的数据(数据错误)
if (item.high < item.low) {
logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item });
return false;
}
// 移除成交量为负数的数据
if (item.volume < 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item });
return false;
}
@@ -213,17 +202,8 @@ export const trimDataByEventTime = (
return item.timestamp >= startTime && item.timestamp <= endTime;
});
logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', {
originalLength: data.length,
trimmedLength: trimmedData.length,
eventTime,
chartType,
dateRange: `${dayjs(startTime).format('YYYY-MM-DD')} ~ ${dayjs(endTime).format('YYYY-MM-DD')}`,
});
return trimmedData;
} catch (error) {
logger.error('dataAdapter', 'trimDataByEventTime', error as Error, { eventTime });
return data; // 出错时返回原始数据
}
};
@@ -260,13 +240,6 @@ export const processChartData = (
data = trimDataByEventTime(data, eventTime, chartType);
}
logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
rawLength: rawData.length,
processedLength: data.length,
chartType,
hasEventTime: !!eventTime,
});
return data;
};

View File

@@ -9,7 +9,6 @@ import type { OverlayCreate } from 'klinecharts';
import type { EventMarker, KLineDataPoint } from '../types';
import { EVENT_MARKER_CONFIG } from '../config';
import { findClosestDataPoint } from './dataAdapter';
import { logger } from '@utils/logger';
/**
* 创建事件标记 OverlayKLineChart 10.0 格式)
@@ -27,10 +26,6 @@ export const createEventMarkerOverlay = (
const closestPoint = findClosestDataPoint(data, marker.timestamp);
if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', {
markerId: marker.id,
timestamp: marker.timestamp,
});
return null;
}
@@ -64,10 +59,6 @@ export const createEventMarkerOverlay = (
style: 'fill',
color: marker.color,
borderRadius: EVENT_MARKER_CONFIG.text.borderRadius,
paddingLeft: EVENT_MARKER_CONFIG.text.padding,
paddingRight: EVENT_MARKER_CONFIG.text.padding,
paddingTop: EVENT_MARKER_CONFIG.text.padding,
paddingBottom: EVENT_MARKER_CONFIG.text.padding,
},
},
// 标记文本内容
@@ -77,17 +68,8 @@ export const createEventMarkerOverlay = (
},
};
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
markerId: marker.id,
timestamp: closestPoint.timestamp,
label: marker.label,
});
return overlay;
} catch (error) {
logger.error('eventMarkerUtils', 'createEventMarkerOverlay', error as Error, {
markerId: marker.id,
});
return null;
}
};
@@ -108,7 +90,6 @@ export const createEventHighlightOverlay = (
const closestPoint = findClosestDataPoint(data, eventTimestamp);
if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点');
return null;
}
@@ -135,14 +116,8 @@ export const createEventHighlightOverlay = (
},
};
logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', {
timestamp: closestPoint.timestamp,
eventTime,
});
return overlay;
} catch (error) {
logger.error('eventMarkerUtils', 'createEventHighlightOverlay', error as Error);
return null;
}
};
@@ -219,11 +194,6 @@ export const createEventMarkerOverlays = (
}
});
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
totalMarkers: markers.length,
createdOverlays: overlays.length,
});
return overlays;
};
@@ -235,10 +205,9 @@ export const createEventMarkerOverlays = (
*/
export const removeEventMarker = (chart: any, markerId: string): void => {
try {
chart.removeOverlay(markerId);
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
chart.removeOverlay({ id: markerId });
} catch (error) {
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId });
// 忽略移除标记时的错误
}
};
@@ -251,9 +220,8 @@ export const removeAllEventMarkers = (chart: any): void => {
try {
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
chart.removeOverlay();
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
} catch (error) {
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error);
// 忽略移除所有标记时的错误
}
};
@@ -275,13 +243,8 @@ export const updateEventMarker = (
// 重新创建标记KLineChart 10.0 不支持直接更新 overlay
// 注意:需要在调用方重新创建并添加 overlay
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
markerId,
updates,
});
} catch (error) {
logger.error('eventMarkerUtils', 'updateEventMarker', error as Error, { markerId });
// 忽略更新标记时的错误
}
};
@@ -309,12 +272,8 @@ export const highlightEventMarker = (
},
});
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
markerId,
highlight,
});
} catch (error) {
logger.error('eventMarkerUtils', 'highlightEventMarker', error as Error, { markerId });
// 忽略高亮标记时的错误
}
};

View File

@@ -4,6 +4,7 @@
* 用于显示股票与事件的关联描述信息
* 固定标题为"关联描述:"
* 自动处理多种数据格式(字符串、对象数组)
* 支持悬停显示来源信息
*
* @example
* ```tsx
@@ -20,7 +21,20 @@
*/
import React, { useMemo } from 'react';
import { Box, Text, BoxProps } from '@chakra-ui/react';
import { Box, Text, BoxProps, Tooltip } from '@chakra-ui/react';
/**
* 关联描述数据项类型
*/
export interface RelationDescItem {
query_part?: string;
sentences?: string;
organization?: string;
report_title?: string;
declare_date?: string;
author?: string;
match_score?: string;
}
/**
* 关联描述数据类型
@@ -30,10 +44,7 @@ import { Box, Text, BoxProps } from '@chakra-ui/react';
export type RelationDescType =
| string
| {
data: Array<{
query_part?: string;
sentences?: string;
}>;
data: Array<RelationDescItem>;
}
| null
| undefined;
@@ -66,33 +77,45 @@ export const RelationDescription: React.FC<RelationDescriptionProps> = ({
lineHeight = '1.7',
containerProps = {}
}) => {
// 处理关联描述(兼容对象和字符串格式
const processedDesc = useMemo(() => {
// 判断是否为对象格式(带来源信息
const isObjectFormat = useMemo(() => {
return typeof relationDesc === 'object' && relationDesc?.data && Array.isArray(relationDesc.data);
}, [relationDesc]);
// 处理关联描述数据
const descData = useMemo(() => {
if (!relationDesc) return null;
// 字符串格式:直接返回
if (typeof relationDesc === 'string') {
return relationDesc;
return { type: 'string' as const, content: relationDesc };
}
// 对象格式:提取并拼接文本
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
return (
relationDesc.data
.map((item) => item.query_part || item.sentences || '')
.filter((s) => s)
.join('') || null
);
// 对象格式:返回数据数组
if (isObjectFormat && relationDesc && typeof relationDesc === 'object') {
const items = relationDesc.data.filter((item) => item.query_part);
if (items.length === 0) return null;
return { type: 'array' as const, items };
}
return null;
}, [relationDesc]);
}, [relationDesc, isObjectFormat]);
// 如果没有有效的描述内容,不渲染组件
if (!processedDesc) {
if (!descData) {
return null;
}
// 格式化日期
const formatDate = (dateStr?: string) => {
if (!dateStr) return '';
try {
return new Date(dateStr).toLocaleDateString('zh-CN');
} catch {
return dateStr;
}
};
return (
<Box
p={4}
@@ -108,14 +131,70 @@ export const RelationDescription: React.FC<RelationDescriptionProps> = ({
>
:
</Text>
<Text
fontSize={fontSize}
color={textColor}
lineHeight={lineHeight}
whiteSpace="pre-wrap"
>
{processedDesc}
</Text>
{descData.type === 'string' ? (
<Text
fontSize={fontSize}
color={textColor}
lineHeight={lineHeight}
whiteSpace="pre-wrap"
>
{descData.content}
</Text>
) : (
<Text
fontSize={fontSize}
color={textColor}
lineHeight={lineHeight}
>
{descData.items.map((item, index, arr) => (
<React.Fragment key={index}>
<Tooltip
label={
<Box maxW="400px" p={2}>
{item.sentences && (
<Text fontSize="xs" mb={2} whiteSpace="pre-wrap">
{item.sentences}
</Text>
)}
<Text fontSize="xs" color="gray.300" mt={1}>
{item.organization || '未知'}{item.author ? ` / ${item.author}` : ''}
</Text>
{item.report_title && (
<Text fontSize="xs" color="gray.300" noOfLines={2}>
{item.report_title}
</Text>
)}
{item.declare_date && (
<Text fontSize="xs" color="gray.400">
{formatDate(item.declare_date)}
</Text>
)}
</Box>
}
placement="top"
hasArrow
bg="rgba(20, 20, 20, 0.95)"
maxW="420px"
>
<Text
as="span"
cursor="help"
borderBottom="1px dashed"
borderBottomColor="gray.400"
_hover={{
color: 'blue.500',
borderBottomColor: 'blue.500',
}}
transition="all 0.2s"
>
{item.query_part}
</Text>
</Tooltip>
{index < arr.length - 1 && ''}
</React.Fragment>
))}
</Text>
)}
</Box>
);
};

View File

@@ -2,11 +2,61 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger';
import { useNotification } from '../contexts/NotificationContext';
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
import { logger } from '@utils/logger';
import { performanceMonitor } from '@utils/performanceMonitor';
import { useNotification } from '@contexts/NotificationContext';
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
// import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
import { SPECIAL_EVENTS } from '@lib/constants';
// ⚡ PostHog 延迟加载模块(动态导入后缓存)
let posthogModule = null;
/**
* ⚡ 延迟加载 PostHog 模块
*/
const loadPostHogModule = async () => {
if (posthogModule) return posthogModule;
try {
posthogModule = await import('@lib/posthog');
return posthogModule;
} catch (error) {
logger.error('AuthContext', 'PostHog 模块加载失败', error);
return null;
}
};
/**
* ⚡ 延迟调用 identifyUser
*/
const identifyUserLazy = async (userId, userProperties) => {
const module = await loadPostHogModule();
if (module) {
module.identifyUser(userId, userProperties);
}
};
/**
* ⚡ 延迟调用 resetUser
*/
const resetUserLazy = async () => {
const module = await loadPostHogModule();
if (module) {
module.resetUser();
}
};
/**
* ⚡ 延迟调用 trackEvent使用异步版本
*/
const trackEventLazy = async (eventName, properties) => {
const module = await loadPostHogModule();
if (module) {
module.trackEventAsync(eventName, properties);
}
};
// 创建认证上下文
const AuthContext = createContext();
@@ -37,6 +87,9 @@ export const AuthProvider = ({ children }) => {
// 检查Session状态
const checkSession = async () => {
// ⚡ 性能标记:认证检查开始
performanceMonitor.mark('auth-check-start');
// 节流检查
const now = Date.now();
const timeSinceLastCheck = now - lastCheckTimeRef.current;
@@ -47,6 +100,8 @@ export const AuthProvider = ({ children }) => {
minInterval: `${MIN_CHECK_INTERVAL}ms`,
reason: '距离上次请求间隔太短'
});
// ⚡ 性能标记:认证检查结束(节流情况)
performanceMonitor.mark('auth-check-end');
return;
}
@@ -93,8 +148,8 @@ export const AuthProvider = ({ children }) => {
return prevUser;
}
// ✅ 识别用户身份到 PostHog
identifyUser(data.user.id, {
// ✅ 识别用户身份到 PostHog(延迟加载)
identifyUserLazy(data.user.id, {
email: data.user.email,
username: data.user.username,
subscription_tier: data.user.subscription_tier,
@@ -125,6 +180,8 @@ export const AuthProvider = ({ children }) => {
setUser((prev) => prev === null ? prev : null);
setIsAuthenticated((prev) => prev === false ? prev : false);
} finally {
// ⚡ 性能标记:认证检查结束
performanceMonitor.mark('auth-check-end');
// ⚡ 只在 isLoading 为 true 时才设置为 false避免不必要的状态更新
setIsLoading((prev) => prev === false ? prev : false);
}
@@ -346,8 +403,8 @@ export const AuthProvider = ({ children }) => {
credentials: 'include'
});
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
// ✅ 追踪登出事件(延迟加载,必须在 resetUser() 之前)
trackEventLazy(SPECIAL_EVENTS.USER_LOGGED_OUT, {
timestamp: new Date().toISOString(),
user_id: user?.id || null,
session_duration_minutes: user?.session_start
@@ -355,8 +412,8 @@ export const AuthProvider = ({ children }) => {
: null,
});
// ✅ 重置 PostHog 用户会话
resetUser();
// ✅ 重置 PostHog 用户会话(延迟加载)
resetUserLazy();
// 清除本地状态
setUser(null);

View File

@@ -9,6 +9,8 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
import { BellIcon } from '@chakra-ui/icons';
import { useSelector } from 'react-redux';
import { selectIsMobile } from '@/store/slices/deviceSlice';
import { logger } from '../utils/logger';
import socket from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav';
@@ -27,6 +29,9 @@ const CONNECTION_STATUS = {
RECONNECTED: 'reconnected', // 重连成功显示2秒后自动变回 CONNECTED
};
// ⚡ 模块级变量:防止 React Strict Mode 导致的重复初始化
let socketInitialized = false;
// 创建通知上下文
const NotificationContext = createContext();
@@ -41,6 +46,10 @@ export const useNotification = () => {
// 通知提供者组件
export const NotificationProvider = ({ children }) => {
// ⚡ 移动端检测(使用 Redux 状态)
const isMobile = useSelector(selectIsMobile);
// ========== 所有 Hooks 必须在条件判断之前调用React 规则) ==========
const toast = useToast();
const [notifications, setNotifications] = useState([]);
const [isConnected, setIsConnected] = useState(false);
@@ -562,8 +571,8 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
await requestBrowserPermission();
}
// 如果权限是denied已拒绝提供设置指引
else if (browserPermission === 'denied') {
// 如果权限是denied已拒绝提供设置指引(仅 PC 端显示)
else if (browserPermission === 'denied' && !isMobile) {
const toastId = 'browser-permission-denied-guide';
if (!toast.isActive(toastId)) {
toast({
@@ -649,183 +658,223 @@ export const NotificationProvider = ({ children }) => {
}, [adaptEventToNotification]);
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次 ==========
// ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏 ==========
useEffect(() => {
logger.info('NotificationContext', '初始化 Socket 连接方案2只注册一次');
// ⚡ 防止 React Strict Mode 导致的重复初始化
if (socketInitialized) {
logger.debug('NotificationContext', 'Socket 已初始化跳过重复执行Strict Mode 保护)');
return;
}
// ========== 监听连接成功(首次连接 + 重连) ==========
socket.on('connect', () => {
setIsConnected(true);
setReconnectAttempt(0);
let cleanupCalled = false;
let idleCallbackId;
let timeoutId;
// 判断是首次连接还是重连
if (isFirstConnect.current) {
logger.info('NotificationContext', '首次连接成功', {
socketId: socket.getSocketId?.()
});
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
isFirstConnect.current = false;
} else {
logger.info('NotificationContext', '重连成功');
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
// ⚡ Socket 初始化函数(将在浏览器空闲时执行)
const initSocketConnection = () => {
if (cleanupCalled || socketInitialized) return; // 防止组件卸载后执行或重复初始化
// 清除之前的定时器
if (reconnectedTimerRef.current) {
clearTimeout(reconnectedTimerRef.current);
socketInitialized = true; // 标记已初始化
logger.info('NotificationContext', '初始化 Socket 连接(异步执行,不阻塞首屏)');
// ========== 监听连接成功(首次连接 + 重连) ==========
socket.on('connect', () => {
setIsConnected(true);
setReconnectAttempt(0);
// 判断是首次连接还是重连
if (isFirstConnect.current) {
logger.info('NotificationContext', '首次连接成功', {
socketId: socket.getSocketId?.()
});
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
isFirstConnect.current = false;
} else {
logger.info('NotificationContext', '重连成功');
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
// 清除之前的定时器
if (reconnectedTimerRef.current) {
clearTimeout(reconnectedTimerRef.current);
}
// 2秒后自动变回 CONNECTED
reconnectedTimerRef.current = setTimeout(() => {
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
}, 2000);
}
// 2秒后自动变回 CONNECTED
reconnectedTimerRef.current = setTimeout(() => {
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
}, 2000);
}
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
// 使用 setTimeout(0) 确保 socketService 内部状态已同步
setTimeout(() => {
logger.info('NotificationContext', '重新订阅事件推送');
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
logger.info('NotificationContext', '重新订阅事件推送');
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType: 'all',
importance: 'all',
onSubscribed: (data) => {
logger.info('NotificationContext', '订阅成功', data);
},
});
} else {
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
}
});
// ========== 监听断开连接 ==========
socket.on('disconnect', (reason) => {
setIsConnected(false);
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket 已断开', { reason });
});
// ========== 监听连接错误 ==========
socket.on('connect_error', (error) => {
logger.error('NotificationContext', 'Socket connect_error', error);
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts);
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
});
// ========== 监听重连失败 ==========
socket.on('reconnect_failed', () => {
logger.error('NotificationContext', '重连失败');
setConnectionStatus(CONNECTION_STATUS.FAILED);
toast({
title: '连接失败',
description: '无法连接到服务器,请检查网络连接',
status: 'error',
duration: null,
isClosable: true,
});
});
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
socket.on('new_event', (data) => {
logger.info('NotificationContext', '收到 new_event 事件', {
id: data?.id,
title: data?.title,
eventType: data?.event_type || data?.type,
importance: data?.importance
});
logger.debug('NotificationContext', '原始事件数据', data);
// ⚠️ 防御性检查:确保 ref 已初始化
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
addNotificationRef: !!addNotificationRef.current,
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
});
return;
}
// ========== Socket层去重检查 ==========
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!data.id) {
logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
eventId,
eventType: data.type,
title: data.title,
});
}
if (processedEventIds.current.has(eventId)) {
logger.warn('NotificationContext', '重复事件已忽略', { eventId });
return;
}
processedEventIds.current.add(eventId);
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
// 限制 Set 大小,避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
const idsArray = Array.from(processedEventIds.current);
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
kept: MAX_PROCESSED_IDS,
});
}
// ========== Socket层去重检查结束 ==========
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
logger.debug('NotificationContext', '正在转换事件格式');
const notification = adaptEventToNotificationRef.current(data);
logger.debug('NotificationContext', '转换后的通知对象', notification);
// ✅ 使用 ref.current 访问最新的 addNotification 函数
logger.debug('NotificationContext', '准备添加通知到队列');
addNotificationRef.current(notification);
logger.info('NotificationContext', '通知已添加到队列');
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
if (eventUpdateCallbacks.current.size > 0) {
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
eventUpdateCallbacks.current.forEach(callback => {
try {
callback(data);
} catch (error) {
logger.error('NotificationContext', '事件更新回调执行失败', error);
if (socket.subscribeToEvents) {
socket.subscribeToEvents({
eventType: 'all',
importance: 'all',
onSubscribed: (data) => {
logger.info('NotificationContext', '订阅成功', data);
},
});
} else {
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
}
}, 0);
});
// ========== 监听断开连接 ==========
socket.on('disconnect', (reason) => {
setIsConnected(false);
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket 已断开', { reason });
});
// ========== 监听连接错误 ==========
socket.on('connect_error', (error) => {
logger.error('NotificationContext', 'Socket connect_error', error);
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts);
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
});
// ========== 监听重连失败 ==========
socket.on('reconnect_failed', () => {
logger.error('NotificationContext', '重连失败');
setConnectionStatus(CONNECTION_STATUS.FAILED);
toast({
title: '连接失败',
description: '无法连接到服务器,请检查网络连接',
status: 'error',
duration: null,
isClosable: true,
});
logger.debug('NotificationContext', '所有事件更新回调已触发');
}
});
});
// ========== 监听系统通知(兼容性 ==========
socket.on('system_notification', (data) => {
logger.info('NotificationContext', '收到系统通知', data);
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数 ==========
socket.on('new_event', (data) => {
logger.info('NotificationContext', '收到 new_event 事件', {
id: data?.id,
title: data?.title,
eventType: data?.event_type || data?.type,
importance: data?.importance
});
logger.debug('NotificationContext', '原始事件数据', data);
if (addNotificationRef.current) {
addNotificationRef.current(data);
} else {
logger.error('NotificationContext', 'addNotificationRef 未初始化');
}
});
// ⚠️ 防御性检查:确保 ref 已初始化
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
addNotificationRef: !!addNotificationRef.current,
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
});
return;
}
logger.info('NotificationContext', '所有监听器已注册(只注册一次)');
// ========== Socket层去重检查 ==========
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// ========== 获取最大重连次数 ==========
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
setMaxReconnectAttempts(maxAttempts);
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
if (!data.id) {
logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
eventId,
eventType: data.type,
title: data.title,
});
}
// ========== 启动连接 ==========
logger.info('NotificationContext', '调用 socket.connect()');
socket.connect();
if (processedEventIds.current.has(eventId)) {
logger.warn('NotificationContext', '重复事件已忽略', { eventId });
return;
}
processedEventIds.current.add(eventId);
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
// 限制 Set 大小,避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
const idsArray = Array.from(processedEventIds.current);
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
kept: MAX_PROCESSED_IDS,
});
}
// ========== Socket层去重检查结束 ==========
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
logger.debug('NotificationContext', '正在转换事件格式');
const notification = adaptEventToNotificationRef.current(data);
logger.debug('NotificationContext', '转换后的通知对象', notification);
// ✅ 使用 ref.current 访问最新的 addNotification 函数
logger.debug('NotificationContext', '准备添加通知到队列');
addNotificationRef.current(notification);
logger.info('NotificationContext', '通知已添加到队列');
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
if (eventUpdateCallbacks.current.size > 0) {
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
eventUpdateCallbacks.current.forEach(callback => {
try {
callback(data);
} catch (error) {
logger.error('NotificationContext', '事件更新回调执行失败', error);
}
});
logger.debug('NotificationContext', '所有事件更新回调已触发');
}
});
// ========== 监听系统通知(兼容性) ==========
socket.on('system_notification', (data) => {
logger.info('NotificationContext', '收到系统通知', data);
if (addNotificationRef.current) {
addNotificationRef.current(data);
} else {
logger.error('NotificationContext', 'addNotificationRef 未初始化');
}
});
logger.info('NotificationContext', '所有监听器已注册');
// ========== 获取最大重连次数 ==========
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
setMaxReconnectAttempts(maxAttempts);
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
// ========== 启动连接 ==========
logger.info('NotificationContext', '调用 socket.connect()');
socket.connect();
};
// ⚡ 使用 requestIdleCallback 在浏览器空闲时初始化 Socket
// 降级到 setTimeout(0) 以兼容不支持的浏览器(如 Safari
if ('requestIdleCallback' in window) {
idleCallbackId = window.requestIdleCallback(initSocketConnection, {
timeout: 3000 // 最多等待 3 秒,确保连接不会延迟太久
});
logger.debug('NotificationContext', 'Socket 初始化已排入 requestIdleCallback');
} else {
timeoutId = setTimeout(initSocketConnection, 0);
logger.debug('NotificationContext', 'Socket 初始化已排入 setTimeout(0)(降级模式)');
}
// ========== 清理函数(组件卸载时) ==========
return () => {
cleanupCalled = true;
logger.info('NotificationContext', '清理 Socket 连接');
// 取消待执行的初始化
if (idleCallbackId && 'cancelIdleCallback' in window) {
window.cancelIdleCallback(idleCallbackId);
}
if (timeoutId) {
clearTimeout(timeoutId);
}
// 清理 reconnected 状态定时器
if (reconnectedTimerRef.current) {
clearTimeout(reconnectedTimerRef.current);
@@ -966,6 +1015,39 @@ export const NotificationProvider = ({ children }) => {
};
}, [browserPermission, toast]);
// ⚡ 移动端禁用完整通知能力:返回空壳 Provider
// 注意:此判断必须在所有 Hooks 之后React 规则要求 Hooks 调用顺序一致)
if (isMobile) {
const emptyValue = {
notifications: [],
isConnected: false,
soundEnabled: false,
browserPermission: 'default',
connectionStatus: CONNECTION_STATUS.DISCONNECTED,
reconnectAttempt: 0,
maxReconnectAttempts: 0,
addNotification: () => null,
removeNotification: () => {},
clearAllNotifications: () => {},
toggleSound: () => {},
requestBrowserPermission: () => Promise.resolve('default'),
trackNotificationClick: () => {},
retryConnection: () => {},
showWelcomeGuide: () => {},
showCommunityGuide: () => {},
showFirstFollowGuide: () => {},
registerEventUpdateCallback: () => () => {},
unregisterEventUpdateCallback: () => {},
};
return (
<NotificationContext.Provider value={emptyValue}>
{children}
</NotificationContext.Provider>
);
}
// ========== 桌面端:完整通知功能 ==========
const value = {
notifications,
isConnected,

View File

@@ -82,7 +82,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
...getBaseProperties(),
source,
});
logger.debug('useAuthEvents', '💬 WeChat Login Initiated', { source });
}, [track, getBaseProperties]);
// ==================== 手机验证码流程 ====================
@@ -186,7 +185,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
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]);
/**
@@ -198,7 +196,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
...getBaseProperties(),
session_id: sessionId?.substring(0, 8) + '...',
});
logger.debug('useAuthEvents', '📱 WeChat QR Code Scanned', { sessionId: sessionId?.substring(0, 8) });
}, [track, getBaseProperties]);
/**
@@ -212,7 +209,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
session_id: sessionId?.substring(0, 8) + '...',
time_elapsed: timeElapsed,
});
logger.debug('useAuthEvents', '⏰ WeChat QR Code Expired', { sessionId: sessionId?.substring(0, 8), timeElapsed });
}, [track, getBaseProperties]);
/**
@@ -226,7 +222,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
old_session_id: oldSessionId?.substring(0, 8) + '...',
new_session_id: newSessionId?.substring(0, 8) + '...',
});
logger.debug('useAuthEvents', '🔄 WeChat QR Code Refreshed');
}, [track, getBaseProperties]);
/**
@@ -242,7 +237,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
old_status: oldStatus,
new_status: newStatus,
});
logger.debug('useAuthEvents', '🔄 WeChat Status Changed', { oldStatus, newStatus });
}, [track, getBaseProperties]);
/**
@@ -250,7 +244,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
*/
const trackWechatH5Redirect = useCallback(() => {
track(ACTIVATION_EVENTS.WECHAT_H5_REDIRECT, getBaseProperties());
logger.debug('useAuthEvents', '🔗 WeChat H5 Redirect');
}, [track, getBaseProperties]);
// ==================== 登录/注册结果 ====================

35
src/hooks/useDevice.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* useDevice Hook
*
* 封装设备类型检测,提供简洁的 API 供组件使用
*
* @example
* const { isMobile, isTablet, isDesktop, deviceType } = useDevice();
*
* if (isMobile) return <MobileView />;
* if (isTablet) return <TabletView />;
* return <DesktopView />;
*/
import { useSelector } from 'react-redux';
import {
selectIsMobile,
selectIsTablet,
selectIsDesktop,
selectDeviceType,
} from '@/store/slices/deviceSlice';
export const useDevice = () => {
const isMobile = useSelector(selectIsMobile);
const isTablet = useSelector(selectIsTablet);
const isDesktop = useSelector(selectIsDesktop);
const deviceType = useSelector(selectDeviceType);
return {
isMobile,
isTablet,
isDesktop,
deviceType,
};
};
export default useDevice;

View File

@@ -12,6 +12,7 @@ import {
resetToFree,
selectSubscriptionInfo,
selectSubscriptionLoading,
selectSubscriptionLoaded,
selectSubscriptionError,
selectSubscriptionModalOpen
} from '../store/slices/subscriptionSlice';
@@ -66,21 +67,24 @@ export const useSubscription = () => {
// Redux 状态
const subscriptionInfo = useSelector(selectSubscriptionInfo);
const loading = useSelector(selectSubscriptionLoading);
const loaded = useSelector(selectSubscriptionLoaded);
const error = useSelector(selectSubscriptionError);
const isSubscriptionModalOpen = useSelector(selectSubscriptionModalOpen);
// 自动加载订阅信息
// 自动加载订阅信息(带防重复逻辑)
useEffect(() => {
if (isAuthenticated && user) {
// 用户已登录,加载订阅信息
dispatch(fetchSubscriptionInfo());
logger.debug('useSubscription', '加载订阅信息', { userId: user.id });
// 只在未加载且未在加载中时才请求,避免多个组件重复调用
if (!loaded && !loading) {
dispatch(fetchSubscriptionInfo());
logger.debug('useSubscription', '加载订阅信息', { userId: user.id });
}
} else {
// 用户未登录,重置为免费版
dispatch(resetToFree());
logger.debug('useSubscription', '用户未登录,重置为免费版');
}
}, [isAuthenticated, user, dispatch]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, user?.id, dispatch, loaded, loading]);
// 获取订阅级别数值
const getSubscriptionLevel = (type = null) => {

View File

@@ -1,394 +0,0 @@
// 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;

View File

@@ -0,0 +1,382 @@
// src/hooks/useSubscriptionEvents.ts
// 订阅和支付事件追踪 Hook
import { useCallback } from 'react';
import { usePostHogTrack } from './usePostHogRedux';
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants';
/**
* 当前订阅信息
*/
interface SubscriptionInfo {
plan?: string;
status?: string;
}
/**
* useSubscriptionEvents Hook 配置选项
*/
interface UseSubscriptionEventsOptions {
currentSubscription?: SubscriptionInfo | null;
}
/**
* 支付信息
*/
interface PaymentInfo {
planName?: string;
paymentMethod?: string;
amount?: number;
billingCycle?: string;
orderId?: string;
transactionId?: string;
}
/**
* 订阅信息
*/
interface SubscriptionData {
plan?: string;
billingCycle?: string;
amount?: number;
startDate?: string;
endDate?: string;
previousEndDate?: string;
newEndDate?: string;
}
/**
* useSubscriptionEvents Hook 返回值
*/
interface UseSubscriptionEventsReturn {
trackPaywallShown: (feature: string, requiredPlan?: string, triggerLocation?: string) => void;
trackPaywallDismissed: (feature: string, closeMethod?: string) => void;
trackUpgradePlanClicked: (targetPlan?: string, source?: string, feature?: string) => void;
trackSubscriptionPageViewed: (source?: string) => void;
trackPricingPlanViewed: (planName: string, price?: number) => void;
trackPricingPlanSelected: (planName: string, billingCycle?: string, price?: number) => void;
trackPaymentPageViewed: (planName: string, amount?: number) => void;
trackPaymentMethodSelected: (paymentMethod: string, amount?: number) => void;
trackPaymentInitiated: (paymentInfo?: PaymentInfo) => void;
trackPaymentSuccessful: (paymentInfo?: PaymentInfo) => void;
trackPaymentFailed: (paymentInfo?: PaymentInfo, errorReason?: string) => void;
trackSubscriptionCreated: (subscription?: SubscriptionData) => void;
trackSubscriptionRenewed: (subscription?: SubscriptionData) => void;
trackSubscriptionCancelled: (reason?: string, cancelImmediately?: boolean) => void;
trackCouponApplied: (couponCode: string, discountAmount?: number, success?: boolean) => void;
}
/**
* 订阅和支付事件追踪 Hook
* @param options - 配置选项
* @returns 事件追踪处理函数集合
*/
export const useSubscriptionEvents = ({
currentSubscription = null,
}: UseSubscriptionEventsOptions = {}): UseSubscriptionEventsReturn => {
const { track } = usePostHogTrack();
/**
* 追踪付费墙展示
*/
const trackPaywallShown = useCallback(
(feature: string, requiredPlan: string = 'pro', triggerLocation: string = '') => {
if (!feature) {
console.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(),
});
},
[track, currentSubscription]
);
/**
* 追踪付费墙关闭
*/
const trackPaywallDismissed = useCallback(
(feature: string, closeMethod: string = 'dismiss') => {
if (!feature) {
console.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(),
});
},
[track, currentSubscription]
);
/**
* 追踪升级按钮点击
*/
const trackUpgradePlanClicked = useCallback(
(targetPlan: string = 'pro', source: string = '', feature: string = '') => {
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
current_plan: currentSubscription?.plan || 'free',
target_plan: targetPlan,
source,
feature: feature || null,
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪订阅页面查看
*/
const trackSubscriptionPageViewed = useCallback(
(source: string = '') => {
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(),
});
},
[track, currentSubscription]
);
/**
* 追踪定价计划查看
*/
const trackPricingPlanViewed = useCallback(
(planName: string, price: number = 0) => {
if (!planName) {
console.warn('useSubscriptionEvents: trackPricingPlanViewed - planName is required');
return;
}
track('Pricing Plan Viewed', {
plan_name: planName,
price,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪定价计划选择
*/
const trackPricingPlanSelected = useCallback(
(planName: string, billingCycle: string = 'monthly', price: number = 0) => {
if (!planName) {
console.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(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付页面查看
*/
const trackPaymentPageViewed = useCallback(
(planName: string, amount: number = 0) => {
track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, {
plan_name: planName,
amount,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付方式选择
*/
const trackPaymentMethodSelected = useCallback(
(paymentMethod: string, amount: number = 0) => {
if (!paymentMethod) {
console.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(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付发起
*/
const trackPaymentInitiated = useCallback(
(paymentInfo: 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(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付成功
*/
const trackPaymentSuccessful = useCallback(
(paymentInfo: 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(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付失败
*/
const trackPaymentFailed = useCallback(
(paymentInfo: PaymentInfo = {}, errorReason: string = '') => {
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(),
});
},
[track, currentSubscription]
);
/**
* 追踪订阅创建成功
*/
const trackSubscriptionCreated = useCallback(
(subscription: SubscriptionData = {}) => {
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(),
});
},
[track, currentSubscription]
);
/**
* 追踪订阅续费
*/
const trackSubscriptionRenewed = useCallback(
(subscription: SubscriptionData = {}) => {
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(),
});
},
[track]
);
/**
* 追踪订阅取消
*/
const trackSubscriptionCancelled = useCallback(
(reason: string = '', cancelImmediately: boolean = false) => {
track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
plan_name: currentSubscription?.plan,
reason,
has_reason: Boolean(reason),
cancel_immediately: cancelImmediately,
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪优惠券应用
*/
const trackCouponApplied = useCallback(
(couponCode: string, discountAmount: number = 0, success: boolean = true) => {
if (!couponCode) {
console.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(),
});
},
[track, currentSubscription]
);
return {
// 付费墙事件
trackPaywallShown,
trackPaywallDismissed,
trackUpgradePlanClicked,
// 订阅页面事件
trackSubscriptionPageViewed,
trackPricingPlanViewed,
trackPricingPlanSelected,
// 支付流程事件
trackPaymentPageViewed,
trackPaymentMethodSelected,
trackPaymentInitiated,
trackPaymentSuccessful,
trackPaymentFailed,
// 订阅管理事件
trackSubscriptionCreated,
trackSubscriptionRenewed,
trackSubscriptionCancelled,
// 优惠券事件
trackCouponApplied,
};
};
export default useSubscriptionEvents;

View File

@@ -3,8 +3,12 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom';
// 导入 Brainwave 样式(空文件,保留以避免错误)
import './styles/brainwave.css';
// ⚡ 性能监控:在应用启动时尽早标记
import { performanceMonitor } from './utils/performanceMonitor';
performanceMonitor.mark('app-start');
// ⚡ 已删除 brainwave.css项目未安装 Tailwind CSS该文件无效
// import './styles/brainwave.css';
// 导入 Select 下拉框颜色修复样式
import './styles/select-fix.css';
@@ -36,98 +40,26 @@ if (process.env.REACT_APP_ENABLE_DEBUG === 'true') {
function registerServiceWorker() {
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
console.log(
'%c[App] Mock 模式已启用,跳过通知 Service Worker 注册(避免与 MSW 冲突)',
'color: #FF9800; font-weight: bold;'
);
return;
}
// 仅在支持 Service Worker 的浏览器中注册
if ('serviceWorker' in navigator) {
// 在页面加载完成后注册
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/service-worker.js')
.then((registration) => {
console.log('[App] ✅ Service Worker 注册成功');
console.log('[App] Scope:', registration.scope);
// 检查当前激活状态
if (navigator.serviceWorker.controller) {
console.log('[App] ✅ Service Worker 已激活并控制页面');
} else {
console.log('[App] ⏳ Service Worker 已注册,等待激活...');
console.log('[App] 💡 刷新页面以激活 Service Worker');
// 监听 controller 变化Service Worker 激活后触发)
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[App] ✅ Service Worker 控制器已更新');
});
}
// 监听 Service Worker 更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('[App] 🔄 发现 Service Worker 更新');
if (newWorker) {
newWorker.addEventListener('statechange', () => {
console.log(`[App] Service Worker 状态: ${newWorker.state}`);
if (newWorker.state === 'activated') {
console.log('[App] ✅ Service Worker 已激活');
// 如果有旧的 Service Worker 在控制页面,提示用户刷新
if (navigator.serviceWorker.controller) {
console.log('[App] 💡 Service Worker 已更新,建议刷新页面');
}
}
});
}
});
})
.catch((error) => {
console.error('[App] Service Worker 注册失败');
console.error('[App] 错误类型:', error.name);
console.error('[App] 错误信息:', error.message);
console.error('[App] 完整错误:', error);
// 额外检查:验证文件是否可访问
fetch('/service-worker.js', { method: 'HEAD' })
.then(response => {
if (response.ok) {
console.error('[App] Service Worker 文件存在但注册失败');
console.error('[App] 💡 可能的原因:');
console.error('[App] 1. Service Worker 文件有语法错误');
console.error('[App] 2. 浏览器不支持某些 Service Worker 特性');
console.error('[App] 3. HTTPS 证书问题Service Worker 需要 HTTPS');
} else {
console.error('[App] Service Worker 文件不存在HTTP', response.status, '');
}
})
.catch(fetchError => {
console.error('[App] 无法访问 Service Worker 文件:', fetchError.message);
});
console.error('[App] Service Worker 注册失败:', error.message);
});
});
} else {
console.warn('[App] Service Worker is not supported in this browser');
}
}
// 启动 Mock Service Worker如果启用
async function startApp() {
// 只在开发环境启动 MSW
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
const { startMockServiceWorker } = await import('./mocks/browser');
await startMockServiceWorker();
}
// Create root
// 渲染应用
function renderApp() {
const root = ReactDOM.createRoot(document.getElementById('root'));
// Render the app with Router wrapper
// ✅ StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题)
// StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题)
root.render(
<React.StrictMode>
<Router
@@ -141,9 +73,26 @@ async function startApp() {
</React.StrictMode>
);
// 注册 Service Worker
// 注册 Service Worker(非 Mock 模式)
registerServiceWorker();
}
// 启动应用
async function startApp() {
// ✅ 开发环境 Mock 模式:先启动 MSW再渲染应用
// 确保所有 API 请求(包括 AuthContext.checkSession都被正确拦截
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
try {
const { startMockServiceWorker } = await import('./mocks/browser');
await startMockServiceWorker();
} catch (error) {
console.error('[MSW] 启动失败:', error);
}
}
// 渲染应用
renderApp();
}
// 启动应用
startApp();

View File

@@ -35,7 +35,7 @@ export default function MainLayout() {
<MemoizedHomeNavbar />
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
<Box flex="1" pt="72px">
<Box flex="1" pt="60px">
<ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet />

View File

@@ -47,18 +47,8 @@ export async function startMockServiceWorker() {
});
isStarted = true;
console.log(
'%c[MSW] Mock Service Worker 已启动 🎭 (警告模式)',
'color: #4CAF50; font-weight: bold; font-size: 14px;'
);
console.log(
'%c警告模式已定义 Mock → 返回假数据 | 未定义 Mock → 显示警告 ⚠️ | 允许 passthrough',
'color: #FF9800; font-weight: bold; font-size: 12px;'
);
console.log(
'%c查看 src/mocks/handlers/ 目录管理 Mock 接口',
'color: #2196F3; font-size: 12px;'
);
// 精简日志:只保留一行启动提示
console.log('%c[MSW] Mock 已启用 🎭', 'color: #4CAF50; font-weight: bold;');
} catch (error) {
console.error('[MSW] 启动失败:', error);
} finally {

View File

@@ -102,7 +102,6 @@ export function setCurrentUser(user) {
subscription_days_left: user.subscription_days_left || 0
};
localStorage.setItem('mock_current_user', JSON.stringify(normalizedUser));
console.log('[Mock State] 设置当前登录用户:', normalizedUser);
}
}

View File

@@ -613,14 +613,6 @@ export const accountHandlers = [
end_date: currentUser.subscription_end_date || null
};
console.log('[Mock API] 获取当前订阅详情:', {
user_id: currentUser.id,
phone: currentUser.phone,
subscription_type: userSubscriptionType,
subscription_status: subscriptionDetails.status,
days_left: subscriptionDetails.days_left
});
return HttpResponse.json({
success: true,
data: subscriptionDetails

View File

@@ -230,4 +230,377 @@ export const agentHandlers = [
count: history.length,
});
}),
// ==================== 投研会议室 API Handlers ====================
// GET /mcp/agent/meeting/roles - 获取会议角色配置
http.get('/mcp/agent/meeting/roles', async () => {
await delay(200);
return HttpResponse.json({
success: true,
roles: [
{
id: 'buffett',
name: '巴菲特',
nickname: '唱多者',
role_type: 'bull',
avatar: '/avatars/buffett.png',
color: '#10B981',
description: '主观多头,善于分析事件的潜在利好和长期价值',
},
{
id: 'big_short',
name: '大空头',
nickname: '大空头',
role_type: 'bear',
avatar: '/avatars/big_short.png',
color: '#EF4444',
description: '善于分析事件和财报中的风险因素,帮助投资者避雷',
},
{
id: 'simons',
name: '量化分析员',
nickname: '西蒙斯',
role_type: 'quant',
avatar: '/avatars/simons.png',
color: '#3B82F6',
description: '中性立场,使用量化分析工具分析技术指标',
},
{
id: 'leek',
name: '韭菜',
nickname: '牢大',
role_type: 'retail',
avatar: '/avatars/leek.png',
color: '#F59E0B',
description: '贪婪又讨厌亏损,热爱追涨杀跌的典型散户',
},
{
id: 'fund_manager',
name: '基金经理',
nickname: '决策者',
role_type: 'manager',
avatar: '/avatars/fund_manager.png',
color: '#8B5CF6',
description: '总结其他人的发言做出最终决策',
},
],
});
}),
// POST /mcp/agent/meeting/start - 启动投研会议
http.post('/mcp/agent/meeting/start', async ({ request }) => {
await delay(2000); // 模拟多角色讨论耗时
const body = await request.json();
const { topic, user_id } = body;
const sessionId = `meeting-${Date.now()}`;
const timestamp = new Date().toISOString();
// 生成模拟的多角色讨论消息
const messages = [
{
role_id: 'buffett',
role_name: '巴菲特',
nickname: '唱多者',
avatar: '/avatars/buffett.png',
color: '#10B981',
content: `关于「${topic}」,我认为这里存在显著的投资机会。从价值投资的角度看,我们应该关注以下几点:\n\n1. **长期价值**:该标的具有较强的护城河\n2. **盈利能力**ROE持续保持在较高水平\n3. **管理层质量**:管理团队稳定且执行力强\n\n我的观点是**看多**,建议逢低布局。`,
timestamp,
round_number: 1,
},
{
role_id: 'big_short',
role_name: '大空头',
nickname: '大空头',
avatar: '/avatars/big_short.png',
color: '#EF4444',
content: `等等,让我泼点冷水。关于「${topic}」,市场似乎过于乐观了:\n\n⚠️ **风险提示**\n1. 当前估值处于历史高位,安全边际不足\n2. 行业竞争加剧,利润率面临压力\n3. 宏观环境不确定性增加\n\n建议投资者**保持谨慎**,不要追高。`,
timestamp: new Date(Date.now() + 1000).toISOString(),
round_number: 1,
},
{
role_id: 'simons',
role_name: '量化分析员',
nickname: '西蒙斯',
avatar: '/avatars/simons.png',
color: '#3B82F6',
content: `从量化角度分析「${topic}」:\n\n📊 **技术指标**\n- MACD金叉形态动能向上\n- RSI58处于中性区域\n- 均线5日>10日>20日多头排列\n\n📈 **资金面**\n- 主力资金近5日净流入2.3亿\n- 北向资金:持续加仓\n\n**结论**短期技术面偏多但需关注60日均线支撑。`,
timestamp: new Date(Date.now() + 2000).toISOString(),
round_number: 1,
},
{
role_id: 'leek',
role_name: '韭菜',
nickname: '牢大',
avatar: '/avatars/leek.png',
color: '#F59E0B',
content: `哇!「${topic}」看起来要涨啊!\n\n🚀 我觉得必须满仓干!隔壁老王都赚翻了!\n\n不过话说回来...万一跌了怎么办?会不会套住?\n\n算了不管了,先冲一把再说!错过这村就没这店了!\n\n内心OS希望别当接盘侠...`,
timestamp: new Date(Date.now() + 3000).toISOString(),
round_number: 1,
},
{
role_id: 'fund_manager',
role_name: '基金经理',
nickname: '决策者',
avatar: '/avatars/fund_manager.png',
color: '#8B5CF6',
content: `## 投资建议总结\n\n综合各方观点,对于「${topic}」,我的判断如下:\n\n### 综合评估\n多空双方都提出了有价值的观点。技术面短期偏多,但估值确实需要关注。\n\n### 关键观点\n- ✅ 基本面优质,长期价值明确\n- ⚠️ 短期估值偏高,需要耐心等待\n- 📊 技术面处于上升趋势\n\n### 风险提示\n注意仓位控制,避免追高\n\n### 操作建议\n**观望为主**,等待回调至支撑位再考虑建仓\n\n### 信心指数7/10`,
timestamp: new Date(Date.now() + 4000).toISOString(),
round_number: 1,
is_conclusion: true,
},
];
return HttpResponse.json({
success: true,
session_id: sessionId,
messages,
round_number: 1,
is_concluded: true,
conclusion: messages[messages.length - 1],
});
}),
// POST /mcp/agent/meeting/continue - 继续会议讨论
http.post('/mcp/agent/meeting/continue', async ({ request }) => {
await delay(1500);
const body = await request.json();
const { topic, user_message, conversation_history } = body;
const roundNumber = Math.floor(conversation_history.length / 5) + 2;
const timestamp = new Date().toISOString();
const messages = [];
// 如果用户有插话,添加用户消息
if (user_message) {
messages.push({
role_id: 'user',
role_name: '用户',
nickname: '你',
avatar: '',
color: '#6366F1',
content: user_message,
timestamp,
round_number: roundNumber,
});
}
// 生成新一轮讨论
messages.push(
{
role_id: 'buffett',
role_name: '巴菲特',
nickname: '唱多者',
avatar: '/avatars/buffett.png',
color: '#10B981',
content: `感谢用户的补充。${user_message ? `关于"${user_message}"` : ''}我依然坚持看多的观点。从更长远的角度看,短期波动不影响长期价值。`,
timestamp: new Date(Date.now() + 1000).toISOString(),
round_number: roundNumber,
},
{
role_id: 'big_short',
role_name: '大空头',
nickname: '大空头',
avatar: '/avatars/big_short.png',
color: '#EF4444',
content: `用户提出了很好的问题。我要再次强调风险控制的重要性。当前市场情绪过热,建议保持警惕。`,
timestamp: new Date(Date.now() + 2000).toISOString(),
round_number: roundNumber,
},
{
role_id: 'fund_manager',
role_name: '基金经理',
nickname: '决策者',
avatar: '/avatars/fund_manager.png',
color: '#8B5CF6',
content: `## 第${roundNumber}轮讨论总结\n\n经过进一步讨论,我维持之前的判断:\n\n- 短期观望为主\n- 中长期可以考虑分批建仓\n- 严格控制仓位,设好止损\n\n**信心指数7.5/10**\n\n会议到此结束,感谢各位的参与!`,
timestamp: new Date(Date.now() + 3000).toISOString(),
round_number: roundNumber,
is_conclusion: true,
}
);
return HttpResponse.json({
success: true,
session_id: body.session_id,
messages,
round_number: roundNumber,
is_concluded: true,
conclusion: messages[messages.length - 1],
});
}),
// POST /mcp/agent/meeting/stream - 流式会议接口V2
http.post('/mcp/agent/meeting/stream', async ({ request }) => {
const body = await request.json();
const { topic, user_id } = body;
const sessionId = `meeting-${Date.now()}`;
// 定义会议角色和他们的消息
const roleMessages = [
{
role_id: 'buffett',
role_name: '巴菲特',
content: `关于「${topic}」,我认为这里存在显著的投资机会。从价值投资的角度看,我们应该关注以下几点:\n\n1. **长期价值**:该标的具有较强的护城河\n2. **盈利能力**ROE持续保持在较高水平\n3. **管理层质量**:管理团队稳定且执行力强\n\n我的观点是**看多**,建议逢低布局。`,
tools: [
{ name: 'search_china_news', result: { articles: [{ title: '相关新闻1' }, { title: '相关新闻2' }] } },
{ name: 'get_stock_basic_info', result: { pe: 25.6, pb: 3.2, roe: 18.5 } },
],
},
{
role_id: 'big_short',
role_name: '大空头',
content: `等等,让我泼点冷水。关于「${topic}」,市场似乎过于乐观了:\n\n⚠️ **风险提示**\n1. 当前估值处于历史高位,安全边际不足\n2. 行业竞争加剧,利润率面临压力\n3. 宏观环境不确定性增加\n\n建议投资者**保持谨慎**,不要追高。`,
tools: [
{ name: 'get_stock_financial_index', result: { debt_ratio: 45.2, current_ratio: 1.8 } },
],
},
{
role_id: 'simons',
role_name: '量化分析员',
content: `从量化角度分析「${topic}」:\n\n📊 **技术指标**\n- MACD金叉形态动能向上\n- RSI58处于中性区域\n- 均线5日>10日>20日多头排列\n\n📈 **资金面**\n- 主力资金近5日净流入2.3亿\n- 北向资金:持续加仓\n\n**结论**短期技术面偏多但需关注60日均线支撑。`,
tools: [
{ name: 'get_stock_trade_data', result: { volume: 1234567, turnover: 5.2 } },
{ name: 'get_concept_statistics', result: { concepts: ['AI概念', '半导体'], avg_change: 2.3 } },
],
},
{
role_id: 'leek',
role_name: '韭菜',
content: `哇!「${topic}」看起来要涨啊!\n\n🚀 我觉得必须满仓干!隔壁老王都赚翻了!\n\n不过话说回来...万一跌了怎么办?会不会套住?\n\n算了不管了,先冲一把再说!错过这村就没这店了!\n\n内心OS希望别当接盘侠...`,
tools: [], // 韭菜不用工具
},
{
role_id: 'fund_manager',
role_name: '基金经理',
content: `## 投资建议总结\n\n综合各方观点,对于「${topic}」,我的判断如下:\n\n### 综合评估\n多空双方都提出了有价值的观点。技术面短期偏多,但估值确实需要关注。\n\n### 关键观点\n- ✅ 基本面优质,长期价值明确\n- ⚠️ 短期估值偏高,需要耐心等待\n- 📊 技术面处于上升趋势\n\n### 风险提示\n注意仓位控制,避免追高\n\n### 操作建议\n**观望为主**,等待回调至支撑位再考虑建仓\n\n### 信心指数7/10`,
tools: [
{ name: 'search_research_reports', result: { reports: [{ title: '深度研报1' }] } },
],
is_conclusion: true,
},
];
// 创建 SSE 流
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
// 发送 session_start
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'session_start',
session_id: sessionId,
})}\n\n`));
await delay(300);
// 发送 order_decided
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'order_decided',
order: roleMessages.map(r => r.role_id),
})}\n\n`));
await delay(300);
// 依次发送每个角色的消息
for (const role of roleMessages) {
// speaking_start
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'speaking_start',
role_id: role.role_id,
role_name: role.role_name,
})}\n\n`));
await delay(200);
// 发送工具调用
const toolCallResults = [];
for (const tool of role.tools) {
const toolCallId = `tc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const execTime = 0.5 + Math.random() * 0.5;
// tool_call_start
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_call_start',
role_id: role.role_id,
tool_call_id: toolCallId,
tool_name: tool.name,
arguments: {},
})}\n\n`));
await delay(500);
// tool_call_result
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'tool_call_result',
role_id: role.role_id,
tool_call_id: toolCallId,
tool_name: tool.name,
result: { success: true, data: tool.result },
status: 'success',
execution_time: execTime,
})}\n\n`));
toolCallResults.push({
tool_call_id: toolCallId,
tool_name: tool.name,
result: { success: true, data: tool.result },
status: 'success',
execution_time: execTime,
});
await delay(200);
}
// 流式发送内容
const chunks = role.content.match(/.{1,20}/g) || [];
for (const chunk of chunks) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'content_delta',
role_id: role.role_id,
content: chunk,
})}\n\n`));
await delay(30);
}
// message_complete
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'message_complete',
role_id: role.role_id,
message: {
role_id: role.role_id,
role_name: role.role_name,
content: role.content,
tool_calls: toolCallResults,
is_conclusion: role.is_conclusion || false,
},
})}\n\n`));
await delay(500);
}
// round_end
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'round_end',
round_number: 1,
is_concluded: false,
})}\n\n`));
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}),
];

View File

@@ -12,7 +12,8 @@ import {
} from '../data/users';
// 模拟网络延迟(毫秒)
const NETWORK_DELAY = 500;
// ⚡ 开发环境使用较短延迟,加快首屏加载速度
const NETWORK_DELAY = 50;
export const authHandlers = [
// ==================== 手机验证码登录 ====================
@@ -31,21 +32,6 @@ export const authHandlers = [
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
});
// 超醒目的验证码提示 - 方便开发调试
console.log(
`%c\n` +
`╔════════════════════════════════════════════╗\n` +
`║ 验证码: ${code.padEnd(22)}\n` +
`╚════════════════════════════════════════════╝\n`,
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
);
// 额外的高亮提示
console.log(
`%c 验证码: ${code} `,
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
);
return HttpResponse.json({
success: true,
message: `验证码已发送到 ${credential}Mock: ${code}`,
@@ -141,8 +127,6 @@ export const authHandlers = [
const body = await request.json();
const { credential, verification_code, login_type } = body;
console.log('[Mock] 验证码登录:', { credential, verification_code, login_type });
// 验证验证码
const storedCode = mockVerificationCodes.get(credential);
if (!storedCode) {
@@ -194,11 +178,8 @@ export const authHandlers = [
subscription_days_left: 0
};
mockUsers[credential] = user;
console.log('[Mock] 创建新用户:', user);
}
console.log('[Mock] 登录成功:', user);
// 设置当前登录用户
setCurrentUser(user);
@@ -345,25 +326,22 @@ export const authHandlers = [
});
}),
// 6. 获取微信 H5 授权 URL
http.post('/api/auth/wechat/h5-auth-url', async ({ request }) => {
// 6. 获取微信 H5 授权 URL(手机浏览器用)
http.post('/api/auth/wechat/h5-auth', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
const { redirect_url } = body;
const state = generateWechatSessionId();
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=mock&redirect_uri=${encodeURIComponent(redirect_url)}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`;
// Mock 模式下直接返回前端回调地址(模拟授权成功)
const authUrl = `${redirect_url}?wechat_login=success&state=${state}`;
console.log('[Mock] 生成微信 H5 授权 URL:', authUrl);
return HttpResponse.json({
code: 0,
message: '成功',
data: {
auth_url: authUrl,
state
}
auth_url: authUrl,
state
});
}),
@@ -371,13 +349,11 @@ export const authHandlers = [
// 7. 检查 SessionAuthContext 使用的正确端点)
http.get('/api/auth/session', async () => {
await delay(300);
await delay(NETWORK_DELAY); // ⚡ 使用统一延迟配置
// 获取当前登录用户
const currentUser = getCurrentUser();
console.log('[Mock] 检查 Session:', currentUser);
if (currentUser) {
return HttpResponse.json({
success: true,
@@ -395,13 +371,11 @@ export const authHandlers = [
// 8. 检查 Session旧端点保留兼容
http.get('/api/auth/check-session', async () => {
await delay(300);
await delay(NETWORK_DELAY); // ⚡ 使用统一延迟配置
// 获取当前登录用户
const currentUser = getCurrentUser();
console.log('[Mock] 检查 Session (旧端点):', currentUser);
if (currentUser) {
return HttpResponse.json({
success: true,
@@ -432,91 +406,3 @@ export const authHandlers = [
});
})
];
// ==================== Mock 调试工具(仅开发环境) ====================
/**
* 暴露全局API方便手动触发微信扫码模拟
* 使用方式:
* 1. 浏览器控制台输入window.mockWechatScan()
* 2. 或者在组件中调用window.mockWechatScan(sessionId)
*/
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
window.mockWechatScan = (sessionId) => {
// 如果没有传入sessionId尝试获取最新的session
let targetSessionId = sessionId;
if (!targetSessionId) {
// 获取最新创建的session
const sessions = Array.from(mockWechatSessions.entries());
if (sessions.length === 0) {
console.warn('[Mock API] 没有活跃的微信session请先获取二维码');
return false;
}
// 按创建时间排序,获取最新的
const latestSession = sessions.sort((a, b) => b[1].createdAt - a[1].createdAt)[0];
targetSessionId = latestSession[0];
}
const session = mockWechatSessions.get(targetSessionId);
if (!session) {
console.error('[Mock API] Session不存在:', targetSessionId);
return false;
}
if (session.status !== 'waiting') {
console.warn('[Mock API] Session状态不是waiting当前状态:', session.status);
return false;
}
// 立即触发扫码
session.status = 'scanned';
console.log(`[Mock API] ✅ 模拟扫码成功: ${targetSessionId}`);
// 1秒后自动确认登录
setTimeout(() => {
const session2 = mockWechatSessions.get(targetSessionId);
if (session2 && session2.status === 'scanned') {
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与自动扫码流程保持一致
session2.user = {
id: 999,
nickname: '微信测试用户',
wechat_openid: 'mock_openid_' + targetSessionId,
avatar_url: 'https://ui-avatars.com/api/?name=微信测试用户&size=150&background=4299e1&color=fff',
phone: null,
email: null,
has_wechat: true,
created_at: new Date().toISOString(),
subscription_type: 'free',
subscription_status: 'active',
subscription_end_date: null,
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);
return true;
};
// 暴露获取当前sessions的方法调试用
window.getMockWechatSessions = () => {
const sessions = Array.from(mockWechatSessions.entries()).map(([id, session]) => ({
sessionId: id,
status: session.status,
createdAt: new Date(session.createdAt).toLocaleString(),
hasUser: !!session.user
}));
console.table(sessions);
return sessions;
};
console.log('%c[Mock API] 微信登录调试工具已加载', 'color: #00D084; font-weight: bold');
console.log('%c使用方法:', 'color: #666');
console.log(' window.mockWechatScan() - 触发最新session的扫码');
console.log(' window.mockWechatScan(sessionId) - 触发指定session的扫码');
console.log(' window.getMockWechatSessions() - 查看所有活跃的sessions');
}

View File

@@ -0,0 +1,22 @@
// src/mocks/handlers/bytedesk.js
/**
* Bytedesk 客服 Widget MSW Handler
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
*/
import { http, passthrough } from 'msw';
export const bytedeskHandlers = [
// Bytedesk API 请求 - 直接 passthrough
// 匹配 /bytedesk/* 路径(通过代理访问后端)
http.all('/bytedesk/*', () => {
return passthrough();
}),
// Bytedesk 外部 CDN/服务请求
http.all('https://www.weiyuai.cn/*', () => {
return passthrough();
}),
];
export default bytedeskHandlers;

View File

@@ -16,6 +16,7 @@ import { limitAnalyseHandlers } from './limitAnalyse';
import { posthogHandlers } from './posthog';
import { externalHandlers } from './external';
import { agentHandlers } from './agent';
import { bytedeskHandlers } from './bytedesk';
// 可以在这里添加更多的 handlers
// import { userHandlers } from './user';
@@ -36,5 +37,6 @@ export const handlers = [
...posthogHandlers,
...externalHandlers,
...agentHandlers,
...bytedeskHandlers, // ⚡ Bytedesk 客服 Widget passthrough
// ...userHandlers,
];

View File

@@ -188,46 +188,3 @@ export const paymentHandlers = [
});
})
];
// ==================== Mock 调试工具(仅开发环境) ====================
/**
* 暴露全局API方便手动触发支付成功
* 使用方式window.mockPaymentSuccess(orderId)
*/
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
window.mockPaymentSuccess = (orderId) => {
const order = mockOrders.get(orderId);
if (!order) {
console.error('[Mock Payment] 订单不存在:', orderId);
return false;
}
if (order.status !== 'pending') {
console.warn('[Mock Payment] 订单状态不是待支付:', order.status);
return false;
}
order.status = 'paid';
order.paid_at = new Date().toISOString();
console.log('[Mock Payment] ✅ 支付成功:', orderId);
return true;
};
window.getMockOrders = () => {
const orders = Array.from(mockOrders.entries()).map(([id, order]) => ({
orderId: id,
status: order.status,
amount: order.amount,
plan: `${order.plan_name} - ${order.billing_cycle}`,
createdAt: new Date(order.created_at).toLocaleString()
}));
console.table(orders);
return orders;
};
console.log('%c[Mock Payment] 支付调试工具已加载', 'color: #00D084; font-weight: bold');
console.log('%c使用方法:', 'color: #666');
console.log(' window.mockPaymentSuccess(orderId) - 手动触发订单支付成功');
console.log(' window.getMockOrders() - 查看所有模拟订单');
}

View File

@@ -9,43 +9,44 @@ import React from 'react';
*/
export const lazyComponents = {
// Home 模块
HomePage: React.lazy(() => import('../views/Home/HomePage')),
CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')),
ProfilePage: React.lazy(() => import('../views/Profile')),
SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')),
Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')),
PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')),
UserAgreement: React.lazy(() => import('../views/Pages/UserAgreement')),
WechatCallback: React.lazy(() => import('../views/Pages/WechatCallback')),
// ⚡ 直接引用 HomePage无需中间层静态页面不需要骨架屏
HomePage: React.lazy(() => import('@views/Home/HomePage')),
CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
ProfilePage: React.lazy(() => import('@views/Profile')),
SettingsPage: React.lazy(() => import('@views/Settings/SettingsPage')),
Subscription: React.lazy(() => import('@views/Pages/Account/Subscription')),
PrivacyPolicy: React.lazy(() => import('@views/Pages/PrivacyPolicy')),
UserAgreement: React.lazy(() => import('@views/Pages/UserAgreement')),
WechatCallback: React.lazy(() => import('@views/Pages/WechatCallback')),
// 社区/内容模块
Community: React.lazy(() => import('../views/Community')),
ConceptCenter: React.lazy(() => import('../views/Concept')),
StockOverview: React.lazy(() => import('../views/StockOverview')),
LimitAnalyse: React.lazy(() => import('../views/LimitAnalyse')),
Community: React.lazy(() => import('@views/Community')),
ConceptCenter: React.lazy(() => import('@views/Concept')),
StockOverview: React.lazy(() => import('@views/StockOverview')),
LimitAnalyse: React.lazy(() => import('@views/LimitAnalyse')),
// 交易模块
TradingSimulation: React.lazy(() => import('../views/TradingSimulation')),
TradingSimulation: React.lazy(() => import('@views/TradingSimulation')),
// 事件模块
EventDetail: React.lazy(() => import('../views/EventDetail')),
EventDetail: React.lazy(() => import('@views/EventDetail')),
// 公司相关模块
CompanyIndex: React.lazy(() => import('../views/Company')),
ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')),
CompanyIndex: React.lazy(() => import('@views/Company')),
ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
// Agent模块
AgentChat: React.lazy(() => import('../views/AgentChat')),
AgentChat: React.lazy(() => import('@views/AgentChat')),
// 价值论坛模块
ValueForum: React.lazy(() => import('../views/ValueForum')),
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
PredictionTopicDetail: React.lazy(() => import('../views/ValueForum/PredictionTopicDetail')),
ValueForum: React.lazy(() => import('@views/ValueForum')),
ForumPostDetail: React.lazy(() => import('@views/ValueForum/PostDetail')),
PredictionTopicDetail: React.lazy(() => import('@views/ValueForum/PredictionTopicDetail')),
// 数据浏览器模块
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
DataBrowser: React.lazy(() => import('@views/DataBrowser')),
};
/**

View File

@@ -10,25 +10,12 @@ import { socketService } from '../socketService';
export const socket = socketService;
export { socketService };
// ⚡ 新增:暴露 Socket 实例到 window用于调试和验证
// ⚡ 暴露 Socket 实例到 window用于调试和验证
// 注意:移除首屏加载时的日志,避免阻塞感知
if (typeof window !== 'undefined') {
window.socket = socketService;
window.socketService = socketService;
console.log(
'%c[Socket Service] ✅ Socket instance exposed to window',
'color: #4CAF50; font-weight: bold; font-size: 14px;'
);
console.log(' 📍 window.socket:', window.socket);
console.log(' 📍 window.socketService:', window.socketService);
console.log(' 📍 Socket.IO instance:', window.socket?.socket);
console.log(' 📍 Connection status:', window.socket?.connected ? '✅ Connected' : '❌ Disconnected');
// 日志已移除,如需调试可在控制台执行: console.log(window.socket)
}
// 打印当前使用的服务类型
console.log(
'%c[Socket Service] Using REAL Socket Service',
'color: #4CAF50; font-weight: bold; font-size: 12px;'
);
export default socket;

View File

@@ -56,17 +56,8 @@ class SocketService {
// 注册所有暂存的事件监听器(保留 pendingListeners不清空
if (this.pendingListeners.length > 0) {
console.log(`[socketService] 📦 注册 ${this.pendingListeners.length} 个暂存的事件监听器`);
this.pendingListeners.forEach(({ event, callback }) => {
// 直接在 Socket.IO 实例上注册(避免递归调用 this.on()
const wrappedCallback = (...args) => {
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
console.log(`[socketService] 事件数据 (${event}):`, ...args);
callback(...args);
};
this.socket.on(event, wrappedCallback);
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
this.socket.on(event, callback);
});
// ⚠️ 重要:不清空 pendingListeners保留用于重连
}
@@ -82,15 +73,8 @@ class SocketService {
this.customReconnectTimer = null;
}
logger.info('socketService', 'Socket.IO connected successfully', {
socketId: this.socket.id,
});
console.log(`%c[socketService] ✅ WebSocket 已连接`, 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] Socket ID:', this.socket.id);
logger.info('socketService', 'Socket.IO connected', { socketId: this.socket.id });
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
// this.subscribeToAllEvents();
});
// 监听断开连接
@@ -174,25 +158,12 @@ class SocketService {
);
if (!exists) {
logger.info('socketService', 'Socket not ready, queuing listener', { event });
console.log(`[socketService] 📦 Socket 未初始化,暂存事件监听器: ${event}`);
this.pendingListeners.push({ event, callback });
} else {
console.log(`[socketService] ⚠️ 监听器已存在,跳过: ${event}`);
}
return;
}
// 包装回调函数,添加日志
const wrappedCallback = (...args) => {
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
console.log(`[socketService] 事件数据 (${event}):`, ...args);
callback(...args);
};
this.socket.on(event, wrappedCallback);
logger.info('socketService', `Event listener added: ${event}`);
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
this.socket.on(event, callback);
}
/**
@@ -210,8 +181,6 @@ class SocketService {
} else {
this.socket.off(event);
}
logger.info('socketService', `Event listener removed: ${event}`);
}
/**
@@ -231,8 +200,6 @@ class SocketService {
} else {
this.socket.emit(event, data);
}
logger.info('socketService', `Event emitted: ${event}`, data);
}
/**
@@ -329,10 +296,18 @@ class SocketService {
onSubscribed,
} = options;
if (!this.socket || !this.connected) {
logger.warn('socketService', 'Cannot subscribe: socket not connected');
// 自动连接
this.connect();
// ⚡ 改进状态检查:同时检查 this.connected 和 socket.connected
// 解决 connect 回调中 this.connected 尚未更新的竞争条件
const isReady = this.socket && (this.socket.connected || this.connected);
if (!isReady) {
logger.debug('socketService', 'Socket 尚未就绪,等待连接后订阅');
if (!this.socket) {
// 自动连接
this.connect();
}
// 等待连接成功后再订阅
this.socket.once('connect', () => {
this._doSubscribe(eventType, importance, onNewEvent, onSubscribed);
@@ -347,65 +322,31 @@ class SocketService {
* 执行订阅操作(内部方法)
*/
_doSubscribe(eventType, importance, onNewEvent, onSubscribed) {
console.log('\n========== [SocketService DEBUG] 开始订阅 ==========');
console.log('[SocketService DEBUG] 事件类型:', eventType);
console.log('[SocketService DEBUG] 重要性:', importance);
console.log('[SocketService DEBUG] Socket 连接状态:', this.connected);
console.log('[SocketService DEBUG] Socket ID:', this.socket?.id);
// 发送订阅请求
const subscribeData = {
event_type: eventType,
importance: importance,
};
console.log('[SocketService DEBUG] 准备发送 subscribe_events:', subscribeData);
this.emit('subscribe_events', subscribeData);
console.log('[SocketService DEBUG] ✓ 已发送 subscribe_events');
// 监听订阅确认
this.socket.once('subscription_confirmed', (data) => {
console.log('\n[SocketService DEBUG] ========== 收到订阅确认 ==========');
console.log('[SocketService DEBUG] 订阅确认数据:', data);
logger.info('socketService', 'Subscription confirmed', data);
if (onSubscribed) {
console.log('[SocketService DEBUG] 调用 onSubscribed 回调');
onSubscribed(data);
}
console.log('[SocketService DEBUG] ========== 订阅确认处理完成 ==========\n');
});
// 监听订阅错误
this.socket.once('subscription_error', (error) => {
console.error('\n[SocketService ERROR] ========== 订阅错误 ==========');
console.error('[SocketService ERROR] 错误信息:', error);
logger.error('socketService', 'Subscription error', error);
console.error('[SocketService ERROR] ========== 订阅错误处理完成 ==========\n');
});
// 监听新事件推送
// ⚠️ 注意:不要移除其他地方注册的 new_event 监听器(如 NotificationContext
// 多个监听器可以共存,都会被触发
if (onNewEvent) {
console.log('[SocketService DEBUG] 设置 new_event 监听器');
// ⚠️ 已移除 this.socket.off('new_event'),允许多个监听器共存
// 添加新的监听器(与其他监听器共存)
this.socket.on('new_event', (eventData) => {
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
console.log('[SocketService DEBUG] 事件数据:', eventData);
console.log('[SocketService DEBUG] 事件 ID:', eventData?.id);
console.log('[SocketService DEBUG] 事件标题:', eventData?.title);
logger.info('socketService', 'New event received', eventData);
console.log('[SocketService DEBUG] 准备调用 onNewEvent 回调');
onNewEvent(eventData);
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
});
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置(与其他监听器共存)');
}
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
}
/**
@@ -432,11 +373,7 @@ class SocketService {
// 监听取消订阅确认
this.socket.once('unsubscription_confirmed', (data) => {
logger.info('socketService', 'Unsubscription confirmed', data);
// 移除新事件监听器
this.socket.off('new_event');
if (onUnsubscribed) {
onUnsubscribed(data);
}
@@ -454,22 +391,10 @@ class SocketService {
* @returns {Function} 取消订阅的函数
*/
subscribeToAllEvents(onNewEvent) {
console.log('%c[socketService] 🔔 自动订阅所有事件...', 'color: #FF9800; font-weight: bold;');
// 如果没有提供回调,添加一个默认的日志回调
const defaultCallback = (event) => {
console.log('%c[socketService] 📨 收到新事件(默认回调)', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 事件数据:', event);
};
this.subscribeToEvents({
eventType: 'all',
importance: 'all',
onNewEvent: onNewEvent || defaultCallback,
onSubscribed: (data) => {
console.log('%c[socketService] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 订阅确认:', data);
},
onNewEvent: onNewEvent || (() => {}),
});
// 返回取消订阅的清理函数

View File

@@ -1,26 +1,38 @@
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import { configureStore, combineReducers } from '@reduxjs/toolkit';
import communityDataReducer from './slices/communityDataSlice';
import posthogReducer from './slices/posthogSlice';
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
// import posthogReducer from './slices/posthogSlice';
// import posthogMiddleware from './middleware/posthogMiddleware';
import industryReducer from './slices/industrySlice';
import stockReducer from './slices/stockSlice';
import authModalReducer from './slices/authModalSlice';
import subscriptionReducer from './slices/subscriptionSlice';
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
import posthogMiddleware from './middleware/posthogMiddleware';
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
// ⚡ 基础 reducers首屏必需
const staticReducers = {
communityData: communityDataReducer,
industry: industryReducer, // ✅ 行业分类数据管理
stock: stockReducer, // ✅ 股票和事件数据管理
authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
};
// ⚡ 动态 reducers 注册表
const asyncReducers = {};
// ⚡ 创建根 reducer 的工厂函数
const createRootReducer = () => combineReducers({
...staticReducers,
...asyncReducers,
});
export const store = configureStore({
reducer: {
communityData: communityDataReducer,
posthog: posthogReducer, // ✅ PostHog Redux 状态管理
industry: industryReducer, // ✅ 行业分类数据管理
stock: stockReducer, // ✅ 股票和事件数据管理
authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
},
reducer: createRootReducer(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
@@ -28,14 +40,27 @@ export const store = configureStore({
ignoredActions: [
'communityData/fetchPopularKeywords/fulfilled',
'communityData/fetchHotEvents/fulfilled',
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪(延迟加载后仍需)
'stock/fetchEventStocks/fulfilled',
'stock/fetchStockQuotes/fulfilled',
],
},
})
.concat(posthogMiddleware) // PostHog 自动追踪中间件
// PostHog 中间件延迟加载,首屏不再需要
.concat(eventsApi.middleware), // ✅ RTK Query 中间件(自动缓存、去重、重试)
});
/**
* ⚡ 动态注入 reducer用于延迟加载模块
* @param {string} key - reducer 的键名
* @param {Function} reducer - reducer 函数
*/
export const injectReducer = (key, reducer) => {
if (asyncReducers[key]) {
return; // 已注入,避免重复
}
asyncReducers[key] = reducer;
store.replaceReducer(createRootReducer());
};
export default store;

View File

@@ -2,27 +2,81 @@
import { createSlice } from '@reduxjs/toolkit';
/**
* 检测当前设备是否为移动设备
*
* 判断逻辑:
* 1. User Agent 检测(移动设备标识)
* 2. 屏幕宽度检测(<= 768px
* 3. 触摸屏检测(支持触摸事件)
*
* @returns {boolean} true 表示移动设备false 表示桌面设备
* 设备类型枚举
*/
const detectIsMobile = () => {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i;
const isMobileUA = mobileRegex.test(userAgent);
const isMobileWidth = window.innerWidth <= 768;
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
return isMobileUA || (isMobileWidth && hasTouchScreen);
export const DeviceType = {
MOBILE: 'mobile',
TABLET: 'tablet',
DESKTOP: 'desktop',
};
/**
* 检测设备类型
*
* 判断逻辑:
* - Mobile: 手机设备iPhone、Android 手机等,宽度 <= 768px
* - Tablet: 平板设备iPad、Android 平板等,宽度 769px - 1024px
* - Desktop: 桌面设备PC、Mac 等,宽度 > 1024px
*
* @returns {{ isMobile: boolean, isTablet: boolean, isDesktop: boolean, deviceType: string }}
*/
const detectDeviceType = () => {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
const screenWidth = window.innerWidth;
// iPad 检测(包括 iPadOS 13+ 的 Safari其 UA 不再包含 iPad
const isIPad =
/iPad/i.test(userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// Android 平板检测Android 设备但 UA 不包含 Mobile
const isAndroidTablet = /Android/i.test(userAgent) && !/Mobile/i.test(userAgent);
// 手机 UA 检测(排除平板)
const mobileRegex = /iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i;
const isAndroidPhone = /Android/i.test(userAgent) && /Mobile/i.test(userAgent);
const isMobileUA = mobileRegex.test(userAgent) || isAndroidPhone;
// 触摸屏检测
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// 综合判断设备类型
let isMobile = false;
let isTablet = false;
let isDesktop = false;
if (isIPad || isAndroidTablet) {
// UA 明确是平板
isTablet = true;
} else if (isMobileUA) {
// UA 明确是手机
isMobile = true;
} else if (screenWidth <= 768 && hasTouchScreen) {
// 小屏幕 + 触摸屏 = 手机
isMobile = true;
} else if (screenWidth > 768 && screenWidth <= 1024 && hasTouchScreen) {
// 中等屏幕 + 触摸屏 = 平板
isTablet = true;
} else {
// 其他情况为桌面设备
isDesktop = true;
}
// 确定设备类型字符串
let deviceType = DeviceType.DESKTOP;
if (isMobile) deviceType = DeviceType.MOBILE;
else if (isTablet) deviceType = DeviceType.TABLET;
return { isMobile, isTablet, isDesktop, deviceType };
};
const initialDeviceState = detectDeviceType();
const initialState = {
isMobile: detectIsMobile(),
isMobile: initialDeviceState.isMobile,
isTablet: initialDeviceState.isTablet,
isDesktop: initialDeviceState.isDesktop,
deviceType: initialDeviceState.deviceType,
};
const deviceSlice = createSlice({
@@ -37,7 +91,11 @@ const deviceSlice = createSlice({
* - 屏幕方向变化时调用orientationchange
*/
updateScreenSize: (state) => {
state.isMobile = detectIsMobile();
const { isMobile, isTablet, isDesktop, deviceType } = detectDeviceType();
state.isMobile = isMobile;
state.isTablet = isTablet;
state.isDesktop = isDesktop;
state.deviceType = deviceType;
},
},
});
@@ -47,6 +105,9 @@ export const { updateScreenSize } = deviceSlice.actions;
// Selectors
export const selectIsMobile = (state) => state.device.isMobile;
export const selectIsTablet = (state) => state.device.isTablet;
export const selectIsDesktop = (state) => state.device.isDesktop;
export const selectDeviceType = (state) => state.device.deviceType;
// Reducer
export default deviceSlice.reducer;

View File

@@ -7,26 +7,49 @@
* 3. selector 函数测试
*/
import deviceReducer, { updateScreenSize, selectIsMobile } from './deviceSlice';
import deviceReducer, {
updateScreenSize,
selectIsMobile,
selectIsTablet,
selectIsDesktop,
selectDeviceType,
DeviceType,
} from './deviceSlice';
describe('deviceSlice', () => {
describe('reducer', () => {
it('should return the initial state', () => {
const initialState = deviceReducer(undefined, { type: '@@INIT' });
expect(initialState).toHaveProperty('isMobile');
expect(initialState).toHaveProperty('isTablet');
expect(initialState).toHaveProperty('isDesktop');
expect(initialState).toHaveProperty('deviceType');
expect(typeof initialState.isMobile).toBe('boolean');
expect(typeof initialState.isTablet).toBe('boolean');
expect(typeof initialState.isDesktop).toBe('boolean');
expect(typeof initialState.deviceType).toBe('string');
});
it('should handle updateScreenSize', () => {
// 模拟初始状态
const initialState = { isMobile: false };
const initialState = {
isMobile: false,
isTablet: false,
isDesktop: true,
deviceType: DeviceType.DESKTOP,
};
// 执行 action注意实际 isMobile 值由 detectIsMobile() 决定)
// 执行 action注意实际值由 detectDeviceType() 决定)
const newState = deviceReducer(initialState, updateScreenSize());
// 验证状态结构
expect(newState).toHaveProperty('isMobile');
expect(newState).toHaveProperty('isTablet');
expect(newState).toHaveProperty('isDesktop');
expect(newState).toHaveProperty('deviceType');
expect(typeof newState.isMobile).toBe('boolean');
expect(typeof newState.isTablet).toBe('boolean');
expect(typeof newState.isDesktop).toBe('boolean');
});
});
@@ -35,22 +58,48 @@ describe('deviceSlice', () => {
const mockState = {
device: {
isMobile: true,
isTablet: false,
isDesktop: false,
deviceType: DeviceType.MOBILE,
},
};
const result = selectIsMobile(mockState);
expect(result).toBe(true);
expect(selectIsMobile(mockState)).toBe(true);
expect(selectIsTablet(mockState)).toBe(false);
expect(selectIsDesktop(mockState)).toBe(false);
expect(selectDeviceType(mockState)).toBe(DeviceType.MOBILE);
});
it('selectIsMobile should return false for desktop', () => {
it('selectIsTablet should return correct value', () => {
const mockState = {
device: {
isMobile: false,
isTablet: true,
isDesktop: false,
deviceType: DeviceType.TABLET,
},
};
const result = selectIsMobile(mockState);
expect(result).toBe(false);
expect(selectIsMobile(mockState)).toBe(false);
expect(selectIsTablet(mockState)).toBe(true);
expect(selectIsDesktop(mockState)).toBe(false);
expect(selectDeviceType(mockState)).toBe(DeviceType.TABLET);
});
it('selectIsDesktop should return correct value', () => {
const mockState = {
device: {
isMobile: false,
isTablet: false,
isDesktop: true,
deviceType: DeviceType.DESKTOP,
},
};
expect(selectIsMobile(mockState)).toBe(false);
expect(selectIsTablet(mockState)).toBe(false);
expect(selectIsDesktop(mockState)).toBe(true);
expect(selectDeviceType(mockState)).toBe(DeviceType.DESKTOP);
});
});
@@ -60,4 +109,12 @@ describe('deviceSlice', () => {
expect(action.type).toBe('device/updateScreenSize');
});
});
describe('DeviceType constants', () => {
it('should have correct values', () => {
expect(DeviceType.MOBILE).toBe('mobile');
expect(DeviceType.TABLET).toBe('tablet');
expect(DeviceType.DESKTOP).toBe('desktop');
});
});
});

View File

@@ -1,190 +0,0 @@
/**
* deviceSlice 使用示例
*
* 本文件展示如何在 React 组件中使用 deviceSlice 来实现响应式设计
*/
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectIsMobile, updateScreenSize } from '@/store/slices/deviceSlice';
import { Box, Text, VStack } from '@chakra-ui/react';
/**
* 示例 1: 基础使用 - 根据设备类型渲染不同内容
*/
export const BasicUsageExample = () => {
const isMobile = useSelector(selectIsMobile);
return (
<Box>
{isMobile ? (
<Text>📱 移动端视图</Text>
) : (
<Text>💻 桌面端视图</Text>
)}
</Box>
);
};
/**
* 示例 2: 监听窗口尺寸变化 - 动态更新设备状态
*/
export const ResizeListenerExample = () => {
const isMobile = useSelector(selectIsMobile);
const dispatch = useDispatch();
useEffect(() => {
// 监听窗口尺寸变化
const handleResize = () => {
dispatch(updateScreenSize());
};
// 监听屏幕方向变化(移动设备)
const handleOrientationChange = () => {
dispatch(updateScreenSize());
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleOrientationChange);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleOrientationChange);
};
}, [dispatch]);
return (
<VStack>
<Text>当前设备: {isMobile ? '移动设备' : '桌面设备'}</Text>
<Text fontSize="sm" color="gray.500">
试试调整浏览器窗口大小
</Text>
</VStack>
);
};
/**
* 示例 3: 响应式布局 - 根据设备类型调整样式
*/
export const ResponsiveLayoutExample = () => {
const isMobile = useSelector(selectIsMobile);
return (
<Box
p={isMobile ? 4 : 8}
bg={isMobile ? 'blue.50' : 'gray.50'}
borderRadius={isMobile ? 'md' : 'xl'}
maxW={isMobile ? '100%' : '800px'}
mx="auto"
>
<Text fontSize={isMobile ? 'md' : 'lg'}>
响应式内容区域
</Text>
<Text fontSize="sm" color="gray.600" mt={2}>
Padding: {isMobile ? '16px' : '32px'}
</Text>
</Box>
);
};
/**
* 示例 4: 条件渲染组件 - 移动端显示简化版
*/
export const ConditionalRenderExample = () => {
const isMobile = useSelector(selectIsMobile);
return (
<Box>
{isMobile ? (
// 移动端:简化版导航栏
<Box bg="blue.500" p={2}>
<Text color="white" fontSize="sm"> 菜单</Text>
</Box>
) : (
// 桌面端:完整导航栏
<Box bg="blue.500" p={4}>
<Text color="white" fontSize="lg">
首页 | 产品 | 关于我们 | 联系方式
</Text>
</Box>
)}
</Box>
);
};
/**
* 示例 5: 在 App.js 中全局监听(推荐方式)
*
* 将以下代码添加到 src/App.js 中:
*/
export const AppLevelResizeListenerExample = () => {
const dispatch = useDispatch();
useEffect(() => {
const handleResize = () => {
dispatch(updateScreenSize());
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleResize);
// 初始化时也调用一次(可选)
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleResize);
};
}, [dispatch]);
// 返回 null 或组件内容
return null;
};
/**
* 示例 6: 自定义 Hook 封装(推荐)
*
* 在 src/hooks/useDevice.js 中创建自定义 Hook
*/
// import { useSelector } from 'react-redux';
// import { selectIsMobile } from '@/store/slices/deviceSlice';
//
// export const useDevice = () => {
// const isMobile = useSelector(selectIsMobile);
//
// return {
// isMobile,
// isDesktop: !isMobile,
// };
// };
/**
* 使用自定义 Hook
*/
export const CustomHookUsageExample = () => {
// const { isMobile, isDesktop } = useDevice();
return (
<Box>
{/* <Text>移动设备: {isMobile ? '是' : '否'}</Text> */}
{/* <Text>桌面设备: {isDesktop ? '是' : '否'}</Text> */}
</Box>
);
};
/**
* 推荐实践:
*
* 1. 在 App.js 中添加全局 resize 监听器
* 2. 创建自定义 Hook (useDevice) 简化使用
* 3. 结合 Chakra UI 的响应式 Props优先使用 Chakra 内置响应式)
* 4. 仅在需要 JS 逻辑判断时使用 Redux如条件渲染、动态导入
*
* Chakra UI 响应式示例(推荐优先使用):
* <Box
* fontSize={{ base: 'sm', md: 'md', lg: 'lg' }} // Chakra 内置响应式
* p={{ base: 4, md: 6, lg: 8 }}
* >
* 内容
* </Box>
*/

View File

@@ -12,6 +12,19 @@ import {
} from '../../lib/posthog';
import { logger } from '../../utils/logger';
// ⚡ 模块级缓存:存储 reducer 注入前的事件(避免丢失)
let pendingEventsBeforeInjection = [];
/**
* 获取并清空注入前缓存的事件
* 在 App.js 中 reducer 注入后调用
*/
export const flushPendingEventsBeforeInjection = () => {
const events = [...pendingEventsBeforeInjection];
pendingEventsBeforeInjection = [];
return events;
};
// ==================== Initial State ====================
const initialState = {
@@ -51,7 +64,15 @@ export const initializePostHog = createAsyncThunk(
'posthog/initialize',
async (_, { getState, rejectWithValue }) => {
try {
const { config } = getState().posthog;
const posthogState = getState().posthog;
// ⚡ 防御性检查reducer 尚未注入
if (!posthogState) {
logger.warn('PostHog', 'PostHog reducer 尚未注入,跳过初始化');
return { isInitialized: false, skipped: true };
}
const { config } = posthogState;
if (!config.apiKey) {
logger.warn('PostHog', '未配置 API Key分析功能将被禁用');
@@ -112,7 +133,20 @@ export const trackEvent = createAsyncThunk(
'posthog/trackEvent',
async ({ eventName, properties = {} }, { getState, rejectWithValue }) => {
try {
const { isInitialized } = getState().posthog;
const posthogState = getState().posthog;
// ⚡ reducer 尚未注入:缓存到模块级队列(不丢弃)
if (!posthogState) {
logger.debug('PostHog', 'PostHog reducer 尚未注入,事件已缓存', { eventName });
pendingEventsBeforeInjection.push({
eventName,
properties,
timestamp: new Date().toISOString()
});
return { eventName, properties, pendingInjection: true };
}
const { isInitialized } = posthogState;
if (!isInitialized) {
logger.warn('PostHog', 'PostHog 未初始化,事件将被缓存', { eventName });
@@ -160,7 +194,14 @@ export const flushCachedEvents = createAsyncThunk(
'posthog/flushCachedEvents',
async (_, { getState, dispatch }) => {
try {
const { eventQueue, isInitialized } = getState().posthog;
const posthogState = getState().posthog;
// ⚡ 防御性检查reducer 尚未注入
if (!posthogState) {
return { flushed: 0, skipped: true };
}
const { eventQueue, isInitialized } = posthogState;
if (!isInitialized || eventQueue.length === 0) {
return { flushed: 0 };
@@ -281,15 +322,16 @@ export const {
// ==================== 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;
// ⚡ 安全的 selectors支持 reducer 未注入的情况)
export const selectPostHog = (state) => state.posthog || initialState;
export const selectIsInitialized = (state) => state.posthog?.isInitialized ?? false;
export const selectUser = (state) => state.posthog?.user ?? null;
export const selectFeatureFlags = (state) => state.posthog?.featureFlags ?? {};
export const selectEventQueue = (state) => state.posthog?.eventQueue ?? [];
export const selectStats = (state) => state.posthog?.stats ?? initialState.stats;
export const selectFeatureFlag = (flagKey) => (state) => {
return state.posthog.featureFlags[flagKey] || posthogGetFeatureFlag(flagKey);
return state.posthog?.featureFlags?.[flagKey] || posthogGetFeatureFlag(flagKey);
};
export const selectIsOptedOut = () => posthogHasOptedOut();

View File

@@ -7,6 +7,7 @@ import { getApiBase } from '../../utils/apiConfig';
/**
* 异步 Thunk: 获取用户订阅信息
* 使用 condition 选项防止同一时刻多个组件重复发起请求
*/
export const fetchSubscriptionInfo = createAsyncThunk(
'subscription/fetchInfo',
@@ -51,6 +52,21 @@ export const fetchSubscriptionInfo = createAsyncThunk(
logger.error('subscriptionSlice', '加载订阅信息失败', error);
return rejectWithValue(error.message);
}
},
{
// 防止重复请求:如果已加载或正在加载中,则跳过本次请求
condition: (_, { getState }) => {
const { subscription } = getState();
// 如果正在加载或已加载完成,返回 false 阻止请求
if (subscription.loading || subscription.loaded) {
logger.debug('subscriptionSlice', '跳过重复请求', {
loading: subscription.loading,
loaded: subscription.loaded
});
return false;
}
return true;
}
}
);
@@ -71,6 +87,7 @@ const subscriptionSlice = createSlice({
},
// 加载状态
loading: false,
loaded: false, // 是否已加载过(用于防止重复请求)
error: null,
// 订阅 Modal 状态
isModalOpen: false,
@@ -104,8 +121,8 @@ const subscriptionSlice = createSlice({
end_date: null
};
state.loading = false;
state.loaded = false; // 重置已加载标记,下次登录时重新获取
state.error = null;
logger.debug('subscriptionSlice', '重置订阅信息为免费版');
},
},
extraReducers: (builder) => {
@@ -118,6 +135,7 @@ const subscriptionSlice = createSlice({
// fetchSubscriptionInfo - fulfilled
.addCase(fetchSubscriptionInfo.fulfilled, (state, action) => {
state.loading = false;
state.loaded = true; // 标记已加载
state.info = action.payload;
state.error = null;
})
@@ -136,6 +154,7 @@ export const { openModal, closeModal, resetToFree } = subscriptionSlice.actions;
// 导出 selectors
export const selectSubscriptionInfo = (state) => state.subscription.info;
export const selectSubscriptionLoading = (state) => state.subscription.loading;
export const selectSubscriptionLoaded = (state) => state.subscription.loaded;
export const selectSubscriptionError = (state) => state.subscription.error;
export const selectSubscriptionModalOpen = (state) => state.subscription.isModalOpen;

View File

@@ -1,12 +0,0 @@
/* Tailwind CSS 入口文件 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 自定义工具类 */
@layer utilities {
/* 毛玻璃效果 */
.backdrop-blur-xl {
backdrop-filter: blur(24px);
}
}

View File

@@ -36,3 +36,37 @@ iframe[src*="/visitor/"] {
[class*="bytedesk-badge"] {
z-index: 1000000 !important;
}
/* ========== H5 端客服组件整体缩小 ========== */
@media (max-width: 768px) {
/* 整个客服容器缩小(包括按钮和提示框) */
[class*="bytedesk"],
[id*="bytedesk"],
[class*="BytedeskWeb"] {
transform: scale(0.7) !important;
transform-origin: bottom right !important;
}
}
/* ========== 提示框 3 秒后自动消失 ========== */
/* 提示框("在线客服 点击咨询"气泡)- 扩展选择器 */
[class*="bytedesk-bubble"],
[class*="bytedesk-tooltip"],
[class*="BytedeskWeb"] [class*="bubble"],
[class*="BytedeskWeb"] [class*="tooltip"],
[class*="bytedesk"] > div:not(button):not(iframe),
[class*="BytedeskWeb"] > div:not(button):not(iframe),
[id*="bytedesk"] > div:not(button):not(iframe) {
animation: bytedeskFadeOut 0.3s ease-out 3s forwards !important;
}
@keyframes bytedeskFadeOut {
from {
opacity: 1;
visibility: visible;
}
to {
opacity: 0;
visibility: hidden;
}
}

44
src/types/static-assets.d.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
/**
* 静态资源模块声明
* 允许 TypeScript 正确处理静态资源导入
*/
declare module '*.png' {
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
}
declare module '*.jpeg' {
const content: string;
export default content;
}
declare module '*.gif' {
const content: string;
export default content;
}
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.webp' {
const content: string;
export default content;
}
declare module '*.ico' {
const content: string;
export default content;
}
declare module '*.bmp' {
const content: string;
export default content;
}

View File

@@ -7,6 +7,34 @@ const isDevelopment =
process.env.NODE_ENV === 'development' ||
process.env.REACT_APP_ENABLE_DEBUG === 'true';
// ========== 日志级别配置 ==========
// 日志级别error < warn < info < debug
// 默认级别warn只显示警告和错误
// 可通过 localStorage.setItem('LOG_LEVEL', 'debug') 开启详细日志
const LOG_LEVELS = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
// 从 localStorage 读取日志级别(允许用户临时开启详细日志)
const getLogLevel = () => {
if (typeof window !== 'undefined' && window.localStorage) {
const level = localStorage.getItem('LOG_LEVEL');
if (level && LOG_LEVELS[level] !== undefined) {
return LOG_LEVELS[level];
}
}
// 默认只显示 warn 和 error
return LOG_LEVELS.warn;
};
// 检查是否应该输出指定级别的日志
const shouldLogLevel = (level) => {
return LOG_LEVELS[level] <= getLogLevel();
};
// ========== 日志限流配置 ==========
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次
const recentLogs = new Map(); // 日志缓存,用于去重
@@ -148,13 +176,13 @@ export const logger = {
},
/**
* 调试日志(仅开发环境)
* 调试日志(仅开发环境 + LOG_LEVEL=debug
* @param {string} component - 组件名称
* @param {string} message - 调试信息
* @param {object} data - 相关数据(可选)
*/
debug: (component, message, data = {}) => {
if (isDevelopment && shouldLog(component, message)) {
if (isDevelopment && shouldLogLevel('debug') && shouldLog(component, message)) {
console.group(`🐛 Debug: ${component}`);
console.log('Message:', message);
if (Object.keys(data).length > 0) {
@@ -166,13 +194,13 @@ export const logger = {
},
/**
* 信息日志(仅开发环境)
* 信息日志(仅开发环境 + LOG_LEVEL>=info
* @param {string} component - 组件名称
* @param {string} message - 信息内容
* @param {object} data - 相关数据(可选)
*/
info: (component, message, data = {}) => {
if (isDevelopment && shouldLog(component, message)) {
if (isDevelopment && shouldLogLevel('info') && shouldLog(component, message)) {
console.group(` Info: ${component}`);
console.log('Message:', message);
if (Object.keys(data).length > 0) {
@@ -181,6 +209,28 @@ export const logger = {
console.log('Timestamp:', new Date().toISOString());
console.groupEnd();
}
},
/**
* 设置日志级别(方便调试)
* @param {string} level - 日志级别 ('error' | 'warn' | 'info' | 'debug')
*/
setLevel: (level) => {
if (LOG_LEVELS[level] !== undefined) {
localStorage.setItem('LOG_LEVEL', level);
console.log(`[Logger] 日志级别已设置为: ${level}`);
console.log(`[Logger] 可用级别: error < warn < info < debug`);
} else {
console.error(`[Logger] 无效的日志级别: ${level}`);
}
},
/**
* 获取当前日志级别
*/
getLevel: () => {
const levelNum = getLogLevel();
return Object.keys(LOG_LEVELS).find(key => LOG_LEVELS[key] === levelNum) || 'warn';
}
};

View File

@@ -58,11 +58,6 @@ const performanceMeasures: Array<{ name: string; duration: number; startMark: st
*/
class PerformanceMonitor {
private metrics: PerformanceMetrics = {};
private isProduction: boolean;
constructor() {
this.isProduction = process.env.NODE_ENV === 'production';
}
/**
* 标记性能时间点
@@ -70,12 +65,6 @@ class PerformanceMonitor {
mark(name: string): void {
const timestamp = performance.now();
performanceMarks.set(name, timestamp);
if (!this.isProduction) {
logger.debug('PerformanceMonitor', `⏱️ Mark: ${name}`, {
time: `${timestamp.toFixed(2)}ms`
});
}
}
/**
@@ -106,12 +95,6 @@ class PerformanceMonitor {
endMark
});
if (!this.isProduction) {
logger.debug('PerformanceMonitor', `📊 Measure: ${measureName}`, {
duration: `${duration.toFixed(2)}ms`
});
}
return duration;
}

View File

@@ -1,8 +1,8 @@
// src/views/AgentChat/components/ChatArea/ExecutionStepsDisplay.js
// 执行步骤显示组件
import React from 'react';
import { motion } from 'framer-motion';
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Accordion,
AccordionItem,
@@ -16,8 +16,328 @@ import {
VStack,
Flex,
Text,
Box,
Code,
IconButton,
Tooltip,
Collapse,
} from '@chakra-ui/react';
import { Activity } from 'lucide-react';
import { Activity, ChevronDown, ChevronRight, Copy, Check, Database, FileJson } from 'lucide-react';
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
/**
* 格式化结果数据用于显示
*/
const formatResultData = (data) => {
if (data === null || data === undefined) return null;
if (typeof data === 'string') return data;
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
};
/**
* 获取结果数据的预览文本
*/
const getResultPreview = (result) => {
if (!result) return '无数据';
// 如果有 data 字段
if (result.data) {
const data = result.data;
// 检查常见的数据结构
if (data.chart_data) {
return `图表数据: ${data.chart_data.labels?.length || 0}`;
}
if (data.sector_data) {
const sectorCount = Object.keys(data.sector_data).length;
return `${sectorCount} 个板块分析`;
}
if (data.stocks) {
return `${data.stocks.length} 只股票`;
}
if (Array.isArray(data)) {
return `${data.length} 条记录`;
}
if (data.date || data.formatted_date) {
return `日期: ${data.formatted_date || data.date}`;
}
}
// 如果结果本身是数组
if (Array.isArray(result)) {
return `${result.length} 条记录`;
}
// 如果是对象,返回键数量
if (typeof result === 'object') {
const keys = Object.keys(result);
return `${keys.length} 个字段`;
}
return '查看详情';
};
/**
* 单个步骤卡片组件
*/
const StepCard = ({ result, idx }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const hasResult = result.result && (
typeof result.result === 'object'
? Object.keys(result.result).length > 0
: result.result
);
const handleCopy = async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(formatResultData(result.result));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('复制失败:', err);
}
};
// 渲染结果数据
const renderResultData = () => {
if (!result.result) return null;
const data = result.result;
// 如果有 echarts 图表数据,尝试生成图表
if (data.data?.chart_data) {
const chartData = data.data.chart_data;
// 验证图表数据是否有效
const hasValidChartData = chartData.labels?.length > 0 && chartData.counts?.length > 0;
return (
<Box mt={3}>
{hasValidChartData ? (
(() => {
const echartsConfig = {
title: { text: `${data.data.formatted_date || ''} 涨停概念分布` },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
data: chartData.labels,
axisLabel: { rotate: 30, fontSize: 10 },
},
yAxis: { type: 'value' },
series: [
{
name: '涨停家数',
type: 'bar',
data: chartData.counts,
itemStyle: {
color: {
type: 'linear',
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: '#ff7043' },
{ offset: 1, color: '#ff5722' },
],
},
},
},
],
};
const markdownContent = `\`\`\`echarts
${JSON.stringify(echartsConfig)}
\`\`\``;
return <MarkdownWithCharts content={markdownContent} variant="dark" />;
})()
) : (
<Text fontSize="xs" color="gray.500">暂无图表数据</Text>
)}
{/* 板块详情 */}
{data.data?.sector_data && (
<Box mt={3}>
<Text fontSize="xs" color="gray.400" mb={2}>
板块详情 ({Object.keys(data.data.sector_data).length} 个板块)
</Text>
<Box
maxH="300px"
overflowY="auto"
fontSize="xs"
sx={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { bg: 'transparent' },
'&::-webkit-scrollbar-thumb': { bg: 'gray.600', borderRadius: 'full' },
}}
>
{Object.entries(data.data.sector_data).map(([sector, info]) => (
<Box
key={sector}
mb={2}
p={2}
bg="rgba(255, 255, 255, 0.02)"
borderRadius="md"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.05)"
>
<HStack justify="space-between" mb={1}>
<Badge colorScheme="purple" fontSize="xs">{sector}</Badge>
<Text color="gray.500">{info.count} </Text>
</HStack>
{info.stocks?.slice(0, 3).map((stock, i) => (
<Box key={i} mt={1} pl={2} borderLeft="2px solid" borderColor="purple.500">
<Text color="gray.300" fontWeight="medium">
{stock.sname} ({stock.scode})
</Text>
{stock.brief && (
<Text color="gray.500" fontSize="xs" noOfLines={2}>
{stock.brief.replace(/<br>/g, ' ')}
</Text>
)}
</Box>
))}
{info.stocks?.length > 3 && (
<Text color="gray.600" fontSize="xs" mt={1}>
还有 {info.stocks.length - 3} ...
</Text>
)}
</Box>
))}
</Box>
</Box>
)}
</Box>
);
}
// 默认显示 JSON 数据
return (
<Box mt={3}>
<Code
display="block"
p={3}
borderRadius="md"
fontSize="xs"
whiteSpace="pre-wrap"
bg="rgba(0, 0, 0, 0.3)"
color="gray.300"
maxH="300px"
overflowY="auto"
sx={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { bg: 'transparent' },
'&::-webkit-scrollbar-thumb': { bg: 'gray.600', borderRadius: 'full' },
}}
>
{formatResultData(data)}
</Code>
</Box>
);
};
return (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card
bg="rgba(255, 255, 255, 0.03)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor={isExpanded ? 'rgba(192, 132, 252, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
transition="all 0.2s"
_hover={{
borderColor: 'rgba(192, 132, 252, 0.2)',
}}
>
<CardBody p={3}>
{/* 步骤头部 - 可点击展开 */}
<Flex
align="center"
justify="space-between"
gap={2}
cursor={hasResult ? 'pointer' : 'default'}
onClick={() => hasResult && setIsExpanded(!isExpanded)}
>
<HStack flex={1} spacing={2}>
{hasResult && (
<Box color="gray.500" transition="transform 0.2s" transform={isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'}>
<ChevronRight className="w-3 h-3" />
</Box>
)}
<Text fontSize="xs" fontWeight="medium" color="gray.300">
步骤 {idx + 1}: {result.tool_name || result.tool}
</Text>
</HStack>
<HStack spacing={2}>
{hasResult && (
<Tooltip label={copied ? '已复制' : '复制数据'} placement="top">
<IconButton
size="xs"
variant="ghost"
icon={copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
onClick={handleCopy}
color={copied ? 'green.400' : 'gray.500'}
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
aria-label="复制"
/>
</Tooltip>
)}
<Badge
bgGradient={
result.status === 'success'
? 'linear(to-r, green.500, teal.500)'
: 'linear(to-r, red.500, orange.500)'
}
color="white"
variant="subtle"
boxShadow={
result.status === 'success'
? '0 2px 8px rgba(16, 185, 129, 0.3)'
: '0 2px 8px rgba(239, 68, 68, 0.3)'
}
>
{result.status}
</Badge>
</HStack>
</Flex>
{/* 步骤元信息 */}
<HStack mt={1} spacing={3} fontSize="xs" color="gray.500">
{result.execution_time && (
<Text> {result.execution_time.toFixed(2)}s</Text>
)}
{hasResult && (
<HStack spacing={1}>
<Database className="w-3 h-3" />
<Text>{getResultPreview(result.result)}</Text>
</HStack>
)}
</HStack>
{/* 错误信息 */}
{result.error && (
<Text fontSize="xs" color="red.400" mt={1}>
{result.error}
</Text>
)}
{/* 展开的详细数据 */}
<Collapse in={isExpanded} animateOpacity>
{isExpanded && renderResultData()}
</Collapse>
</CardBody>
</Card>
</motion.div>
);
};
/**
* ExecutionStepsDisplay - 执行步骤显示组件
@@ -61,51 +381,7 @@ const ExecutionStepsDisplay = ({ steps, plan }) => {
<AccordionPanel pb={4}>
<VStack spacing={2} align="stretch">
{steps.map((result, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<Card
bg="rgba(255, 255, 255, 0.03)"
backdropFilter="blur(10px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
<CardBody p={3}>
<Flex align="start" justify="space-between" gap={2}>
<Text fontSize="xs" fontWeight="medium" color="gray.300">
步骤 {idx + 1}: {result.tool_name}
</Text>
<Badge
bgGradient={
result.status === 'success'
? 'linear(to-r, green.500, teal.500)'
: 'linear(to-r, red.500, orange.500)'
}
color="white"
variant="subtle"
boxShadow={
result.status === 'success'
? '0 2px 8px rgba(16, 185, 129, 0.3)'
: '0 2px 8px rgba(239, 68, 68, 0.3)'
}
>
{result.status}
</Badge>
</Flex>
<Text fontSize="xs" color="gray.500" mt={1}>
{result.execution_time?.toFixed(2)}s
</Text>
{result.error && (
<Text fontSize="xs" color="red.400" mt={1}>
{result.error}
</Text>
)}
</CardBody>
</Card>
</motion.div>
<StepCard key={idx} result={result} idx={idx} />
))}
</VStack>
</AccordionPanel>

View File

@@ -19,6 +19,7 @@ import {
import { Cpu, User, Copy, ThumbsUp, ThumbsDown, File } from 'lucide-react';
import { MessageTypes } from '../../constants/messageTypes';
import ExecutionStepsDisplay from './ExecutionStepsDisplay';
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
/**
* MessageRenderer - 消息渲染器组件
@@ -83,6 +84,7 @@ const MessageRenderer = ({ message, userAvatar }) => {
<Flex justify="flex-start">
<HStack align="start" spacing={3} maxW="75%">
<Avatar
src="/images/agent/基金经理.png"
icon={<Cpu className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, purple.500, pink.500)"
@@ -118,18 +120,21 @@ const MessageRenderer = ({ message, userAvatar }) => {
case MessageTypes.AGENT_RESPONSE:
return (
<Flex justify="flex-start">
<HStack align="start" spacing={3} maxW="75%">
<Flex justify="flex-start" w="100%">
<HStack align="start" spacing={3} maxW={{ base: '95%', md: '85%', lg: '80%' }} w="100%">
<Avatar
src="/images/agent/基金经理.png"
icon={<Cpu className="w-4 h-4" />}
size="sm"
bgGradient="linear(to-br, purple.500, pink.500)"
boxShadow="0 0 12px rgba(236, 72, 153, 0.4)"
flexShrink={0}
/>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{ flex: 1, minWidth: 0 }}
>
<Card
bg="rgba(255, 255, 255, 0.05)"
@@ -137,11 +142,26 @@ const MessageRenderer = ({ message, userAvatar }) => {
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
w="100%"
>
<CardBody px={5} py={3}>
<Text fontSize="sm" color="gray.100" whiteSpace="pre-wrap" lineHeight="relaxed">
{message.content}
</Text>
<Box
fontSize="sm"
color="gray.100"
lineHeight="relaxed"
w="100%"
overflow="hidden"
sx={{
'& p': { mb: 2 },
'& h1, & h2, & h3': { color: 'gray.50' },
'& ul, & ol': { pl: 4 },
'& li': { mb: 1 },
'& code': { bg: 'rgba(255,255,255,0.1)', px: 1, borderRadius: 'sm' },
'& blockquote': { borderLeftColor: 'purple.400', color: 'gray.300' },
}}
>
<MarkdownWithCharts content={message.content} variant="dark" />
</Box>
{message.stepResults && message.stepResults.length > 0 && (
<Box mt={3}>

View File

@@ -0,0 +1,310 @@
// src/views/AgentChat/components/ChatArea/WelcomeScreen.js
// 欢迎界面组件 - 类似 Gemini/ChatGPT 风格
import React from 'react';
import { motion } from 'framer-motion';
import {
Box,
VStack,
HStack,
Text,
SimpleGrid,
Icon,
} from '@chakra-ui/react';
import {
Cpu,
TrendingUp,
BarChart3,
Newspaper,
Target,
Lightbulb,
Search,
PieChart,
Sparkles,
} from 'lucide-react';
/**
* 建议任务卡片数据
*/
const SUGGESTION_CARDS = [
{
icon: TrendingUp,
title: '今日涨停分析',
description: '分析今天涨停板的概念分布和热点板块',
prompt: '分析一下今天的涨停板,有哪些热点概念?',
gradient: 'linear(to-br, orange.400, red.500)',
shadowColor: 'rgba(251, 146, 60, 0.3)',
},
{
icon: Search,
title: '个股深度研究',
description: '全面分析某只股票的基本面和技术面',
prompt: '帮我分析一下贵州茅台的投资价值',
gradient: 'linear(to-br, blue.400, cyan.500)',
shadowColor: 'rgba(59, 130, 246, 0.3)',
},
{
icon: BarChart3,
title: '板块轮动追踪',
description: '追踪近期市场板块轮动和资金流向',
prompt: '最近有哪些板块在轮动?资金流向如何?',
gradient: 'linear(to-br, purple.400, pink.500)',
shadowColor: 'rgba(168, 85, 247, 0.3)',
},
{
icon: Newspaper,
title: '财经热点解读',
description: '解读最新的财经新闻和政策影响',
prompt: '今天有什么重要的财经新闻?对市场有什么影响?',
gradient: 'linear(to-br, green.400, teal.500)',
shadowColor: 'rgba(34, 197, 94, 0.3)',
},
];
/**
* 能力标签
*/
const CAPABILITIES = [
{ icon: Target, text: '精准股票分析' },
{ icon: Lightbulb, text: '智能投资建议' },
{ icon: PieChart, text: '数据可视化' },
{ icon: Sparkles, text: '实时热点追踪' },
];
/**
* 建议卡片组件
*/
const SuggestionCard = ({ card, onClick, index }) => {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 + index * 0.1, duration: 0.4 }}
whileHover={{ scale: 1.02, y: -4 }}
whileTap={{ scale: 0.98 }}
>
<Box
as="button"
w="full"
p={4}
bg="rgba(255, 255, 255, 0.03)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.08)"
borderRadius="xl"
textAlign="left"
cursor="pointer"
transition="all 0.3s"
onClick={() => onClick(card.prompt)}
_hover={{
bg: 'rgba(255, 255, 255, 0.06)',
borderColor: 'rgba(255, 255, 255, 0.15)',
boxShadow: `0 8px 32px ${card.shadowColor}`,
}}
>
<HStack spacing={3} mb={2}>
<Box
p={2}
borderRadius="lg"
bgGradient={card.gradient}
boxShadow={`0 4px 12px ${card.shadowColor}`}
>
<Icon as={card.icon} w={4} h={4} color="white" />
</Box>
<Text fontWeight="semibold" color="gray.200" fontSize="sm">
{card.title}
</Text>
</HStack>
<Text fontSize="xs" color="gray.500" lineHeight="tall">
{card.description}
</Text>
</Box>
</motion.div>
);
};
/**
* WelcomeScreen - 欢迎界面组件
*
* @param {Object} props
* @param {Function} props.onSuggestionClick - 点击建议时的回调
* @returns {JSX.Element}
*/
const WelcomeScreen = ({ onSuggestionClick }) => {
return (
<Box
flex={1}
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
px={6}
py={8}
>
<VStack spacing={8} maxW="700px" w="full">
{/* Logo 和标题 */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
>
<VStack spacing={4}>
{/* 动态 Logo */}
<Box position="relative">
{/* 外层光晕 */}
<motion.div
animate={{
boxShadow: [
'0 0 30px rgba(139, 92, 246, 0.3)',
'0 0 60px rgba(139, 92, 246, 0.5)',
'0 0 30px rgba(139, 92, 246, 0.3)',
],
}}
transition={{ duration: 2, repeat: Infinity }}
style={{
position: 'absolute',
inset: -8,
borderRadius: '50%',
}}
/>
{/* 旋转边框 */}
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 8, repeat: Infinity, ease: 'linear' }}
style={{
position: 'absolute',
inset: -4,
borderRadius: '50%',
background: 'linear-gradient(45deg, transparent 40%, rgba(139, 92, 246, 0.5) 50%, transparent 60%)',
}}
/>
<Box
w={20}
h={20}
borderRadius="full"
bgGradient="linear(to-br, purple.500, pink.500, blue.500)"
display="flex"
alignItems="center"
justifyContent="center"
position="relative"
boxShadow="0 8px 32px rgba(139, 92, 246, 0.4)"
>
<Cpu className="w-10 h-10" color="white" />
</Box>
</Box>
{/* 标题 */}
<VStack spacing={1}>
<Text
fontSize="3xl"
fontWeight="bold"
bgGradient="linear(to-r, blue.300, purple.400, pink.400)"
bgClip="text"
letterSpacing="tight"
>
你好我是价小前
</Text>
<Text fontSize="md" color="gray.400" textAlign="center">
你的 AI 投研助手
</Text>
</VStack>
</VStack>
</motion.div>
{/* 简介 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.4 }}
>
<Box
p={4}
bg="rgba(255, 255, 255, 0.02)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.05)"
>
<Text fontSize="sm" color="gray.400" textAlign="center" lineHeight="tall">
基于最先进的大语言模型结合专业微调的金融理解能力
<br />
整合实时投研数据与专业分析工具为你提供智能投资研究服务
</Text>
</Box>
</motion.div>
{/* 能力标签 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3, duration: 0.4 }}
>
<HStack spacing={4} flexWrap="wrap" justify="center">
{CAPABILITIES.map((cap, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 + idx * 0.05 }}
>
<HStack
spacing={2}
px={3}
py={1.5}
bg="rgba(139, 92, 246, 0.1)"
borderRadius="full"
border="1px solid"
borderColor="rgba(139, 92, 246, 0.2)"
>
<Icon as={cap.icon} w={3.5} h={3.5} color="purple.400" />
<Text fontSize="xs" color="purple.300" fontWeight="medium">
{cap.text}
</Text>
</HStack>
</motion.div>
))}
</HStack>
</motion.div>
{/* 建议任务卡片 */}
<Box w="full">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
<HStack spacing={2} mb={4} justify="center">
<Sparkles className="w-4 h-4" color="#a78bfa" />
<Text fontSize="sm" color="gray.400" fontWeight="medium">
试试这些问题
</Text>
</HStack>
</motion.div>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={3}>
{SUGGESTION_CARDS.map((card, idx) => (
<SuggestionCard
key={idx}
card={card}
index={idx}
onClick={onSuggestionClick}
/>
))}
</SimpleGrid>
</Box>
{/* 底部提示 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
>
<Text fontSize="xs" color="gray.600" textAlign="center">
输入你的问题或点击上方卡片快速开始
</Text>
</motion.div>
</VStack>
</Box>
);
};
export default WelcomeScreen;

Some files were not shown because too many files have changed in this diff Show More