Compare commits

...

104 Commits

Author SHA1 Message Date
zdl
b1d5b217d3 refactor: 事件详情弹窗改用 Drawer 组件从底部弹出
- EventDetailModal: Modal 替换为 Drawer,placement="bottom"
- 使用 destroyOnHidden 替代已弃用的 destroyOnClose
- 关闭按钮改用 CloseOutlined 图标,移到右上角
- 简化 Less 文件,删除与 TSX styles 属性重复的配置
- BytedeskWidget: H5 端降低 z-index,避免遮挡发布按钮

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 15:45:48 +08:00
zdl
5f6b933172 fix: 在 viewport meta 标签中添加了 viewport-fit=cover,这样浏览器会将页面内容延伸到曲面屏边缘,同时启用 safe-area-inset-* CSS 环境变量
在普通设备上保持至少 16px 的右侧内边距
在华为 MATE70 PRO 等曲面屏设备上,使用系统提供的安全区域值,避免右侧导航被遮挡
2025-12-04 14:53:17 +08:00
zdl
0c291de182 fix: 概念中心H5端卡片尺寸优化,一屏可显示更多内容
- H5端改为两列布局,间距从6改为3
    - 卡片背景高度从180px减小到100px
    - Logo尺寸从120px减小到60px
    - 内容区域padding和间距响应式调整
    - 描述文字H5端显示1行
    - 时间轴按钮尺寸H5端缩小
2025-12-04 14:47:36 +08:00
zdl
61ed1510c2 fix: 修复自选股添加失败 405 错误
- useWatchlist.js: 修正 API 路径从 /api/account/watchlist/add 改为 /api/account/watchlist
- account.js: 同步修改 mock handler 路径

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 14:40:35 +08:00
zdl
0edc6a5e00 fix: H5端热门事件移除Tooltip避免黑色悬浮框无法消除
- 使用 useBreakpointValue 检测移动端设备
- H5端不显示标题和描述的 Tooltip 提示
- PC端保留 Tooltip 功能不变

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 14:38:14 +08:00
zdl
a569a63a85 feat: 日k 和 分时h5UI调整 2025-12-04 14:11:37 +08:00
zdl
77af61a93a fix: 弹窗固定高度 2025-12-04 14:02:21 +08:00
zdl
999fd9b0a3 feat: K线添加mock数据 2025-12-04 14:02:03 +08:00
zdl
8d3e92dfaf feat: 添加批量获取K线数据的 mock handler
- 新增 /api/stock/batch-kline POST 接口 mock
- 支持批量获取多只股票的分时图和日K线数据
- 修复事件详情页面相关股票的K线和分时图无数据问题

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:46:47 +08:00
zdl
e8c21f7863 refactor: DynamicNewsDetailPanel 组件优化
- 使用 useReducer 整合 7 个折叠状态为统一的 sectionState
- 提取自选股逻辑到 useWatchlist Hook,移除 70 行重复代码
- 扩展 useWatchlist 添加 handleAddToWatchlist、isInWatchlist 方法
- 清理未使用的导入(HStack、useColorModeValue)
- 移除调试 console.log 日志
- RelatedStocksSection 改用 isInWatchlist 函数替代 watchlistSet

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:29:59 +08:00
zdl
3f518def09 fix: 预加载行业数据(解决第一次点击无数据问题) 2025-12-04 12:33:59 +08:00
zdl
f521b89c27 fix:修复添加自选股没反应 2025-12-04 12:20:27 +08:00
zdl
ac421011eb fix:修复事件中心刚进页面向上滚动了一部分 2025-12-04 11:57:30 +08:00
zdl
6628ddc7b2 fix: 导航效果UI修复 2025-12-04 11:52:44 +08:00
zdl
5dc480f5f4 feat: 个股添加个股列表弹窗 2025-12-04 11:51:21 +08:00
zdl
99f102a213 fix: 概念中心UI 2025-12-04 11:35:29 +08:00
zdl
9f6c98135f fix: 个股中心页面日期数据源统一
- fetchTopConcepts: 始终设置 selectedDate 和 availableDates
- fetchHeatmapData: 移除 setSelectedDate
- fetchMarketStats: 移除 setSelectedDate 和 setAvailableDates
- 新增 src/data/tradingDays.json: 交易日历数据(从 tdays.csv 转换)
- availableDates 基于交易日历生成,确保日期列表完整且包含最新日期

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:57:03 +08:00
zdl
f0074bca42 fix: 修改的后端代码
/api/market/statistics 接口 添加日期格式化逻辑
//api/concepts/daily-top 添加日期格式化逻辑
/api/market/heatmap 接口 已经有正确的格式化
2025-12-04 10:20:42 +08:00
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
900aff17df update pay function 2025-11-27 11:28:57 +08:00
121 changed files with 19768 additions and 2866 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.

950
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

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#000000" />

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

@@ -75,9 +75,11 @@ const BytedeskWidget = ({
const rightVal = parseInt(style.right);
const bottomVal = parseInt(style.bottom);
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
// H5 端设置按钮尺寸为 48x48只执行一次
// H5 端设置按钮尺寸为 48x48 并降低 z-index(只执行一次)
if (isMobile && !el.dataset.bytedeskStyled) {
el.dataset.bytedeskStyled = 'true';
// 降低 z-index避免遮挡页面内的发布按钮等交互元素
el.style.zIndex = 10;
const button = el.querySelector('button');
if (button) {
button.style.width = '48px';

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

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

@@ -161,7 +161,7 @@ export default function HomeNavbar() {
borderColor={navbarBorder}
py={{ base: 2, md: 3 }}
>
<Container maxW="container.xl" px={{ base: 3, md: 4 }}>
<Container maxW="container.xl" px={{ base: 3, md: 4 }} style={{ paddingRight: 'max(16px, env(safe-area-inset-right))' }}>
<Flex justify="space-between" align="center">
{/* Logo - 价小前投研 */}
<BrandLogo />

View File

@@ -61,8 +61,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
borderColor={isActive(['/community', '/concepts']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={highFreqMenu.handleMouseEnter}
@@ -128,8 +128,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
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'}
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
borderColor={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={marketReviewMenu.handleMouseEnter}
@@ -204,8 +204,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
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"
border={isActive(['/agent-chat', '/value-forum']) ? '2px solid' : 'none'}
borderColor={isActive(['/agent-chat', '/value-forum']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={agentCommunityMenu.handleMouseEnter}

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,8 +1,11 @@
// 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 { useSelector } from 'react-redux';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { stockService } from '@services/eventService';
import { selectIsMobile } from '@store/slices/deviceSlice';
/**
* 股票信息
@@ -40,6 +43,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 +78,15 @@ 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);
// H5 响应式适配
const isMobile = useSelector(selectIsMobile);
// 调试日志
console.log('[KLineChartModal] 渲染状态:', {
@@ -60,38 +95,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 +223,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 +233,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) {
@@ -170,16 +301,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
}
}
// 图表配置
// 图表配置H5 响应式)
const option: echarts.EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
left: 'center',
top: 10,
top: isMobile ? 5 : 10,
textStyle: {
color: '#e0e0e0',
fontSize: 18,
fontSize: isMobile ? 14 : 18,
fontWeight: 'bold',
},
},
@@ -244,16 +375,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
grid: [
{
left: '5%',
right: '5%',
top: '12%',
height: '60%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '12%' : '12%',
height: isMobile ? '55%' : '60%',
},
{
left: '5%',
right: '5%',
top: '77%',
height: '18%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '72%' : '77%',
height: isMobile ? '20%' : '18%',
},
],
xAxis: [
@@ -268,7 +399,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(dates.length / 8),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
},
splitLine: {
show: false,
@@ -285,7 +417,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(dates.length / 8),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
},
},
],
@@ -293,6 +426,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
{
scale: true,
gridIndex: 0,
splitNumber: isMobile ? 4 : 5,
splitLine: {
show: true,
lineStyle: {
@@ -306,12 +440,14 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => value.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitNumber: isMobile ? 2 : 3,
splitLine: {
show: false,
},
@@ -322,6 +458,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => {
if (value >= 100000000) {
return (value / 100000000).toFixed(1) + '亿';
@@ -419,7 +556,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
return () => clearTimeout(retryTimer);
}
}, [data, stock]);
}, [data, stock, isMobile]);
// 加载数据
useEffect(() => {
@@ -474,13 +611,13 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '1400px',
maxHeight: '85vh',
width: isMobile ? '96vw' : '90vw',
maxWidth: isMobile ? 'none' : '1400px',
maxHeight: isMobile ? '85vh' : '85vh',
backgroundColor: '#1a1a1a',
border: '2px solid #ffd700',
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
borderRadius: '8px',
borderRadius: isMobile ? '12px' : '8px',
zIndex: 10002,
display: 'flex',
flexDirection: 'column',
@@ -490,7 +627,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
{/* Header */}
<div
style={{
padding: '16px 24px',
padding: isMobile ? '12px 16px' : '16px 24px',
borderBottom: '1px solid #404040',
display: 'flex',
justifyContent: 'space-between',
@@ -498,20 +635,35 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '12px', flexWrap: isMobile ? 'wrap' : 'nowrap' }}>
<span style={{ fontSize: isMobile ? '14px' : '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</span>
{data.length > 0 && (
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
{data.length}1
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666', fontStyle: 'italic' }}>
{data.length}
{hasMore ? '(向左滑动加载更多)' : '(已加载全部)'}
</span>
)}
{loadingMore && (
<span style={{ fontSize: isMobile ? '10px' : '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>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
<span style={{ fontSize: '14px', color: '#999' }}>K线图</span>
<span style={{ fontSize: '12px', color: '#666' }}>
💡 |
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '16px', marginTop: '4px' }}>
<span style={{ fontSize: isMobile ? '12px' : '14px', color: '#999' }}>K线图</span>
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666' }}>
💡 {isMobile ? '滚轮缩放 | 拖动查看' : '鼠标滚轮缩放 | 拖动查看不同时间段'}
</span>
</div>
</div>
@@ -534,26 +686,33 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
</div>
{/* Body */}
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
<div style={{
padding: isMobile ? '8px' : '16px',
flex: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}>
{error && (
<div
style={{
backgroundColor: '#2a1a1a',
border: '1px solid #ef5350',
borderRadius: '4px',
padding: '12px 16px',
marginBottom: '16px',
padding: isMobile ? '8px 12px' : '12px 16px',
marginBottom: isMobile ? '8px' : '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span style={{ color: '#ef5350' }}></span>
<span style={{ color: '#e0e0e0' }}>{error}</span>
<span style={{ color: '#e0e0e0', fontSize: isMobile ? '12px' : '14px' }}>{error}</span>
</div>
)}
<div style={{ position: 'relative', height: '680px', width: '100%' }}>
<div style={{ position: 'relative', height: isMobile ? '450px' : '680px', width: '100%' }}>
{loading && (
<div
style={{

View File

@@ -1,5 +1,6 @@
// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件
import React, { useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import {
Modal,
ModalOverlay,
@@ -17,7 +18,9 @@ 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';
import { selectIsMobile } from '@store/slices/deviceSlice';
/**
* 股票信息
@@ -67,7 +70,10 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<TimelineDataPoint[]>([]);
// 加载分时图数据
// H5 响应式适配
const isMobile = useSelector(selectIsMobile);
// 加载分时图数据(优先使用缓存)
const loadData = async () => {
if (!stock?.stock_code) return;
@@ -75,20 +81,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);
@@ -176,16 +192,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
}
}
// 图表配置
// 图表配置H5 响应式)
const option: echarts.EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
left: 'center',
top: 10,
top: isMobile ? 5 : 10,
textStyle: {
color: '#e0e0e0',
fontSize: 18,
fontSize: isMobile ? 14 : 18,
fontWeight: 'bold',
},
},
@@ -236,16 +252,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
grid: [
{
left: '5%',
right: '5%',
top: '15%',
height: '55%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '12%' : '15%',
height: isMobile ? '58%' : '55%',
},
{
left: '5%',
right: '5%',
top: '75%',
height: '15%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '75%' : '75%',
height: isMobile ? '18%' : '15%',
},
],
xAxis: [
@@ -260,7 +276,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(times.length / 6),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
},
splitLine: {
show: true,
@@ -280,7 +297,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(times.length / 6),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
},
},
],
@@ -288,6 +306,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
{
scale: true,
gridIndex: 0,
splitNumber: isMobile ? 4 : 5,
splitLine: {
show: true,
lineStyle: {
@@ -301,12 +320,14 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => value.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitNumber: isMobile ? 2 : 3,
splitLine: {
show: false,
},
@@ -317,6 +338,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => {
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万';
@@ -432,7 +454,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
return () => clearTimeout(retryTimer);
}
}, [data, stock]);
}, [data, stock, isMobile]);
// 加载数据
useEffect(() => {
@@ -444,29 +466,30 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
if (!stock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size={size}>
<Modal isOpen={isOpen} onClose={onClose} size={size} isCentered>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent
maxW="90vw"
maxW={isMobile ? '96vw' : '90vw'}
maxH="85vh"
borderRadius={isMobile ? '12px' : '8px'}
bg="#1a1a1a"
borderColor="#404040"
borderWidth="1px"
border="2px solid #ffd700"
boxShadow="0 0 30px rgba(255, 215, 0, 0.5)"
>
<ModalHeader pb={3} borderBottomWidth="1px" borderColor="#404040">
<VStack align="flex-start" spacing={1}>
<ModalHeader pb={isMobile ? 2 : 3} borderBottomWidth="1px" borderColor="#404040">
<VStack align="flex-start" spacing={0}>
<HStack>
<Text fontSize="lg" fontWeight="bold" color="#e0e0e0">
<Text fontSize={isMobile ? 'md' : 'lg'} fontWeight="bold" color="#e0e0e0">
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</Text>
</HStack>
<Text fontSize="sm" color="#999">
<Text fontSize={isMobile ? 'xs' : 'sm'} color="#999">
</Text>
</VStack>
</ModalHeader>
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
<ModalBody p={4}>
<ModalBody p={isMobile ? 2 : 4}>
{error && (
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
<AlertIcon color="#ef5350" />
@@ -474,7 +497,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
</Alert>
)}
<Box position="relative" h="600px" w="100%">
<Box position="relative" h={isMobile ? '400px' : '600px'} w="100%">
{loading && (
<Flex
position="absolute"

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(

File diff suppressed because one or more lines are too long

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

@@ -10,7 +10,7 @@ const WATCHLIST_PAGE_SIZE = 10;
/**
* 自选股管理 Hook
* 提供自选股加载、分页、移除等功能
* 提供自选股加载、分页、添加、移除等功能
*
* @returns {{
* watchlistQuotes: Array,
@@ -19,7 +19,9 @@ const WATCHLIST_PAGE_SIZE = 10;
* setWatchlistPage: Function,
* WATCHLIST_PAGE_SIZE: number,
* loadWatchlistQuotes: Function,
* handleRemoveFromWatchlist: Function
* handleAddToWatchlist: Function,
* handleRemoveFromWatchlist: Function,
* isInWatchlist: Function
* }}
*/
export const useWatchlist = () => {
@@ -58,6 +60,32 @@ export const useWatchlist = () => {
}
}, []);
// 添加到自选股
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
try {
const base = getApiBase();
const resp = await fetch(base + '/api/account/watchlist', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.success) {
// 刷新自选股列表
loadWatchlistQuotes();
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
return true;
} else {
toast({ title: '添加失败', status: 'error', duration: 2000 });
return false;
}
} catch (e) {
toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 });
return false;
}
}, [toast, loadWatchlistQuotes]);
// 从自选股移除
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
try {
@@ -85,9 +113,20 @@ export const useWatchlist = () => {
}
} catch (e) {
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
return false;
}
}, [toast]);
// 判断股票是否在自选股中
const isInWatchlist = useCallback((stockCode) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
return watchlistQuotes.some(item => normalize6(item.stock_code) === target);
}, [watchlistQuotes]);
return {
watchlistQuotes,
watchlistLoading,
@@ -95,6 +134,8 @@ export const useWatchlist = () => {
setWatchlistPage,
WATCHLIST_PAGE_SIZE,
loadWatchlistQuotes,
handleRemoveFromWatchlist
handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist
};
};

View File

@@ -61,6 +61,20 @@ export const generateDailyData = (indexCode, days = 30) => {
return data;
};
/**
* 计算简单移动均价(用于分时图均价线)
* @param {Array} data - 已有数据
* @param {number} currentPrice - 当前价格
* @param {number} period - 均线周期默认5
* @returns {number} 均价
*/
function calculateAvgPrice(data, currentPrice, period = 5) {
const recentPrices = data.slice(-period).map(d => d.price || d.close);
recentPrices.push(currentPrice);
const sum = recentPrices.reduce((acc, p) => acc + p, 0);
return parseFloat((sum / recentPrices.length).toFixed(2));
}
/**
* 生成时间范围内的数据
*/
@@ -80,6 +94,11 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
// ✅ 修复:为分时图添加完整的 OHLC 字段
const closePrice = parseFloat(price.toFixed(2));
// 计算均价和涨跌幅
const avgPrice = calculateAvgPrice(data, closePrice);
const changePercent = parseFloat(((closePrice - basePrice) / basePrice * 100).toFixed(2));
data.push({
time: formatTime(current),
timestamp: current.getTime(), // ✅ 新增:毫秒时间戳
@@ -88,6 +107,8 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘)
close: closePrice, // ✅ 保留:收盘价
price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用)
avg_price: avgPrice, // ✅ 新增:均价(供 TimelineChartModal 使用)
change_percent: changePercent, // ✅ 新增:涨跌幅(供 TimelineChartModal 使用)
volume: volume,
prev_close: basePrice
});

View File

@@ -159,7 +159,7 @@ export const accountHandlers = [
}),
// 6. 添加自选股
http.post('/api/account/watchlist/add', async ({ request }) => {
http.post('/api/account/watchlist', async ({ request }) => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
@@ -188,6 +188,22 @@ export const accountHandlers = [
mockWatchlist.push(newItem);
// 同步添加到 mockRealtimeQuotes导航栏自选股菜单使用此数组
mockRealtimeQuotes.push({
stock_code: stock_code,
stock_name: stock_name,
current_price: null,
change_percent: 0,
change: 0,
volume: 0,
turnover: 0,
high: 0,
low: 0,
open: 0,
prev_close: 0,
update_time: new Date().toTimeString().slice(0, 8)
});
return HttpResponse.json({
success: true,
message: '添加成功',
@@ -210,9 +226,20 @@ export const accountHandlers = [
const { id } = params;
console.log('[Mock] 删除自选股:', id);
const index = mockWatchlist.findIndex(item => item.id === parseInt(id));
// 支持按 stock_code 或 id 匹配删除
const index = mockWatchlist.findIndex(item =>
item.stock_code === id || item.id === parseInt(id)
);
if (index !== -1) {
const stockCode = mockWatchlist[index].stock_code;
mockWatchlist.splice(index, 1);
// 同步从 mockRealtimeQuotes 移除
const quotesIndex = mockRealtimeQuotes.findIndex(item => item.stock_code === stockCode);
if (quotesIndex !== -1) {
mockRealtimeQuotes.splice(quotesIndex, 1);
}
}
return HttpResponse.json({
@@ -263,6 +290,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 +743,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

@@ -71,4 +71,269 @@ 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' },
{ stock_code: '600036', stock_name: '招商银行' },
{ stock_code: '000858', stock_name: '五粮液' },
{ stock_code: '601166', stock_name: '兴业银行' },
{ stock_code: '600276', stock_name: '恒瑞医药' },
{ stock_code: '000333', stock_name: '美的集团' },
{ stock_code: '600887', stock_name: '伊利股份' },
{ stock_code: '002415', stock_name: '海康威视' },
{ stock_code: '601888', stock_name: '中国中免' },
{ stock_code: '300059', stock_name: '东方财富' },
{ stock_code: '002475', stock_name: '立讯精密' },
{ stock_code: '600900', stock_name: '长江电力' },
{ stock_code: '601398', stock_name: '工商银行' },
{ stock_code: '600030', stock_name: '中信证券' },
{ stock_code: '000568', stock_name: '泸州老窖' },
{ stock_code: '002352', stock_name: '顺丰控股' },
{ stock_code: '600809', stock_name: '山西汾酒' },
{ stock_code: '300015', stock_name: '爱尔眼科' },
{ stock_code: '002142', stock_name: '宁波银行' },
{ stock_code: '601899', stock_name: '紫金矿业' },
{ stock_code: '600309', stock_name: '万华化学' },
{ stock_code: '002304', stock_name: '洋河股份' },
{ stock_code: '600585', stock_name: '海螺水泥' },
{ stock_code: '601288', stock_name: '农业银行' },
{ stock_code: '600050', stock_name: '中国联通' },
{ stock_code: '000001', stock_name: '平安银行' },
{ stock_code: '601668', stock_name: '中国建筑' },
{ stock_code: '600028', stock_name: '中国石化' },
{ stock_code: '601857', stock_name: '中国石油' },
{ stock_code: '600000', stock_name: '浦发银行' },
{ stock_code: '601328', stock_name: '交通银行' },
{ stock_code: '000002', stock_name: '万科A' },
{ stock_code: '600104', stock_name: '上汽集团' },
{ stock_code: '601601', stock_name: '中国太保' },
{ stock_code: '600016', stock_name: '民生银行' },
{ stock_code: '601628', stock_name: '中国人寿' },
{ stock_code: '600031', stock_name: '三一重工' },
{ stock_code: '002230', stock_name: '科大讯飞' },
{ stock_code: '300124', stock_name: '汇川技术' },
{ stock_code: '002049', stock_name: '紫光国微' },
{ stock_code: '688012', stock_name: '中微公司' },
{ stock_code: '688008', stock_name: '澜起科技' },
{ stock_code: '603501', stock_name: '韦尔股份' },
];
// 生成历史触发时间
const generateHappenedTimes = (seed) => {
const times = [];
const count = 3 + (seed % 3); // 3-5个时间点
for (let k = 0; k < count; k++) {
const daysAgo = 30 + (seed * 7 + k * 11) % 330;
const d = new Date();
d.setDate(d.getDate() - daysAgo);
times.push(d.toISOString().split('T')[0]);
}
return times.sort().reverse();
};
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
// 生成概念数据
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() * 20) + 15; // 15-35只股票
// 生成与 stockCount 一致的股票列表(包含完整字段)
const relatedStocks = [];
for (let j = 0; j < stockCount; j++) {
const idx = (i * 7 + j) % stockPool.length;
const stock = stockPool[idx];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.stock_name,
reason: `作为行业龙头企业,${stock.stock_name}在该领域具有核心竞争优势,市场份额领先。`,
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
});
}
concepts.push({
concept_id: `CONCEPT_${1001 + i}`,
concept: concept.name, // 原始字段名
concept_name: concept.name, // 兼容字段名
description: concept.desc,
stock_count: stockCount,
score: parseFloat((Math.random() * 5 + 3).toFixed(2)), // 3-8 分数
match_type: matchTypes[i % 3],
price_info: {
avg_change_pct: changePercent,
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
},
change_percent: changePercent, // 兼容字段
happened_times: generateHappenedTimes(i),
stocks: relatedStocks,
hot_score: Math.floor(Math.random() * 100)
});
}
// 按涨跌幅降序排序
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

@@ -224,4 +224,59 @@ export const stockHandlers = [
);
}
}),
// 批量获取股票K线数据
http.post('/api/stock/batch-kline', async ({ request }) => {
await delay(400);
try {
const body = await request.json();
const { codes, type = 'timeline', event_time } = body;
console.log('[Mock Stock] 批量获取K线数据:', {
stockCount: codes?.length,
type,
eventTime: event_time
});
if (!codes || !Array.isArray(codes) || codes.length === 0) {
return HttpResponse.json(
{ error: '股票代码列表不能为空' },
{ status: 400 }
);
}
// 为每只股票生成数据
const batchData = {};
codes.forEach(stockCode => {
let data;
if (type === 'timeline') {
data = generateTimelineData('000001.SH');
} else if (type === 'daily') {
data = generateDailyData('000001.SH', 60);
} else {
data = [];
}
batchData[stockCode] = {
success: true,
data: data,
stock_code: stockCode
};
});
return HttpResponse.json({
success: true,
data: batchData,
type: type,
message: '批量获取成功'
});
} catch (error) {
console.error('[Mock Stock] 批量获取K线数据失败:', error);
return HttpResponse.json(
{ error: '批量获取K线数据失败' },
{ status: 500 }
);
}
}),
];

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

@@ -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 覆盖层(如果存在) */
@@ -37,16 +41,6 @@ iframe[src*="/visitor/"] {
z-index: 1000000 !important;
}
/* ========== H5 端客服组件整体缩小 ========== */
@media (max-width: 768px) {
/* 整个客服容器缩小(包括按钮和提示框) */
[class*="bytedesk"],
[id*="bytedesk"],
[class*="BytedeskWeb"] {
transform: scale(0.7) !important;
transform-origin: bottom right !important;
}
}
/* ========== 提示框 3 秒后自动消失 ========== */
/* 提示框("在线客服 点击咨询"气泡)- 扩展选择器 */

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

@@ -93,6 +93,13 @@ const CompactSearchBox = ({
loadStocks();
}, []);
// 预加载行业数据(解决第一次点击无数据问题)
useEffect(() => {
if (!industryData || industryData.length === 0) {
dispatch(fetchIndustryData());
}
}, [dispatch, industryData]);
// 初始化筛选条件
const findIndustryPath = useCallback((targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;

View File

@@ -683,15 +683,6 @@ const [currentMode, setCurrentMode] = useState('vertical');
</ModalContent>
</Modal>
)}
{/* 右侧分页控制器仅在纵向模式显示H5 放不下时折行 */}
{mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChangeWithScroll}
/>
)}
</Card>
);
});

View File

@@ -141,6 +141,9 @@ const EventScrollList = React.memo(({
onToggleFollow={onToggleFollow}
getTimelineBoxStyle={getTimelineBoxStyle}
borderColor={borderColor}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</Box>
);

View File

@@ -15,6 +15,7 @@ import { InfoIcon } from '@chakra-ui/icons';
import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard';
import EventDetailScrollPanel from './EventDetailScrollPanel';
import EventDetailModal from '../EventDetailModal';
import PaginationControl from './PaginationControl';
/**
* 纵向分栏模式布局
@@ -28,6 +29,9 @@ import EventDetailModal from '../EventDetailModal';
* @param {Function} onToggleFollow - 关注按钮回调
* @param {Function} getTimelineBoxStyle - 时间线样式获取函数
* @param {string} borderColor - 边框颜色
* @param {number} currentPage - 当前页码
* @param {number} totalPages - 总页数
* @param {Function} onPageChange - 页码改变回调
*/
const VerticalModeLayout = React.memo(({
display = 'flex',
@@ -38,6 +42,9 @@ const VerticalModeLayout = React.memo(({
onToggleFollow,
getTimelineBoxStyle,
borderColor,
currentPage = 1,
totalPages = 1,
onPageChange,
}) => {
// 详情面板重置 key预留用于未来功能
const [detailPanelKey] = useState(0);
@@ -137,6 +144,17 @@ const VerticalModeLayout = React.memo(({
</VStack>
</Center>
)}
{/* 分页控制器 - 放在事件列表下方 */}
{totalPages > 1 && onPageChange && (
<Box pt={3} pb={1}>
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</Box>
)}
</Box>
{/* 右侧:事件详情 - 独立滚动 - 移动端隐藏 */}

View File

@@ -63,15 +63,21 @@ const CollapsibleHeader = ({
<Flex
justify="space-between"
align="center"
cursor={showModeToggle ? 'default' : 'pointer'}
cursor="pointer"
onClick={showModeToggle ? undefined : onToggle}
p={3}
bg={sectionBg}
borderRadius="md"
_hover={showModeToggle ? {} : { bg: hoverBg }}
_hover={{ bg: hoverBg }}
transition="background 0.2s"
>
<HStack spacing={2}>
{/* 左侧:标题区域(可点击切换展开) */}
<HStack
spacing={2}
cursor="pointer"
onClick={showModeToggle ? onToggle : undefined}
flex="1"
>
<Heading size="sm" color={headingColor}>
{title}
</Heading>
@@ -85,6 +91,19 @@ const CollapsibleHeader = ({
{count}
</Badge>
)}
{/* 展开/收起图标showModeToggle 时显示在标题旁边) */}
{showModeToggle && (
<IconButton
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="xs"
variant="ghost"
aria-label={isOpen ? '收起' : '展开'}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
/>
)}
</HStack>
{/* 只有 showModeToggle=true 时才显示模式切换按钮 */}
@@ -93,13 +112,12 @@ const CollapsibleHeader = ({
size="sm"
variant="ghost"
colorScheme="blue"
rightIcon={getButtonIcon()}
onClick={(e) => {
e.stopPropagation();
onModeToggle(e);
}}
>
{getButtonText()}
{currentMode === 'simple' ? '详细信息' : '精简模式'}
</Button>
)}

View File

@@ -1,10 +1,9 @@
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
// 动态新闻详情面板主组件(组装所有子组件)
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useReducer } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Box,
Card,
CardBody,
VStack,
@@ -13,14 +12,13 @@ import {
Center,
Wrap,
WrapItem,
useColorModeValue,
useToast,
Box,
} from '@chakra-ui/react';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { eventService } from '../../../../services/eventService';
import { getImportanceConfig } from '@constants/importanceLevels';
import { eventService } from '@services/eventService';
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
import { useAuth } from '../../../../contexts/AuthContext';
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
import { useAuth } from '@contexts/AuthContext';
import EventHeaderInfo from './EventHeaderInfo';
import CompactMetaBar from './CompactMetaBar';
import EventDescriptionSection from './EventDescriptionSection';
@@ -28,12 +26,56 @@ import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection';
import CompactStockItem from './CompactStockItem';
import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
import EventCommentSection from '../../../../components/EventCommentSection';
import HistoricalEvents from '@views/EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '@views/EventDetail/components/TransmissionChainAnalysis';
import SubscriptionBadge from '@components/SubscriptionBadge';
import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import { useWatchlist } from '@hooks/useWatchlist';
import EventCommentSection from '@components/EventCommentSection';
// 折叠区块状态管理 - 使用 useReducer 整合
const initialSectionState = {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
const sectionReducer = (state, action) => {
switch (action.type) {
case 'TOGGLE':
return {
...state,
[action.section]: {
...state[action.section],
isOpen: !state[action.section].isOpen
}
};
case 'SET_LOADED':
return {
...state,
[action.section]: {
...state[action.section],
hasLoaded: true
}
};
case 'SET_QUOTES_LOADED':
return {
...state,
stocks: { ...state.stocks, hasLoadedQuotes: true }
};
case 'RESET_ALL':
return {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
default:
return state;
}
};
/**
* 动态新闻详情面板主组件
@@ -47,7 +89,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default;
const textColor = PROFESSIONAL_COLORS.text.secondary;
const toast = useToast();
// 使用 useWatchlist Hook 管理自选股
const {
handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist,
loadWatchlistQuotes
} = useWatchlist();
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type
const userTier = user?.subscription_type || 'free';
@@ -75,7 +124,9 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
featureName: ''
});
// 使用 Hook 获取实时数据(禁用自动加载,改为手动触发)
// 使用 Hook 获取实时数据
// - autoLoad: false - 禁用自动加载所有数据,改为手动触发
// - autoLoadQuotes: true - 股票数据加载后自动加载行情(相关股票默认展开)
const {
stocks,
quotes,
@@ -85,8 +136,9 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
loading,
loadStocksData,
loadHistoricalData,
loadChainAnalysis
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false });
loadChainAnalysis,
refreshQuotes
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false, autoLoadQuotes: true });
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
const loadEventDetail = useCallback(async () => {
@@ -97,11 +149,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const response = await eventService.getEventDetail(event.id);
if (response.success) {
setFullEventDetail(response.data);
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
eventId: event.id,
viewCount: response.data.view_count,
title: response.data.title
});
}
} catch (error) {
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
@@ -118,28 +165,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const canAccessHistorical = hasAccess('pro');
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪
// 初始值为 false由 useEffect 根据权限动态设置
const [isStocksOpen, setIsStocksOpen] = useState(false);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
// 自选股管理(使用 localStorage
const [watchlistSet, setWatchlistSet] = useState(() => {
try {
const saved = localStorage.getItem('stock_watchlist');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
// 子区块折叠状态管理 - 使用 useReducer 整合
const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
// 锁定点击处理 - 弹出升级弹窗
const handleLockedClick = useCallback((featureName, requiredLevel) => {
@@ -159,74 +186,63 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
});
}, []);
// 相关股票 - 展开时加载(需要 PRO 权限)
// 相关股票 - 展开时加载行情(需要 PRO 权限)
const handleStocksToggle = useCallback(() => {
const newState = !isStocksOpen;
setIsStocksOpen(newState);
const willOpen = !sectionState.stocks.isOpen;
dispatchSection({ type: 'TOGGLE', section: 'stocks' });
if (newState && !hasLoadedStocks) {
console.log('%c📊 [相关股票] 首次展开,加载股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
loadStocksData();
setHasLoadedStocks(true);
// 展开时加载行情数据(如果还没加载过)
if (willOpen && !sectionState.stocks.hasLoadedQuotes && stocks.length > 0) {
refreshQuotes();
dispatchSection({ type: 'SET_QUOTES_LOADED' });
}
}, [isStocksOpen, hasLoadedStocks, loadStocksData, event?.id]);
}, [sectionState.stocks, stocks.length, refreshQuotes]);
// 相关概念 - 展开/收起(无需加载)
const handleConceptsToggle = useCallback(() => {
setIsConceptsOpen(!isConceptsOpen);
}, [isConceptsOpen]);
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
}, []);
// 历史事件对比 - 展开时加载
// 历史事件对比 - 数据已预加载,只需切换展开状态
const handleHistoricalToggle = useCallback(() => {
const newState = !isHistoricalOpen;
setIsHistoricalOpen(newState);
if (newState && !hasLoadedHistorical) {
console.log('%c📜 [历史事件] 首次展开,加载历史事件数据', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
loadHistoricalData();
setHasLoadedHistorical(true);
}
}, [isHistoricalOpen, hasLoadedHistorical, loadHistoricalData, event?.id]);
dispatchSection({ type: 'TOGGLE', section: 'historical' });
}, []);
// 传导链分析 - 展开时加载
const handleTransmissionToggle = useCallback(() => {
const newState = !isTransmissionOpen;
setIsTransmissionOpen(newState);
const willOpen = !sectionState.transmission.isOpen;
dispatchSection({ type: 'TOGGLE', section: 'transmission' });
if (newState && !hasLoadedTransmission) {
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
if (willOpen && !sectionState.transmission.hasLoaded) {
loadChainAnalysis();
setHasLoadedTransmission(true);
dispatchSection({ type: 'SET_LOADED', section: 'transmission' });
}
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]);
}, [sectionState.transmission, loadChainAnalysis]);
// 事件切换时重置所有子模块状态
useEffect(() => {
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
// 🎯 加载事件详情(增加浏览量)
// 加载事件详情(增加浏览量)
loadEventDetail();
// 重置所有加载状态
setHasLoadedStocks(false);
setHasLoadedHistorical(false);
setHasLoadedTransmission(false);
// 加载自选股数据(用于判断股票是否已关注)
loadWatchlistQuotes();
// 相关股票默认展开(有权限时)
// 重置所有折叠区块状态
dispatchSection({ type: 'RESET_ALL' });
// 相关股票默认展开,预加载股票列表和行情数据
if (canAccessStocks) {
setIsStocksOpen(true);
// 立即加载股票数据
console.log('%c📊 [相关股票] 事件切换,加载股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
loadStocksData();
setHasLoadedStocks(true);
} else {
setIsStocksOpen(false);
dispatchSection({ type: 'SET_LOADED', section: 'stocks' });
dispatchSection({ type: 'SET_QUOTES_LOADED' });
}
setIsConceptsOpen(false);
setIsHistoricalOpen(false);
setIsTransmissionOpen(false);
}, [event?.id, canAccessStocks, userTier, loadStocksData, loadEventDetail]);
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
if (canAccessHistorical) {
loadHistoricalData();
dispatchSection({ type: 'SET_LOADED', section: 'historical' });
}
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail, loadWatchlistQuotes]);
// 切换关注状态
const handleToggleFollow = useCallback(async () => {
@@ -234,42 +250,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
dispatch(toggleEventFollow(event.id));
}, [dispatch, event?.id]);
// 切换自选股
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
try {
const newWatchlist = new Set(watchlistSet);
if (isInWatchlist) {
newWatchlist.delete(stockCode);
toast({
title: '已移除自选股',
status: 'info',
duration: 2000,
isClosable: true,
});
} else {
newWatchlist.add(stockCode);
toast({
title: '已添加至自选股',
status: 'success',
duration: 2000,
isClosable: true,
});
}
setWatchlistSet(newWatchlist);
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
} catch (error) {
console.error('切换自选股失败:', error);
toast({
title: '操作失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
// 切换自选股(使用 useWatchlist Hook
const handleWatchlistToggle = useCallback(async (stockCode, stockName, currentlyInWatchlist) => {
if (currentlyInWatchlist) {
await handleRemoveFromWatchlist(stockCode);
} else {
await handleAddToWatchlist(stockCode, stockName);
}
}, [watchlistSet, toast]);
}, [handleAddToWatchlist, handleRemoveFromWatchlist]);
// 空状态
if (!event) {
@@ -318,15 +306,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
<CollapsibleSection
title="相关股票"
isOpen={isStocksOpen}
isOpen={sectionState.stocks.isOpen}
onToggle={handleStocksToggle}
count={stocks?.length || 0}
subscriptionBadge={(() => {
if (!canAccessStocks) {
return <SubscriptionBadge tier="pro" size="sm" />;
}
return null;
})()}
subscriptionBadge={!canAccessStocks ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessStocks}
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
showModeToggle={canAccessStocks}
@@ -361,7 +344,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
stocks={stocks}
quotes={quotes}
eventTime={event.created_at}
watchlistSet={watchlistSet}
isInWatchlist={isInWatchlist}
onWatchlistToggle={handleWatchlistToggle}
/>
)}
@@ -372,7 +355,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at}
isOpen={isConceptsOpen}
isOpen={sectionState.concepts.isOpen}
onToggle={handleConceptsToggle}
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessConcepts}
@@ -382,7 +365,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
<CollapsibleSection
title="历史事件对比"
isOpen={isHistoricalOpen}
isOpen={sectionState.historical.isOpen}
onToggle={handleHistoricalToggle}
count={historicalEvents?.length || 0}
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
@@ -405,7 +388,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
<CollapsibleSection
title="传导链分析"
isOpen={isTransmissionOpen}
isOpen={sectionState.transmission.isOpen}
onToggle={handleTransmissionToggle}
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
isLocked={!canAccessTransmission}
@@ -433,7 +416,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
featureName={upgradeModal.featureName}
currentLevel={userTier}
/>
): null }
) : null}
</Card>
);
};

View File

@@ -15,9 +15,11 @@ import {
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间(可选)
* @param {Function} onClick - 点击回调(可选)
* @param {Array} preloadedData - 预加载的K线数据可选由父组件批量加载后传入
* @param {boolean} loading - 外部加载状态(可选)
* @returns {JSX.Element}
*/
const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime, onClick }) {
const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime, onClick, preloadedData, loading: externalLoading }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const mountedRef = useRef(true);
@@ -44,6 +46,21 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
return;
}
// 优先使用预加载的数据(由父组件批量请求后传入)
if (preloadedData !== undefined) {
setData(preloadedData || []);
setLoading(false);
loadedRef.current = true;
dataFetchedRef.current = true;
return;
}
// 如果外部正在加载显示loading状态不发起单独请求
if (externalLoading) {
setLoading(true);
return;
}
if (dataFetchedRef.current) {
return;
}
@@ -52,8 +69,8 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily');
const cachedData = klineDataCache.get(cacheKey);
if (cachedData && cachedData.length > 0) {
setData(cachedData);
if (cachedData !== undefined) {
setData(cachedData || []);
loadedRef.current = true;
dataFetchedRef.current = true;
return;
@@ -62,7 +79,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
dataFetchedRef.current = true;
setLoading(true);
// 获取日K线数据
// 获取日K线数据(备用方案)
fetchKlineData(stockCode, stableEventTime, 'daily')
.then((result) => {
if (mountedRef.current) {
@@ -78,7 +95,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
loadedRef.current = true;
}
});
}, [stockCode, stableEventTime]);
}, [stockCode, stableEventTime, preloadedData, externalLoading]);
const chartOption = useMemo(() => {
// 提取K线数据 [open, close, low, high]
@@ -179,7 +196,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
}, (prevProps, nextProps) => {
return prevProps.stockCode === nextProps.stockCode &&
prevProps.eventTime === nextProps.eventTime &&
prevProps.onClick === nextProps.onClick;
prevProps.onClick === nextProps.onClick &&
prevProps.preloadedData === nextProps.preloadedData &&
prevProps.loading === nextProps.loading;
});
export default MiniKLineChart;

View File

@@ -1,9 +1,12 @@
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
// 相关股票列表区组件(纯内容,不含标题)
import React from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { VStack } from '@chakra-ui/react';
import dayjs from 'dayjs';
import StockListItem from './StockListItem';
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '../StockDetailPanel/utils/klineDataCache';
import { logger } from '../../../../utils/logger';
/**
* 相关股票列表区组件(纯内容部分)
@@ -12,16 +15,145 @@ import StockListItem from './StockListItem';
* @param {Array<Object>} props.stocks - 股票数组
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
* @param {string} props.eventTime - 事件时间
* @param {Set} props.watchlistSet - 自选股代码集合
* @param {Function} props.isInWatchlist - 检查股票是否在自选股中的函数
* @param {Function} props.onWatchlistToggle - 切换自选股回调
*/
const RelatedStocksSection = ({
stocks,
quotes = {},
eventTime = null,
watchlistSet = new Set(),
isInWatchlist = () => false,
onWatchlistToggle
}) => {
// 分时图数据状态:{ [stockCode]: data[] }
const [timelineDataMap, setTimelineDataMap] = useState({});
const [timelineLoading, setTimelineLoading] = useState(false);
// 日K线数据状态{ [stockCode]: data[] }
const [dailyDataMap, setDailyDataMap] = useState({});
const [dailyLoading, setDailyLoading] = useState(false);
// 稳定的事件时间
const stableEventTime = useMemo(() => {
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]);
// 稳定的股票列表 key
const stocksKey = useMemo(() => {
if (!stocks || stocks.length === 0) return '';
return stocks.map(s => s.stock_code).sort().join(',');
}, [stocks]);
// 计算分时图是否应该显示 loading
const shouldShowTimelineLoading = useMemo(() => {
if (!stocks || stocks.length === 0) return false;
const currentDataKeys = Object.keys(timelineDataMap).sort().join(',');
if (stocksKey !== currentDataKeys) {
return true;
}
return timelineLoading;
}, [stocks, stocksKey, timelineDataMap, timelineLoading]);
// 计算日K线是否应该显示 loading
const shouldShowDailyLoading = useMemo(() => {
if (!stocks || stocks.length === 0) return false;
const currentDataKeys = Object.keys(dailyDataMap).sort().join(',');
if (stocksKey !== currentDataKeys) {
return true;
}
return dailyLoading;
}, [stocks, stocksKey, dailyDataMap, dailyLoading]);
// 批量加载分时图数据
useEffect(() => {
if (!stocks || stocks.length === 0) {
setTimelineDataMap({});
setTimelineLoading(false);
return;
}
setTimelineLoading(true);
const stockCodes = stocks.map(s => s.stock_code);
// 检查缓存
const cachedData = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, stableEventTime, 'timeline');
const cached = klineDataCache.get(cacheKey);
if (cached !== undefined) {
cachedData[code] = cached;
}
});
if (Object.keys(cachedData).length === stockCodes.length) {
setTimelineDataMap(cachedData);
setTimelineLoading(false);
logger.debug('RelatedStocksSection', '分时图数据全部来自缓存', { stockCount: stockCodes.length });
return;
}
logger.debug('RelatedStocksSection', '批量加载分时图数据', {
totalCount: stockCodes.length,
eventTime: stableEventTime
});
fetchBatchKlineData(stockCodes, stableEventTime, 'timeline')
.then((batchData) => {
setTimelineDataMap({ ...cachedData, ...batchData });
setTimelineLoading(false);
})
.catch((error) => {
logger.error('RelatedStocksSection', '批量加载分时图数据失败', error);
setTimelineDataMap(cachedData);
setTimelineLoading(false);
});
}, [stocksKey, stableEventTime]);
// 批量加载日K线数据
useEffect(() => {
if (!stocks || stocks.length === 0) {
setDailyDataMap({});
setDailyLoading(false);
return;
}
setDailyLoading(true);
const stockCodes = stocks.map(s => s.stock_code);
// 检查缓存
const cachedData = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, stableEventTime, 'daily');
const cached = klineDataCache.get(cacheKey);
if (cached !== undefined) {
cachedData[code] = cached;
}
});
if (Object.keys(cachedData).length === stockCodes.length) {
setDailyDataMap(cachedData);
setDailyLoading(false);
logger.debug('RelatedStocksSection', '日K线数据全部来自缓存', { stockCount: stockCodes.length });
return;
}
logger.debug('RelatedStocksSection', '批量加载日K线数据', {
totalCount: stockCodes.length,
eventTime: stableEventTime
});
fetchBatchKlineData(stockCodes, stableEventTime, 'daily')
.then((batchData) => {
setDailyDataMap({ ...cachedData, ...batchData });
setDailyLoading(false);
})
.catch((error) => {
logger.error('RelatedStocksSection', '批量加载日K线数据失败', error);
setDailyDataMap(cachedData);
setDailyLoading(false);
});
}, [stocksKey, stableEventTime]);
// 如果没有股票数据,不渲染
if (!stocks || stocks.length === 0) {
return null;
@@ -35,8 +167,12 @@ const RelatedStocksSection = ({
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
isInWatchlist={isInWatchlist(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
timelineData={timelineDataMap[stock.stock_code]}
timelineLoading={shouldShowTimelineLoading && !timelineDataMap[stock.stock_code]}
dailyData={dailyDataMap[stock.stock_code]}
dailyLoading={shouldShowDailyLoading && !dailyDataMap[stock.stock_code]}
/>
))}
</VStack>

View File

@@ -39,13 +39,21 @@ import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
* @param {string} props.eventTime - 事件时间(可选)
* @param {boolean} props.isInWatchlist - 是否在自选股中
* @param {Function} props.onWatchlistToggle - 切换自选股回调
* @param {Array} props.timelineData - 预加载的分时图数据(可选,由父组件批量加载后传入)
* @param {boolean} props.timelineLoading - 分时图数据加载状态
* @param {Array} props.dailyData - 预加载的日K线数据可选由父组件批量加载后传入
* @param {boolean} props.dailyLoading - 日K线数据加载状态
*/
const StockListItem = ({
stock,
quote = null,
eventTime = null,
isInWatchlist = false,
onWatchlistToggle
onWatchlistToggle,
timelineData,
timelineLoading = false,
dailyData,
dailyLoading = false
}) => {
const isMobile = useSelector(selectIsMobile);
const cardBg = PROFESSIONAL_COLORS.background.card;
@@ -66,7 +74,7 @@ const StockListItem = ({
const handleWatchlistClick = (e) => {
e.stopPropagation();
onWatchlistToggle?.(stock.stock_code, isInWatchlist);
onWatchlistToggle?.(stock.stock_code, stock.stock_name, isInWatchlist);
};
// 格式化涨跌幅显示
@@ -187,12 +195,13 @@ const StockListItem = ({
{onWatchlistToggle && (
<IconButton
size="xs"
variant={isInWatchlist ? 'solid' : 'ghost'}
variant={isInWatchlist ? 'solid' : 'outline'}
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
icon={<StarIcon />}
icon={<StarIcon color={isInWatchlist ? undefined : 'gray.400'} />}
onClick={handleWatchlistClick}
aria-label={isInWatchlist ? '已关注' : '加自选'}
borderRadius="full"
borderColor={isInWatchlist ? undefined : 'gray.300'}
/>
)}
</HStack>
@@ -236,6 +245,8 @@ const StockListItem = ({
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
preloadedData={timelineData}
loading={timelineLoading}
/>
</Box>
</VStack>
@@ -278,6 +289,8 @@ const StockListItem = ({
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
preloadedData={dailyData}
loading={dailyLoading}
/>
</Box>
</VStack>
@@ -286,7 +299,7 @@ const StockListItem = ({
{/* 关联描述 - 升级和降级处理 */}
{stock.relation_desc && (
<Box flex={1} minW={0} flexBasis={isMobile ? '100%' : ''}>
{stock.relation_desc?.data ? (
{Array.isArray(stock.relation_desc?.data) ? (
// 升级:带引用来源的版本 - 添加折叠功能
<Tooltip
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
@@ -325,14 +338,61 @@ const StockListItem = ({
>
AI合成
</Tag>
{/* 直接渲染文字内容 */}
{/* 渲染 query_part每句带来源悬停提示 */}
<Text
as="span"
fontSize="sm"
color={PROFESSIONAL_COLORS.text.primary}
lineHeight="1.8"
>
{stock.relation_desc?.data?.map(item => item.sentences || item.query_part).filter(Boolean).join('')}
{Array.isArray(stock.relation_desc?.data) && stock.relation_desc.data.filter(item => item.query_part).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">
{new Date(item.declare_date).toLocaleDateString('zh-CN')}
</Text>
)}
</Box>
}
placement="top"
hasArrow
bg="rgba(20, 20, 20, 0.95)"
color="white"
maxW="420px"
>
<Text
as="span"
cursor="help"
borderBottom="1px dashed"
borderBottomColor="gray.400"
_hover={{
color: PROFESSIONAL_COLORS.gold[500],
borderBottomColor: PROFESSIONAL_COLORS.gold[500],
}}
transition="all 0.2s"
>
{item.query_part}
</Text>
</Tooltip>
{index < arr.length - 1 && ''}
</React.Fragment>
))}
</Text>
</Collapse>
</Box>

View File

@@ -120,7 +120,7 @@ const DetailedEventCard = ({
<EventPriceDisplay
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
expectationScore={event.expectation_surprise_score}
compact={false}
/>

View File

@@ -16,7 +16,6 @@ import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { getChangeColor } from '../../../../utils/colorUtils';
// 导入子组件
import ImportanceStamp from './ImportanceStamp';
import EventFollowButton from './EventFollowButton';
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
@@ -194,11 +193,6 @@ const DynamicNewsEventCard = React.memo(({
return (
<VStack align="stretch" spacing={2} w="100%" pt={8} position="relative">
{/* 右上角:重要性印章(放在卡片外层) */}
<Box position="absolute" top={-4} right={4} zIndex={10}>
<ImportanceStamp importance={event.importance} />
</Box>
{/* 事件卡片 */}
<Card
position="relative"
@@ -309,7 +303,7 @@ const DynamicNewsEventCard = React.memo(({
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
expectationScore={event.expectation_surprise_score}
/>
</Box>
</VStack>

View File

@@ -1,6 +1,6 @@
// src/views/Community/components/EventCard/EventPriceDisplay.js
import React from 'react';
import { HStack, Badge, Text, Tooltip } from '@chakra-ui/react';
import React, { useState } from 'react';
import { HStack, Box, Text, Tooltip, Progress } from '@chakra-ui/react';
import { PriceArrow } from '../../../../utils/priceFormatters';
/**
@@ -8,17 +8,20 @@ import { PriceArrow } from '../../../../utils/priceFormatters';
* @param {Object} props
* @param {number|null} props.avgChange - 平均涨跌幅
* @param {number|null} props.maxChange - 最大涨跌幅
* @param {number|null} props.weekChange - 周涨跌幅
* @param {number|null} props.expectationScore - 超预期得分满分100
* @param {boolean} props.compact - 是否为紧凑模式(只显示平均值,默认 false
* @param {boolean} props.inline - 是否内联显示(默认 false
*/
const EventPriceDisplay = ({
avgChange,
maxChange,
weekChange,
expectationScore,
compact = false,
inline = false
}) => {
// 点击切换显示最大超额/平均超额
const [showAvg, setShowAvg] = useState(false);
// 获取颜色方案
const getColorScheme = (value) => {
if (value == null) return 'gray';
@@ -31,12 +34,23 @@ const EventPriceDisplay = ({
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
};
// 获取超预期得分的颜色(渐变色系)
const getScoreColor = (score) => {
if (score == null) return { bg: 'gray.100', color: 'gray.500', progressColor: 'gray' };
if (score >= 80) return { bg: 'red.50', color: 'red.600', progressColor: 'red' };
if (score >= 60) return { bg: 'orange.50', color: 'orange.600', progressColor: 'orange' };
if (score >= 40) return { bg: 'yellow.50', color: 'yellow.700', progressColor: 'yellow' };
if (score >= 20) return { bg: 'blue.50', color: 'blue.600', progressColor: 'blue' };
return { bg: 'gray.50', color: 'gray.600', progressColor: 'gray' };
};
// 紧凑模式:只显示平均值,内联在标题后
if (compact && avgChange != null) {
return (
<Tooltip label="平均" placement="top">
<Badge
colorScheme={getColorScheme(avgChange)}
<Tooltip label="平均超额" placement="top">
<Box
bg={avgChange > 0 ? 'red.50' : avgChange < 0 ? 'green.50' : 'gray.100'}
color={avgChange > 0 ? 'red.600' : avgChange < 0 ? 'green.600' : 'gray.500'}
fontSize="xs"
px={2}
py={1}
@@ -49,71 +63,91 @@ const EventPriceDisplay = ({
>
<PriceArrow value={avgChange} />
{formatPercent(avgChange)}
</Badge>
</Box>
</Tooltip>
);
}
// 详细模式:显示所有价格变动
const displayValue = showAvg ? avgChange : maxChange;
const displayLabel = showAvg ? '平均超额' : '最大超额';
const scoreColors = getScoreColor(expectationScore);
// 详细模式:显示最大超额(可点击切换)+ 超预期得分
return (
<HStack spacing={2} flexWrap="wrap">
{/* 平均涨幅 - 始终显示,无数据时显示 -- */}
<Badge
colorScheme={getColorScheme(avgChange)}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
cursor="pointer"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
<HStack spacing={3} flexWrap="wrap">
{/* 最大超额/平均超额 - 点击切换 */}
<Tooltip
label={showAvg ? "点击查看最大超额" : "点击查看平均超额"}
placement="top"
hasArrow
>
<HStack spacing={1}>
<Text fontSize="xs" opacity={0.8}>平均</Text>
<Text fontWeight="bold">
{formatPercent(avgChange)}
</Text>
</HStack>
</Badge>
<Box
bg={displayValue > 0 ? 'red.50' : displayValue < 0 ? 'green.50' : 'gray.100'}
color={displayValue > 0 ? 'red.600' : displayValue < 0 ? 'green.600' : 'gray.500'}
fontSize="xs"
px={2.5}
py={1}
borderRadius="md"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
setShowAvg(!showAvg);
}}
_hover={{
transform: 'scale(1.02)',
boxShadow: 'sm',
opacity: 0.9
}}
transition="all 0.2s"
border="1px solid"
borderColor={displayValue > 0 ? 'red.200' : displayValue < 0 ? 'green.200' : 'gray.200'}
>
<HStack spacing={1.5}>
<Text fontSize="xs" opacity={0.7} fontWeight="medium">{displayLabel}</Text>
<Text fontWeight="bold" fontSize="sm">
{formatPercent(displayValue)}
</Text>
</HStack>
</Box>
</Tooltip>
{/* 最大涨幅 - 始终显示,无数据时显示 -- */}
<Badge
colorScheme={getColorScheme(maxChange)}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
cursor="pointer"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<HStack spacing={1}>
<Text fontSize="xs" opacity={0.8}>最大</Text>
<Text fontWeight="bold">
{formatPercent(maxChange)}
</Text>
</HStack>
</Badge>
{/* 周涨幅 - 始终显示,无数据时显示 -- */}
<Badge
colorScheme={getColorScheme(weekChange)}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
cursor="pointer"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<HStack spacing={1}>
<Text fontSize="xs" opacity={0.8}></Text>
{weekChange != null && <PriceArrow value={weekChange} />}
<Text fontWeight="bold">
{formatPercent(weekChange)}
</Text>
</HStack>
</Badge>
{/* 超预期得分 - 精致的进度条样式 */}
{expectationScore != null && (
<Tooltip
label={`超预期得分:${expectationScore.toFixed(0)}满分100分`}
placement="top"
hasArrow
>
<Box
bg={scoreColors.bg}
px={2.5}
py={1}
borderRadius="md"
border="1px solid"
borderColor={`${scoreColors.progressColor}.200`}
minW="90px"
>
<HStack spacing={2}>
<Text fontSize="xs" color={scoreColors.color} fontWeight="medium" opacity={0.8}>
超预期
</Text>
<Box flex={1} minW="40px">
<Progress
value={expectationScore}
max={100}
size="xs"
colorScheme={scoreColors.progressColor}
borderRadius="full"
bg={`${scoreColors.progressColor}.100`}
/>
</Box>
<Text fontSize="xs" fontWeight="bold" color={scoreColors.color}>
{expectationScore.toFixed(0)}
</Text>
</HStack>
</Box>
</Tooltip>
)}
</HStack>
);
};

View File

@@ -247,9 +247,9 @@ const HorizontalDynamicNewsEventCard = React.memo(({
{/* 第二行:涨跌幅数据 */}
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
avgChange={event.related_avg_chg}
expectationScore={event.expectation_surprise_score}
size={indicatorSize}
/>
</VStack>

View File

@@ -1,36 +1,8 @@
.event-detail-modal {
top: 20% !important;
margin: 0 auto !important;
padding-bottom: 0 !important;
.ant-modal-content {
border-radius: 24px !important;
background: transparent;
}
// 标题样式 - 深色文字(白色背景)
.ant-modal-title {
// 事件详情抽屉样式(从底部弹出)
// 注意:大部分样式已在 TSX 的 styles 属性中配置,这里只保留必要的覆盖
.event-detail-drawer {
// 标题样式
.ant-drawer-title {
color: #1A202C;
}
// 关闭按钮样式 - 深色(白色背景)
.ant-modal-close {
color: #4A5568;
&:hover {
color: #1A202C;
}
}
}
// 自底向上滑入动画
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Modal } from 'antd';
import { Drawer } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { selectIsMobile } from '@store/slices/deviceSlice';
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
import './EventDetailModal.less';
@@ -15,7 +16,7 @@ interface EventDetailModalProps {
}
/**
* 事件详情弹窗组件
* 事件详情抽屉组件(从底部弹出)
*/
const EventDetailModal: React.FC<EventDetailModalProps> = ({
open,
@@ -25,23 +26,35 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
const isMobile = useSelector(selectIsMobile);
return (
<Modal
<Drawer
open={open}
onCancel={onClose}
footer={null}
onClose={onClose}
placement="bottom"
height={isMobile ? 'calc(100vh - 60px)' : 'calc(100vh - 100px)'}
width={isMobile ? '100%' : '70vw'}
title={event?.title || '事件详情'}
width='100vw'
destroyOnClose
className="event-detail-modal"
destroyOnHidden
rootClassName="event-detail-drawer"
closeIcon={null}
extra={
<CloseOutlined
onClick={onClose}
style={{ cursor: 'pointer', fontSize: 16, color: '#4A5568' }}
/>
}
styles={{
mask: { background: 'transparent' },
content: { borderRadius: 24, padding: 0, maxWidth: 1400, background: 'transparent', margin: '0 auto' },
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px', borderRadius: '24px 24px 0 0', margin: 0 },
body: { padding: 0 },
wrapper: isMobile ? {} : {
maxWidth: 1400,
margin: '0 auto',
borderRadius: '16px 16px 0 0',
},
content: { borderRadius: '16px 16px 0 0' },
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px' },
body: { padding: 0, background: '#FFFFFF' },
}}
>
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
</Modal>
</Drawer>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
import React, { useState } from 'react';
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import { useDisclosure } from '@chakra-ui/react';
import { useDisclosure, useBreakpointValue } from '@chakra-ui/react';
import EventDetailModal from './EventDetailModal';
import dayjs from 'dayjs';
import './HotEvents.css';
@@ -31,6 +31,8 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
const [currentSlide, setCurrentSlide] = useState(0);
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
const [modalEvent, setModalEvent] = useState(null);
// H5 端不显示 Tooltip避免触摸触发后无法消除的黑色悬浮框
const isMobile = useBreakpointValue({ base: true, md: false });
const renderPriceChange = (value) => {
if (value === null || value === undefined) {
@@ -154,21 +156,33 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
>
{/* Custom layout without Card.Meta */}
<div className="event-header">
<Tooltip title={event.title}>
{isMobile ? (
<span className="event-title">
{event.title}
</span>
</Tooltip>
) : (
<Tooltip title={event.title}>
<span className="event-title">
{event.title}
</span>
</Tooltip>
)}
<span className="event-tag">
{renderPriceChange(event.related_avg_chg)}
</span>
</div>
<Tooltip title={event.description}>
{isMobile ? (
<div className="event-description">
{event.description}
</div>
</Tooltip>
) : (
<Tooltip title={event.description}>
<div className="event-description">
{event.description}
</div>
</Tooltip>
)}
<div className="event-footer">
<span className="creator">{event.creator?.username || 'Anonymous'}</span>

View File

@@ -93,16 +93,60 @@ const InvestmentCalendar = () => {
return code.split('.')[0];
};
/**
* 归一化股票数据格式
* 支持两种格式:
* 1. 旧格式数组:[code, name, description, score]
* 2. 新格式对象:{ code, name, description, score, report }
* 返回统一的对象格式
*/
const normalizeStock = (stock) => {
if (!stock) return null;
// 新格式:对象
if (typeof stock === 'object' && !Array.isArray(stock)) {
return {
code: stock.code || '',
name: stock.name || '',
description: stock.description || '',
score: stock.score || 0,
report: stock.report || null // 研报引用信息
};
}
// 旧格式:数组 [code, name, description, score]
if (Array.isArray(stock)) {
return {
code: stock[0] || '',
name: stock[1] || '',
description: stock[2] || '',
score: stock[3] || 0,
report: null
};
}
return null;
};
/**
* 归一化股票列表
*/
const normalizeStocks = (stocks) => {
if (!stocks || !Array.isArray(stocks)) return [];
return stocks.map(normalizeStock).filter(Boolean);
};
// 加载股票行情
const loadStockQuotes = async (stocks, eventTime) => {
try {
const codes = stocks.map(stock => getSixDigitCode(stock[0])); // 确保使用六位代码
const normalizedStocks = normalizeStocks(stocks);
const codes = normalizedStocks.map(stock => getSixDigitCode(stock.code));
const quotes = {};
// 使用市场API获取最新行情数据
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
const originalCode = stocks[i][0]; // 保持原始代码作为key
const originalCode = normalizedStocks[i].code; // 使用归一化后的代码作为key
try {
const response = await fetch(`/api/market/trade/${code}?days=1`);
if (response.ok) {
@@ -257,11 +301,13 @@ const InvestmentCalendar = () => {
message.info('暂无相关股票');
return;
}
// 按相关度排序(限降序)
const sortedStocks = [...stocks].sort((a, b) => (b[3] || 0) - (a[3] || 0));
// 归一化数据后按相关度排序(降序)
const normalizedList = normalizeStocks(stocks);
const sortedStocks = normalizedList.sort((a, b) => (b.score || 0) - (a.score || 0));
setSelectedStocks(sortedStocks);
setStockModalVisible(true);
loadStockQuotes(sortedStocks, eventTime);
loadStockQuotes(stocks, eventTime); // 传原始数据给 loadStockQuotes它内部会归一化
};
// 添加交易所后缀
@@ -281,24 +327,27 @@ const InvestmentCalendar = () => {
return sixDigitCode;
};
// 显示K线图
// 显示K线图(支持新旧格式)
const showKline = (stock) => {
const stockCode = addExchangeSuffix(stock[0]);
// 兼容新旧格式
const code = stock.code || stock[0];
const name = stock.name || stock[1];
const stockCode = addExchangeSuffix(code);
// 将 selectedDate 转换为 YYYY-MM-DD 格式日K线只需要日期不需要时间
const formattedEventTime = selectedDate ? selectedDate.format('YYYY-MM-DD') : null;
console.log('[InvestmentCalendar] 打开K线图:', {
originalCode: stock[0],
originalCode: code,
processedCode: stockCode,
stockName: stock[1],
stockName: name,
selectedDate: selectedDate?.format('YYYY-MM-DD'),
formattedEventTime: formattedEventTime
});
setSelectedStock({
stock_code: stockCode, // 添加交易所后缀
stock_name: stock[1]
stock_name: name
});
setSelectedEventTime(formattedEventTime);
setKlineModalVisible(true);
@@ -330,10 +379,13 @@ const InvestmentCalendar = () => {
}
};
// 添加单只股票到自选
// 添加单只股票到自选(支持新旧格式)
const addSingleToWatchlist = async (stock) => {
const stockCode = getSixDigitCode(stock[0]);
// 兼容新旧格式
const code = stock.code || stock[0];
const name = stock.name || stock[1];
const stockCode = getSixDigitCode(code);
setAddingToWatchlist(prev => ({ ...prev, [stockCode]: true }));
try {
@@ -345,20 +397,20 @@ const InvestmentCalendar = () => {
credentials: 'include',
body: JSON.stringify({
stock_code: stockCode, // 使用六位代码
stock_name: stock[1] // 股票名称
stock_name: name // 股票名称
})
});
const data = await response.json();
if (data.success) {
message.success(`已将 ${stock[1]}(${stockCode}) 添加到自选`);
message.success(`已将 ${name}(${stockCode}) 添加到自选`);
} else {
message.error(data.error || '添加失败');
}
} catch (error) {
logger.error('InvestmentCalendar', 'addSingleToWatchlist', error, {
stockCode,
stockName: stock[1]
stockName: name
});
message.error('添加失败,请重试');
} finally {
@@ -415,7 +467,23 @@ const InvestmentCalendar = () => {
</Button>
)
},
{
title: '未来推演',
dataIndex: 'forecast',
key: 'forecast',
width: 80,
render: (text) => (
<Button
type="link"
size="small"
icon={<RobotOutlined />}
onClick={() => showContentDetail(text, '未来推演')}
disabled={!text}
>
{text ? '查看' : '无'}
</Button>
)
},
{
title: (
<span>
@@ -484,17 +552,17 @@ const InvestmentCalendar = () => {
}
];
// 股票表格列定义
// 股票表格列定义(使用归一化后的对象格式)
const stockColumns = [
{
title: '代码',
dataIndex: '0',
dataIndex: 'code',
key: 'code',
width: 100,
render: (code) => {
const sixDigitCode = getSixDigitCode(code);
return (
<a
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
@@ -506,13 +574,13 @@ const InvestmentCalendar = () => {
},
{
title: '名称',
dataIndex: '1',
dataIndex: 'name',
key: 'name',
width: 100,
render: (name, record) => {
const sixDigitCode = getSixDigitCode(record[0]);
const sixDigitCode = getSixDigitCode(record.code);
return (
<a
<a
href={`https://valuefrontier.cn/company?scode=${sixDigitCode}`}
target="_blank"
rel="noopener noreferrer"
@@ -527,7 +595,7 @@ const InvestmentCalendar = () => {
key: 'price',
width: 80,
render: (_, record) => {
const quote = stockQuotes[record[0]];
const quote = stockQuotes[record.code];
if (quote && quote.price !== undefined) {
return (
<Text type={quote.change > 0 ? 'danger' : 'success'}>
@@ -543,7 +611,7 @@ const InvestmentCalendar = () => {
key: 'change',
width: 100,
render: (_, record) => {
const quote = stockQuotes[record[0]];
const quote = stockQuotes[record.code];
if (quote && quote.changePercent !== undefined) {
const changePercent = quote.changePercent || 0;
return (
@@ -557,11 +625,12 @@ const InvestmentCalendar = () => {
},
{
title: '关联理由',
dataIndex: '2',
dataIndex: 'description',
key: 'reason',
render: (reason, record) => {
const stockCode = record[0];
render: (description, record) => {
const stockCode = record.code;
const isExpanded = expandedReasons[stockCode] || false;
const reason = description || '';
const shouldTruncate = reason && reason.length > 100;
const toggleExpanded = () => {
@@ -571,8 +640,8 @@ const InvestmentCalendar = () => {
}));
};
// 检查是否有引用数据reason 就是 record[2]
const citationData = reason;
// 检查是否有引用数据
const citationData = description;
const hasCitation = citationData && citationData.data && Array.isArray(citationData.data);
if (hasCitation) {
@@ -582,11 +651,11 @@ const InvestmentCalendar = () => {
if (processed) {
// 计算所有段落的总长度
const totalLength = processed.segments.reduce((sum, seg) => sum + seg.text.length, 0);
const shouldTruncate = totalLength > 100;
const shouldTruncateProcessed = totalLength > 100;
// 确定要显示的段落
let displaySegments = processed.segments;
if (shouldTruncate && !isExpanded) {
if (shouldTruncateProcessed && !isExpanded) {
// 需要截断:计算应该显示到哪个段落
let charCount = 0;
displaySegments = [];
@@ -621,7 +690,7 @@ const InvestmentCalendar = () => {
</React.Fragment>
))}
</div>
{shouldTruncate && (
{shouldTruncateProcessed && (
<Button
type="link"
size="small"
@@ -665,7 +734,44 @@ const InvestmentCalendar = () => {
);
}
},
{
title: '研报引用',
dataIndex: 'report',
key: 'report',
width: 200,
render: (report, record) => {
if (!report || !report.title) {
return <Text type="secondary">-</Text>;
}
return (
<div style={{ fontSize: '12px' }}>
<Tooltip title={report.sentences || report.title}>
<div>
<Text strong style={{ display: 'block', marginBottom: 2 }}>
{report.title.length > 20 ? `${report.title.slice(0, 20)}...` : report.title}
</Text>
{report.author && (
<Text type="secondary" style={{ display: 'block', fontSize: '11px' }}>
{report.author}
</Text>
)}
{report.declare_date && (
<Text type="secondary" style={{ fontSize: '11px' }}>
{dayjs(report.declare_date).format('YYYY-MM-DD')}
</Text>
)}
{report.match_score && (
<Tag color={report.match_score === '好' ? 'green' : 'blue'} style={{ marginLeft: 4, fontSize: '10px' }}>
匹配度: {report.match_score}
</Tag>
)}
</div>
</Tooltip>
</div>
);
}
},
{
title: 'K线图',
key: 'kline',
@@ -685,9 +791,9 @@ const InvestmentCalendar = () => {
key: 'action',
width: 100,
render: (_, record) => {
const stockCode = getSixDigitCode(record[0]);
const stockCode = getSixDigitCode(record.code);
const isAdding = addingToWatchlist[stockCode] || false;
return (
<Button
type="default"

View File

@@ -1,12 +1,13 @@
// src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
import React, { useState, useEffect, useMemo, useRef } from 'react';
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import {
fetchKlineData,
getCacheKey,
klineDataCache
klineDataCache,
batchPendingRequests
} from '../utils/klineDataCache';
/**
@@ -16,9 +17,11 @@ import {
* @param {string} stockCode - 股票代码
* @param {string} eventTime - 事件时间(可选)
* @param {Function} onClick - 点击回调(可选)
* @param {Array} preloadedData - 预加载的K线数据可选由父组件批量加载后传入
* @param {boolean} loading - 外部加载状态(可选)
* @returns {JSX.Element}
*/
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick }) {
const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eventTime, onClick, preloadedData, loading: externalLoading }) {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const mountedRef = useRef(true);
@@ -37,6 +40,25 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
};
}, []);
// 从缓存或API获取数据的函数
const loadData = useCallback(() => {
if (!stockCode || !mountedRef.current) return false;
// 检查缓存
const cacheKey = getCacheKey(stockCode, stableEventTime);
const cachedData = klineDataCache.get(cacheKey);
// 如果有缓存数据(包括空数组,表示已请求过但无数据),直接使用
if (cachedData !== undefined) {
setData(cachedData || []);
setLoading(false);
loadedRef.current = true;
dataFetchedRef.current = true;
return true; // 表示数据已加载(或确认无数据)
}
return false; // 表示需要请求
}, [stockCode, stableEventTime]);
useEffect(() => {
if (!stockCode) {
setData([]);
@@ -45,44 +67,108 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
return;
}
// 如果已经请求过数据,不再重复请求
if (dataFetchedRef.current) {
return;
}
// 检查缓存
const cacheKey = getCacheKey(stockCode, stableEventTime);
const cachedData = klineDataCache.get(cacheKey);
// 如果有缓存数据,直接使用
if (cachedData && cachedData.length > 0) {
setData(cachedData);
// 优先使用预加载的数据(由父组件批量请求后传入)
if (preloadedData !== undefined) {
setData(preloadedData || []);
setLoading(false);
loadedRef.current = true;
dataFetchedRef.current = true;
return;
}
// 标记正在请求
dataFetchedRef.current = true;
setLoading(true);
// 如果外部正在加载显示loading状态不发起单独请求
// 父组件StockTable会通过 preloadedData 传入数据
if (externalLoading) {
setLoading(true);
return;
}
// 使用全局的fetchKlineData函数
fetchKlineData(stockCode, stableEventTime)
.then((result) => {
if (mountedRef.current) {
setData(result);
setLoading(false);
loadedRef.current = true;
}
})
.catch(() => {
if (mountedRef.current) {
setData([]);
setLoading(false);
loadedRef.current = true;
}
});
}, [stockCode, stableEventTime]); // 注意这里使用 stableEventTime
// 如果已经请求过数据,不再重复请求
if (dataFetchedRef.current) {
return;
}
// 尝试从缓存加载
if (loadData()) {
return;
}
// 检查批量请求的函数
const checkBatchAndLoad = () => {
// 再次检查缓存(批量请求可能已完成)
const cacheKey = getCacheKey(stockCode, stableEventTime);
const cachedData = klineDataCache.get(cacheKey);
if (cachedData !== undefined) {
setData(cachedData || []);
setLoading(false);
loadedRef.current = true;
dataFetchedRef.current = true;
return true; // 从缓存加载成功
}
const batchKey = `${stableEventTime || 'today'}|timeline`;
const pendingBatch = batchPendingRequests.get(batchKey);
if (pendingBatch) {
// 等待批量请求完成后再从缓存读取
setLoading(true);
dataFetchedRef.current = true;
pendingBatch.then(() => {
if (mountedRef.current) {
const newCachedData = klineDataCache.get(cacheKey);
setData(newCachedData || []);
setLoading(false);
loadedRef.current = true;
}
}).catch(() => {
if (mountedRef.current) {
setData([]);
setLoading(false);
}
});
return true; // 找到批量请求
}
return false; // 没有批量请求
};
// 先立即检查一次
if (checkBatchAndLoad()) {
return;
}
// 延迟检查(等待批量请求启动)
// 注意:如果父组件正在批量加载,会传入 externalLoading=true不会执行到这里
setLoading(true);
const timeoutId = setTimeout(() => {
if (!mountedRef.current || dataFetchedRef.current) return;
// 再次检查批量请求
if (checkBatchAndLoad()) {
return;
}
// 仍然没有批量请求,发起单独请求(备用方案 - 用于非批量加载场景)
dataFetchedRef.current = true;
fetchKlineData(stockCode, stableEventTime)
.then((result) => {
if (mountedRef.current) {
setData(result);
setLoading(false);
loadedRef.current = true;
}
})
.catch(() => {
if (mountedRef.current) {
setData([]);
setLoading(false);
loadedRef.current = true;
}
});
}, 200); // 延迟 200ms 等待批量请求(增加等待时间)
return () => clearTimeout(timeoutId);
}, [stockCode, stableEventTime, loadData, preloadedData, externalLoading]); // 添加 preloadedData 和 externalLoading 依赖
const chartOption = useMemo(() => {
const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number');
@@ -181,10 +267,12 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
</div>
);
}, (prevProps, nextProps) => {
// 自定义比较函数只有当stockCode、eventTime或onClick变化时才重新渲染
// 自定义比较函数
return prevProps.stockCode === nextProps.stockCode &&
prevProps.eventTime === nextProps.eventTime &&
prevProps.onClick === nextProps.onClick;
prevProps.onClick === nextProps.onClick &&
prevProps.preloadedData === nextProps.preloadedData &&
prevProps.loading === nextProps.loading;
});
export default MiniTimelineChart;

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