Compare commits

..

130 Commits

Author SHA1 Message Date
zdl
cdca889083 fix: 去除个股中心动画,添加mock数据 2025-12-03 17:28:23 +08:00
zdl
c0d8bf20a3 feat: 首页代码优化 2025-12-03 17:15:48 +08:00
zdl
662d140439 feat: 添加mock数据 2025-12-03 15:56:24 +08:00
c136c2aed8 update pay ui 2025-12-03 15:19:23 +08:00
ea1adcb2ca update pay ui 2025-12-03 15:05:41 +08:00
43f32c5af2 update pay ui 2025-12-03 14:28:33 +08:00
6c69ad407d update pay ui 2025-12-03 14:12:14 +08:00
2e7ed4b899 update pay ui 2025-12-03 13:57:38 +08:00
be496290bb update pay ui 2025-12-03 13:51:48 +08:00
51ed56726c update pay ui 2025-12-03 13:43:55 +08:00
9a6230e51e update pay ui 2025-12-03 13:06:23 +08:00
5042d1ee46 update pay ui 2025-12-03 12:52:27 +08:00
01d0a06f6a update pay ui 2025-12-03 12:47:32 +08:00
dd975a65b2 update pay ui 2025-12-03 12:39:59 +08:00
ae9904cd03 update pay ui 2025-12-03 12:22:27 +08:00
368af3f498 update pay ui 2025-12-03 10:45:33 +08:00
03d0a6514c update pay ui 2025-12-03 10:30:49 +08:00
f7f9774caa fix: 恢复原有涨跌幅样式,将周涨幅改为超预期得分
- 恢复HorizontalDynamicNewsEventCard使用StockChangeIndicators组件
- 修改StockChangeIndicators:周涨幅→超预期得分,平均涨幅→平均超额,最大涨幅→最大超额
- 超预期得分显示为分数形式(如60分),根据分数显示不同颜色
2025-12-03 08:38:17 +08:00
1f592b6775 fix: 修复相关股票默认展开和添加超预期得分显示
- 修复事件切换时相关股票被设为折叠的问题,改为默认展开
- 在事件详情面板中添加超预期得分显示(带进度条和配色)
- 超预期得分显示在事件描述下方、相关股票上方
2025-12-03 08:34:41 +08:00
2f580c3c1f fix: 修复Community页面事件卡片显示,替换StockChangeIndicators为EventPriceDisplay
- HorizontalDynamicNewsEventCard 使用 EventPriceDisplay 替换 StockChangeIndicators
- 移除周涨幅、平均涨幅,改为显示最大超额和超预期得分
- 点击最大超额可切换显示平均超额
2025-12-03 08:29:21 +08:00
259b298ea6 update pay ui 2025-12-03 08:24:37 +08:00
5ff68d0790 update pay ui 2025-12-03 08:02:49 +08:00
a14313fdbd update pay ui 2025-12-03 07:26:12 +08:00
4ba6fd34ff update pay ui 2025-12-02 19:44:46 +08:00
642de62566 update pay ui 2025-12-02 18:55:59 +08:00
4ea1ef08f4 update pay ui 2025-12-02 18:50:01 +08:00
2b3700369f update pay ui 2025-12-02 17:55:01 +08:00
f60c6a8ae9 update pay ui 2025-12-02 17:36:35 +08:00
f24f37c50d update pay ui 2025-12-02 17:30:52 +08:00
zdl
0dfbac7248 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  feat: 修复 pc 客服弹窗UI展示问题
2025-12-02 16:10:54 +08:00
zdl
143933b480 feat: 修复 pc 客服弹窗UI展示问题 2025-12-02 16:07:41 +08:00
06beeeaee4 update pay ui 2025-12-02 14:30:27 +08:00
d1a222d9e9 update pay ui 2025-12-02 12:22:49 +08:00
bd86ccce85 update pay ui 2025-12-02 12:01:59 +08:00
ed14031d65 update pay ui 2025-12-02 11:07:45 +08:00
9b16d9d162 update pay ui 2025-12-02 10:49:50 +08:00
7708cb1a69 update pay ui 2025-12-02 10:33:55 +08:00
2395d92b17 update pay ui 2025-12-02 08:07:46 +08:00
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
144 changed files with 20469 additions and 3801 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

977
app.py

File diff suppressed because it is too large Load Diff

1024
app_vx.py

File diff suppressed because it is too large Load Diff

1096
concept_api_openapi.json Normal file

File diff suppressed because it is too large Load Diff

1176
concept_hierarchy.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

@@ -6,6 +6,8 @@ Flask-Compress==1.14
Flask-SocketIO==5.3.6
Flask-Mail==0.9.1
Flask-Migrate==4.0.5
Flask-Session==0.5.0
redis==5.0.1
pandas==2.0.3
numpy==1.24.3
requests==2.31.0

View File

@@ -27,19 +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 初始化和渲染路由
@@ -53,15 +100,45 @@ function AppContent() {
const pageEnterTimeRef = useRef(Date.now());
const currentPathRef = useRef(location.pathname);
// 🎯 PostHog Redux 初始化(延迟执行,不阻塞首屏)
// 🎯 PostHog 空闲时加载 + Redux 初始化(首屏不加载 ~180KB
useEffect(() => {
// ⚡ 延迟 2 秒初始化 PostHog确保首屏渲染不被阻塞
const timer = setTimeout(() => {
dispatch(initializePostHog());
logger.info('App', 'PostHog Redux 初始化已触发(延迟 2 秒)');
}, 2000);
const initPostHogRedux = async () => {
try {
const modules = await loadPostHogModules();
if (!modules) return;
return () => clearTimeout(timer);
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 初始化完成
@@ -90,22 +167,31 @@ function AppContent() {
};
}, [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');
}
@@ -120,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

@@ -35,6 +35,13 @@ export const bytedeskConfig = {
subtitle: '点击咨询', // 副标题
},
// 按钮大小配置
buttonConfig: {
show: true,
width: 40,
height: 40,
},
// 主题配置
theme: {
mode: 'system', // light | dark | system

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -18,7 +18,6 @@ import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-
import { FaCrown } from 'react-icons/fa';
import { useNavigate } from 'react-router-dom';
import UserAvatar from './UserAvatar';
import SubscriptionModal from '../../../Subscription/SubscriptionModal';
import { useSubscription } from '../../../../hooks/useSubscription';
/**
@@ -38,12 +37,7 @@ const TabletUserMenu = memo(({
followingEvents
}) => {
const navigate = useNavigate();
const {
subscriptionInfo,
isSubscriptionModalOpen,
openSubscriptionModal,
closeSubscriptionModal
} = useSubscription();
const { subscriptionInfo } = useSubscription();
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -90,8 +84,8 @@ const TabletUserMenu = memo(({
)}
</Box>
{/* 订阅管理 */}
<MenuItem icon={<FaCrown />} onClick={openSubscriptionModal}>
{/* 订阅管理 - 移动端导航到订阅页面 */}
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
<Flex justify="space-between" align="center" w="100%">
<Text>订阅管理</Text>
<Badge colorScheme={getSubscriptionBadgeColor()}>
@@ -149,14 +143,6 @@ const TabletUserMenu = memo(({
</MenuList>
</Menu>
{/* 订阅弹窗 */}
{isSubscriptionModalOpen && (
<SubscriptionModal
isOpen={isSubscriptionModalOpen}
onClose={closeSubscriptionModal}
subscriptionInfo={subscriptionInfo}
/>
)}
</>
);
});

View File

@@ -100,7 +100,7 @@ export const PerformancePanel: React.FC = () => {
aria-label="Open performance panel"
icon={<MdSpeed />}
position="fixed"
bottom="20px"
bottom="100px"
right="20px"
colorScheme="blue"
size="lg"

View File

@@ -7,17 +7,17 @@ import { TriangleUpIcon, TriangleDownIcon } from '@chakra-ui/icons';
import { getChangeColor } from '../utils/colorUtils';
/**
* 股票涨跌幅指标组件3分天下布局
* 股票涨跌幅指标组件3个指标:平均超额、最大超额、超预期得分)
* @param {Object} props
* @param {number} props.avgChange - 平均涨
* @param {number} props.maxChange - 最大涨
* @param {number} props.weekChange - 周涨跌幅
* @param {number} props.avgChange - 平均超额涨幅
* @param {number} props.maxChange - 最大超额涨幅
* @param {number} props.expectationScore - 超预期得分0-100
* @param {'default'|'comfortable'|'large'} props.size - 尺寸模式default=紧凑comfortable=舒适事件列表large=大卡片(详情面板)
*/
const StockChangeIndicators = ({
avgChange,
maxChange,
weekChange,
expectationScore,
size = 'default',
}) => {
const isLarge = size === 'large';
@@ -99,7 +99,7 @@ const StockChangeIndicators = ({
{/* Large 和 Default 模式:标签单独一行 */}
{(isLarge || isDefault) && (
<Text fontSize={isLarge ? "sm" : "xs"} color={labelColor} fontWeight="medium">
{label.trim()}
{label}
</Text>
)}
@@ -135,7 +135,7 @@ const StockChangeIndicators = ({
{/* Comfortable 模式:标签和数字在同一行 */}
{!isLarge && !isDefault && (
<Text as="span" color={labelColor} fontWeight="medium" fontSize="sm">
{label}
{label}{' '}
</Text>
)}
{sign}{numStr}
@@ -146,16 +146,92 @@ const StockChangeIndicators = ({
);
};
// 渲染超预期得分指标(特殊样式,分数而非百分比)
const renderScoreIndicator = (label, score) => {
if (score == null) return null;
const labelColor = useColorModeValue('gray.600', 'gray.400');
// 根据分数确定颜色:>=60红色>=40橙色>=20蓝色其他灰色
const getScoreColor = (s) => {
if (s >= 60) return useColorModeValue('red.600', 'red.400');
if (s >= 40) return useColorModeValue('orange.600', 'orange.400');
if (s >= 20) return useColorModeValue('blue.600', 'blue.400');
return useColorModeValue('gray.600', 'gray.400');
};
const getScoreBgColor = (s) => {
if (s >= 60) return useColorModeValue('red.50', 'red.900');
if (s >= 40) return useColorModeValue('orange.50', 'orange.900');
if (s >= 20) return useColorModeValue('blue.50', 'blue.900');
return useColorModeValue('gray.50', 'gray.800');
};
const getScoreBorderColor = (s) => {
if (s >= 60) return useColorModeValue('red.200', 'red.700');
if (s >= 40) return useColorModeValue('orange.200', 'orange.700');
if (s >= 20) return useColorModeValue('blue.200', 'blue.700');
return useColorModeValue('gray.200', 'gray.700');
};
const scoreColor = getScoreColor(score);
const bgColor = getScoreBgColor(score);
const borderColor = getScoreBorderColor(score);
return (
<Box
bg={bgColor}
borderWidth={isLarge ? "2px" : "1px"}
borderColor={borderColor}
borderRadius="md"
px={isLarge ? 4 : (isDefault ? 1.5 : (isComfortable ? 3 : 2))}
py={isLarge ? 3 : (isDefault ? 1.5 : (isComfortable ? 2 : 1))}
display="flex"
flexDirection={(isLarge || isDefault) ? "column" : "row"}
alignItems={(isLarge || isDefault) ? "flex-start" : "center"}
gap={(isLarge || isDefault) ? (isLarge ? 2 : 1) : 1}
maxW={isLarge ? "200px" : "none"}
flex="0 1 auto"
minW={isDefault ? "58px" : "0"}
>
{/* Large 和 Default 模式:标签单独一行 */}
{(isLarge || isDefault) && (
<Text fontSize={isLarge ? "sm" : "xs"} color={labelColor} fontWeight="medium">
{label}
</Text>
)}
{/* 数值 */}
<Text
fontSize={isLarge ? "2xl" : (isDefault ? "md" : "lg")}
fontWeight="bold"
color={scoreColor}
lineHeight="1.2"
whiteSpace="nowrap"
>
{/* Comfortable 模式:标签和数字在同一行 */}
{!isLarge && !isDefault && (
<Text as="span" color={labelColor} fontWeight="medium" fontSize="sm">
{label}
</Text>
)}
{Math.round(score)}
<Text as="span" fontWeight="medium" fontSize="sm"></Text>
</Text>
</Box>
);
};
// 如果没有任何数据,不渲染
if (avgChange == null && maxChange == null && weekChange == null) {
if (avgChange == null && maxChange == null && expectationScore == null) {
return null;
}
return (
<Flex width="100%" justify="flex-start" align="center" gap={isLarge ? 4 : (isDefault ? 2 : 1)}>
{renderIndicator('平均涨幅', avgChange)}
{renderIndicator('最大涨幅', maxChange)}
{renderIndicator('周涨幅', weekChange)}
{renderIndicator('平均超额', avgChange)}
{renderIndicator('最大超额', maxChange)}
{renderScoreIndicator('超预期', expectationScore)}
</Flex>
);
};

View File

@@ -1,7 +1,8 @@
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { stockService } from '@services/eventService';
/**
@@ -40,6 +41,31 @@ interface KLineDataPoint {
volume: number;
}
/**
* 批量K线API响应
*/
interface BatchKlineResponse {
success: boolean;
data: {
[stockCode: string]: {
code: string;
name: string;
data: KLineDataPoint[];
trade_date: string;
type: string;
earliest_date?: string;
};
};
has_more: boolean;
query_start_date?: string;
query_end_date?: string;
}
// 每次加载的天数
const DAYS_PER_LOAD = 60;
// 最大加载天数(一年)
const MAX_DAYS = 365;
const KLineChartModal: React.FC<KLineChartModalProps> = ({
isOpen,
onClose,
@@ -50,8 +76,12 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<KLineDataPoint[]>([]);
const [hasMore, setHasMore] = useState(true);
const [earliestDate, setEarliestDate] = useState<string | null>(null);
const [totalDaysLoaded, setTotalDaysLoaded] = useState(0);
// 调试日志
console.log('[KLineChartModal] 渲染状态:', {
@@ -60,38 +90,102 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
eventTime,
dataLength: data.length,
loading,
error
loadingMore,
hasMore,
earliestDate
});
// 加载K线数据
const loadData = async () => {
// 加载更多历史数据
const loadMoreData = useCallback(async () => {
if (!stock?.stock_code || !hasMore || loadingMore || !earliestDate) return;
console.log('[KLineChartModal] 加载更多历史数据, earliestDate:', earliestDate);
setLoadingMore(true);
try {
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
// 请求更早的数据end_date 设置为当前最早日期的前一天
const endDate = dayjs(earliestDate).subtract(1, 'day').format('YYYY-MM-DD');
const response = await stockService.getBatchKlineData(
[stock.stock_code],
'daily',
stableEventTime,
{ days_before: DAYS_PER_LOAD, end_date: endDate }
) as BatchKlineResponse;
if (response?.success && response.data) {
const stockData = response.data[stock.stock_code];
const newData = stockData?.data || [];
if (newData.length > 0) {
// 将新数据添加到现有数据的前面
setData(prevData => [...newData, ...prevData]);
setEarliestDate(newData[0].time);
setTotalDaysLoaded(prev => prev + DAYS_PER_LOAD);
console.log('[KLineChartModal] 加载了更多数据:', newData.length, '条');
}
// 检查是否还有更多数据
const noMoreData = !response.has_more || totalDaysLoaded + DAYS_PER_LOAD >= MAX_DAYS || newData.length === 0;
setHasMore(!noMoreData);
}
} catch (err) {
console.error('[KLineChartModal] 加载更多数据失败:', err);
} finally {
setLoadingMore(false);
}
}, [stock?.stock_code, hasMore, loadingMore, earliestDate, eventTime, totalDaysLoaded]);
// 初始加载K线数据
const loadData = useCallback(async () => {
if (!stock?.stock_code) return;
setLoading(true);
setError(null);
setData([]);
setHasMore(true);
setEarliestDate(null);
setTotalDaysLoaded(0);
try {
const response = await stockService.getKlineData(
stock.stock_code,
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
// 使用新的带分页参数的接口
const response = await stockService.getBatchKlineData(
[stock.stock_code],
'daily',
eventTime || undefined
);
stableEventTime,
{ days_before: DAYS_PER_LOAD, end_date: '' }
) as BatchKlineResponse;
console.log('[KLineChartModal] API响应:', response);
if (response?.success && response.data) {
const stockData = response.data[stock.stock_code];
const klineData = stockData?.data || [];
if (!response || !response.data || response.data.length === 0) {
throw new Error('暂无K线数据');
if (klineData.length === 0) {
throw new Error('暂无K线数据');
}
console.log('[KLineChartModal] 初始数据条数:', klineData.length);
setData(klineData);
setEarliestDate(klineData[0]?.time || null);
setTotalDaysLoaded(DAYS_PER_LOAD);
setHasMore(response.has_more !== false);
} else {
throw new Error('数据加载失败');
}
console.log('[KLineChartModal] 数据条数:', response.data.length);
setData(response.data);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
setError(errorMsg);
} finally {
setLoading(false);
}
};
}, [stock?.stock_code, eventTime]);
// 用于防抖的 ref
const loadMoreDebounceRef = useRef<NodeJS.Timeout | null>(null);
// 初始化图表
useEffect(() => {
@@ -124,6 +218,9 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
return () => {
clearTimeout(timer);
if (loadMoreDebounceRef.current) {
clearTimeout(loadMoreDebounceRef.current);
}
if (chartInstance.current) {
chartInstance.current.dispose();
chartInstance.current = null;
@@ -131,6 +228,35 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
};
}, [isOpen]);
// 监听 dataZoom 事件,当滑到左边界时加载更多数据
useEffect(() => {
if (!chartInstance.current || !hasMore || loadingMore) return;
const handleDataZoom = (params: any) => {
// 获取当前 dataZoom 的 start 值
const start = params.start ?? params.batch?.[0]?.start ?? 0;
// 当 start 接近 0左边界触发加载更多
if (start <= 5 && hasMore && !loadingMore) {
console.log('[KLineChartModal] 检测到滑动到左边界,准备加载更多数据');
// 防抖处理
if (loadMoreDebounceRef.current) {
clearTimeout(loadMoreDebounceRef.current);
}
loadMoreDebounceRef.current = setTimeout(() => {
loadMoreData();
}, 300);
}
};
chartInstance.current.on('datazoom', handleDataZoom);
return () => {
chartInstance.current?.off('datazoom', handleDataZoom);
};
}, [hasMore, loadingMore, loadMoreData]);
// 更新图表数据
useEffect(() => {
if (data.length === 0) {
@@ -504,7 +630,22 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
</span>
{data.length > 0 && (
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
{data.length}1
{data.length}
{hasMore ? '(向左滑动加载更多)' : '(已加载全部)'}
</span>
)}
{loadingMore && (
<span style={{ fontSize: '12px', color: '#3182ce', display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{
width: '12px',
height: '12px',
border: '2px solid #404040',
borderTop: '2px solid #3182ce',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
display: 'inline-block'
}} />
...
</span>
)}
</div>

View File

@@ -17,7 +17,8 @@ import {
AlertIcon,
} from '@chakra-ui/react';
import * as echarts from 'echarts';
import { stockService } from '@services/eventService';
import dayjs from 'dayjs';
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
/**
* 股票信息
@@ -67,7 +68,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<TimelineDataPoint[]>([]);
// 加载分时图数据
// 加载分时图数据(优先使用缓存)
const loadData = async () => {
if (!stock?.stock_code) return;
@@ -75,20 +76,30 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
setError(null);
try {
const response = await stockService.getKlineData(
stock.stock_code,
'timeline',
eventTime || undefined
);
// 标准化事件时间
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
console.log('[TimelineChartModal] API响应:', response);
// 先检查缓存
const cacheKey = getCacheKey(stock.stock_code, stableEventTime, 'timeline');
const cachedData = klineDataCache.get(cacheKey);
if (!response || !response.data || response.data.length === 0) {
if (cachedData && cachedData.length > 0) {
console.log('[TimelineChartModal] 使用缓存数据, 数据条数:', cachedData.length);
setData(cachedData);
setLoading(false);
return;
}
// 缓存没有则请求(会自动存入缓存)
console.log('[TimelineChartModal] 缓存未命中,发起请求');
const result = await fetchKlineData(stock.stock_code, stableEventTime, 'timeline');
if (!result || result.length === 0) {
throw new Error('暂无分时数据');
}
console.log('[TimelineChartModal] 数据条数:', response.data.length);
setData(response.data);
console.log('[TimelineChartModal] 数据条数:', result.length);
setData(result);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
setError(errorMsg);

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

@@ -1049,10 +1049,26 @@ export default function SubscriptionContent() {
</Text>
</HStack>
</Flex>
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
<Text fontSize="xs" color={secondaryText} pl={11} flex={1}>
{plan.description}
</Text>
<Flex justify="space-between" align="flex-start" flexWrap="wrap" gap={2}>
<VStack align="start" spacing={0.5} pl={11} flex={1}>
{plan.description && plan.description.includes('|') ? (
plan.description.split('|').map((item, idx) => (
<Text
key={idx}
fontSize="sm"
color={plan.name === 'max' ? 'purple.600' : 'blue.600'}
lineHeight="1.5"
fontWeight="medium"
>
{item.trim()}
</Text>
))
) : (
<Text fontSize="xs" color={secondaryText}>
{plan.description}
</Text>
)}
</VStack>
{(() => {
// 获取当前选中的周期信息
if (plan.pricing_options) {

View File

@@ -22,6 +22,7 @@ import {
Input,
Icon,
Container,
useBreakpointValue,
} from '@chakra-ui/react';
import {
FaWeixin,
@@ -42,6 +43,87 @@ import { useAuth } from '../../contexts/AuthContext';
import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents';
import { subscriptionConfig, themeColors } from '../../views/Pages/Account/subscription-content';
// 计费周期选择器组件 - 移动端垂直布局(年付在上),桌面端水平布局
interface CycleSelectorProps {
options: any[];
selectedCycle: string;
onSelectCycle: (cycle: string) => void;
}
function CycleSelector({ options, selectedCycle, onSelectCycle }: CycleSelectorProps) {
// 使用 useBreakpointValue 动态获取是否是移动端
const isMobile = useBreakpointValue({ base: true, md: false });
// 移动端倒序显示(年付在上),桌面端正常顺序
const displayOptions = isMobile ? [...options].reverse() : options;
return (
<Flex
direction={{ base: 'column', md: 'row' }}
gap={3}
p={2}
bg="rgba(255, 255, 255, 0.03)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(10px)"
justify="center"
align="center"
w={{ base: 'full', md: 'auto' }}
maxW={{ base: '320px', md: 'none' }}
mx="auto"
>
{displayOptions.map((option: any) => (
<Box key={option.cycleKey} position="relative" w={{ base: 'full', md: 'auto' }}>
{option.discountPercent > 0 && (
<Badge
position="absolute"
top={{ base: '50%', md: '-10px' }}
right={{ base: '10px', md: '-10px' }}
transform={{ base: 'translateY(-50%)', md: 'none' }}
colorScheme="red"
fontSize="xs"
px={2}
py={1}
borderRadius="full"
fontWeight="bold"
zIndex={1}
>
{option.discountPercent}%
</Badge>
)}
<Button
size="lg"
w={{ base: 'full', md: 'auto' }}
px={6}
py={6}
borderRadius="lg"
bg={selectedCycle === option.cycleKey ? 'linear-gradient(135deg, #D4AF37, #B8941F)' : 'transparent'}
color={selectedCycle === option.cycleKey ? '#000' : '#fff'}
border="1px solid"
borderColor={selectedCycle === option.cycleKey ? 'rgba(212, 175, 55, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
onClick={() => onSelectCycle(option.cycleKey)}
_hover={{
transform: 'translateY(-2px)',
borderColor: 'rgba(212, 175, 55, 0.5)',
shadow: selectedCycle === option.cycleKey
? '0 0 20px rgba(212, 175, 55, 0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.5)',
}}
transition="all 0.3s"
fontWeight="bold"
justifyContent={{ base: 'flex-start', md: 'center' }}
pl={{ base: 6, md: 6 }}
>
{option.label}
</Button>
</Box>
))}
</Flex>
);
}
export default function SubscriptionContentNew() {
const { user } = useAuth();
const subscriptionEvents = useSubscriptionEvents({
@@ -751,61 +833,11 @@ export default function SubscriptionContentNew() {
·
</Text>
<HStack
spacing={3}
p={2}
bg="rgba(255, 255, 255, 0.03)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(10px)"
flexWrap="wrap"
justify="center"
>
{getMergedPlans()[1]?.pricingOptions?.map((option: any, index: number) => (
<Box key={index} position="relative">
{option.discountPercent > 0 && (
<Badge
position="absolute"
top="-10px"
right="-10px"
colorScheme="red"
fontSize="xs"
px={2}
py={1}
borderRadius="full"
fontWeight="bold"
zIndex={1}
>
{option.discountPercent}%
</Badge>
)}
<Button
size="lg"
px={6}
py={6}
borderRadius="lg"
bg={selectedCycle === option.cycleKey ? 'linear-gradient(135deg, #D4AF37, #B8941F)' : 'transparent'}
color={selectedCycle === option.cycleKey ? '#000' : '#fff'}
border="1px solid"
borderColor={selectedCycle === option.cycleKey ? 'rgba(212, 175, 55, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
onClick={() => setSelectedCycle(option.cycleKey)}
_hover={{
transform: 'translateY(-2px)',
borderColor: 'rgba(212, 175, 55, 0.5)',
shadow: selectedCycle === option.cycleKey
? '0 0 20px rgba(212, 175, 55, 0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.5)',
}}
transition="all 0.3s"
fontWeight="bold"
>
{option.label}
</Button>
</Box>
))}
</HStack>
<CycleSelector
options={getMergedPlans()[1]?.pricingOptions || []}
selectedCycle={selectedCycle}
onSelectCycle={setSelectedCycle}
/>
{(() => {
const currentOption = getMergedPlans()[1]?.pricingOptions?.find(

View File

@@ -5,9 +5,58 @@ import { useToast } from '@chakra-ui/react';
import { logger } from '@utils/logger';
import { performanceMonitor } from '@utils/performanceMonitor';
import { useNotification } from '@contexts/NotificationContext';
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
// ⚡ 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();
@@ -99,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,
@@ -354,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
@@ -363,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';
@@ -44,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);
@@ -565,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({
@@ -1009,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]);
// ==================== 登录/注册结果 ====================

261
src/hooks/useIndexQuote.js Normal file
View File

@@ -0,0 +1,261 @@
// src/hooks/useIndexQuote.js
// 指数实时行情 Hook - 交易时间内每分钟自动更新
import { useState, useEffect, useCallback, useRef } from 'react';
import { logger } from '../utils/logger';
// 交易日数据会从后端获取,这里只做时间判断
const TRADING_SESSIONS = [
{ start: { hour: 9, minute: 30 }, end: { hour: 11, minute: 30 } },
{ start: { hour: 13, minute: 0 }, end: { hour: 15, minute: 0 } },
];
/**
* 判断当前时间是否在交易时段内
*/
const isInTradingSession = () => {
const now = new Date();
const currentMinutes = now.getHours() * 60 + now.getMinutes();
return TRADING_SESSIONS.some(session => {
const startMinutes = session.start.hour * 60 + session.start.minute;
const endMinutes = session.end.hour * 60 + session.end.minute;
return currentMinutes >= startMinutes && currentMinutes <= endMinutes;
});
};
/**
* 获取指数实时行情
*/
const fetchIndexRealtime = async (indexCode) => {
try {
const response = await fetch(`/api/index/${indexCode}/realtime`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success && result.data) {
return result.data;
}
return null;
} catch (error) {
logger.error('useIndexQuote', 'fetchIndexRealtime error', { indexCode, error: error.message });
return null;
}
};
/**
* 指数实时行情 Hook
*
* @param {string} indexCode - 指数代码,如 '000001' (上证指数) 或 '399001' (深证成指)
* @param {Object} options - 配置选项
* @param {number} options.refreshInterval - 刷新间隔(毫秒),默认 600001分钟
* @param {boolean} options.autoRefresh - 是否自动刷新,默认 true
*
* @returns {Object} { quote, loading, error, isTrading, refresh }
*/
export const useIndexQuote = (indexCode, options = {}) => {
const {
refreshInterval = 60000, // 默认1分钟
autoRefresh = true,
} = options;
const [quote, setQuote] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isTrading, setIsTrading] = useState(false);
const intervalRef = useRef(null);
const isMountedRef = useRef(true);
// 加载数据
const loadQuote = useCallback(async () => {
if (!indexCode) return;
try {
const data = await fetchIndexRealtime(indexCode);
if (!isMountedRef.current) return;
if (data) {
setQuote(data);
setIsTrading(data.is_trading);
setError(null);
} else {
setError('无法获取行情数据');
}
} catch (err) {
if (isMountedRef.current) {
setError(err.message);
}
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
}, [indexCode]);
// 手动刷新
const refresh = useCallback(() => {
setLoading(true);
loadQuote();
}, [loadQuote]);
// 初始加载
useEffect(() => {
isMountedRef.current = true;
loadQuote();
return () => {
isMountedRef.current = false;
};
}, [loadQuote]);
// 自动刷新逻辑
useEffect(() => {
if (!autoRefresh || !indexCode) return;
// 清除旧的定时器
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// 设置定时器,检查是否在交易时间内
const checkAndRefresh = () => {
const inSession = isInTradingSession();
setIsTrading(inSession);
if (inSession) {
loadQuote();
}
};
// 立即检查一次
checkAndRefresh();
// 设置定时刷新
intervalRef.current = setInterval(checkAndRefresh, refreshInterval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [autoRefresh, indexCode, refreshInterval, loadQuote]);
return {
quote,
loading,
error,
isTrading,
refresh,
};
};
/**
* 批量获取多个指数的实时行情
*
* @param {string[]} indexCodes - 指数代码数组
* @param {Object} options - 配置选项
*/
export const useMultiIndexQuotes = (indexCodes = [], options = {}) => {
const {
refreshInterval = 60000,
autoRefresh = true,
} = options;
const [quotes, setQuotes] = useState({});
const [loading, setLoading] = useState(true);
const [isTrading, setIsTrading] = useState(false);
const intervalRef = useRef(null);
const isMountedRef = useRef(true);
// 批量加载数据
const loadQuotes = useCallback(async () => {
if (!indexCodes || indexCodes.length === 0) return;
try {
const results = await Promise.all(
indexCodes.map(code => fetchIndexRealtime(code))
);
if (!isMountedRef.current) return;
const newQuotes = {};
let hasTrading = false;
results.forEach((data, idx) => {
if (data) {
newQuotes[indexCodes[idx]] = data;
if (data.is_trading) hasTrading = true;
}
});
setQuotes(newQuotes);
setIsTrading(hasTrading);
} catch (err) {
logger.error('useMultiIndexQuotes', 'loadQuotes error', err);
} finally {
if (isMountedRef.current) {
setLoading(false);
}
}
}, [indexCodes]);
// 手动刷新
const refresh = useCallback(() => {
setLoading(true);
loadQuotes();
}, [loadQuotes]);
// 初始加载
useEffect(() => {
isMountedRef.current = true;
loadQuotes();
return () => {
isMountedRef.current = false;
};
}, [loadQuotes]);
// 自动刷新逻辑
useEffect(() => {
if (!autoRefresh || indexCodes.length === 0) return;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
const checkAndRefresh = () => {
const inSession = isInTradingSession();
setIsTrading(inSession);
if (inSession) {
loadQuotes();
}
};
checkAndRefresh();
intervalRef.current = setInterval(checkAndRefresh, refreshInterval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [autoRefresh, indexCodes, refreshInterval, loadQuotes]);
return {
quotes,
loading,
isTrading,
refresh,
};
};
export default useIndexQuote;

View File

@@ -7,8 +7,8 @@ import { BrowserRouter as Router } from 'react-router-dom';
import { performanceMonitor } from './utils/performanceMonitor';
performanceMonitor.mark('app-start');
// 导入 Brainwave 样式(空文件,保留以避免错误
import './styles/brainwave.css';
// ⚡ 已删除 brainwave.css项目未安装 Tailwind CSS该文件无效
// import './styles/brainwave.css';
// 导入 Select 下拉框颜色修复样式
import './styles/select-fix.css';
@@ -40,91 +40,25 @@ 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');
}
}
// 启动应用MSW 异步初始化,不阻塞首屏渲染)
function startApp() {
// Create root
// 渲染应用
function renderApp() {
const root = ReactDOM.createRoot(document.getElementById('root'));
// ✅ 先渲染应用,不等待 MSW
// StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题)
root.render(
<React.StrictMode>
@@ -139,33 +73,26 @@ function startApp() {
</React.StrictMode>
);
// ✅ 后台异步启动 MSW不阻塞首屏渲染
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
const initMSW = async () => {
try {
const { startMockServiceWorker } = await import('./mocks/browser');
await startMockServiceWorker();
console.log(
'%c[MSW] ✅ Mock Service Worker 已在后台启动',
'color: #4CAF50; font-weight: bold;'
);
} catch (error) {
console.error('[MSW] ❌ Mock Service Worker 启动失败:', error);
}
};
// 使用 requestIdleCallback 在浏览器空闲时初始化,不阻塞首屏
if ('requestIdleCallback' in window) {
requestIdleCallback(() => initMSW(), { timeout: 3000 });
} else {
// 降级:使用 setTimeout(0) 延迟执行
setTimeout(initMSW, 0);
}
}
// 注册 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

@@ -263,6 +263,26 @@ export const accountHandlers = [
});
}),
// 10. 获取事件帖子(用户发布的评论/帖子)
http.get('/api/account/events/posts', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
console.log('[Mock] 获取事件帖子');
return HttpResponse.json({
success: true,
data: mockEventComments // 复用 mockEventComments 数据
});
}),
// ==================== 投资计划与复盘 ====================
// 10. 获取投资计划列表
@@ -696,4 +716,81 @@ export const accountHandlers = [
}
});
}),
// 21. 获取订阅套餐列表
http.get('/api/subscription/plans', async () => {
await delay(NETWORK_DELAY);
const plans = [
{
id: 1,
name: 'pro',
display_name: 'Pro 专业版',
description: '事件关联股票深度分析 | 历史事件智能对比复盘 | 事件概念关联与挖掘 | 概念板块个股追踪 | 概念深度研报与解读 | 个股异动实时预警',
monthly_price: 299,
yearly_price: 2699,
pricing_options: [
{ cycle_key: 'monthly', label: '月付', months: 1, price: 299, original_price: null, discount_percent: 0 },
{ cycle_key: 'quarterly', label: '季付', months: 3, price: 799, original_price: 897, discount_percent: 11 },
{ cycle_key: 'semiannual', label: '半年付', months: 6, price: 1499, original_price: 1794, discount_percent: 16 },
{ cycle_key: 'yearly', label: '年付', months: 12, price: 2699, original_price: 3588, discount_percent: 25 }
],
features: [
'新闻信息流',
'历史事件对比',
'事件传导链分析(AI)',
'事件-相关标的分析',
'相关概念展示',
'AI复盘功能',
'企业概览',
'个股深度分析(AI) - 50家/月',
'高效数据筛选工具',
'概念中心(548大概念)',
'历史时间轴查询 - 100天',
'涨停板块数据分析',
'个股涨停分析'
],
sort_order: 1
},
{
id: 2,
name: 'max',
display_name: 'Max 旗舰版',
description: '包含Pro版全部功能 | 事件传导链路智能分析 | 概念演变时间轴追溯 | 个股全方位深度研究 | 价小前投研助手无限使用 | 新功能优先体验权 | 专属客服一对一服务',
monthly_price: 599,
yearly_price: 5399,
pricing_options: [
{ cycle_key: 'monthly', label: '月付', months: 1, price: 599, original_price: null, discount_percent: 0 },
{ cycle_key: 'quarterly', label: '季付', months: 3, price: 1599, original_price: 1797, discount_percent: 11 },
{ cycle_key: 'semiannual', label: '半年付', months: 6, price: 2999, original_price: 3594, discount_percent: 17 },
{ cycle_key: 'yearly', label: '年付', months: 12, price: 5399, original_price: 7188, discount_percent: 25 }
],
features: [
'新闻信息流',
'历史事件对比',
'事件传导链分析(AI)',
'事件-相关标的分析',
'相关概念展示',
'板块深度分析(AI)',
'AI复盘功能',
'企业概览',
'个股深度分析(AI) - 无限制',
'高效数据筛选工具',
'概念中心(548大概念)',
'历史时间轴查询 - 无限制',
'概念高频更新',
'涨停板块数据分析',
'个股涨停分析'
],
sort_order: 2
}
];
console.log('[Mock] 获取订阅套餐列表:', plans.length, '个套餐');
return HttpResponse.json({
success: true,
data: plans
});
}),
];

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

@@ -326,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
});
}),
@@ -409,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

@@ -71,4 +71,197 @@ export const marketHandlers = [
const data = generateMarketData(stockCode);
return HttpResponse.json(data.latestMinuteData);
}),
// 9. 热门概念数据(个股中心页面使用)
http.get('/api/concepts/daily-top', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '6');
const date = url.searchParams.get('date');
// 获取当前日期或指定日期
const tradeDate = date || new Date().toISOString().split('T')[0];
// 热门概念列表
const conceptPool = [
{ name: '人工智能', desc: '人工智能是"技术突破+政策扶持"双轮驱动的硬科技主题。随着大模型技术的突破AI应用场景不断拓展。' },
{ name: '新能源汽车', desc: '新能源汽车行业景气度持续向好,渗透率不断提升。政策支持力度大,产业链上下游企业均受益。' },
{ name: '半导体', desc: '国产半导体替代加速,自主可控需求强烈。政策和资金支持力度大,行业迎来黄金发展期。' },
{ name: '光伏', desc: '光伏装机量快速增长,成本持续下降,行业景气度维持高位。双碳目标下前景广阔。' },
{ name: '锂电池', desc: '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。新能源汽车和储能需求旺盛。' },
{ name: '储能', desc: '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。未来市场空间巨大。' },
{ name: '算力', desc: 'AI大模型推动算力需求爆发数据中心、服务器、芯片等产业链受益明显。' },
{ name: '机器人', desc: '人形机器人产业化加速,特斯拉、小米等巨头入局,产业链迎来发展机遇。' },
];
// 股票池
const stockPool = [
{ stock_code: '600519', stock_name: '贵州茅台' },
{ stock_code: '300750', stock_name: '宁德时代' },
{ stock_code: '601318', stock_name: '中国平安' },
{ stock_code: '002594', stock_name: '比亚迪' },
{ stock_code: '601012', stock_name: '隆基绿能' },
{ stock_code: '300274', stock_name: '阳光电源' },
{ stock_code: '688981', stock_name: '中芯国际' },
{ stock_code: '000725', stock_name: '京东方A' },
];
// 生成概念数据
const concepts = [];
for (let i = 0; i < Math.min(limit, conceptPool.length); i++) {
const concept = conceptPool[i];
const changePercent = parseFloat((Math.random() * 8 - 1).toFixed(2)); // -1% ~ 7%
const stockCount = Math.floor(Math.random() * 40) + 20; // 20-60只股票
// 随机选取3-4只相关股票
const relatedStocks = [];
const stockIndices = new Set();
while (stockIndices.size < Math.min(4, stockPool.length)) {
stockIndices.add(Math.floor(Math.random() * stockPool.length));
}
stockIndices.forEach(idx => relatedStocks.push(stockPool[idx]));
concepts.push({
concept_id: `CONCEPT_${1001 + i}`,
concept_name: concept.name,
change_percent: changePercent,
stock_count: stockCount,
description: concept.desc,
stocks: relatedStocks
});
}
// 按涨跌幅降序排序
concepts.sort((a, b) => b.change_percent - a.change_percent);
console.log('[Mock Market] 获取热门概念:', { limit, date: tradeDate, count: concepts.length });
return HttpResponse.json({
success: true,
data: concepts,
trade_date: tradeDate
});
}),
// 10. 市值热力图数据(个股中心页面使用)
http.get('/api/market/heatmap', async ({ request }) => {
await delay(400);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '500');
const date = url.searchParams.get('date');
const tradeDate = date || new Date().toISOString().split('T')[0];
// 行业列表
const industries = ['食品饮料', '银行', '医药生物', '电子', '计算机', '汽车', '电力设备', '机械设备', '化工', '房地产', '有色金属', '钢铁'];
const provinces = ['北京', '上海', '广东', '浙江', '江苏', '山东', '四川', '湖北', '福建', '安徽'];
// 常见股票数据
const majorStocks = [
{ code: '600519', name: '贵州茅台', cap: 1850, industry: '食品饮料', province: '贵州' },
{ code: '601318', name: '中国平安', cap: 920, industry: '保险', province: '广东' },
{ code: '600036', name: '招商银行', cap: 850, industry: '银行', province: '广东' },
{ code: '300750', name: '宁德时代', cap: 780, industry: '电力设备', province: '福建' },
{ code: '601166', name: '兴业银行', cap: 420, industry: '银行', province: '福建' },
{ code: '000858', name: '五粮液', cap: 580, industry: '食品饮料', province: '四川' },
{ code: '002594', name: '比亚迪', cap: 650, industry: '汽车', province: '广东' },
{ code: '601012', name: '隆基绿能', cap: 320, industry: '电力设备', province: '陕西' },
{ code: '688981', name: '中芯国际', cap: 280, industry: '电子', province: '上海' },
{ code: '600900', name: '长江电力', cap: 520, industry: '公用事业', province: '湖北' },
];
// 生成热力图数据
const heatmapData = [];
let risingCount = 0;
let fallingCount = 0;
// 先添加主要股票
majorStocks.forEach(stock => {
const changePercent = parseFloat((Math.random() * 12 - 4).toFixed(2)); // -4% ~ 8%
const amount = parseFloat((Math.random() * 100 + 10).toFixed(2)); // 10-110亿
if (changePercent > 0) risingCount++;
else if (changePercent < 0) fallingCount++;
heatmapData.push({
stock_code: stock.code,
stock_name: stock.name,
market_cap: stock.cap,
change_percent: changePercent,
amount: amount,
industry: stock.industry,
province: stock.province
});
});
// 生成更多随机股票数据
for (let i = majorStocks.length; i < Math.min(limit, 200); i++) {
const changePercent = parseFloat((Math.random() * 14 - 5).toFixed(2)); // -5% ~ 9%
const marketCap = parseFloat((Math.random() * 500 + 20).toFixed(2)); // 20-520亿
const amount = parseFloat((Math.random() * 50 + 1).toFixed(2)); // 1-51亿
if (changePercent > 0) risingCount++;
else if (changePercent < 0) fallingCount++;
heatmapData.push({
stock_code: `${600000 + i}`,
stock_name: `股票${i}`,
market_cap: marketCap,
change_percent: changePercent,
amount: amount,
industry: industries[Math.floor(Math.random() * industries.length)],
province: provinces[Math.floor(Math.random() * provinces.length)]
});
}
console.log('[Mock Market] 获取热力图数据:', { limit, date: tradeDate, count: heatmapData.length });
return HttpResponse.json({
success: true,
data: heatmapData,
trade_date: tradeDate,
statistics: {
rising_count: risingCount,
falling_count: fallingCount
}
});
}),
// 11. 市场统计数据(个股中心页面使用)
http.get('/api/market/statistics', async ({ request }) => {
await delay(200);
const url = new URL(request.url);
const date = url.searchParams.get('date');
const tradeDate = date || new Date().toISOString().split('T')[0];
// 生成最近30个交易日
const availableDates = [];
const currentDate = new Date(tradeDate);
for (let i = 0; i < 30; i++) {
const d = new Date(currentDate);
d.setDate(d.getDate() - i);
// 跳过周末
if (d.getDay() !== 0 && d.getDay() !== 6) {
availableDates.push(d.toISOString().split('T')[0]);
}
}
console.log('[Mock Market] 获取市场统计数据:', { date: tradeDate });
return HttpResponse.json({
success: true,
summary: {
total_market_cap: parseFloat((Math.random() * 5000 + 80000).toFixed(2)), // 80000-85000亿
total_amount: parseFloat((Math.random() * 3000 + 8000).toFixed(2)), // 8000-11000亿
avg_pe: parseFloat((Math.random() * 5 + 12).toFixed(2)), // 12-17
avg_pb: parseFloat((Math.random() * 0.5 + 1.3).toFixed(2)), // 1.3-1.8
rising_stocks: Math.floor(Math.random() * 1500 + 1500), // 1500-3000
falling_stocks: Math.floor(Math.random() * 1500 + 1000), // 1000-2500
unchanged_stocks: Math.floor(Math.random() * 200 + 100) // 100-300
},
trade_date: tradeDate,
available_dates: availableDates.slice(0, 20) // 返回最近20个交易日
});
}),
];

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

@@ -12,7 +12,9 @@ export const lazyComponents = {
// ⚡ 直接引用 HomePage无需中间层静态页面不需要骨架屏
HomePage: React.lazy(() => import('@views/Home/HomePage')),
CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
ProfilePage: React.lazy(() => import('@views/Profile')),
ProfilePage: React.lazy(() => import('@views/Profile/ProfilePage')),
// 价值论坛 - 我的积分页面
ForumMyPoints: 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')),
@@ -56,6 +58,7 @@ export const {
HomePage,
CenterDashboard,
ProfilePage,
ForumMyPoints,
SettingsPage,
Subscription,
PrivacyPolicy,

View File

@@ -191,6 +191,16 @@ export const routeConfig = [
description: '预测市场话题详细信息'
}
},
{
path: 'value-forum/my-points',
component: lazyComponents.ForumMyPoints,
protection: PROTECTION_MODES.MODAL,
layout: 'main',
meta: {
title: '我的积分',
description: '价值论坛积分账户'
}
},
// ==================== Agent模块 ====================
{

View File

@@ -358,6 +358,47 @@ export const stockService = {
throw error;
}
},
/**
* 批量获取多只股票的K线数据
* @param {string[]} stockCodes - 股票代码数组
* @param {string} chartType - 图表类型 (timeline/daily)
* @param {string} eventTime - 事件时间
* @param {Object} options - 额外选项
* @param {number} options.days_before - 查询事件日期前多少天的数据默认60最大365
* @param {string} options.end_date - 分页加载时指定结束日期(用于加载更早的数据)
* @returns {Promise<Object>} { success, data: { [stockCode]: data[] }, has_more, query_start_date, query_end_date }
*/
getBatchKlineData: async (stockCodes, chartType = 'timeline', eventTime = null, options = {}) => {
try {
const requestBody = {
codes: stockCodes,
type: chartType
};
if (eventTime) {
requestBody.event_time = eventTime;
}
// 添加分页参数
if (options.days_before) {
requestBody.days_before = options.days_before;
}
if (options.end_date) {
requestBody.end_date = options.end_date;
}
logger.debug('stockService', '批量获取K线数据', { stockCount: stockCodes.length, chartType, eventTime, options });
const response = await apiRequest('/api/stock/batch-kline', {
method: 'POST',
body: JSON.stringify(requestBody)
});
return response;
} catch (error) {
logger.error('stockService', 'getBatchKlineData', error, { stockCodes, chartType });
throw error;
}
},
getTransmissionChainAnalysis: async (eventId) => {
return await apiRequest(`/api/events/${eventId}/transmission`);
},

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);
}
/**
@@ -355,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');
}
/**
@@ -440,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);
}
@@ -462,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

@@ -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;
}
}
);

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

@@ -23,6 +23,10 @@ iframe[src*="/chat/"],
iframe[src*="/visitor/"] {
position: fixed !important;
z-index: 999999 !important;
max-height: 80vh !important; /* 限制最大高度为视口的80% */
max-width: 40vh !important; /* 限制最大高度为视口的80% */
bottom: 10px !important; /* 确保底部有足够空间 */
right: 10px !important; /* 右侧边距 */
}
/* Bytedesk 覆盖层(如果存在) */
@@ -36,3 +40,27 @@ iframe[src*="/visitor/"] {
[class*="bytedesk-badge"] {
z-index: 1000000 !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;
}
}

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;

View File

@@ -5,7 +5,6 @@ import React, { useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Button,
Input,
Avatar,
Badge,
@@ -27,14 +26,13 @@ import {
Settings,
Cpu,
Zap,
Sparkles,
Paperclip,
Image as ImageIcon,
} from 'lucide-react';
import { AVAILABLE_MODELS } from '../../constants/models';
import { quickQuestions } from '../../constants/quickQuestions';
import { animations } from '../../constants/animations';
import MessageRenderer from './MessageRenderer';
import WelcomeScreen from './WelcomeScreen';
/**
* ChatArea - 中间聊天区域组件
@@ -87,15 +85,16 @@ const ChatArea = ({
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<Flex flex={1} direction="column">
<Flex flex={1} direction="column" h="100%" overflow="hidden" minH={0}>
{/* 顶部标题栏 - 深色毛玻璃 */}
<Box
bg="rgba(17, 24, 39, 0.8)"
backdropFilter="blur(20px) saturate(180%)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
px={4}
py={3}
flexShrink={0}
boxShadow="0 8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Flex align="center" justify="space-between">
@@ -215,94 +214,50 @@ const ChatArea = ({
</Flex>
</Box>
{/* 消息列表 */}
{/* 消息列表 / 欢迎界面 */}
<Box
flex={1}
minH={0}
bgGradient="linear(to-b, rgba(17, 24, 39, 0.5), rgba(17, 24, 39, 0.3))"
overflowY="auto"
display="flex"
flexDirection="column"
>
<motion.div
style={{ maxWidth: '896px', margin: '0 auto' }}
variants={animations.staggerContainer}
initial="initial"
animate="animate"
>
<VStack spacing={4} align="stretch">
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<motion.div
key={message.id}
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: -20 }}
layout
>
<MessageRenderer message={message} userAvatar={userAvatar} />
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</VStack>
</motion.div>
</Box>
{/* 快捷问题 */}
<AnimatePresence>
{messages.length <= 2 && !isProcessing && (
{/* 判断是否显示欢迎界面只有初始欢迎消息1条或没有消息时显示 */}
{messages.length <= 1 && !isProcessing ? (
<WelcomeScreen
onSuggestionClick={(prompt) => {
onInputChange(prompt);
inputRef.current?.focus();
}}
/>
) : (
<motion.div
variants={animations.fadeInUp}
style={{ maxWidth: '896px', margin: '0 auto', width: '100%', padding: '16px' }}
variants={animations.staggerContainer}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: 20 }}
>
<Box px={6}>
<Box maxW="896px" mx="auto">
<HStack fontSize="xs" color="gray.500" mb={2} fontWeight="medium" spacing={1}>
<Sparkles className="w-3 h-3" />
<Text>快速开始</Text>
</HStack>
<Box display="grid" gridTemplateColumns="repeat(2, 1fr)" gap={2}>
{quickQuestions.map((question, idx) => (
<motion.div
key={idx}
whileHover={{ scale: 1.02, y: -2 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 17 }}
>
<Button
variant="outline"
w="full"
justifyContent="flex-start"
h="auto"
py={3}
bg="rgba(255, 255, 255, 0.05)"
backdropFilter="blur(12px)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="gray.300"
_hover={{
bg: 'rgba(59, 130, 246, 0.15)',
borderColor: 'blue.400',
boxShadow: '0 4px 12px rgba(59, 130, 246, 0.3)',
color: 'white',
}}
onClick={() => {
onInputChange(question.text);
inputRef.current?.focus();
}}
>
<Text mr={2}>{question.emoji}</Text>
<Text>{question.text}</Text>
</Button>
</motion.div>
))}
</Box>
</Box>
</Box>
<VStack spacing={4} align="stretch">
<AnimatePresence mode="popLayout">
{messages.map((message) => (
<motion.div
key={message.id}
variants={animations.fadeInUp}
initial="initial"
animate="animate"
exit={{ opacity: 0, y: -20 }}
layout
>
<MessageRenderer message={message} userAvatar={userAvatar} />
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</VStack>
</motion.div>
)}
</AnimatePresence>
</Box>
{/* 输入栏 - 深色毛玻璃 */}
<Box
@@ -310,8 +265,9 @@ const ChatArea = ({
backdropFilter="blur(20px) saturate(180%)"
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={1}
px={4}
py={2}
flexShrink={0}
boxShadow="0 -8px 32px 0 rgba(31, 38, 135, 0.37)"
>
<Box maxW="896px" mx="auto">

View File

@@ -0,0 +1,127 @@
// src/views/AgentChat/components/LeftSidebar/DateGroup.js
// 可折叠的日期分组组件
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Box, Text, HStack, VStack, Badge } from '@chakra-ui/react';
import { ChevronDown, ChevronRight, Calendar } from 'lucide-react';
import SessionCard from './SessionCard';
/**
* DateGroup - 可折叠的日期分组组件
*
* @param {Object} props
* @param {string} props.label - 日期标签(如"今天"、"昨天"、"11月28日"
* @param {Array} props.sessions - 该日期下的会话列表
* @param {string|null} props.currentSessionId - 当前选中的会话 ID
* @param {Function} props.onSessionSwitch - 切换会话回调
* @param {boolean} props.defaultExpanded - 默认是否展开
* @param {number} props.index - 分组索引(用于动画延迟)
* @returns {JSX.Element}
*/
const DateGroup = ({
label,
sessions,
currentSessionId,
onSessionSwitch,
defaultExpanded = true,
index = 0,
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const hasActiveSession = sessions.some((s) => s.session_id === currentSessionId);
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05, duration: 0.2 }}
>
<Box mb={2}>
{/* 分组标题 - 可点击折叠 */}
<HStack
as="button"
w="100%"
px={2}
py={1.5}
spacing={2}
cursor="pointer"
onClick={() => setIsExpanded(!isExpanded)}
borderRadius="md"
bg={hasActiveSession ? 'rgba(139, 92, 246, 0.1)' : 'transparent'}
_hover={{
bg: 'rgba(255, 255, 255, 0.05)',
}}
transition="all 0.2s"
>
{/* 折叠图标 */}
<motion.div
animate={{ rotate: isExpanded ? 0 : -90 }}
transition={{ duration: 0.2 }}
>
<ChevronDown className="w-3.5 h-3.5" color="#9CA3AF" />
</motion.div>
{/* 日历图标 */}
<Calendar className="w-3.5 h-3.5" color={hasActiveSession ? '#A78BFA' : '#6B7280'} />
{/* 日期标签 */}
<Text
fontSize="xs"
fontWeight="semibold"
color={hasActiveSession ? 'purple.300' : 'gray.500'}
flex={1}
textAlign="left"
>
{label}
</Text>
{/* 会话数量徽章 */}
<Badge
size="sm"
bg={hasActiveSession ? 'rgba(139, 92, 246, 0.2)' : 'rgba(255, 255, 255, 0.1)'}
color={hasActiveSession ? 'purple.300' : 'gray.500'}
borderRadius="full"
px={2}
py={0.5}
fontSize="10px"
fontWeight="semibold"
>
{sessions.length}
</Badge>
</HStack>
{/* 会话列表 - 折叠动画 */}
<AnimatePresence initial={false}>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<VStack spacing={1.5} align="stretch" mt={1.5} pl={2}>
{sessions.map((session, idx) => (
<motion.div
key={session.session_id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.03 }}
>
<SessionCard
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
</motion.div>
))}
</VStack>
</motion.div>
)}
</AnimatePresence>
</Box>
</motion.div>
);
};
export default DateGroup;

View File

@@ -40,16 +40,22 @@ const SessionCard = ({ session, isActive, onPress }) => {
<CardBody p={3}>
<Flex align="start" justify="space-between" gap={2}>
<Box flex={1} minW={0}>
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={1}>
{session.title || '新对话'}
<Text fontSize="sm" fontWeight="medium" color="gray.100" noOfLines={2}>
{session.title || session.last_message?.substring(0, 30) || '新对话'}
</Text>
<Text fontSize="xs" color="gray.500" mt={1}>
{new Date(session.created_at || session.timestamp).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
{(() => {
const dateStr = session.created_at || session.last_timestamp || session.timestamp;
if (!dateStr) return '刚刚';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return '刚刚';
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
})()}
</Text>
</Box>
{session.message_count && (

View File

@@ -1,7 +1,7 @@
// src/views/AgentChat/components/LeftSidebar/index.js
// 左侧栏组件 - 对话历史列表
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
@@ -15,11 +15,12 @@ import {
HStack,
VStack,
Flex,
Button,
} from '@chakra-ui/react';
import { MessageSquare, Plus, Search, ChevronLeft } from 'lucide-react';
import { MessageSquare, Plus, Search, ChevronLeft, ChevronDown, MoreHorizontal } from 'lucide-react';
import { animations } from '../../constants/animations';
import { groupSessionsByDate } from '../../utils/sessionUtils';
import SessionCard from './SessionCard';
import DateGroup from './DateGroup';
/**
* LeftSidebar - 左侧栏组件
@@ -35,6 +36,9 @@ import SessionCard from './SessionCard';
* @param {Object} props.user - 用户信息
* @returns {JSX.Element|null}
*/
// 最多显示的日期分组数量
const MAX_VISIBLE_GROUPS = 10;
const LeftSidebar = ({
isOpen,
onClose,
@@ -46,18 +50,33 @@ const LeftSidebar = ({
user,
}) => {
const [searchQuery, setSearchQuery] = useState('');
// 按日期分组会话
const sessionGroups = groupSessionsByDate(sessions);
const [showAllGroups, setShowAllGroups] = useState(false);
// 搜索过滤
const filteredSessions = searchQuery
? sessions.filter(
(s) =>
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.session_id?.toLowerCase().includes(searchQuery.toLowerCase())
)
: sessions;
const filteredSessions = useMemo(() => {
if (!searchQuery) return sessions;
return sessions.filter(
(s) =>
s.title?.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.session_id?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [sessions, searchQuery]);
// 按日期分组会话(新版本返回数组)
const sessionGroups = useMemo(() => {
return groupSessionsByDate(filteredSessions);
}, [filteredSessions]);
// 控制显示的分组数量
const visibleGroups = useMemo(() => {
if (showAllGroups || sessionGroups.length <= MAX_VISIBLE_GROUPS) {
return sessionGroups;
}
return sessionGroups.slice(0, MAX_VISIBLE_GROUPS);
}, [sessionGroups, showAllGroups]);
const hasMoreGroups = sessionGroups.length > MAX_VISIBLE_GROUPS;
const hiddenGroupsCount = sessionGroups.length - MAX_VISIBLE_GROUPS;
return (
<AnimatePresence>
@@ -170,86 +189,97 @@ const LeftSidebar = ({
</Box>
</Box>
{/* 会话列表 */}
<Box flex={1} p={3} overflowY="auto">
{/* 会话列表 - 滚动容器 */}
<Box
flex={1}
p={3}
overflowY="auto"
overflowX="hidden"
css={{
'&::-webkit-scrollbar': {
width: '6px',
},
'&::-webkit-scrollbar-track': {
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '3px',
},
'&::-webkit-scrollbar-thumb': {
background: 'rgba(139, 92, 246, 0.3)',
borderRadius: '3px',
'&:hover': {
background: 'rgba(139, 92, 246, 0.5)',
},
},
}}
>
{/* 按日期分组显示会话 */}
{sessionGroups.today.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
今天
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.today.map((session, idx) => (
<motion.div
key={session.session_id}
custom={idx}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<SessionCard
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
</motion.div>
))}
</VStack>
</Box>
{visibleGroups.map((group, index) => (
<DateGroup
key={group.dateKey}
label={group.label}
sessions={group.sessions}
currentSessionId={currentSessionId}
onSessionSwitch={onSessionSwitch}
defaultExpanded={index < 3} // 前3个分组默认展开
index={index}
/>
))}
{/* 查看更多按钮 */}
{hasMoreGroups && !showAllGroups && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
<Button
w="100%"
size="sm"
variant="ghost"
leftIcon={<MoreHorizontal className="w-4 h-4" />}
onClick={() => setShowAllGroups(true)}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
border="1px dashed"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(139, 92, 246, 0.1)',
borderColor: 'purple.400',
color: 'purple.300',
}}
mt={2}
>
查看更多 ({hiddenGroupsCount} 个日期)
</Button>
</motion.div>
)}
{sessionGroups.yesterday.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
昨天
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.yesterday.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{sessionGroups.thisWeek.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
本周
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.thisWeek.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
))}
</VStack>
</Box>
)}
{sessionGroups.older.length > 0 && (
<Box mb={4}>
<Text fontSize="xs" fontWeight="semibold" color="gray.500" mb={2} px={2}>
更早
</Text>
<VStack spacing={2} align="stretch">
{sessionGroups.older.map((session) => (
<SessionCard
key={session.session_id}
session={session}
isActive={currentSessionId === session.session_id}
onPress={() => onSessionSwitch(session.session_id)}
/>
))}
</VStack>
</Box>
{/* 收起按钮 */}
{showAllGroups && hasMoreGroups && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<Button
w="100%"
size="sm"
variant="ghost"
leftIcon={<ChevronDown className="w-4 h-4" style={{ transform: 'rotate(180deg)' }} />}
onClick={() => setShowAllGroups(false)}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
border="1px dashed"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{
bg: 'rgba(139, 92, 246, 0.1)',
borderColor: 'purple.400',
color: 'purple.300',
}}
mt={2}
>
收起
</Button>
</motion.div>
)}
{/* 加载状态 */}
@@ -273,6 +303,15 @@ const LeftSidebar = ({
<Text fontSize="xs">开始一个新对话吧</Text>
</VStack>
)}
{/* 搜索无结果 */}
{searchQuery && filteredSessions.length === 0 && sessions.length > 0 && (
<VStack textAlign="center" py={8} color="gray.500" fontSize="sm" spacing={2}>
<Search className="w-8 h-8" style={{ opacity: 0.5, margin: '0 auto' }} />
<Text>未找到匹配的对话</Text>
<Text fontSize="xs">尝试其他关键词</Text>
</VStack>
)}
</Box>
{/* 用户信息卡片 */}

View File

@@ -0,0 +1,743 @@
// src/views/AgentChat/components/MeetingRoom/MeetingMessageBubble.js
// 会议消息气泡组件 - V2: 支持工具调用展示和流式输出
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Flex,
HStack,
VStack,
Text,
Avatar,
Badge,
IconButton,
Tooltip,
Card,
CardBody,
Spinner,
Code,
Collapse,
} from '@chakra-ui/react';
import {
TrendingUp,
TrendingDown,
BarChart2,
Users,
Crown,
Copy,
ThumbsUp,
ChevronRight,
ChevronDown,
Database,
Check,
Wrench,
AlertCircle,
Brain,
} from 'lucide-react';
import { getRoleConfig, MEETING_ROLES } from '../../constants/meetingRoles';
import { MarkdownWithCharts } from '@components/ChatBot/MarkdownWithCharts';
/**
* 清理 DeepSeek 模型输出中的工具调用标记
* DeepSeek 有时会以文本形式输出工具调用,格式如:
* <tool▁calls▁begin><tool▁call▁begin>tool_name<tool▁sep>{"args": "value"}<tool▁call▁end><tool▁calls▁end>
*/
const cleanDeepseekToolMarkers = (content) => {
if (!content) return content;
// 清理 DeepSeek 工具调用标记(匹配整个块)
let cleaned = content.replace(/<tool▁calls▁begin>[\s\S]*?<tool▁calls▁end>/g, '');
// 清理可能残留的单个标记
const markers = [
'<tool▁calls▁begin>',
'<tool▁calls▁end>',
'<tool▁call▁begin>',
'<tool▁call▁end>',
'<tool▁sep>',
];
markers.forEach((marker) => {
cleaned = cleaned.split(marker).join('');
});
return cleaned.trim();
};
/**
* 解析 deepmoney 格式的内容
* 格式: <think>思考过程</think><answer>回答内容</answer>
*
* @param {string} content - 原始内容
* @returns {{ thinking: string | null, answer: string }} 解析后的内容
*/
const parseDeepmoneyContent = (content) => {
if (!content) return { thinking: null, answer: '' };
// 先清理 DeepSeek 工具调用标记
const cleanedContent = cleanDeepseekToolMarkers(content);
// 匹配 <think>...</think> 标签
const thinkMatch = cleanedContent.match(/<think>([\s\S]*?)<\/think>/i);
// 匹配 <answer>...</answer> 标签
const answerMatch = cleanedContent.match(/<answer>([\s\S]*?)<\/answer>/i);
// 如果有 answer 标签,提取内容
if (answerMatch) {
return {
thinking: thinkMatch ? thinkMatch[1].trim() : null,
answer: answerMatch[1].trim(),
};
}
// 如果只有 think 标签但没有 answer 标签,可能正在流式输出中
if (thinkMatch && !answerMatch) {
// 检查 think 后面是否有其他内容
const afterThink = cleanedContent.replace(/<think>[\s\S]*?<\/think>/i, '').trim();
// 如果 think 后面有内容但不是 answer 标签包裹的,可能是部分输出
if (afterThink && !afterThink.startsWith('<answer>')) {
return {
thinking: thinkMatch[1].trim(),
answer: afterThink.replace(/<\/?answer>/gi, '').trim(),
};
}
return {
thinking: thinkMatch[1].trim(),
answer: '',
};
}
// 如果没有特殊标签,返回清理后的内容
return {
thinking: null,
answer: cleanedContent,
};
};
/**
* 获取角色图标
*/
const getRoleIcon = (roleType) => {
switch (roleType) {
case 'bull':
return <TrendingUp className="w-4 h-4" />;
case 'bear':
return <TrendingDown className="w-4 h-4" />;
case 'quant':
return <BarChart2 className="w-4 h-4" />;
case 'retail':
return <Users className="w-4 h-4" />;
case 'manager':
return <Crown className="w-4 h-4" />;
default:
return <Users className="w-4 h-4" />;
}
};
/**
* 工具名称映射
*/
const TOOL_NAME_MAP = {
// 基础数据工具
search_china_news: '搜索新闻',
search_research_reports: '搜索研报',
get_stock_basic_info: '获取股票信息',
get_stock_financial_index: '获取财务指标',
get_stock_balance_sheet: '获取资产负债表',
get_stock_cashflow: '获取现金流量表',
get_stock_trade_data: '获取交易数据',
search_limit_up_stocks: '搜索涨停股',
get_concept_statistics: '获取概念统计',
// 经典技术指标
get_macd_signal: 'MACD信号',
check_oscillator_status: 'RSI/KDJ指标',
analyze_bollinger_bands: '布林带分析',
calc_stop_loss_atr: 'ATR止损计算',
// 资金与情绪
analyze_market_heat: '市场热度分析',
check_volume_price_divergence: '量价背离检测',
analyze_obv_trend: 'OBV能量潮分析',
// 形态与突破
check_new_high_breakout: '新高突破检测',
identify_candlestick_pattern: 'K线形态识别',
find_price_gaps: '跳空缺口分析',
// 风险与估值
calc_max_drawdown: '最大回撤计算',
check_valuation_rank: 'PE估值百分位',
calc_price_zscore: 'Z-Score乖离率',
// 分钟级高阶算子
calc_market_profile_vpoc: 'VPOC筹码峰',
calc_realized_volatility: '已实现波动率',
analyze_buying_pressure: '买卖压力分析',
calc_parkinson_volatility: '帕金森波动率',
// 高级趋势分析
calc_bollinger_squeeze: '布林带挤压',
calc_trend_slope: '趋势斜率分析',
calc_hurst_exponent: 'Hurst指数',
decompose_trend_simple: '趋势分解',
// 流动性与统计
calc_amihud_illiquidity: 'Amihud流动性',
calc_price_entropy: '价格熵值',
calc_rsi_divergence: 'RSI背离检测',
// 配对与策略
test_cointegration: '协整性测试',
calc_kelly_position: '凯利仓位计算',
search_similar_kline: '相似K线检索',
// 综合分析
get_comprehensive_analysis: '综合技术分析',
};
/**
* 格式化结果数据用于显示
*/
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 '无数据';
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 (Array.isArray(result)) {
return `${result.length} 条记录`;
}
if (typeof result === 'object') {
const keys = Object.keys(result);
return `${keys.length} 个字段`;
}
return '查看详情';
};
/**
* 单个工具调用卡片
*/
const ToolCallCard = ({ toolCall, idx, roleColor }) => {
const [isExpanded, setIsExpanded] = useState(false);
const [copied, setCopied] = useState(false);
const hasResult = toolCall.result && (
typeof toolCall.result === 'object'
? Object.keys(toolCall.result).length > 0
: toolCall.result
);
const handleCopy = async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(formatResultData(toolCall.result));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error('复制失败:', err);
}
};
const toolDisplayName = TOOL_NAME_MAP[toolCall.tool_name] || toolCall.tool_name;
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)"
border="1px solid"
borderColor={isExpanded ? `${roleColor}40` : 'rgba(255, 255, 255, 0.1)'}
borderRadius="md"
transition="all 0.2s"
_hover={{
borderColor: `${roleColor}30`,
}}
size="sm"
>
<CardBody p={2}>
{/* 工具调用头部 */}
<Flex
align="center"
justify="space-between"
gap={2}
cursor={hasResult ? 'pointer' : 'default'}
onClick={() => hasResult && setIsExpanded(!isExpanded)}
>
<HStack flex={1} spacing={2}>
{toolCall.status === 'calling' ? (
<Spinner size="xs" color={roleColor} />
) : toolCall.status === 'success' ? (
<Box color="green.400">
<Check className="w-3 h-3" />
</Box>
) : (
<Box color="red.400">
<AlertCircle className="w-3 h-3" />
</Box>
)}
<Wrench className="w-3 h-3" style={{ color: roleColor }} />
<Text fontSize="xs" fontWeight="medium" color="gray.300">
{toolDisplayName}
</Text>
{hasResult && (
<Box
color="gray.500"
transition="transform 0.2s"
transform={isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'}
>
<ChevronRight className="w-3 h-3" />
</Box>
)}
</HStack>
<HStack spacing={2}>
{hasResult && (
<Tooltip label={copied ? '已复制' : '复制数据'} placement="top">
<IconButton
size="xs"
variant="ghost"
icon={copied ? <Check className="w-2 h-2" /> : <Copy className="w-2 h-2" />}
onClick={handleCopy}
color={copied ? 'green.400' : 'gray.500'}
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
aria-label="复制"
minW="20px"
h="20px"
/>
</Tooltip>
)}
{toolCall.execution_time && (
<Text fontSize="10px" color="gray.500">
{toolCall.execution_time.toFixed(2)}s
</Text>
)}
</HStack>
</Flex>
{/* 展开的详细数据 */}
<Collapse in={isExpanded} animateOpacity>
{isExpanded && hasResult && (
<Box mt={2}>
<Code
display="block"
p={2}
borderRadius="sm"
fontSize="10px"
whiteSpace="pre-wrap"
bg="rgba(0, 0, 0, 0.3)"
color="gray.300"
maxH="200px"
overflowY="auto"
sx={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { bg: 'transparent' },
'&::-webkit-scrollbar-thumb': { bg: 'gray.600', borderRadius: 'full' },
}}
>
{formatResultData(toolCall.result)}
</Code>
</Box>
)}
</Collapse>
</CardBody>
</Card>
</motion.div>
);
};
/**
* 工具调用列表组件
*/
const ToolCallsList = ({ toolCalls, roleColor }) => {
if (!toolCalls || toolCalls.length === 0) return null;
return (
<Box mt={3} mb={2}>
<HStack spacing={2} mb={2}>
<Wrench className="w-3 h-3" style={{ color: roleColor }} />
<Text fontSize="xs" color="gray.400">
工具调用 ({toolCalls.length})
</Text>
</HStack>
<VStack spacing={1} align="stretch">
{toolCalls.map((toolCall, idx) => (
<ToolCallCard
key={toolCall.tool_call_id || idx}
toolCall={toolCall}
idx={idx}
roleColor={roleColor}
/>
))}
</VStack>
</Box>
);
};
/**
* 思考过程展示组件
* 用于显示 deepmoney 等模型的思考过程,默认折叠
*/
const ThinkingBlock = ({ thinking, roleColor }) => {
const [isExpanded, setIsExpanded] = useState(false);
if (!thinking) return null;
return (
<Box mb={3}>
<HStack
spacing={2}
cursor="pointer"
onClick={() => setIsExpanded(!isExpanded)}
p={2}
bg="rgba(255, 255, 255, 0.03)"
borderRadius="md"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
_hover={{ borderColor: `${roleColor}30` }}
transition="all 0.2s"
>
<Brain className="w-3 h-3" style={{ color: roleColor }} />
<Text fontSize="xs" color="gray.400" flex={1}>
AI 思考过程
</Text>
<Box
color="gray.500"
transition="transform 0.2s"
transform={isExpanded ? 'rotate(180deg)' : 'rotate(0deg)'}
>
<ChevronDown className="w-3 h-3" />
</Box>
</HStack>
<Collapse in={isExpanded} animateOpacity>
<Box
mt={2}
p={3}
bg="rgba(0, 0, 0, 0.2)"
borderRadius="md"
borderLeft="2px solid"
borderColor={`${roleColor}50`}
maxH="200px"
overflowY="auto"
sx={{
'&::-webkit-scrollbar': { width: '4px' },
'&::-webkit-scrollbar-track': { bg: 'transparent' },
'&::-webkit-scrollbar-thumb': { bg: 'gray.600', borderRadius: 'full' },
}}
>
<Text fontSize="xs" color="gray.400" whiteSpace="pre-wrap" lineHeight="tall">
{thinking}
</Text>
</Box>
</Collapse>
</Box>
);
};
/**
* MeetingMessageBubble - 会议消息气泡组件
*
* @param {Object} props
* @param {Object} props.message - 消息对象
* @param {boolean} props.isLatest - 是否是最新消息
* @returns {JSX.Element}
*/
const MeetingMessageBubble = ({ message, isLatest }) => {
const roleConfig = getRoleConfig(message.role_id) || {
name: message.role_name,
nickname: message.nickname,
color: message.color,
roleType: 'retail',
};
const isUser = message.role_id === 'user';
const isManager = roleConfig.roleType === 'manager';
const isConclusion = message.is_conclusion;
const isStreaming = message.isStreaming;
const hasToolCalls = message.tool_calls && message.tool_calls.length > 0;
// 复制到剪贴板
const handleCopy = () => {
navigator.clipboard.writeText(message.content);
};
return (
<Flex
direction="column"
align={isUser ? 'flex-end' : 'flex-start'}
w="100%"
>
{/* 消息头部:角色信息 */}
<HStack
spacing={2}
mb={2}
flexDirection={isUser ? 'row-reverse' : 'row'}
>
<motion.div
whileHover={{ scale: 1.1 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<Avatar
size="sm"
src={roleConfig.avatar}
icon={getRoleIcon(roleConfig.roleType)}
bg={roleConfig.color}
boxShadow={`0 0 12px ${roleConfig.color}40`}
/>
</motion.div>
<VStack spacing={0} align={isUser ? 'flex-end' : 'flex-start'}>
<HStack spacing={2}>
<Text
fontSize="sm"
fontWeight="bold"
color={roleConfig.color}
>
{roleConfig.name}
</Text>
{roleConfig.nickname !== roleConfig.name && (
<Text fontSize="xs" color="gray.500">
@{roleConfig.nickname}
</Text>
)}
{isManager && (
<Badge
colorScheme="purple"
size="sm"
variant="subtle"
>
主持人
</Badge>
)}
{isStreaming && (
<Badge
colorScheme="blue"
size="sm"
variant="subtle"
display="flex"
alignItems="center"
gap={1}
>
<Spinner size="xs" />
发言中
</Badge>
)}
{isConclusion && (
<Badge
colorScheme="green"
size="sm"
variant="solid"
>
最终结论
</Badge>
)}
</HStack>
<Text fontSize="xs" color="gray.500">
{message.round_number} ·{' '}
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})}
</Text>
</VStack>
</HStack>
{/* 消息内容卡片 */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
style={{ maxWidth: isUser ? '70%' : '85%', width: '100%' }}
>
<Card
bg={
isUser
? `linear-gradient(135deg, ${roleConfig.color}20, ${roleConfig.color}10)`
: isConclusion
? 'linear-gradient(135deg, rgba(139, 92, 246, 0.15), rgba(139, 92, 246, 0.05))'
: 'rgba(255, 255, 255, 0.05)'
}
border="1px solid"
borderColor={
isConclusion
? 'purple.500'
: `${roleConfig.color}30`
}
borderRadius="xl"
overflow="hidden"
boxShadow={
isConclusion
? '0 0 20px rgba(139, 92, 246, 0.3)'
: isLatest
? `0 4px 20px ${roleConfig.color}20`
: 'none'
}
>
{/* 结论标题 */}
{isConclusion && (
<Box
bgGradient="linear(to-r, purple.600, violet.600)"
px={4}
py={2}
>
<HStack>
<Crown className="w-4 h-4" />
<Text fontWeight="bold" fontSize="sm" color="white">
基金经理投资建议
</Text>
</HStack>
</Box>
)}
<CardBody p={4}>
{/* 工具调用列表 */}
{hasToolCalls && (
<ToolCallsList
toolCalls={message.tool_calls}
roleColor={roleConfig.color}
/>
)}
{/* 解析 deepmoney 格式的内容 */}
{(() => {
const parsedContent = parseDeepmoneyContent(message.content);
return (
<>
{/* 思考过程(可折叠) */}
<ThinkingBlock
thinking={parsedContent.thinking}
roleColor={roleConfig.color}
/>
{/* 消息内容 */}
<Box
fontSize="sm"
color="gray.100"
lineHeight="tall"
sx={{
'& p': { mb: 2 },
'& h1, & h2, & h3': { color: 'gray.50', fontWeight: 'bold' },
'& ul, & ol': { pl: 4 },
'& li': { mb: 1 },
'& code': {
bg: 'rgba(255,255,255,0.1)',
px: 1,
borderRadius: 'sm',
},
'& blockquote': {
borderLeftWidth: '3px',
borderLeftColor: roleConfig.color,
pl: 3,
color: 'gray.300',
fontStyle: 'italic',
},
'& strong': { color: roleConfig.color },
}}
>
{parsedContent.answer ? (
<MarkdownWithCharts content={parsedContent.answer} variant="dark" />
) : isStreaming ? (
<HStack spacing={2} color="gray.500">
<Spinner size="sm" />
<Text>正在思考...</Text>
</HStack>
) : null}
{/* 流式输出时的光标 */}
{isStreaming && parsedContent.answer && (
<motion.span
animate={{ opacity: [1, 0, 1] }}
transition={{ duration: 0.8, repeat: Infinity }}
style={{ color: roleConfig.color }}
>
</motion.span>
)}
</Box>
</>
);
})()}
{/* 操作按钮 */}
<Flex mt={3} pt={3} borderTop="1px solid" borderColor="whiteAlpha.100">
<HStack spacing={2}>
<Tooltip label="复制">
<IconButton
size="xs"
variant="ghost"
icon={<Copy className="w-3 h-3" />}
onClick={handleCopy}
color="gray.500"
_hover={{ color: 'white', bg: 'whiteAlpha.100' }}
/>
</Tooltip>
<Tooltip label="有用">
<IconButton
size="xs"
variant="ghost"
icon={<ThumbsUp className="w-3 h-3" />}
color="gray.500"
_hover={{ color: 'green.400', bg: 'green.900' }}
/>
</Tooltip>
</HStack>
{/* 角色标签 */}
<Box ml="auto">
<Badge
bg={`${roleConfig.color}20`}
color={roleConfig.color}
fontSize="xs"
px={2}
py={0.5}
borderRadius="full"
>
{roleConfig.roleType === 'bull' && '📈 看多'}
{roleConfig.roleType === 'bear' && '📉 看空'}
{roleConfig.roleType === 'quant' && '📊 量化'}
{roleConfig.roleType === 'retail' && '🌱 散户'}
{roleConfig.roleType === 'manager' && '👔 决策'}
</Badge>
</Box>
</Flex>
</CardBody>
</Card>
</motion.div>
</Flex>
);
};
export default MeetingMessageBubble;

View File

@@ -0,0 +1,303 @@
// src/views/AgentChat/components/MeetingRoom/MeetingRolePanel.js
// 会议角色面板组件 - 显示所有参会角色状态
import React from 'react';
import { motion } from 'framer-motion';
import {
Box,
VStack,
HStack,
Text,
Avatar,
Badge,
Tooltip,
} from '@chakra-ui/react';
import {
TrendingUp,
TrendingDown,
BarChart2,
Users,
Crown,
Mic,
MicOff,
} from 'lucide-react';
import { MEETING_ROLES, MeetingStatus } from '../../constants/meetingRoles';
/**
* 获取角色图标
*/
const getRoleIcon = (roleType) => {
switch (roleType) {
case 'bull':
return <TrendingUp className="w-4 h-4" />;
case 'bear':
return <TrendingDown className="w-4 h-4" />;
case 'quant':
return <BarChart2 className="w-4 h-4" />;
case 'retail':
return <Users className="w-4 h-4" />;
case 'manager':
return <Crown className="w-4 h-4" />;
default:
return <Users className="w-4 h-4" />;
}
};
/**
* RoleCard - 单个角色卡片
*/
const RoleCard = ({ role, isSpeaking }) => {
return (
<Tooltip label={role.description} placement="right" hasArrow>
<motion.div
animate={
isSpeaking
? {
scale: [1, 1.02, 1],
boxShadow: [
`0 0 0px ${role.color}`,
`0 0 20px ${role.color}`,
`0 0 0px ${role.color}`,
],
}
: {}
}
transition={{
duration: 1.5,
repeat: isSpeaking ? Infinity : 0,
}}
>
<Box
p={3}
bg={isSpeaking ? `${role.color}15` : 'rgba(255, 255, 255, 0.03)'}
border="1px solid"
borderColor={isSpeaking ? role.color : 'rgba(255, 255, 255, 0.1)'}
borderRadius="lg"
cursor="pointer"
transition="all 0.2s"
_hover={{
bg: 'rgba(255, 255, 255, 0.05)',
borderColor: `${role.color}50`,
}}
>
<HStack spacing={3}>
{/* 头像 - 使用 PNG 图片 */}
<Box position="relative">
<Avatar
size="sm"
src={role.avatar}
name={role.name}
icon={getRoleIcon(role.roleType)}
bg={role.color}
boxShadow={isSpeaking ? `0 0 12px ${role.color}` : 'none'}
/>
{isSpeaking && (
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 0.5, repeat: Infinity }}
style={{
position: 'absolute',
bottom: -2,
right: -2,
}}
>
<Box
bg="green.500"
borderRadius="full"
p={1}
boxShadow="0 0 8px rgba(34, 197, 94, 0.6)"
>
<Mic className="w-2 h-2" />
</Box>
</motion.div>
)}
</Box>
{/* 角色信息 */}
<VStack spacing={0} align="start" flex={1}>
<Text
fontSize="sm"
fontWeight="bold"
color={isSpeaking ? role.color : 'gray.200'}
>
{role.name}
</Text>
<Text fontSize="xs" color="gray.500">
@{role.nickname}
</Text>
</VStack>
{/* 状态指示 */}
<Badge
size="sm"
colorScheme={
role.roleType === 'bull'
? 'green'
: role.roleType === 'bear'
? 'red'
: role.roleType === 'quant'
? 'blue'
: role.roleType === 'manager'
? 'purple'
: 'yellow'
}
variant="subtle"
fontSize="10px"
>
{role.roleType === 'bull' && '多头'}
{role.roleType === 'bear' && '空头'}
{role.roleType === 'quant' && '量化'}
{role.roleType === 'retail' && '散户'}
{role.roleType === 'manager' && '主持'}
</Badge>
</HStack>
</Box>
</motion.div>
</Tooltip>
);
};
/**
* MeetingRolePanel - 会议角色面板
*
* @param {Object} props
* @param {string|null} props.speakingRoleId - 正在发言的角色 ID
* @param {MeetingStatus} props.status - 会议状态
* @returns {JSX.Element}
*/
const MeetingRolePanel = ({ speakingRoleId, status }) => {
// 将角色按类型分组
const analysts = Object.values(MEETING_ROLES).filter(
(r) => r.roleType !== 'manager'
);
const manager = MEETING_ROLES.fund_manager;
return (
<Box
w="220px"
bg="rgba(17, 24, 39, 0.8)"
borderRight="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
p={4}
overflowY="auto"
>
{/* 标题 */}
<Text
fontSize="xs"
fontWeight="bold"
color="gray.500"
textTransform="uppercase"
letterSpacing="wider"
mb={3}
>
参会成员
</Text>
<VStack spacing={2} align="stretch">
{/* 分析师组 */}
<Text fontSize="xs" color="gray.600" mt={2} mb={1}>
分析团队
</Text>
{analysts.map((role) => (
<RoleCard
key={role.id}
role={role}
isSpeaking={speakingRoleId === role.id}
/>
))}
{/* 分隔线 */}
<Box
h="1px"
bg="rgba(255, 255, 255, 0.1)"
my={2}
/>
{/* 基金经理 */}
<Text fontSize="xs" color="gray.600" mb={1}>
决策层
</Text>
<RoleCard
role={manager}
isSpeaking={speakingRoleId === 'fund_manager'}
/>
</VStack>
{/* 会议状态指示 */}
<Box
mt={4}
p={3}
bg="rgba(255, 255, 255, 0.03)"
borderRadius="lg"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
<Text fontSize="xs" color="gray.500" mb={2}>
会议状态
</Text>
<HStack spacing={2}>
<Box
w={2}
h={2}
borderRadius="full"
bg={
status === MeetingStatus.IDLE
? 'gray.500'
: status === MeetingStatus.CONCLUDED
? 'green.500'
: status === MeetingStatus.ERROR
? 'red.500'
: 'blue.500'
}
boxShadow={
status !== MeetingStatus.IDLE
? `0 0 8px ${
status === MeetingStatus.CONCLUDED
? 'rgba(34, 197, 94, 0.6)'
: status === MeetingStatus.ERROR
? 'rgba(239, 68, 68, 0.6)'
: 'rgba(59, 130, 246, 0.6)'
}`
: 'none'
}
/>
<Text fontSize="xs" color="gray.400">
{status === MeetingStatus.IDLE && '等待开始'}
{status === MeetingStatus.STARTING && '召集中...'}
{status === MeetingStatus.DISCUSSING && '讨论中'}
{status === MeetingStatus.SPEAKING && '发言中'}
{status === MeetingStatus.WAITING_INPUT && '等待输入'}
{status === MeetingStatus.CONCLUDED && '已结束'}
{status === MeetingStatus.ERROR && '异常'}
</Text>
</HStack>
</Box>
{/* 角色说明 */}
<Box mt={4}>
<Text fontSize="xs" color="gray.600" mb={2}>
💡 角色说明
</Text>
<VStack spacing={1} align="start">
<Text fontSize="10px" color="gray.500">
📈 多头挖掘利好因素
</Text>
<Text fontSize="10px" color="gray.500">
📉 空头发现风险隐患
</Text>
<Text fontSize="10px" color="gray.500">
📊 量化技术指标分析
</Text>
<Text fontSize="10px" color="gray.500">
🌱 散户反向指标参考
</Text>
<Text fontSize="10px" color="gray.500">
👔 主持综合判断决策
</Text>
</VStack>
</Box>
</Box>
);
};
export default MeetingRolePanel;

View File

@@ -0,0 +1,294 @@
// src/views/AgentChat/components/MeetingRoom/MeetingWelcome.js
// 会议欢迎界面 - 显示议题建议
import React from 'react';
import { motion } from 'framer-motion';
import {
Box,
VStack,
HStack,
Text,
SimpleGrid,
Card,
CardBody,
Icon,
} from '@chakra-ui/react';
import {
TrendingUp,
FileText,
AlertTriangle,
LineChart,
Briefcase,
Building,
Zap,
Target,
} from 'lucide-react';
/**
* 议题建议列表
*/
const TOPIC_SUGGESTIONS = [
{
id: 1,
icon: FileText,
color: 'blue.400',
title: '财报分析',
example: '分析贵州茅台2024年三季报评估投资价值',
},
{
id: 2,
icon: AlertTriangle,
color: 'red.400',
title: '风险评估',
example: '分析宁德时代面临的主要风险和挑战',
},
{
id: 3,
icon: TrendingUp,
color: 'green.400',
title: '趋势判断',
example: '当前AI概念股还能不能追',
},
{
id: 4,
icon: LineChart,
color: 'purple.400',
title: '技术分析',
example: '从技术面分析上证指数短期走势',
},
{
id: 5,
icon: Building,
color: 'orange.400',
title: '行业研究',
example: '新能源汽车行业2025年投资机会分析',
},
{
id: 6,
icon: Target,
color: 'cyan.400',
title: '事件驱动',
example: '美联储降息对A股的影响分析',
},
];
/**
* TopicCard - 议题建议卡片
*/
const TopicCard = ({ suggestion, onClick }) => {
const IconComponent = suggestion.icon;
return (
<motion.div
whileHover={{ scale: 1.02, y: -4 }}
whileTap={{ scale: 0.98 }}
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
>
<Card
bg="rgba(255, 255, 255, 0.03)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
borderRadius="xl"
cursor="pointer"
onClick={() => onClick(suggestion.example)}
transition="all 0.2s"
_hover={{
bg: 'rgba(255, 255, 255, 0.05)',
borderColor: suggestion.color,
boxShadow: `0 8px 30px ${suggestion.color}20`,
}}
>
<CardBody p={4}>
<VStack align="start" spacing={3}>
<HStack spacing={3}>
<Box
p={2}
bg={`${suggestion.color}15`}
borderRadius="lg"
>
<Icon
as={IconComponent}
color={suggestion.color}
boxSize={5}
/>
</Box>
<Text
fontSize="sm"
fontWeight="bold"
color="gray.200"
>
{suggestion.title}
</Text>
</HStack>
<Text
fontSize="xs"
color="gray.400"
lineHeight="tall"
>
{suggestion.example}
</Text>
</VStack>
</CardBody>
</Card>
</motion.div>
);
};
/**
* MeetingWelcome - 会议欢迎界面
*
* @param {Object} props
* @param {Function} props.onTopicSelect - 选择议题回调
* @returns {JSX.Element}
*/
const MeetingWelcome = ({ onTopicSelect }) => {
return (
<Box
flex={1}
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
p={8}
>
<VStack spacing={8} maxW="800px" w="100%">
{/* 标题区域 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<VStack spacing={4}>
<motion.div
animate={{
rotate: [0, 5, -5, 0],
}}
transition={{
duration: 4,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<Box
p={4}
bgGradient="linear(to-br, orange.400, red.500)"
borderRadius="2xl"
boxShadow="0 0 40px rgba(251, 146, 60, 0.4)"
>
<Briefcase className="w-10 h-10" />
</Box>
</motion.div>
<Text
fontSize="2xl"
fontWeight="bold"
bgGradient="linear(to-r, orange.300, red.300)"
bgClip="text"
textAlign="center"
>
欢迎来到投研会议室
</Text>
<Text
fontSize="sm"
color="gray.400"
textAlign="center"
maxW="500px"
>
多位 AI 分析师将从不同角度分析您的投资议题
包括多头空头量化分析师和散户视角
最终由基金经理给出投资建议
</Text>
</VStack>
</motion.div>
{/* 特点说明 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
<HStack
spacing={6}
flexWrap="wrap"
justify="center"
>
{[
{ icon: '📈', text: '多空对决' },
{ icon: '📊', text: '量化分析' },
{ icon: '🎯', text: '投资建议' },
{ icon: '💬', text: '实时参与' },
].map((item, index) => (
<HStack
key={index}
spacing={2}
bg="rgba(255, 255, 255, 0.03)"
px={4}
py={2}
borderRadius="full"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
>
<Text fontSize="lg">{item.icon}</Text>
<Text fontSize="sm" color="gray.300">
{item.text}
</Text>
</HStack>
))}
</HStack>
</motion.div>
{/* 议题建议 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
style={{ width: '100%' }}
>
<VStack spacing={4} w="100%">
<HStack spacing={2}>
<Zap className="w-4 h-4" style={{ color: '#F59E0B' }} />
<Text fontSize="sm" color="gray.400">
点击下方议题快速开始或输入自定义议题
</Text>
</HStack>
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4} w="100%">
{TOPIC_SUGGESTIONS.map((suggestion) => (
<TopicCard
key={suggestion.id}
suggestion={suggestion}
onClick={onTopicSelect}
/>
))}
</SimpleGrid>
</VStack>
</motion.div>
{/* 使用提示 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
>
<Box
bg="rgba(251, 146, 60, 0.1)"
border="1px solid"
borderColor="rgba(251, 146, 60, 0.2)"
borderRadius="lg"
p={4}
maxW="500px"
>
<Text fontSize="xs" color="orange.300">
💡 提示会议进行中您可以随时插话参与讨论
您的观点会影响分析师的判断
讨论结束后基金经理会给出最终的投资建议
</Text>
</Box>
</motion.div>
</VStack>
</Box>
);
};
export default MeetingWelcome;

View File

@@ -0,0 +1,442 @@
// src/views/AgentChat/components/MeetingRoom/index.js
// 投研会议室主组件
import React, { useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Box,
Flex,
VStack,
HStack,
Text,
Input,
IconButton,
Avatar,
Badge,
Spinner,
Tooltip,
Kbd,
useColorModeValue,
} from '@chakra-ui/react';
import {
Send,
Users,
RefreshCw,
MessageCircle,
CheckCircle,
AlertCircle,
} from 'lucide-react';
import {
MEETING_ROLES,
MeetingStatus,
getRoleConfig,
} from '../../constants/meetingRoles';
import { useInvestmentMeeting } from '../../hooks/useInvestmentMeeting';
import MeetingMessageBubble from './MeetingMessageBubble';
import MeetingRolePanel from './MeetingRolePanel';
import MeetingWelcome from './MeetingWelcome';
/**
* MeetingRoom - 投研会议室主组件
*
* @param {Object} props
* @param {Object} props.user - 当前用户信息
* @param {Function} props.onToast - Toast 通知函数
* @returns {JSX.Element}
*/
const MeetingRoom = ({ user, onToast }) => {
const inputRef = useRef(null);
const messagesEndRef = useRef(null);
// 使用投研会议 Hook
const {
messages,
status,
speakingRoleId,
currentRound,
isConcluded,
conclusion,
inputValue,
setInputValue,
startMeeting,
continueMeeting,
sendUserMessage,
resetMeeting,
currentTopic,
isLoading,
} = useInvestmentMeeting({
userId: user?.id ? String(user.id) : 'anonymous',
userNickname: user?.nickname || '匿名用户',
onToast,
});
// 自动滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// 处理键盘事件
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// 处理发送
const handleSend = () => {
if (!inputValue.trim()) return;
if (status === MeetingStatus.IDLE) {
// 启动新会议
startMeeting(inputValue.trim());
setInputValue('');
} else if (status === MeetingStatus.CONCLUDED) {
// 如果已结论,开始新会议
resetMeeting();
startMeeting(inputValue.trim());
setInputValue('');
} else if (
status === MeetingStatus.WAITING_INPUT ||
status === MeetingStatus.DISCUSSING ||
status === MeetingStatus.SPEAKING
) {
// 用户可以在任何时候插话(包括讨论中和发言中)
sendUserMessage(inputValue.trim());
setInputValue('');
}
};
// 获取状态提示文字
const getStatusText = () => {
switch (status) {
case MeetingStatus.IDLE:
return '请输入投研议题,开始会议讨论';
case MeetingStatus.STARTING:
return '正在召集会议成员...';
case MeetingStatus.DISCUSSING:
return `${currentRound} 轮讨论进行中...`;
case MeetingStatus.SPEAKING:
const role = getRoleConfig(speakingRoleId);
return `${role?.name || '成员'} 正在发言...`;
case MeetingStatus.WAITING_INPUT:
return '讨论暂停,您可以插话或等待继续';
case MeetingStatus.CONCLUDED:
return '会议已结束,已得出投资建议';
case MeetingStatus.ERROR:
return '会议出现异常,请重试';
default:
return '';
}
};
// 获取输入框占位符
const getPlaceholder = () => {
if (status === MeetingStatus.IDLE) {
return '输入投研议题,如:分析茅台最新财报...';
} else if (status === MeetingStatus.WAITING_INPUT) {
return '输入您的观点参与讨论,或点击继续按钮...';
} else if (status === MeetingStatus.CONCLUDED) {
return '会议已结束,输入新议题开始新会议...';
} else if (status === MeetingStatus.STARTING) {
return '正在召集会议成员...';
} else if (status === MeetingStatus.DISCUSSING || status === MeetingStatus.SPEAKING) {
return '随时输入您的观点参与讨论...';
}
return '输入您的观点...';
};
return (
<Flex h="100%" direction="column" bg="gray.900">
{/* 顶部标题栏 */}
<Box
bg="rgba(17, 24, 39, 0.9)"
backdropFilter="blur(20px)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
>
<Flex align="center" justify="space-between">
<HStack spacing={4}>
<motion.div
animate={{ rotate: isLoading ? 360 : 0 }}
transition={{
duration: 2,
repeat: isLoading ? Infinity : 0,
ease: 'linear',
}}
>
<Avatar
icon={<Users className="w-6 h-6" />}
bgGradient="linear(to-br, orange.400, red.500)"
boxShadow="0 0 20px rgba(251, 146, 60, 0.5)"
/>
</motion.div>
<Box>
<Text
fontSize="xl"
fontWeight="bold"
bgGradient="linear(to-r, orange.400, red.400)"
bgClip="text"
letterSpacing="tight"
>
投研会议室
</Text>
<HStack spacing={2} mt={1}>
<Badge
bg={
status === MeetingStatus.CONCLUDED
? 'green.500'
: status === MeetingStatus.ERROR
? 'red.500'
: 'blue.500'
}
color="white"
px={2}
py={1}
borderRadius="md"
display="flex"
alignItems="center"
gap={1}
>
{status === MeetingStatus.CONCLUDED ? (
<CheckCircle className="w-3 h-3" />
) : status === MeetingStatus.ERROR ? (
<AlertCircle className="w-3 h-3" />
) : (
<MessageCircle className="w-3 h-3" />
)}
{getStatusText()}
</Badge>
{currentRound > 0 && (
<Badge
bgGradient="linear(to-r, purple.500, pink.500)"
color="white"
px={2}
py={1}
borderRadius="md"
>
{currentRound}
</Badge>
)}
</HStack>
</Box>
</HStack>
<HStack spacing={2}>
<Tooltip label="重置会议">
<motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
<IconButton
size="sm"
variant="ghost"
icon={<RefreshCw className="w-4 h-4" />}
onClick={resetMeeting}
bg="rgba(255, 255, 255, 0.05)"
color="gray.400"
_hover={{
bg: 'rgba(255, 255, 255, 0.1)',
color: 'white',
}}
/>
</motion.div>
</Tooltip>
</HStack>
</Flex>
</Box>
{/* 主内容区 */}
<Flex flex={1} overflow="hidden">
{/* 角色面板(左侧) */}
<MeetingRolePanel
speakingRoleId={speakingRoleId}
status={status}
/>
{/* 消息区域(中间) */}
<Box
flex={1}
overflowY="auto"
bg="rgba(17, 24, 39, 0.5)"
p={4}
>
{messages.length === 0 && status === MeetingStatus.IDLE ? (
<MeetingWelcome
onTopicSelect={(topic) => {
setInputValue(topic);
inputRef.current?.focus();
}}
/>
) : (
<VStack spacing={4} align="stretch" maxW="800px" mx="auto">
{/* 当前议题展示 */}
{currentTopic && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
<Box
bg="rgba(251, 146, 60, 0.1)"
border="1px solid"
borderColor="rgba(251, 146, 60, 0.3)"
borderRadius="lg"
p={4}
mb={4}
>
<Text fontSize="sm" color="orange.300" fontWeight="medium">
📋 本次议题
</Text>
<Text color="white" mt={1}>
{currentTopic}
</Text>
</Box>
</motion.div>
)}
{/* 消息列表 */}
<AnimatePresence mode="popLayout">
{messages.map((message, index) => (
<motion.div
key={message.id || index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<MeetingMessageBubble
message={message}
isLatest={index === messages.length - 1}
/>
</motion.div>
))}
</AnimatePresence>
{/* 正在发言指示器 */}
{speakingRoleId && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<HStack
spacing={3}
p={4}
bg="rgba(255, 255, 255, 0.05)"
borderRadius="lg"
>
<Spinner size="sm" color="purple.400" />
<Text color="gray.400" fontSize="sm">
{getRoleConfig(speakingRoleId)?.name} 正在思考...
</Text>
</HStack>
</motion.div>
)}
<div ref={messagesEndRef} />
</VStack>
)}
</Box>
</Flex>
{/* 输入栏 */}
<Box
bg="rgba(17, 24, 39, 0.9)"
backdropFilter="blur(20px)"
borderTop="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={6}
py={4}
>
<Box maxW="800px" mx="auto">
<HStack spacing={3}>
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={getPlaceholder()}
isDisabled={status === MeetingStatus.STARTING}
size="lg"
bg="rgba(255, 255, 255, 0.05)"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
color="white"
_placeholder={{ color: 'gray.500' }}
_hover={{
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
_focus={{
borderColor: 'orange.400',
boxShadow: '0 0 0 1px var(--chakra-colors-orange-400)',
}}
/>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<IconButton
size="lg"
icon={isLoading && status === MeetingStatus.STARTING ? <Spinner size="sm" /> : <Send className="w-5 h-5" />}
onClick={handleSend}
isDisabled={
!inputValue.trim() ||
status === MeetingStatus.STARTING
}
bgGradient="linear(to-r, orange.400, red.500)"
color="white"
_hover={{
bgGradient: 'linear(to-r, orange.500, red.600)',
boxShadow: '0 8px 20px rgba(251, 146, 60, 0.4)',
}}
/>
</motion.div>
{/* 继续讨论按钮 */}
{status === MeetingStatus.WAITING_INPUT && !isConcluded && (
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Tooltip label="继续下一轮讨论">
<IconButton
size="lg"
icon={<MessageCircle className="w-5 h-5" />}
onClick={() => continueMeeting()}
isDisabled={isLoading}
bgGradient="linear(to-r, purple.400, blue.500)"
color="white"
_hover={{
bgGradient: 'linear(to-r, purple.500, blue.600)',
}}
/>
</Tooltip>
</motion.div>
)}
</HStack>
<HStack spacing={4} mt={2} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<Kbd bg="rgba(255, 255, 255, 0.05)" color="gray.400">
Enter
</Kbd>
<Text>
{status === MeetingStatus.IDLE ? '开始会议' : '发送消息'}
</Text>
</HStack>
{(status === MeetingStatus.WAITING_INPUT ||
status === MeetingStatus.DISCUSSING ||
status === MeetingStatus.SPEAKING) && (
<Text color="orange.400">
💡 随时输入观点参与讨论您的发言会影响分析师的判断
</Text>
)}
</HStack>
</Box>
</Box>
</Flex>
);
};
export default MeetingRoom;

View File

@@ -20,3 +20,4 @@ export * from './messageTypes';
export * from './models';
export * from './tools';
export * from './quickQuestions';
export * from './meetingRoles';

View File

@@ -0,0 +1,266 @@
// src/views/AgentChat/constants/meetingRoles.ts
// 投研会议室角色配置
import * as React from 'react';
import {
TrendingUp,
TrendingDown,
BarChart2,
Users,
Crown,
} from 'lucide-react';
/**
* 角色类型枚举
*/
export type MeetingRoleType = 'bull' | 'bear' | 'quant' | 'retail' | 'manager';
/**
* 会议角色配置接口
*/
export interface MeetingRoleConfig {
/** 角色唯一标识 */
id: string;
/** 角色名称 */
name: string;
/** 角色昵称 */
nickname: string;
/** 角色类型 */
roleType: MeetingRoleType;
/** 头像路径 */
avatar: string;
/** 主题颜色 */
color: string;
/** 渐变背景 */
gradient: string;
/** 角色描述 */
description: string;
/** 图标 */
icon: React.ReactNode;
}
/**
* 工具调用结果接口
*/
export interface ToolCallResult {
/** 工具调用 ID */
tool_call_id: string;
/** 工具名称 */
tool_name: string;
/** 工具参数 */
arguments?: Record<string, any>;
/** 调用状态 */
status: 'calling' | 'success' | 'error';
/** 调用结果 */
result?: any;
/** 错误信息 */
error?: string;
/** 执行时间(秒) */
execution_time?: number;
}
/**
* 会议消息接口
*/
export interface MeetingMessage {
/** 消息 ID */
id?: string | number;
/** 角色 ID */
role_id: string;
/** 角色名称 */
role_name: string;
/** 角色昵称 */
nickname: string;
/** 头像 */
avatar: string;
/** 颜色 */
color: string;
/** 消息内容 */
content: string;
/** 时间戳 */
timestamp: string;
/** 轮次 */
round_number: number;
/** 是否为结论 */
is_conclusion?: boolean;
/** 工具调用列表 */
tool_calls?: ToolCallResult[];
/** 是否正在流式输出 */
isStreaming?: boolean;
}
/**
* 会议响应接口
*/
export interface MeetingResponse {
success: boolean;
session_id: string;
messages: MeetingMessage[];
round_number: number;
is_concluded: boolean;
conclusion?: MeetingMessage | null;
}
/**
* 会议请求接口
*/
export interface MeetingRequest {
topic: string;
user_id?: string;
user_nickname?: string;
session_id?: string;
user_message?: string;
conversation_history?: MeetingMessage[];
}
/**
* 投研会议室角色配置
*/
export const MEETING_ROLES: Record<string, MeetingRoleConfig> = {
buffett: {
id: 'buffett',
name: '巴菲特',
nickname: '唱多者',
roleType: 'bull',
avatar: '/images/agent/巴菲特.png',
color: '#10B981',
gradient: 'linear(to-br, green.400, emerald.600)',
description: '主观多头,善于分析事件的潜在利好和长期价值',
icon: React.createElement(TrendingUp, { className: 'w-5 h-5' }),
},
big_short: {
id: 'big_short',
name: '大空头',
nickname: '大空头',
roleType: 'bear',
avatar: '/images/agent/大空头.png',
color: '#EF4444',
gradient: 'linear(to-br, red.400, rose.600)',
description: '善于分析事件和财报中的风险因素,帮助投资者避雷',
icon: React.createElement(TrendingDown, { className: 'w-5 h-5' }),
},
simons: {
id: 'simons',
name: '量化研究员',
nickname: '西蒙斯',
roleType: 'quant',
avatar: '/images/agent/simons.png',
color: '#3B82F6',
gradient: 'linear(to-br, blue.400, cyan.600)',
description: '中性立场使用28个专业量化因子分析技术指标和市场特征',
icon: React.createElement(BarChart2, { className: 'w-5 h-5' }),
},
leek: {
id: 'leek',
name: '韭菜',
nickname: '牢大',
roleType: 'retail',
avatar: '/images/agent/牢大.png',
color: '#F59E0B',
gradient: 'linear(to-br, amber.400, yellow.600)',
description: '贪婪又讨厌亏损,热爱追涨杀跌的典型散户',
icon: React.createElement(Users, { className: 'w-5 h-5' }),
},
fund_manager: {
id: 'fund_manager',
name: '基金经理',
nickname: '决策者',
roleType: 'manager',
avatar: '/images/agent/基金经理.png',
color: '#8B5CF6',
gradient: 'linear(to-br, purple.400, violet.600)',
description: '总结其他人的发言做出最终决策',
icon: React.createElement(Crown, { className: 'w-5 h-5' }),
},
};
/**
* 用户角色配置(用于用户插话)
*/
export const USER_ROLE: MeetingRoleConfig = {
id: 'user',
name: '用户',
nickname: '你',
roleType: 'retail',
avatar: '',
color: '#6366F1',
gradient: 'linear(to-br, indigo.400, purple.600)',
description: '参与讨论的用户',
icon: React.createElement(Users, { className: 'w-5 h-5' }),
};
/**
* 获取角色配置
*/
export const getRoleConfig = (roleId: string): MeetingRoleConfig | undefined => {
if (roleId === 'user') return USER_ROLE;
return MEETING_ROLES[roleId];
};
/**
* 获取所有非管理者角色(用于发言顺序)
*/
export const getSpeakingRoles = (): MeetingRoleConfig[] => {
return Object.values(MEETING_ROLES).filter(
(role) => role.roleType !== 'manager'
);
};
/**
* 会议状态枚举
*/
export enum MeetingStatus {
/** 空闲,等待用户输入议题 */
IDLE = 'idle',
/** 正在开始会议 */
STARTING = 'starting',
/** 正在讨论中 */
DISCUSSING = 'discussing',
/** 某个角色正在发言 */
SPEAKING = 'speaking',
/** 等待用户输入(可以插话或继续) */
WAITING_INPUT = 'waiting_input',
/** 会议已结束,得出结论 */
CONCLUDED = 'concluded',
/** 发生错误 */
ERROR = 'error',
}
/**
* SSE 事件类型
*/
export type MeetingEventType =
| 'session_start'
| 'order_decided'
| 'speaking_start'
| 'tool_call_start'
| 'tool_call_result'
| 'content_delta'
| 'message_complete'
| 'round_end'
| 'error';
/**
* SSE 事件接口
*/
export interface MeetingEvent {
type: MeetingEventType;
session_id?: string;
order?: string[];
role_id?: string;
role_name?: string;
message?: MeetingMessage;
is_concluded?: boolean;
round_number?: number;
/** 工具调用相关 */
tool_call_id?: string;
tool_name?: string;
arguments?: Record<string, any>;
result?: any;
status?: string;
execution_time?: number;
/** 流式内容 */
content?: string;
/** 错误信息 */
error?: string;
}

View File

@@ -17,6 +17,25 @@ import {
DollarSign,
Search,
Users,
// 量化工具图标
TrendingDown,
BarChart2,
Gauge,
Flame,
ArrowUpDown,
Waves,
Target,
CandlestickChart,
Sparkles,
ShieldAlert,
Calculator,
Zap,
Percent,
GitCompare,
Shuffle,
Brain,
Combine,
Scale,
} from 'lucide-react';
/**
@@ -29,6 +48,15 @@ export enum ToolCategory {
RESEARCH = '研报路演',
STOCK_DATA = '股票数据',
USER_DATA = '用户数据',
// 量化分析类别
QUANT_CLASSIC = '经典技术指标',
QUANT_VOLUME = '资金与情绪',
QUANT_PATTERN = '形态与突破',
QUANT_RISK = '风险与估值',
QUANT_MINUTE = '分钟级算子',
QUANT_TREND = '高级趋势',
QUANT_LIQUIDITY = '流动性统计',
QUANT_STRATEGY = '配对与策略',
}
/**
@@ -203,6 +231,218 @@ export const MCP_TOOLS: MCPTool[] = [
category: ToolCategory.USER_DATA,
description: '用户关注的重大事件',
},
// ==================== 量化工具:经典技术指标 ====================
{
id: 'get_macd_signal',
name: 'MACD信号',
icon: React.createElement(TrendingUp, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_CLASSIC,
description: 'MACD金叉/死叉、动能分析、背离检测',
},
{
id: 'check_oscillator_status',
name: 'RSI/KDJ指标',
icon: React.createElement(Gauge, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_CLASSIC,
description: 'RSI + KDJ 超买超卖分析',
},
{
id: 'analyze_bollinger_bands',
name: '布林带分析',
icon: React.createElement(ArrowUpDown, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_CLASSIC,
description: '带宽、位置、收窄判断',
},
{
id: 'calc_stop_loss_atr',
name: 'ATR止损计算',
icon: React.createElement(ShieldAlert, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_CLASSIC,
description: '基于ATR的动态止损位计算',
},
// ==================== 量化工具:资金与情绪 ====================
{
id: 'analyze_market_heat',
name: '市场热度分析',
icon: React.createElement(Flame, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_VOLUME,
description: '换手率热度分级 + OBV趋势',
},
{
id: 'check_volume_price_divergence',
name: '量价背离检测',
icon: React.createElement(GitCompare, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_VOLUME,
description: '价量不匹配异常检测',
},
{
id: 'analyze_obv_trend',
name: 'OBV能量潮',
icon: React.createElement(Waves, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_VOLUME,
description: 'OBV独立分析+背离检测',
},
// ==================== 量化工具:形态与突破 ====================
{
id: 'check_new_high_breakout',
name: '新高突破检测',
icon: React.createElement(Target, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_PATTERN,
description: '20/60日唐奇安通道新高突破',
},
{
id: 'identify_candlestick_pattern',
name: 'K线形态识别',
icon: React.createElement(CandlestickChart, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_PATTERN,
description: '10+种经典K线组合形态',
},
{
id: 'find_price_gaps',
name: '跳空缺口分析',
icon: React.createElement(Sparkles, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_PATTERN,
description: '未回补缺口筛选与分析',
},
// ==================== 量化工具:风险与估值 ====================
{
id: 'calc_max_drawdown',
name: '最大回撤计算',
icon: React.createElement(TrendingDown, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_RISK,
description: '含夏普比率的回撤分析',
},
{
id: 'check_valuation_rank',
name: 'PE估值百分位',
icon: React.createElement(Percent, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_RISK,
description: 'PE历史百分位 + PEG修正',
},
{
id: 'calc_price_zscore',
name: 'Z-Score乖离率',
icon: React.createElement(Calculator, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_RISK,
description: '价格偏离均值程度+回归概率',
},
// ==================== 量化工具:分钟级高阶算子 ====================
{
id: 'calc_market_profile_vpoc',
name: 'VPOC筹码峰',
icon: React.createElement(BarChart2, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_MINUTE,
description: '成交量密集区分析',
},
{
id: 'calc_realized_volatility',
name: '已实现波动率',
icon: React.createElement(Activity, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_MINUTE,
description: '分钟级RV精确波动率',
},
{
id: 'analyze_buying_pressure',
name: '买卖压力分析',
icon: React.createElement(Scale, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_MINUTE,
description: '主力意图捕捉与压力失衡',
},
{
id: 'calc_parkinson_volatility',
name: '帕金森波动率',
icon: React.createElement(Zap, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_MINUTE,
description: '基于High/Low的精确波动率',
},
// ==================== 量化工具:高级趋势分析 ====================
{
id: 'calc_bollinger_squeeze',
name: '布林带挤压',
icon: React.createElement(Combine, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_TREND,
description: '带宽历史百分位,变盘预警',
},
{
id: 'calc_trend_slope',
name: '趋势斜率分析',
icon: React.createElement(LineChart, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_TREND,
description: 'R²拟合度+斜率方向判断',
},
{
id: 'calc_hurst_exponent',
name: 'Hurst指数',
icon: React.createElement(Brain, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_TREND,
description: '趋势/均值回归特征判断',
},
{
id: 'decompose_trend_simple',
name: '趋势分解',
icon: React.createElement(Shuffle, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_TREND,
description: '趋势+周期+残差分解',
},
// ==================== 量化工具:流动性与统计 ====================
{
id: 'calc_amihud_illiquidity',
name: 'Amihud流动性',
icon: React.createElement(DollarSign, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_LIQUIDITY,
description: '大单冲击成本评估',
},
{
id: 'calc_price_entropy',
name: '价格熵值',
icon: React.createElement(Activity, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_LIQUIDITY,
description: '市场混乱度/可预测性分析',
},
{
id: 'calc_rsi_divergence',
name: 'RSI背离检测',
icon: React.createElement(GitCompare, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_LIQUIDITY,
description: 'RSI顶底背离独立分析',
},
// ==================== 量化工具:配对与策略 ====================
{
id: 'test_cointegration',
name: '协整性测试',
icon: React.createElement(Combine, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_STRATEGY,
description: '配对交易信号与对冲比率',
},
{
id: 'calc_kelly_position',
name: '凯利仓位计算',
icon: React.createElement(Calculator, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_STRATEGY,
description: '基于胜率盈亏比的最优仓位',
},
{
id: 'search_similar_kline',
name: '相似K线检索',
icon: React.createElement(Search, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_STRATEGY,
description: '历史形态匹配预测',
},
{
id: 'get_comprehensive_analysis',
name: '综合技术分析',
icon: React.createElement(BarChart3, { className: 'w-4 h-4' }),
category: ToolCategory.QUANT_STRATEGY,
description: '多指标汇总分析报告',
},
];
/**
@@ -216,19 +456,22 @@ export const TOOL_CATEGORIES: Record<ToolCategory, MCPTool[]> = {
[ToolCategory.RESEARCH]: MCP_TOOLS.filter((t) => t.category === ToolCategory.RESEARCH),
[ToolCategory.STOCK_DATA]: MCP_TOOLS.filter((t) => t.category === ToolCategory.STOCK_DATA),
[ToolCategory.USER_DATA]: MCP_TOOLS.filter((t) => t.category === ToolCategory.USER_DATA),
// 量化工具类别
[ToolCategory.QUANT_CLASSIC]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_CLASSIC),
[ToolCategory.QUANT_VOLUME]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_VOLUME),
[ToolCategory.QUANT_PATTERN]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_PATTERN),
[ToolCategory.QUANT_RISK]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_RISK),
[ToolCategory.QUANT_MINUTE]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_MINUTE),
[ToolCategory.QUANT_TREND]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_TREND),
[ToolCategory.QUANT_LIQUIDITY]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_LIQUIDITY),
[ToolCategory.QUANT_STRATEGY]: MCP_TOOLS.filter((t) => t.category === ToolCategory.QUANT_STRATEGY),
};
/**
* 默认选中的工具 ID 列表
* 这些工具在页面初始化时自动选中
* 所有工具在页面初始化时自动选中
*/
export const DEFAULT_SELECTED_TOOLS: string[] = [
'search_news',
'search_china_news',
'search_concepts',
'search_limit_up_stocks',
'search_research_reports',
];
export const DEFAULT_SELECTED_TOOLS: string[] = MCP_TOOLS.map((tool) => tool.id);
/**
* 根据 ID 查找工具配置

View File

@@ -32,3 +32,9 @@ export type {
UseAgentChatParams,
UseAgentChatReturn,
} from './useAgentChat';
export { useInvestmentMeeting } from './useInvestmentMeeting';
export type {
UseInvestmentMeetingParams,
UseInvestmentMeetingReturn,
} from './useInvestmentMeeting';

View File

@@ -43,6 +43,10 @@ export interface UseAgentChatParams {
toast: ToastFunction;
/** 重新加载会话列表(发送消息成功后调用) */
loadSessions: () => Promise<void>;
/** 消息列表(从外部传入) */
messages: Message[];
/** 设置消息列表(从外部传入) */
setMessages: Dispatch<SetStateAction<Message[]>>;
}
/**
@@ -107,8 +111,9 @@ export const useAgentChat = ({
clearFiles,
toast,
loadSessions,
messages,
setMessages,
}: UseAgentChatParams): UseAgentChatReturn => {
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [isProcessing, setIsProcessing] = useState(false);
@@ -164,7 +169,7 @@ export const useAgentChat = ({
isUser: m.type === MessageTypes.USER,
content: m.content,
})),
user_id: user?.id || 'anonymous',
user_id: user?.id ? String(user.id) : 'anonymous',
user_nickname: user?.nickname || '匿名用户',
user_avatar: user?.avatar || '',
subscription_type: user?.subscription_type || 'free',
@@ -185,6 +190,9 @@ export const useAgentChat = ({
setCurrentSessionId(data.session_id);
}
// 获取执行步骤(后端返回 step_results 字段)
const stepResults = data.step_results || data.steps || [];
// 显示执行计划(如果有)
if (data.plan) {
addMessage({
@@ -195,24 +203,24 @@ export const useAgentChat = ({
}
// 显示执行步骤(如果有)
if (data.steps && data.steps.length > 0) {
if (stepResults.length > 0) {
addMessage({
type: MessageTypes.AGENT_EXECUTING,
content: '正在执行步骤...',
plan: data.plan,
stepResults: data.steps,
stepResults: stepResults,
});
}
// 移除 "执行中" 消息
setMessages((prev) => prev.filter((m) => m.type !== MessageTypes.AGENT_EXECUTING));
// 显示最终回复
// 显示最终回复(使用 final_summary 或 final_answer 或 message
addMessage({
type: MessageTypes.AGENT_RESPONSE,
content: data.final_answer || data.message || '处理完成',
content: data.final_summary || data.final_answer || data.message || '处理完成',
plan: data.plan,
stepResults: data.steps,
stepResults: stepResults,
metadata: data.metadata,
});

View File

@@ -95,7 +95,7 @@ export const useAgentSessions = ({
setIsLoadingSessions(true);
try {
const response = await axios.get('/mcp/agent/sessions', {
params: { user_id: user.id, limit: 50 },
params: { user_id: String(user.id), limit: 50 },
});
if (response.data.success) {
@@ -108,6 +108,23 @@ export const useAgentSessions = ({
}
}, [user?.id]);
/**
* 安全解析 JSON 字符串,如果已经是对象则直接返回
*/
const safeJsonParse = (value: unknown): unknown => {
if (!value) return null;
if (typeof value === 'object') return value;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch {
console.warn('JSON 解析失败:', value?.toString().substring(0, 100));
return null;
}
}
return null;
};
/**
* 加载指定会话的历史消息
*/
@@ -125,9 +142,9 @@ export const useAgentSessions = ({
const formattedMessages: Message[] = history.map((msg: any, idx: number) => ({
id: `${sessionId}-${idx}`,
type: msg.message_type === 'user' ? MessageTypes.USER : MessageTypes.AGENT_RESPONSE,
content: msg.message,
plan: msg.plan ? JSON.parse(msg.plan) : null,
stepResults: msg.steps ? JSON.parse(msg.steps) : null,
content: msg.message || '',
plan: safeJsonParse(msg.plan),
stepResults: safeJsonParse(msg.steps),
timestamp: msg.timestamp,
}));

View File

@@ -0,0 +1,711 @@
// src/views/AgentChat/hooks/useInvestmentMeeting.ts
// 投研会议室 Hook - 管理会议状态、发送消息、处理 SSE 流
// V2: 支持流式输出、工具调用展示、用户中途发言
import { useState, useCallback, useRef } from 'react';
import axios from 'axios';
import {
MeetingMessage,
MeetingStatus,
MeetingEvent,
MeetingResponse,
ToolCallResult,
getRoleConfig,
} from '../constants/meetingRoles';
/**
* useInvestmentMeeting Hook 参数
*/
export interface UseInvestmentMeetingParams {
/** 当前用户 ID */
userId?: string;
/** 当前用户昵称 */
userNickname?: string;
/** Toast 通知函数 */
onToast?: (options: {
title: string;
description?: string;
status: 'success' | 'error' | 'warning' | 'info';
}) => void;
}
/**
* useInvestmentMeeting Hook 返回值
*/
export interface UseInvestmentMeetingReturn {
/** 会议消息列表 */
messages: MeetingMessage[];
/** 会议状态 */
status: MeetingStatus;
/** 当前正在发言的角色 ID */
speakingRoleId: string | null;
/** 当前会话 ID */
sessionId: string | null;
/** 当前轮次 */
currentRound: number;
/** 是否已得出结论 */
isConcluded: boolean;
/** 结论消息 */
conclusion: MeetingMessage | null;
/** 输入框内容 */
inputValue: string;
/** 设置输入框内容 */
setInputValue: (value: string) => void;
/** 开始会议(用户提出议题) */
startMeeting: (topic: string) => Promise<void>;
/** 继续会议(下一轮讨论) */
continueMeeting: (userMessage?: string) => Promise<void>;
/** 用户插话 */
sendUserMessage: (message: string) => Promise<void>;
/** 重置会议 */
resetMeeting: () => void;
/** 当前议题 */
currentTopic: string;
/** 是否正在加载 */
isLoading: boolean;
}
/**
* 投研会议室 Hook
*
* 管理投研会议的完整生命周期:
* 1. 启动会议(用户提出议题)
* 2. 处理角色发言(支持流式和非流式)
* 3. 用户插话
* 4. 继续讨论
* 5. 得出结论
*/
export const useInvestmentMeeting = ({
userId = 'anonymous',
userNickname = '匿名用户',
onToast,
}: UseInvestmentMeetingParams = {}): UseInvestmentMeetingReturn => {
// 会议状态
const [messages, setMessages] = useState<MeetingMessage[]>([]);
const [status, setStatus] = useState<MeetingStatus>(MeetingStatus.IDLE);
const [speakingRoleId, setSpeakingRoleId] = useState<string | null>(null);
const [sessionId, setSessionId] = useState<string | null>(null);
const [currentRound, setCurrentRound] = useState(0);
const [isConcluded, setIsConcluded] = useState(false);
const [conclusion, setConclusion] = useState<MeetingMessage | null>(null);
const [currentTopic, setCurrentTopic] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [inputValue, setInputValue] = useState('');
// 用于取消 SSE 连接
const eventSourceRef = useRef<EventSource | null>(null);
/**
* 添加消息到列表
*/
const addMessage = useCallback((message: MeetingMessage) => {
setMessages((prev) => [
...prev,
{
...message,
id: message.id || Date.now() + Math.random(),
},
]);
}, []);
/**
* 重置会议状态
*/
const resetMeeting = useCallback(() => {
// 关闭 SSE 连接
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setMessages([]);
setStatus(MeetingStatus.IDLE);
setSpeakingRoleId(null);
setSessionId(null);
setCurrentRound(0);
setIsConcluded(false);
setConclusion(null);
setCurrentTopic('');
setIsLoading(false);
setInputValue('');
}, []);
/**
* 更新消息内容(用于流式输出)
*/
const updateMessageContent = useCallback((roleId: string, content: string) => {
setMessages((prev) => {
const lastIndex = prev.findIndex(
(m) => m.role_id === roleId && m.isStreaming
);
if (lastIndex >= 0) {
const newMessages = [...prev];
newMessages[lastIndex] = {
...newMessages[lastIndex],
content: newMessages[lastIndex].content + content,
};
return newMessages;
}
return prev;
});
}, []);
/**
* 添加工具调用到消息
*/
const addToolCallToMessage = useCallback(
(roleId: string, toolCall: ToolCallResult) => {
setMessages((prev) => {
const lastIndex = prev.findIndex(
(m) => m.role_id === roleId && m.isStreaming
);
if (lastIndex >= 0) {
const newMessages = [...prev];
const existingToolCalls = newMessages[lastIndex].tool_calls || [];
newMessages[lastIndex] = {
...newMessages[lastIndex],
tool_calls: [...existingToolCalls, toolCall],
};
return newMessages;
}
return prev;
});
},
[]
);
/**
* 更新工具调用结果
*/
const updateToolCallResult = useCallback(
(roleId: string, toolCallId: string, result: any, status: string, executionTime?: number) => {
setMessages((prev) => {
const lastIndex = prev.findIndex(
(m) => m.role_id === roleId && m.isStreaming
);
if (lastIndex >= 0) {
const newMessages = [...prev];
const toolCalls = newMessages[lastIndex].tool_calls || [];
const toolIndex = toolCalls.findIndex((t) => t.tool_call_id === toolCallId);
if (toolIndex >= 0) {
const newToolCalls = [...toolCalls];
newToolCalls[toolIndex] = {
...newToolCalls[toolIndex],
result,
status: status as 'success' | 'error',
execution_time: executionTime,
};
newMessages[lastIndex] = {
...newMessages[lastIndex],
tool_calls: newToolCalls,
};
}
return newMessages;
}
return prev;
});
},
[]
);
/**
* 完成消息流式输出
*/
const finishStreamingMessage = useCallback((roleId: string, finalContent?: string) => {
setMessages((prev) => {
const lastIndex = prev.findIndex(
(m) => m.role_id === roleId && m.isStreaming
);
if (lastIndex >= 0) {
const newMessages = [...prev];
newMessages[lastIndex] = {
...newMessages[lastIndex],
content: finalContent || newMessages[lastIndex].content,
isStreaming: false,
};
return newMessages;
}
return prev;
});
}, []);
/**
* 创建流式消息占位
*/
const createStreamingMessage = useCallback(
(roleId: string, roleName: string, roundNumber: number): MeetingMessage => {
const roleConfig = getRoleConfig(roleId);
return {
id: `${roleId}-${Date.now()}`,
role_id: roleId,
role_name: roleName,
nickname: roleConfig?.nickname || roleName,
avatar: roleConfig?.avatar || '',
color: roleConfig?.color || '#6366F1',
content: '',
timestamp: new Date().toISOString(),
round_number: roundNumber,
tool_calls: [],
isStreaming: true,
};
},
[]
);
/**
* 启动会议(使用 POST + fetch 流式 SSE
*/
const startMeetingStream = useCallback(
async (topic: string) => {
setCurrentTopic(topic);
setStatus(MeetingStatus.STARTING);
setIsLoading(true);
setMessages([]);
setCurrentRound(1);
try {
// 使用 fetch 进行 POST 请求的 SSE
const response = await fetch('/mcp/agent/meeting/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
topic,
user_id: userId,
user_nickname: userNickname,
conversation_history: [],
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('无法获取响应流');
}
const decoder = new TextDecoder();
let buffer = '';
const processLine = (line: string) => {
if (line.startsWith('data: ')) {
try {
const data: MeetingEvent = JSON.parse(line.slice(6));
handleSSEEvent(data, 1);
} catch (e) {
console.error('解析 SSE 数据失败:', e, line);
}
}
};
const handleSSEEvent = (data: MeetingEvent, roundNum: number) => {
switch (data.type) {
case 'session_start':
setSessionId(data.session_id || null);
setStatus(MeetingStatus.DISCUSSING);
break;
case 'order_decided':
// 发言顺序已决定,可以显示提示
break;
case 'speaking_start':
setSpeakingRoleId(data.role_id || null);
setStatus(MeetingStatus.SPEAKING);
// 创建流式消息占位
if (data.role_id && data.role_name) {
const streamingMsg = createStreamingMessage(
data.role_id,
data.role_name,
roundNum
);
addMessage(streamingMsg);
}
break;
case 'tool_call_start':
if (data.role_id && data.tool_call_id && data.tool_name) {
const toolCall: ToolCallResult = {
tool_call_id: data.tool_call_id,
tool_name: data.tool_name,
arguments: data.arguments,
status: 'calling',
};
addToolCallToMessage(data.role_id, toolCall);
}
break;
case 'tool_call_result':
if (data.role_id && data.tool_call_id) {
updateToolCallResult(
data.role_id,
data.tool_call_id,
data.result,
data.status || 'success',
data.execution_time
);
}
break;
case 'content_delta':
if (data.role_id && data.content) {
updateMessageContent(data.role_id, data.content);
}
break;
case 'message_complete':
{
// 后端发送的是 message 对象role_id 在 message 里
const roleId = data.role_id || data.message?.role_id;
if (roleId) {
// 后端可能发送 message 对象或直接 content
const finalContent = data.message?.content || data.content;
finishStreamingMessage(roleId, finalContent);
setSpeakingRoleId(null);
// 如果是结论消息,记录下来
if (data.message?.is_conclusion) {
setConclusion(data.message);
}
}
}
break;
case 'round_end':
setCurrentRound(data.round_number || 1);
setIsConcluded(data.is_concluded || false);
setStatus(
data.is_concluded
? MeetingStatus.CONCLUDED
: MeetingStatus.WAITING_INPUT
);
setIsLoading(false);
break;
case 'error':
console.error('会议错误:', data.error);
setStatus(MeetingStatus.ERROR);
setIsLoading(false);
onToast?.({
title: '会议出错',
description: data.error || '未知错误',
status: 'error',
});
break;
}
};
// 读取流
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
processLine(line);
}
}
}
// 处理剩余 buffer
if (buffer.trim()) {
processLine(buffer);
}
} catch (error: any) {
console.error('启动会议失败:', error);
setStatus(MeetingStatus.ERROR);
setIsLoading(false);
onToast?.({
title: '启动会议失败',
description: error.message || '请稍后重试',
status: 'error',
});
}
},
[
userId,
userNickname,
addMessage,
createStreamingMessage,
addToolCallToMessage,
updateToolCallResult,
updateMessageContent,
finishStreamingMessage,
onToast,
]
);
/**
* 启动会议(默认使用流式)
*/
const startMeeting = useCallback(
async (topic: string) => {
// 使用流式版本
await startMeetingStream(topic);
},
[startMeetingStream]
);
/**
* 继续会议讨论(使用流式)
*/
const continueMeeting = useCallback(
async (userMessage?: string) => {
if (!currentTopic) {
onToast?.({
title: '无法继续',
description: '请先启动会议',
status: 'warning',
});
return;
}
setStatus(MeetingStatus.DISCUSSING);
setIsLoading(true);
const nextRound = currentRound + 1;
setCurrentRound(nextRound);
try {
// 构建会话历史(排除正在流式传输的消息)
const historyMessages = messages
.filter((m) => !m.isStreaming)
.map((m) => ({
role_id: m.role_id,
role_name: m.role_name,
content: m.content,
}));
// 使用 fetch 进行 POST 请求的 SSE
const response = await fetch('/mcp/agent/meeting/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
topic: currentTopic,
user_id: userId,
user_nickname: userNickname,
session_id: sessionId,
user_message: userMessage,
conversation_history: historyMessages,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error('无法获取响应流');
}
const decoder = new TextDecoder();
let buffer = '';
const processLine = (line: string) => {
if (line.startsWith('data: ')) {
try {
const data: MeetingEvent = JSON.parse(line.slice(6));
handleSSEEvent(data);
} catch (e) {
console.error('解析 SSE 数据失败:', e, line);
}
}
};
const handleSSEEvent = (data: MeetingEvent) => {
switch (data.type) {
case 'session_start':
setSessionId(data.session_id || null);
break;
case 'speaking_start':
setSpeakingRoleId(data.role_id || null);
setStatus(MeetingStatus.SPEAKING);
if (data.role_id && data.role_name) {
const streamingMsg = createStreamingMessage(
data.role_id,
data.role_name,
nextRound
);
addMessage(streamingMsg);
}
break;
case 'tool_call_start':
if (data.role_id && data.tool_call_id && data.tool_name) {
const toolCall: ToolCallResult = {
tool_call_id: data.tool_call_id,
tool_name: data.tool_name,
arguments: data.arguments,
status: 'calling',
};
addToolCallToMessage(data.role_id, toolCall);
}
break;
case 'tool_call_result':
if (data.role_id && data.tool_call_id) {
updateToolCallResult(
data.role_id,
data.tool_call_id,
data.result,
data.status || 'success',
data.execution_time
);
}
break;
case 'content_delta':
if (data.role_id && data.content) {
updateMessageContent(data.role_id, data.content);
}
break;
case 'message_complete':
{
// 后端发送的是 message 对象role_id 在 message 里
const roleId = data.role_id || data.message?.role_id;
if (roleId) {
// 后端可能发送 message 对象或直接 content
const finalContent = data.message?.content || data.content;
finishStreamingMessage(roleId, finalContent);
setSpeakingRoleId(null);
// 如果是结论消息,记录下来
if (data.message?.is_conclusion) {
setConclusion(data.message);
}
}
}
break;
case 'round_end':
setCurrentRound(data.round_number || nextRound);
setIsConcluded(data.is_concluded || false);
setStatus(
data.is_concluded
? MeetingStatus.CONCLUDED
: MeetingStatus.WAITING_INPUT
);
setIsLoading(false);
break;
case 'error':
console.error('会议错误:', data.error);
setStatus(MeetingStatus.ERROR);
setIsLoading(false);
onToast?.({
title: '会议出错',
description: data.error || '未知错误',
status: 'error',
});
break;
}
};
// 读取流
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
processLine(line);
}
}
}
// 处理剩余 buffer
if (buffer.trim()) {
processLine(buffer);
}
} catch (error: any) {
console.error('继续会议失败:', error);
setStatus(MeetingStatus.ERROR);
setIsLoading(false);
onToast?.({
title: '继续会议失败',
description: error.message || '请稍后重试',
status: 'error',
});
}
},
[
currentTopic,
userId,
userNickname,
sessionId,
messages,
currentRound,
addMessage,
createStreamingMessage,
addToolCallToMessage,
updateToolCallResult,
updateMessageContent,
finishStreamingMessage,
onToast,
]
);
/**
* 用户发送消息(插话)
*/
const sendUserMessage = useCallback(
async (message: string) => {
if (!message.trim()) return;
// 先添加用户消息到列表
const userRole = getRoleConfig('user');
addMessage({
role_id: 'user',
role_name: '用户',
nickname: userNickname,
avatar: userRole?.avatar || '',
color: userRole?.color || '#6366F1',
content: message,
timestamp: new Date().toISOString(),
round_number: currentRound,
});
// 清空输入框
setInputValue('');
// 继续会议,带上用户消息
await continueMeeting(message);
},
[userNickname, currentRound, addMessage, continueMeeting]
);
return {
messages,
status,
speakingRoleId,
sessionId,
currentRound,
isConcluded,
conclusion,
inputValue,
setInputValue,
startMeeting,
continueMeeting,
sendUserMessage,
resetMeeting,
currentTopic,
isLoading,
};
};
export default useInvestmentMeeting;

View File

@@ -1,9 +1,12 @@
// src/views/AgentChat/index.js
// 超炫酷的 AI 投研助手 - HeroUI v3 现代深色主题版本
// 使用 Framer Motion 物理动画引擎 + 毛玻璃效果
// 支持两种模式:单一聊天模式 & 投研会议室模式
import React, { useState } from 'react';
import { Box, Flex, useToast } from '@chakra-ui/react';
import { Box, Flex, useToast, HStack, Button, Tooltip } from '@chakra-ui/react';
import { motion, AnimatePresence } from 'framer-motion';
import { MessageSquare, Users } from 'lucide-react';
import { useAuth } from '@contexts/AuthContext';
// 常量配置 - 从 TypeScript 模块导入
@@ -14,10 +17,19 @@ import { DEFAULT_SELECTED_TOOLS } from './constants/tools';
import LeftSidebar from './components/LeftSidebar';
import ChatArea from './components/ChatArea';
import RightSidebar from './components/RightSidebar';
import MeetingRoom from './components/MeetingRoom';
// 自定义 Hooks
import { useAgentSessions, useAgentChat, useFileUpload } from './hooks';
/**
* 聊天模式枚举
*/
const ChatMode = {
SINGLE: 'single', // 单一聊天模式
MEETING: 'meeting', // 投研会议室模式
};
/**
* Agent Chat - 主组件HeroUI v3 深色主题)
*
@@ -28,13 +40,19 @@ import { useAgentSessions, useAgentChat, useFileUpload } from './hooks';
*
* 主组件职责:
* 1. 组合各个自定义 Hooks
* 2. 管理 UI 状态(侧边栏开关、模型选择、工具选择)
* 2. 管理 UI 状态(侧边栏开关、模型选择、工具选择、聊天模式
* 3. 组合渲染子组件
*
* 新增功能2024-11
* - 投研会议室模式:多 AI 角色协作讨论投资议题
*/
const AgentChat = () => {
const { user } = useAuth();
const toast = useToast();
// ==================== 聊天模式状态 ====================
const [chatMode, setChatMode] = useState(ChatMode.SINGLE);
// ==================== UI 状态(主组件管理)====================
const [selectedModel, setSelectedModel] = useState(DEFAULT_MODEL_ID);
const [selectedTools, setSelectedTools] = useState(DEFAULT_SELECTED_TOOLS);
@@ -79,6 +97,8 @@ const AgentChat = () => {
clearFiles,
toast,
loadSessions,
messages,
setMessages,
});
// ==================== 输入框引用(保留在主组件)====================
@@ -86,52 +106,129 @@ const AgentChat = () => {
// ==================== 渲染组件 ====================
return (
<Flex h="100%" position="relative" bg="gray.900">
{/* 左侧栏 */}
<LeftSidebar
isOpen={isLeftSidebarOpen}
onClose={() => setIsLeftSidebarOpen(false)}
sessions={sessions}
currentSessionId={currentSessionId}
onSessionSwitch={switchSession}
onNewSession={createNewSession}
isLoadingSessions={isLoadingSessions}
user={user}
/>
<Flex h="100%" position="relative" bg="gray.900" direction="column">
{/* 模式切换栏 */}
<Box
bg="rgba(17, 24, 39, 0.95)"
borderBottom="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
px={4}
py={2}
>
<HStack spacing={2} justify="center">
<Tooltip label="单一聊天模式:与 AI 助手一对一对话">
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
leftIcon={<MessageSquare className="w-4 h-4" />}
variant={chatMode === ChatMode.SINGLE ? 'solid' : 'ghost'}
colorScheme={chatMode === ChatMode.SINGLE ? 'purple' : 'gray'}
onClick={() => setChatMode(ChatMode.SINGLE)}
bg={chatMode === ChatMode.SINGLE ? 'purple.500' : 'transparent'}
color={chatMode === ChatMode.SINGLE ? 'white' : 'gray.400'}
_hover={{
bg: chatMode === ChatMode.SINGLE ? 'purple.600' : 'whiteAlpha.100',
}}
>
智能助手
</Button>
</motion.div>
</Tooltip>
{/* 中间聊天区 */}
<ChatArea
messages={messages}
inputValue={inputValue}
onInputChange={setInputValue}
isProcessing={isProcessing}
onSendMessage={handleSendMessage}
onKeyPress={handleKeyPress}
uploadedFiles={uploadedFiles}
onFileSelect={handleFileSelect}
onFileRemove={removeFile}
selectedModel={selectedModel}
isLeftSidebarOpen={isLeftSidebarOpen}
isRightSidebarOpen={isRightSidebarOpen}
onToggleLeftSidebar={() => setIsLeftSidebarOpen(true)}
onToggleRightSidebar={() => setIsRightSidebarOpen(true)}
onNewSession={createNewSession}
userAvatar={user?.avatar}
inputRef={inputRef}
fileInputRef={fileInputRef}
/>
<Tooltip label="投研会议室:多位 AI 分析师协作讨论投资议题">
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
size="sm"
leftIcon={<Users className="w-4 h-4" />}
variant={chatMode === ChatMode.MEETING ? 'solid' : 'ghost'}
colorScheme={chatMode === ChatMode.MEETING ? 'orange' : 'gray'}
onClick={() => setChatMode(ChatMode.MEETING)}
bg={chatMode === ChatMode.MEETING ? 'orange.500' : 'transparent'}
color={chatMode === ChatMode.MEETING ? 'white' : 'gray.400'}
_hover={{
bg: chatMode === ChatMode.MEETING ? 'orange.600' : 'whiteAlpha.100',
}}
>
投研会议室
</Button>
</motion.div>
</Tooltip>
</HStack>
</Box>
{/* 右侧栏 */}
<RightSidebar
isOpen={isRightSidebarOpen}
onClose={() => setIsRightSidebarOpen(false)}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
selectedTools={selectedTools}
onToolsChange={setSelectedTools}
sessionsCount={sessions.length}
messagesCount={messages.length}
/>
{/* 主内容区 */}
<Flex flex={1} overflow="hidden">
<AnimatePresence mode="wait">
{chatMode === ChatMode.SINGLE ? (
<motion.div
key="single-chat"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
style={{ display: 'flex', flex: 1, height: '100%' }}
>
{/* 左侧栏 */}
<LeftSidebar
isOpen={isLeftSidebarOpen}
onClose={() => setIsLeftSidebarOpen(false)}
sessions={sessions}
currentSessionId={currentSessionId}
onSessionSwitch={switchSession}
onNewSession={createNewSession}
isLoadingSessions={isLoadingSessions}
user={user}
/>
{/* 中间聊天区 */}
<ChatArea
messages={messages}
inputValue={inputValue}
onInputChange={setInputValue}
isProcessing={isProcessing}
onSendMessage={handleSendMessage}
onKeyPress={handleKeyPress}
uploadedFiles={uploadedFiles}
onFileSelect={handleFileSelect}
onFileRemove={removeFile}
selectedModel={selectedModel}
isLeftSidebarOpen={isLeftSidebarOpen}
isRightSidebarOpen={isRightSidebarOpen}
onToggleLeftSidebar={() => setIsLeftSidebarOpen(true)}
onToggleRightSidebar={() => setIsRightSidebarOpen(true)}
onNewSession={createNewSession}
userAvatar={user?.avatar}
inputRef={inputRef}
fileInputRef={fileInputRef}
/>
{/* 右侧栏 */}
<RightSidebar
isOpen={isRightSidebarOpen}
onClose={() => setIsRightSidebarOpen(false)}
selectedModel={selectedModel}
onModelChange={setSelectedModel}
selectedTools={selectedTools}
onToolsChange={setSelectedTools}
sessionsCount={sessions.length}
messagesCount={messages.length}
/>
</motion.div>
) : (
<motion.div
key="meeting-room"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
style={{ flex: 1, height: '100%' }}
>
{/* 投研会议室 */}
<MeetingRoom user={user} onToast={toast} />
</motion.div>
)}
</AnimatePresence>
</Flex>
</Flex>
);
};

View File

@@ -2,17 +2,113 @@
// 会话管理工具函数
/**
* 按日期分组会话列表
* 格式化日期为显示标签
* @param {Date} date - 日期对象
* @param {Date} today - 今天的日期
* @returns {string} 格式化后的日期标签
*/
const formatDateLabel = (date, today) => {
const daysDiff = Math.floor((today - date) / (1000 * 60 * 60 * 24));
if (daysDiff === 0) {
return '今天';
} else if (daysDiff === 1) {
return '昨天';
} else if (daysDiff < 7) {
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return weekDays[date.getDay()];
} else {
// 超过一周,显示具体日期
return `${date.getMonth() + 1}${date.getDate()}`;
}
};
/**
* 获取日期的纯日期字符串(用于分组 key
* @param {Date} date - 日期对象
* @returns {string} YYYY-MM-DD 格式的日期字符串
*/
const getDateKey = (date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
/**
* 按日期分组会话列表(新版本 - 按具体日期分组)
*
* @param {Array} sessions - 会话列表
* @returns {Object} 分组后的会话对象 { today, yesterday, thisWeek, older }
* @returns {Array} 分组后的会话数组 [{ dateKey, label, sessions, date }]
*
* @example
* const groups = groupSessionsByDate(sessions);
* console.log(groups.today); // 今天的会话
* console.log(groups.yesterday); // 昨天的会话
* // 返回: [
* // { dateKey: '2025-11-30', label: '今天', sessions: [...], date: Date },
* // { dateKey: '2025-11-29', label: '昨天', sessions: [...], date: Date },
* // ...
* // ]
*/
export const groupSessionsByDate = (sessions) => {
if (!sessions || sessions.length === 0) {
return [];
}
const today = new Date();
today.setHours(0, 0, 0, 0);
// 按日期分组到 Map
const groupMap = new Map();
sessions.forEach((session) => {
const sessionDate = new Date(session.created_at || session.timestamp);
if (isNaN(sessionDate.getTime())) {
// 无效日期,归到今天
const todayKey = getDateKey(today);
if (!groupMap.has(todayKey)) {
groupMap.set(todayKey, {
dateKey: todayKey,
label: '今天',
sessions: [],
date: today,
});
}
groupMap.get(todayKey).sessions.push(session);
return;
}
const dateOnly = new Date(sessionDate);
dateOnly.setHours(0, 0, 0, 0);
const dateKey = getDateKey(dateOnly);
if (!groupMap.has(dateKey)) {
groupMap.set(dateKey, {
dateKey,
label: formatDateLabel(dateOnly, today),
sessions: [],
date: dateOnly,
});
}
groupMap.get(dateKey).sessions.push(session);
});
// 转换为数组并按日期降序排序
const groups = Array.from(groupMap.values()).sort((a, b) => b.date - a.date);
// 每个分组内部按时间降序排序
groups.forEach((group) => {
group.sessions.sort((a, b) => {
const dateA = new Date(a.created_at || a.timestamp);
const dateB = new Date(b.created_at || b.timestamp);
return dateB - dateA;
});
});
return groups;
};
/**
* 旧版分组函数(保留兼容性)
* @deprecated 请使用 groupSessionsByDate 替代
*/
export const groupSessionsByDateLegacy = (sessions) => {
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);

View File

@@ -0,0 +1,80 @@
/* CompactSearchBox.css */
/* 紧凑版搜索和筛选组件样式 */
/* 搜索框 placeholder 白色 - 全覆盖选择器 */
.gold-placeholder input::placeholder,
.gold-placeholder input[type="text"]::placeholder,
.gold-placeholder .ant-input::placeholder,
.gold-placeholder .ant-input-affix-wrapper input::placeholder,
.gold-placeholder .ant-select-selection-search-input::placeholder,
.gold-placeholder .ant-input-affix-wrapper .ant-input::placeholder {
color: #FFFFFF !important;
opacity: 0.8 !important;
}
/* AutoComplete placeholder - 关键选择器 */
.gold-placeholder .ant-select-selection-placeholder {
color: #FFFFFF !important;
opacity: 0.8 !important;
}
.gold-placeholder .ant-input-affix-wrapper .ant-input,
.gold-placeholder .ant-input {
color: #FFFFFF !important;
}
.gold-placeholder .ant-input-affix-wrapper {
background: transparent !important;
}
/* 透明下拉框样式 */
.transparent-select .ant-select-selector,
.transparent-cascader .ant-select-selector {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* 行业筛选宽度自适应,减少间距 */
.transparent-cascader {
width: auto !important;
}
.transparent-cascader .ant-select-selector {
padding-right: 8px !important;
min-width: unset !important;
}
/* 行业筛选 Cascader placeholder 白色 */
.transparent-select .ant-select-selection-placeholder,
.transparent-cascader .ant-select-selection-placeholder,
.transparent-cascader input::placeholder,
.transparent-cascader .ant-cascader-input::placeholder {
color: #FFFFFF !important;
}
.transparent-cascader .ant-cascader-input {
background: transparent !important;
}
/* 行业筛选 Cascader 选中值白色 */
.transparent-cascader .ant-select-selection-item,
.transparent-cascader .ant-cascader-picker-label {
color: #FFFFFF !important;
}
/* 方括号样式下拉框 - 无边框 */
.bracket-select .ant-select-selector {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.bracket-select .ant-select-selection-item,
.bracket-select .ant-select-selection-placeholder {
color: #FFFFFF !important;
}
.bracket-select .ant-select-arrow {
color: rgba(255, 255, 255, 0.65) !important;
}

View File

@@ -4,30 +4,49 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import {
Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect,
Tooltip
Tooltip, Divider, Flex
} from 'antd';
import {
SearchOutlined, CloseCircleOutlined, StockOutlined, FilterOutlined,
CalendarOutlined, SortAscendingOutlined
CalendarOutlined, SortAscendingOutlined, ReloadOutlined, ThunderboltOutlined
} from '@ant-design/icons';
import dayjs from 'dayjs';
import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
import { stockService } from '../../../services/stockService';
import { logger } from '../../../utils/logger';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '@store/slices/industrySlice';
import { stockService } from '@services/stockService';
import { logger } from '@utils/logger';
import TradingTimeFilter from './TradingTimeFilter';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import './CompactSearchBox.css';
const { Option } = AntSelect;
// 排序选项常量
const SORT_OPTIONS = [
{ value: 'new', label: '最新排序', mobileLabel: '最新' },
{ value: 'hot', label: '最热排序', mobileLabel: '热门' },
{ value: 'importance', label: '重要性排序', mobileLabel: '重要' },
{ value: 'returns_avg', label: '平均收益', mobileLabel: '均收' },
{ value: 'returns_week', label: '周收益', mobileLabel: '周收' },
];
// 重要性等级常量
const IMPORTANCE_OPTIONS = [
{ value: 'S', label: 'S级' },
{ value: 'A', label: 'A级' },
{ value: 'B', label: 'B级' },
{ value: 'C', label: 'C级' },
];
const CompactSearchBox = ({
onSearch,
onSearchFocus,
filters = {},
mode,
pageSize,
trackingFunctions = {}
trackingFunctions = {},
isMobile = false
}) => {
// 状态
const [stockOptions, setStockOptions] = useState([]);
@@ -420,19 +439,21 @@ const CompactSearchBox = ({
dispatch(fetchIndustryData());
}
};
return (
<div style={{
padding: window.innerWidth < 768 ? '12px 16px' : '16px 20px',
background: PROFESSIONAL_COLORS.background.card,
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3), 0 0 20px rgba(255, 195, 0, 0.1)',
border: `1px solid ${PROFESSIONAL_COLORS.border.default}`,
backdropFilter: 'blur(10px)'
}}>
{/* 单行紧凑布局 - 移动端自动换行 */}
<Space wrap style={{ width: '100%' }} size={window.innerWidth < 768 ? 'small' : 'medium'}>
{/* 搜索框 */}
<div style={{ padding: 0, background: 'transparent' }}>
{/* 第一行:搜索框 + 日期筛选 */}
<Flex
align="center"
gap={isMobile ? 8 : 12}
style={{
background: 'rgba(255, 255, 255, 0.03)',
border: `1px solid ${PROFESSIONAL_COLORS.border.light}`,
borderRadius: '24px',
padding: isMobile ? '2px 4px' : '8px 16px',
marginBottom: isMobile ? 8 : 12
}}
>
{/* 搜索框 - flex: 1 占满剩余空间 */}
<AutoComplete
value={inputValue}
onChange={handleInputChange}
@@ -440,46 +461,57 @@ const CompactSearchBox = ({
onSelect={handleStockSelect}
onFocus={onSearchFocus}
options={stockOptions}
placeholder="搜索股票/话题..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleMainSearch();
}
}}
style={{ width: window.innerWidth < 768 ? '100%' : 240, minWidth: window.innerWidth < 768 ? 0 : 240 }}
style={{ flex: 1, minWidth: isMobile ? 100 : 200 }}
className="gold-placeholder"
>
<Input
prefix={<SearchOutlined style={{ color: PROFESSIONAL_COLORS.gold[500] }} />}
placeholder="搜索股票/话题..."
style={{
borderRadius: '8px',
border: `1px solid ${PROFESSIONAL_COLORS.border.default}`,
boxShadow: `0 2px 8px rgba(255, 195, 0, 0.1)`,
background: PROFESSIONAL_COLORS.background.secondary,
color: PROFESSIONAL_COLORS.text.primary
border: 'none',
background: 'transparent',
color: PROFESSIONAL_COLORS.text.primary,
boxShadow: 'none'
}}
/>
</AutoComplete>
{/* 时间筛选 */}
<Tooltip title="时间筛选">
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<CalendarOutlined style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 12 }} />
<TradingTimeFilter
value={tradingTimeRange?.key || null}
onChange={handleTradingTimeChange}
compact
/>
</div>
</Tooltip>
{/* 分隔线 - H5 时隐藏 */}
{!isMobile && <Divider type="vertical" style={{ height: 24, margin: '0 8px', borderColor: 'rgba(255,255,255,0.15)' }} />}
{/* 行业筛选 */}
<Tooltip title="行业分类">
{/* 日期筛选按钮组 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
<CalendarOutlined style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 14, marginRight: 8 }} />
<TradingTimeFilter
value={tradingTimeRange?.key || null}
onChange={handleTradingTimeChange}
compact={!isMobile}
mobile={isMobile}
/>
</div>
</Flex>
{/* 第二行:筛选条件 */}
<Flex justify="space-between" align="center">
{/* 左侧筛选 */}
<Space size={isMobile ? 4 : 8}>
{/* 行业筛选 */}
<Cascader
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder="行业"
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<FilterOutlined style={{ fontSize: 12 }} />
{isMobile ? '行业' : '行业筛选'}
</span>
}
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
@@ -489,145 +521,65 @@ const CompactSearchBox = ({
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels[labels.length - 1] || '行业'}
displayRender={(labels) => labels[labels.length - 1] || (isMobile ? '行业' : '行业筛选')}
disabled={industryLoading}
style={{
width: window.innerWidth < 768 ? '100%' : 120,
minWidth: window.innerWidth < 768 ? 0 : 120,
borderRadius: '8px'
}}
suffixIcon={<FilterOutlined style={{ fontSize: 14, color: PROFESSIONAL_COLORS.gold[500] }} />}
style={{ minWidth: isMobile ? 70 : 80 }}
suffixIcon={null}
className="transparent-cascader"
/>
</Tooltip>
{/* 重要性筛选 */}
<Tooltip title="事件等级筛选">
{/* 事件等级 */}
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{
width: window.innerWidth < 768 ? '100%' : 120,
minWidth: window.innerWidth < 768 ? 0 : 120,
borderRadius: '8px'
}}
placeholder="事件等级"
style={{ minWidth: isMobile ? 100 : 120 }}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ThunderboltOutlined style={{ fontSize: 12 }} />
{isMobile ? '等级' : '事件等级'}
</span>
}
maxTagCount={0}
maxTagPlaceholder={(omittedValues) => `已选 ${omittedValues.length}`}
maxTagPlaceholder={(omittedValues) => isMobile ? `${omittedValues.length}` : `已选 ${omittedValues.length}`}
className="bracket-select"
>
<Option value="S">S级</Option>
<Option value="A">A级</Option>
<Option value="B">B级</Option>
<Option value="C">C级</Option>
{IMPORTANCE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</AntSelect>
</Tooltip>
</Space>
{/* 排序 */}
<Tooltip title="排序方式">
{/* 右侧排序和重置 */}
<Space size={isMobile ? 4 : 8}>
{/* 排序 */}
<AntSelect
value={sort}
onChange={handleSortChange}
style={{
width: window.innerWidth < 768 ? '100%' : 130,
minWidth: window.innerWidth < 768 ? 0 : 130,
borderRadius: '8px'
}}
suffixIcon={<SortAscendingOutlined style={{ fontSize: 14, color: PROFESSIONAL_COLORS.gold[500] }} />}
style={{ minWidth: isMobile ? 55 : 120 }}
className="bracket-select"
>
<Option value="new"> 最新</Option>
<Option value="hot">🔥 最热</Option>
<Option value="importance"> 重要性</Option>
<Option value="returns_avg">📊 平均收益</Option>
<Option value="returns_week">📈 周收益</Option>
{SORT_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<SortAscendingOutlined style={{ fontSize: 12 }} />
{isMobile ? opt.mobileLabel : opt.label}
</span>
</Option>
))}
</AntSelect>
</Tooltip>
{/* 重置按钮 */}
<Tooltip title="重置所有筛选">
{/* 重置按钮 */}
<Button
icon={<CloseCircleOutlined />}
icon={<ReloadOutlined />}
onClick={handleReset}
danger
type="primary"
style={{
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.2)'
}}
type="text"
style={{ color: PROFESSIONAL_COLORS.text.secondary }}
>
重置
{!isMobile && '重置筛选'}
</Button>
</Tooltip>
</Space>
{/* 激活的筛选标签(如果有的话) */}
{(inputValue || industryValue.length > 0 || importance.length > 0 || tradingTimeRange || sort !== 'new') && (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{inputValue && (
<Tag closable onClose={() => {
setInputValue('');
const params = buildFilterParams({ q: '' });
triggerSearch(params);
}} color="blue">
搜索: {inputValue}
</Tag>
)}
{tradingTimeRange && (
<Tag closable onClose={() => {
setTradingTimeRange(null);
const params = buildFilterParams({
start_date: '',
end_date: '',
recent_days: ''
});
triggerSearch(params);
}} color="green">
{tradingTimeRange.label}
</Tag>
)}
{industryValue.length > 0 && industryData && (
<Tag closable onClose={() => {
setIndustryValue([]);
const params = buildFilterParams({ industry_code: '' });
triggerSearch(params);
}} color="orange">
行业: {(() => {
const findLabel = (code, data) => {
for (const item of data) {
if (code.startsWith(item.value)) {
if (item.value === code) {
return item.label;
} else {
return findLabel(code, item.children);
}
}
}
return null;
};
const lastLevelCode = industryValue[industryValue.length - 1];
return findLabel(lastLevelCode, industryData);
})()}
</Tag>
)}
{importance.length > 0 && (
<Tag closable onClose={() => {
setImportance([]);
const params = buildFilterParams({ importance: 'all' });
triggerSearch(params);
}} color="purple">
重要性: {importance.map(imp => ({ 'S': '极高', 'A': '高', 'B': '中', 'C': '低' }[imp] || imp)).join(', ')}
</Tag>
)}
{sort && sort !== 'new' && (
<Tag closable onClose={() => {
setSort('new');
const params = buildFilterParams({ sort: 'new' });
triggerSearch(params);
}} color="cyan">
排序: {({ 'hot': '最热', 'importance': '重要性', 'returns_avg': '平均收益', 'returns_week': '周收益' }[sort] || sort)}
</Tag>
)}
</div>
)}
</Space>
</Flex>
</div>
);
};

View File

@@ -47,6 +47,7 @@ import { usePagination } from './DynamicNewsCard/hooks/usePagination';
import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './DynamicNewsCard/constants';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
import { debounce } from '../../../utils/debounce';
import { useDevice } from '@hooks/useDevice';
// 🔍 调试:渲染计数器
let dynamicNewsCardRenderCount = 0;
@@ -81,6 +82,7 @@ const DynamicNewsCardComponent = forwardRef(({
// 通知权限相关
const { browserPermission, requestBrowserPermission } = useNotification();
const { isMobile } = useDevice();
// Refs
const cardHeaderRef = useRef(null);
@@ -534,73 +536,53 @@ const [currentMode, setCurrentMode] = useState('vertical');
position="relative"
zIndex={1}
pb={3}
px={isMobile ? 2 : undefined}
>
<VStack spacing={3} align="stretch">
{/* 第一行:标题 + 通知开关 + 更新时间 */}
{/* 第一行:标题 + 模式切换 + 通知开关 + 更新时间 */}
<Flex justify="space-between" align="center">
{/* 左侧:标题 */}
<Heading size="md" color={PROFESSIONAL_COLORS.text.primary}>
<HStack spacing={2}>
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</HStack>
</Heading>
{/* 左侧:标题 + 模式切换按钮 */}
<HStack spacing={4}>
<Heading size={isMobile ? "sm" : "md"} color={PROFESSIONAL_COLORS.text.primary}>
<HStack spacing={2}>
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</HStack>
</Heading>
{/* 模式切换按钮(移动端隐藏) */}
{!isMobile && <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />}
</HStack>
{/* 右侧:通知开关 + 更新时间 */}
<HStack spacing={3}>
{/* 通知开关 */}
<Tooltip
label={browserPermission === 'granted'
? '浏览器通知已开启'
: '开启实时推送通知'}
placement="left"
hasArrow
>
{/* 通知开关 - 移动端隐藏 */}
{!isMobile && (
<HStack
spacing={2}
px={3}
py={1.5}
borderRadius="md"
bg={browserPermission === 'granted'
? useColorModeValue('green.50', 'green.900')
: useColorModeValue('gray.50', 'gray.700')}
borderWidth="1px"
borderColor={browserPermission === 'granted'
? useColorModeValue('green.200', 'green.700')
: useColorModeValue('gray.200', 'gray.600')}
cursor="pointer"
_hover={{
borderColor: browserPermission === 'granted'
? useColorModeValue('green.300', 'green.600')
: useColorModeValue('blue.300', 'blue.600'),
}}
transition="all 0.2s"
onClick={handleNotificationToggle}
_hover={{ opacity: 0.8 }}
transition="opacity 0.2s"
>
<Icon
as={BellIcon}
boxSize={3.5}
color={browserPermission === 'granted'
? useColorModeValue('green.600', 'green.300')
: useColorModeValue('gray.500', 'gray.400')}
color={PROFESSIONAL_COLORS.gold[500]}
/>
<Text
fontSize="sm"
fontWeight="medium"
color={browserPermission === 'granted'
? useColorModeValue('green.700', 'green.200')
: useColorModeValue('gray.600', 'gray.300')}
color={PROFESSIONAL_COLORS.text.secondary}
>
{browserPermission === 'granted' ? '已开启' : '开启通知'}
实时消息推送{browserPermission === 'granted' ? '已开启' : '开启'}
</Text>
<Switch
size="sm"
isChecked={browserPermission === 'granted'}
pointerEvents="none"
colorScheme="green"
colorScheme="yellow"
/>
</HStack>
</Tooltip>
)}
{/* 更新时间 */}
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">
@@ -618,6 +600,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
mode={mode}
pageSize={pageSize}
trackingFunctions={trackingFunctions}
isMobile={isMobile}
/>
</Box>
</VStack>
@@ -627,41 +610,14 @@ const [currentMode, setCurrentMode] = useState('vertical');
<CardBody
ref={cardBodyRef}
position="relative"
pt={4}
pt={0}
px={0}
mx={0}
display="flex"
flexDirection="column"
overflow="visible"
zIndex={1}
>
{/* 顶部控制栏:模式切换按钮 + 分页控制器(滚动时固定在顶部) */}
<Box
position="sticky"
top="0"
zIndex={10}
bg={cardBg}
py={2}
mb={2}
borderBottom="1px solid"
borderColor={borderColor}
mx={-6}
px={6}
boxShadow="sm"
>
<Flex justify="space-between" align="center">
{/* 左侧:模式切换按钮 */}
<ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />
{/* 右侧:分页控制器(仅在纵向模式显示) */}
{mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChangeWithScroll}
/>
)}
</Flex>
</Box>
{/* 内容区域 - 撑满剩余高度 */}
<Box flex="1" minH={0} position="relative">
{/* Loading 蒙层 - 数据请求时显示 */}

View File

@@ -87,7 +87,7 @@ const EventScrollList = React.memo(({
h="100%"
pt={0}
pb={4}
px={mode === 'four-row' ? 0 : 2}
px={mode === 'four-row' ? 0 : { base: 0, md: 2 }}
position="relative"
data-scroll-container="true"
css={{
@@ -141,6 +141,9 @@ const EventScrollList = React.memo(({
onToggleFollow={onToggleFollow}
getTimelineBoxStyle={getTimelineBoxStyle}
borderColor={borderColor}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</Box>
);

View File

@@ -17,7 +17,7 @@ const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
colorScheme="blue"
variant={mode === 'vertical' ? 'solid' : 'outline'}
>
纵向
列表
</Button>
<Button
onClick={() => onModeChange('four-row')}

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