Compare commits

...

68 Commits

Author SHA1 Message Date
02d5311005 update pay function 2025-12-01 14:28:46 +08:00
7fa3d26470 update pay function 2025-12-01 14:16:11 +08:00
21eb1783e9 update pay function 2025-12-01 14:01:14 +08:00
ec31801ccd update pay function 2025-12-01 07:48:03 +08:00
ff9c68295b update pay function 2025-11-30 23:58:06 +08:00
a72978c200 update pay function 2025-11-30 23:39:48 +08:00
2c4f5152e4 update pay function 2025-11-30 22:54:15 +08:00
846e66fecb update pay function 2025-11-30 22:51:24 +08:00
ef6c58b247 update pay function 2025-11-30 21:45:18 +08:00
b753d29dbf update pay function 2025-11-30 21:14:27 +08:00
455e1c1d32 update pay function 2025-11-30 18:55:35 +08:00
7b65cac358 update pay function 2025-11-30 18:45:36 +08:00
8843c81d8b update pay function 2025-11-30 18:31:13 +08:00
6763151c57 update pay function 2025-11-30 17:41:55 +08:00
9d9d3430b7 update pay function 2025-11-30 17:18:05 +08:00
25c3d9d828 update pay function 2025-11-30 17:06:34 +08:00
41368f82a7 update pay function 2025-11-30 16:39:24 +08:00
608ac4a962 update pay function 2025-11-30 16:33:34 +08:00
5a24cb9eec update pay function 2025-11-30 16:16:48 +08:00
33a3c16421 update pay function 2025-11-30 15:36:20 +08:00
2f8388ba41 update pay function 2025-11-30 13:57:39 +08:00
4127e4c816 update pay function 2025-11-30 13:47:47 +08:00
05aa0c89f0 update pay function 2025-11-30 13:38:29 +08:00
14ab2f62f3 update pay function 2025-11-30 09:15:24 +08:00
fc738dc639 update pay function 2025-11-29 18:43:43 +08:00
059275d1a2 update pay function 2025-11-29 18:28:32 +08:00
d14be2081d update pay function 2025-11-29 14:07:55 +08:00
1676d69917 update pay function 2025-11-29 13:47:18 +08:00
20b3d624f0 update pay function 2025-11-29 10:05:57 +08:00
34323cc63d update pay function 2025-11-29 09:42:41 +08:00
42fdb7d754 update pay function 2025-11-29 08:16:41 +08:00
5526705254 update pay function 2025-11-28 17:57:10 +08:00
f6e8d673a8 update pay function 2025-11-28 17:00:02 +08:00
547424fff6 update pay function 2025-11-28 16:51:28 +08:00
ec2978026a update pay function 2025-11-28 16:32:27 +08:00
250d585b87 update pay function 2025-11-28 16:08:31 +08:00
8cf2850660 update pay function 2025-11-28 15:32:03 +08:00
9b7a221315 update pay function 2025-11-28 14:49:16 +08:00
18f8f75116 update pay function 2025-11-28 14:09:47 +08:00
56a7ca7eb3 update pay function 2025-11-28 14:00:36 +08:00
c1937b9e31 update pay function 2025-11-28 12:37:01 +08:00
9c5900c7f5 update pay function 2025-11-28 12:27:30 +08:00
007de2d76d update pay function 2025-11-28 09:45:36 +08:00
49656e6e88 update pay function 2025-11-28 09:17:44 +08:00
bc6e993dec update pay function 2025-11-28 08:59:36 +08:00
72a490c789 update pay function 2025-11-28 08:52:09 +08:00
zdl
b88bfebcef Merge branch 'feature_2025/251121_h5UI' into feature_2025/251117_pref
* feature_2025/251121_h5UI:
  feat: 传导练UI调整
  fix: UI调试
  fix: 调整相关概念卡片UI
  fix: 文案调整
  fix: AI合成h5换行,pc一行,评论标题上方margin去掉
  fix: 调整AI合成UI
  fix: 分时图UI调整
  fix:事件详情弹窗UI
  fix:调整客服UI
  fix: 事件详情弹窗UI调整
  fix: 事件详情弹窗UI调整 重要性h5不展示 事件列表卡片间距调整
  fix: h5 去掉通知弹窗引导
  fix: 关注按钮UI调整
2025-11-28 07:15:11 +08:00
zdl
cf4fdf6a68 feat: 传导练UI调整 2025-11-28 07:14:52 +08:00
zdl
34338373cd fix: UI调试 2025-11-27 18:27:44 +08:00
zdl
589e1c20f9 fix: 调整相关概念卡片UI 2025-11-27 17:22:49 +08:00
zdl
60e9a40a1f fix: 文案调整 2025-11-27 17:03:35 +08:00
zdl
b8b24643fe fix: AI合成h5换行,pc一行,评论标题上方margin去掉 2025-11-27 16:55:25 +08:00
zdl
e9e9ec9051 fix: 调整AI合成UI 2025-11-27 16:40:35 +08:00
zdl
5b0e420770 fix: 分时图UI调整 2025-11-27 16:20:15 +08:00
zdl
93f43054fd fix:事件详情弹窗UI 2025-11-27 15:35:48 +08:00
zdl
101d042b0e fix:调整客服UI 2025-11-27 15:31:07 +08:00
zdl
a1aa6718e6 fix: 事件详情弹窗UI调整 2025-11-27 15:08:14 +08:00
zdl
753727c1c0 fix: 事件详情弹窗UI调整
重要性h5不展示
事件列表卡片间距调整
2025-11-27 14:40:38 +08:00
zdl
afc92ee583 fix: h5 去掉通知弹窗引导 2025-11-27 13:37:01 +08:00
900aff17df update pay function 2025-11-27 11:28:57 +08:00
zdl
d825e4fe59 fix: 关注按钮UI调整 2025-11-27 11:19:20 +08:00
zdl
62cf0a6c7d feat: 修改小程序跳转链接 2025-11-27 10:46:14 +08:00
zdl
805d446775 feat: 调整搜索框UI 2025-11-26 19:33:00 +08:00
zdl
24ddfcd4b5 feat: 新增:H5 时左右 padding 改为 8px 2025-11-26 19:31:12 +08:00
zdl
a90158239b feat: 模式切花移动到标题恻,通知UI调整 2025-11-26 19:11:33 +08:00
zdl
a8d4245595 pref: 文案调整 2025-11-26 17:49:39 +08:00
zdl
5aedde7528 feat:H5 移动端已隐藏整个顶部控制栏 2025-11-26 16:51:52 +08:00
zdl
f5f89a1c72 feat:箭头绝对定位
移除左右 padding
隐藏重复箭头
2025-11-26 16:50:46 +08:00
88 changed files with 15982 additions and 2458 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

470
app.py
View File

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

381
app_vx.py
View File

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

1096
concept_api_openapi.json Normal file

File diff suppressed because it is too large Load Diff

166
gunicorn_config.py Normal file
View File

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

View File

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

View File

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

2780
mcp_quant.py Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 918 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 795 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1017 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 607 KiB

2089
report_zt_api.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -63,6 +63,59 @@ const BytedeskWidget = ({
bytedesk.init();
widgetRef.current = bytedesk;
// ⚡ H5 端样式适配:使用 MutationObserver 立即应用样式(避免闪烁)
const isMobile = window.innerWidth <= 768;
const applyBytedeskStyles = () => {
const allElements = document.querySelectorAll('body > div');
allElements.forEach(el => {
const style = window.getComputedStyle(el);
// 检查是否是右下角固定定位的元素Bytedesk 按钮)
if (style.position === 'fixed' && style.right && style.bottom) {
const rightVal = parseInt(style.right);
const bottomVal = parseInt(style.bottom);
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
// H5 端设置按钮尺寸为 48x48只执行一次
if (isMobile && !el.dataset.bytedeskStyled) {
el.dataset.bytedeskStyled = 'true';
const button = el.querySelector('button');
if (button) {
button.style.width = '48px';
button.style.height = '48px';
button.style.minWidth = '48px';
button.style.minHeight = '48px';
}
}
// 提示框 3 秒后隐藏(查找白色气泡框)
const children = el.querySelectorAll('div');
children.forEach(child => {
if (child.dataset.bytedeskTooltip) return; // 已处理过
const childStyle = window.getComputedStyle(child);
// 白色背景的提示框
if (childStyle.backgroundColor === 'rgb(255, 255, 255)') {
child.dataset.bytedeskTooltip = 'true';
setTimeout(() => {
child.style.transition = 'opacity 0.3s';
child.style.opacity = '0';
setTimeout(() => child.remove(), 300);
}, 3000);
}
});
}
}
});
};
// 立即执行一次
applyBytedeskStyles();
// 监听 DOM 变化,新元素出现时立即应用样式
const observer = new MutationObserver(applyBytedeskStyles);
observer.observe(document.body, { childList: true, subtree: true });
// 5 秒后停止监听(避免性能问题)
setTimeout(() => observer.disconnect(), 5000);
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能)
const originalConsoleError = console.error;
console.error = function(...args) {

View File

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

View File

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

View File

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

View File

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

@@ -571,8 +571,8 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
await requestBrowserPermission();
}
// 如果权限是denied已拒绝提供设置指引
else if (browserPermission === 'denied') {
// 如果权限是denied已拒绝提供设置指引(仅 PC 端显示)
else if (browserPermission === 'denied' && !isMobile) {
const toastId = 'browser-permission-denied-guide';
if (!toast.isActive(toastId)) {
toast({

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -536,74 +536,52 @@ const [currentMode, setCurrentMode] = useState('vertical');
position="relative"
zIndex={1}
pb={3}
px={isMobile ? 2 : undefined}
>
<VStack spacing={3} align="stretch">
{/* 第一行:标题 + 通知开关 + 更新时间 */}
{/* 第一行:标题 + 模式切换 + 通知开关 + 更新时间 */}
<Flex justify="space-between" align="center">
{/* 左侧:标题 */}
<Heading size="md" color={PROFESSIONAL_COLORS.text.primary}>
<HStack spacing={2}>
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</HStack>
</Heading>
{/* 左侧:标题 + 模式切换按钮 */}
<HStack spacing={4}>
<Heading size={isMobile ? "sm" : "md"} color={PROFESSIONAL_COLORS.text.primary}>
<HStack spacing={2}>
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</HStack>
</Heading>
{/* 模式切换按钮(移动端隐藏) */}
{!isMobile && <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />}
</HStack>
{/* 右侧:通知开关 + 更新时间 */}
<HStack spacing={3}>
{/* 通知开关 - 移动端隐藏 */}
{!isMobile && (
<Tooltip
label={browserPermission === 'granted'
? '浏览器通知已开启'
: '开启实时推送通知'}
placement="left"
hasArrow
<HStack
spacing={2}
cursor="pointer"
onClick={handleNotificationToggle}
_hover={{ opacity: 0.8 }}
transition="opacity 0.2s"
>
<HStack
spacing={2}
px={3}
py={1.5}
borderRadius="md"
bg={browserPermission === 'granted'
? useColorModeValue('green.50', 'green.900')
: useColorModeValue('gray.50', 'gray.700')}
borderWidth="1px"
borderColor={browserPermission === 'granted'
? useColorModeValue('green.200', 'green.700')
: useColorModeValue('gray.200', 'gray.600')}
cursor="pointer"
_hover={{
borderColor: browserPermission === 'granted'
? useColorModeValue('green.300', 'green.600')
: useColorModeValue('blue.300', 'blue.600'),
}}
transition="all 0.2s"
onClick={handleNotificationToggle}
<Icon
as={BellIcon}
boxSize={3.5}
color={PROFESSIONAL_COLORS.gold[500]}
/>
<Text
fontSize="sm"
color={PROFESSIONAL_COLORS.text.secondary}
>
<Icon
as={BellIcon}
boxSize={3.5}
color={browserPermission === 'granted'
? useColorModeValue('green.600', 'green.300')
: useColorModeValue('gray.500', 'gray.400')}
/>
<Text
fontSize="sm"
fontWeight="medium"
color={browserPermission === 'granted'
? useColorModeValue('green.700', 'green.200')
: useColorModeValue('gray.600', 'gray.300')}
>
{browserPermission === 'granted' ? '已开启' : '开启通知'}
</Text>
<Switch
size="sm"
isChecked={browserPermission === 'granted'}
pointerEvents="none"
colorScheme="green"
/>
</HStack>
</Tooltip>
实时消息推送{browserPermission === 'granted' ? '已开启' : '未开启'}
</Text>
<Switch
size="sm"
isChecked={browserPermission === 'granted'}
pointerEvents="none"
colorScheme="yellow"
/>
</HStack>
)}
{/* 更新时间 */}
@@ -622,6 +600,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
mode={mode}
pageSize={pageSize}
trackingFunctions={trackingFunctions}
isMobile={isMobile}
/>
</Box>
</VStack>
@@ -631,41 +610,14 @@ const [currentMode, setCurrentMode] = useState('vertical');
<CardBody
ref={cardBodyRef}
position="relative"
pt={4}
pt={0}
px={0}
mx={0}
display="flex"
flexDirection="column"
overflow="visible"
zIndex={1}
>
{/* 顶部控制栏:模式切换按钮 + 分页控制器(滚动时固定在顶部) */}
<Box
position="sticky"
top="0"
zIndex={10}
bg={cardBg}
py={2}
mb={2}
borderBottom="1px solid"
borderColor={borderColor}
mx={-6}
px={6}
boxShadow="sm"
>
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
{/* 左侧:模式切换按钮 */}
<ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />
{/* 右侧分页控制器仅在纵向模式显示H5 放不下时折行 */}
{!isMobile && mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChangeWithScroll}
/>
)}
</Flex>
</Box>
{/* 内容区域 - 撑满剩余高度 */}
<Box flex="1" minH={0} position="relative">
{/* Loading 蒙层 - 数据请求时显示 */}
@@ -731,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

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

View File

@@ -9,18 +9,13 @@ import {
Center,
Text,
useBreakpointValue,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react';
import { InfoIcon } from '@chakra-ui/icons';
import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard';
import EventDetailScrollPanel from './EventDetailScrollPanel';
import DynamicNewsDetailPanel from '../DynamicNewsDetail/DynamicNewsDetailPanel';
import EventDetailModal from '../EventDetailModal';
import PaginationControl from './PaginationControl';
/**
* 纵向分栏模式布局
@@ -34,6 +29,9 @@ import DynamicNewsDetailPanel from '../DynamicNewsDetail/DynamicNewsDetailPanel'
* @param {Function} onToggleFollow - 关注按钮回调
* @param {Function} getTimelineBoxStyle - 时间线样式获取函数
* @param {string} borderColor - 边框颜色
* @param {number} currentPage - 当前页码
* @param {number} totalPages - 总页数
* @param {Function} onPageChange - 页码改变回调
*/
const VerticalModeLayout = React.memo(({
display = 'flex',
@@ -44,6 +42,9 @@ const VerticalModeLayout = React.memo(({
onToggleFollow,
getTimelineBoxStyle,
borderColor,
currentPage = 1,
totalPages = 1,
onPageChange,
}) => {
// 详情面板重置 key预留用于未来功能
const [detailPanelKey] = useState(0);
@@ -143,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>
{/* 右侧:事件详情 - 独立滚动 - 移动端隐藏 */}
@@ -165,20 +177,11 @@ const VerticalModeLayout = React.memo(({
{/* 移动端详情弹窗 */}
{isMobile && (
<Modal isOpen={isMobileModalOpen} onClose={onMobileModalClose} size="full" scrollBehavior="inside">
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(10px)" />
<ModalContent maxW="100vw" m={0} borderRadius={0}>
<ModalHeader bg="gray.900" color="white" borderBottom="1px solid" borderColor="gray.700">
{mobileSelectedEvent?.title || '事件详情'}
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody p={0} bg="gray.900">
{mobileSelectedEvent && (
<DynamicNewsDetailPanel event={mobileSelectedEvent} showHeader={false} />
)}
</ModalBody>
</ModalContent>
</Modal>
<EventDetailModal
open={isMobileModalOpen}
onClose={onMobileModalClose}
event={mobileSelectedEvent}
/>
)}
</Flex>
);

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

@@ -47,7 +47,7 @@ const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggl
spacing={3}
zIndex={1}
>
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 */}
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 - H5 隐藏 */}
<Badge
px={3}
py={1.5}
@@ -62,7 +62,7 @@ const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggl
}
color="white"
boxShadow="lg"
display="flex"
display={{ base: 'none', lg: 'flex' }}
alignItems="center"
gap={1}
>

View File

@@ -2,13 +2,15 @@
// 精简模式股票卡片组件(浮动卡片样式)
import React from 'react';
import { useSelector } from 'react-redux';
import {
Box,
Text,
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '../../../../utils/colorUtils';
import { selectIsMobile } from '@store/slices/deviceSlice';
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '@utils/colorUtils';
/**
* 精简模式股票卡片组件
@@ -17,6 +19,7 @@ import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } fro
* @param {Object} props.quote - 股票行情数据(可选)
*/
const CompactStockItem = ({ stock, quote = null }) => {
const isMobile = useSelector(selectIsMobile);
const nameColor = useColorModeValue('gray.700', 'gray.300');
const handleViewDetail = () => {
@@ -45,10 +48,10 @@ const CompactStockItem = ({ stock, quote = null }) => {
>
<Box
bgGradient={getChangeBackgroundGradient(change)}
borderWidth="3px"
borderWidth="1px"
borderColor={getChangeBorderColor(change)}
borderRadius="2xl"
p={4}
borderRadius="xl"
p={2}
onClick={handleViewDetail}
cursor="pointer"
boxShadow="lg"
@@ -69,14 +72,14 @@ const CompactStockItem = ({ stock, quote = null }) => {
}}
transition="all 0.3s ease-in-out"
display="inline-block"
minW="150px"
minW="100px"
>
{/* 股票代码 */}
<Text
fontSize="md"
fontSize={isMobile ? "sm" : "md"}
fontWeight="bold"
color={getChangeColor(change)}
mb={2}
mb={isMobile ? 1 : 2}
textAlign="center"
>
{stock.stock_code}
@@ -84,7 +87,7 @@ const CompactStockItem = ({ stock, quote = null }) => {
{/* 涨跌幅 - 超大号显示 */}
<Text
fontSize="3xl"
fontSize={isMobile ? "xl" : "3xl"}
fontWeight="black"
color={getChangeColor(change)}
textAlign="center"
@@ -96,9 +99,9 @@ const CompactStockItem = ({ stock, quote = null }) => {
{/* 股票名称(小字) */}
<Text
fontSize="xs"
fontSize={isMobile ? "2xs" : "xs"}
color={nameColor}
mt={2}
mt={isMobile ? 1 : 2}
textAlign="center"
noOfLines={1}
fontWeight="medium"

View File

@@ -75,7 +75,9 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
featureName: ''
});
// 使用 Hook 获取实时数据(禁用自动加载,改为手动触发)
// 使用 Hook 获取实时数据
// - autoLoad: false - 禁用自动加载所有数据,改为手动触发
// - autoLoadQuotes: false - 禁用自动加载行情,延迟到展开时加载(减少请求)
const {
stocks,
quotes,
@@ -85,8 +87,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: false });
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
const loadEventDetail = useCallback(async () => {
@@ -119,12 +122,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪
// 初始值为 false由 useEffect 根据权限动态设置
// 相关股票默认折叠,只显示数量吸引点击
const [isStocksOpen, setIsStocksOpen] = useState(false);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
// 历史事件默认折叠,但预加载数量
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
@@ -159,34 +164,41 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
});
}, []);
// 相关股票 - 展开时加载(需要 PRO 权限)
// 相关股票 - 展开时加载行情(需要 PRO 权限)
// 股票列表在事件切换时预加载(显示数量),行情在展开时才加载
const handleStocksToggle = useCallback(() => {
const newState = !isStocksOpen;
setIsStocksOpen(newState);
if (newState && !hasLoadedStocks) {
console.log('%c📊 [相关股票] 首次展开,加载股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
loadStocksData();
setHasLoadedStocks(true);
// 展开时加载行情数据(如果还没加载过)
if (newState && !hasLoadedQuotes && stocks.length > 0) {
console.log('%c📈 [相关股票] 首次展开,加载行情数据', 'color: #10B981; font-weight: bold;', {
eventId: event?.id,
stockCount: stocks.length
});
refreshQuotes();
setHasLoadedQuotes(true);
}
}, [isStocksOpen, hasLoadedStocks, loadStocksData, event?.id]);
}, [isStocksOpen, hasLoadedQuotes, stocks.length, refreshQuotes, event?.id]);
// 相关概念 - 展开/收起(无需加载)
const handleConceptsToggle = useCallback(() => {
setIsConceptsOpen(!isConceptsOpen);
}, [isConceptsOpen]);
// 历史事件对比 - 展开时加载
// 历史事件对比 - 数据已预加载,只需切换展开状态
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);
// 数据已在事件切换时预加载,这里只需展开
if (newState) {
console.log('%c📜 [历史事件] 展开(数据已预加载)', 'color: #3B82F6; font-weight: bold;', {
eventId: event?.id,
count: historicalEvents?.length || 0
});
}
}, [isHistoricalOpen, hasLoadedHistorical, loadHistoricalData, event?.id]);
}, [isHistoricalOpen, event?.id, historicalEvents?.length]);
// 传导链分析 - 展开时加载
const handleTransmissionToggle = useCallback(() => {
@@ -209,24 +221,29 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
// 重置所有加载状态
setHasLoadedStocks(false);
setHasLoadedQuotes(false); // 重置行情加载状态
setHasLoadedHistorical(false);
setHasLoadedTransmission(false);
// 相关股票默认展开(有权限时
// 相关股票默认折叠,但预加载股票列表(显示数量吸引点击
setIsStocksOpen(false);
if (canAccessStocks) {
setIsStocksOpen(true);
// 立即加载股票数据
console.log('%c📊 [相关股票] 事件切换,加载股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
console.log('%c📊 [相关股票] 事件切换,预加载股票列表(获取数量)', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
loadStocksData();
setHasLoadedStocks(true);
} else {
setIsStocksOpen(false);
}
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
setIsHistoricalOpen(false);
if (canAccessHistorical) {
console.log('%c📜 [历史事件] 事件切换,预加载历史事件(获取数量)', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
loadHistoricalData();
setHasLoadedHistorical(true);
}
setIsConceptsOpen(false);
setIsHistoricalOpen(false);
setIsTransmissionOpen(false);
}, [event?.id, canAccessStocks, userTier, loadStocksData, loadEventDetail]);
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail]);
// 切换关注状态
const handleToggleFollow = useCallback(async () => {
@@ -418,7 +435,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
</CollapsibleSection>
{/* 讨论区(评论区) - 所有登录用户可用 */}
<Box mt={4}>
<Box>
<EventCommentSection eventId={event.id} />
</Box>
</VStack>

View File

@@ -37,16 +37,16 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
return (
<VStack
align="stretch"
spacing={2}
spacing={1}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
px={4}
py={2}
px={2}
py={1}
cursor="pointer"
transition="all 0.2s"
minW="200px"
minW="100px"
_hover={{
transform: 'translateY(-1px)',
boxShadow: 'md',
@@ -68,17 +68,17 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
</Text>
{/* 第二行:相关度 + 涨跌幅 */}
<Flex justify="space-between" align="center" gap={2} flexWrap="wrap">
<Flex justify="space-between" align="center" gap={1} flexWrap="wrap">
{/* 相关度标签 */}
<Box
bg={relevanceColors.bg}
color={relevanceColors.color}
px={2}
px={1.5}
py={0.5}
borderRadius="sm"
flexShrink={0}
>
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap">
<Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap">
相关度: {relevanceScore}%
</Text>
</Box>
@@ -87,8 +87,8 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
{changePct !== null && (
<Badge
colorScheme={changeColor}
fontSize="xs"
px={2}
fontSize="10px"
px={1.5}
py={0.5}
flexShrink={0}
>

View File

@@ -243,7 +243,7 @@ const RelatedConceptsSection = ({
}
}}
>
{isExpanded ? '收起' : '查看详细描述'}
{isExpanded ? '收起' : '查看详细'}
</Button>
</Flex>
{/* 第二行:交易日期信息 */}

View File

@@ -2,6 +2,7 @@
// 股票卡片组件(融合表格功能的卡片样式)
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import {
Box,
Flex,
@@ -16,13 +17,15 @@ import {
useColorModeValue,
} from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
import { Tag } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import { selectIsMobile } from '@store/slices/deviceSlice';
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
import MiniKLineChart from './MiniKLineChart';
import TimelineChartModal from '../../../../components/StockChart/TimelineChartModal';
import KLineChartModal from '../../../../components/StockChart/KLineChartModal';
import CitedContent from '../../../../components/Citation/CitedContent';
import { getChangeColor } from '../../../../utils/colorUtils';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
import TimelineChartModal from '@components/StockChart/TimelineChartModal';
import KLineChartModal from '@components/StockChart/KLineChartModal';
import { getChangeColor } from '@utils/colorUtils';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
/**
* 股票卡片组件
@@ -44,6 +47,7 @@ const StockListItem = ({
isInWatchlist = false,
onWatchlistToggle
}) => {
const isMobile = useSelector(selectIsMobile);
const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default;
const codeColor = '#3B82F6';
@@ -128,9 +132,9 @@ const StockListItem = ({
transition="all 0.2s"
>
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */}
<HStack spacing={2} align="center" flexWrap="wrap">
<HStack spacing={2} align="center" flexWrap={isMobile ? 'wrap' : 'nowrap'}>
{/* 左侧:股票信息区 */}
<HStack spacing={2} minW="360px" maxW="380px" flexShrink={0}>
<HStack spacing={2} overflow="hidden">
{/* 股票代码 + 名称 + 涨跌幅 */}
<VStack
align="stretch"
@@ -194,24 +198,24 @@ const StockListItem = ({
</HStack>
</VStack>
{/* 分时图 - 更紧凑 */}
{/* 分时图 - 自适应 */}
<VStack
w="115px"
flex={1}
minW="80px"
maxW="150px"
borderWidth="1px"
borderColor="rgba(59, 130, 246, 0.3)"
borderRadius="md"
px={1.5}
py={1}
px={2}
py={1.5}
bg="rgba(59, 130, 246, 0.1)"
onClick={(e) => {
e.stopPropagation();
setIsTimelineModalOpen(true);
}}
cursor="pointer"
flexShrink={0}
align="stretch"
spacing={0}
h="fit-content"
_hover={{
borderColor: '#3B82F6',
boxShadow: '0 0 10px rgba(59, 130, 246, 0.3)',
@@ -228,7 +232,7 @@ const StockListItem = ({
>
📈 分时
</Text>
<Box h="32px">
<Box h="28px">
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
@@ -236,24 +240,24 @@ const StockListItem = ({
</Box>
</VStack>
{/* K线图 - 更紧凑 */}
{/* K线图 - 自适应 */}
<VStack
w="115px"
flex={1}
minW="80px"
maxW="150px"
borderWidth="1px"
borderColor="rgba(168, 85, 247, 0.3)"
borderRadius="md"
px={1.5}
py={1}
px={2}
py={1.5}
bg="rgba(168, 85, 247, 0.1)"
onClick={(e) => {
e.stopPropagation();
setIsKLineModalOpen(true);
}}
cursor="pointer"
flexShrink={0}
align="stretch"
spacing={0}
h="fit-content"
_hover={{
borderColor: '#A855F7',
boxShadow: '0 0 10px rgba(168, 85, 247, 0.3)',
@@ -270,7 +274,7 @@ const StockListItem = ({
>
📊 日线
</Text>
<Box h="32px">
<Box h="28px">
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
@@ -281,8 +285,8 @@ const StockListItem = ({
{/* 关联描述 - 升级和降级处理 */}
{stock.relation_desc && (
<Box flex={1} minW={0}>
{stock.relation_desc?.data ? (
<Box flex={1} minW={0} flexBasis={isMobile ? '100%' : ''}>
{Array.isArray(stock.relation_desc?.data) ? (
// 升级:带引用来源的版本 - 添加折叠功能
<Tooltip
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
@@ -298,8 +302,6 @@ const StockListItem = ({
setIsDescExpanded(!isDescExpanded);
}}
cursor="pointer"
px={3}
py={2}
bg={PROFESSIONAL_COLORS.background.secondary}
borderRadius="md"
_hover={{
@@ -308,18 +310,77 @@ const StockListItem = ({
transition="background 0.2s"
position="relative"
>
<Collapse in={isDescExpanded} startingHeight={40}>
<CitedContent
data={stock.relation_desc}
title=""
showAIBadge={true}
textColor={PROFESSIONAL_COLORS.text.primary}
containerStyle={{
backgroundColor: 'transparent',
borderRadius: '0',
padding: '0',
<Collapse in={isDescExpanded} startingHeight={56}>
{/* AI 标识 - 行内显示在文字前面 */}
<Tag
icon={<RobotOutlined />}
color="purple"
style={{
fontSize: 12,
padding: '2px 8px',
marginRight: 8,
verticalAlign: 'middle',
display: 'inline-flex',
}}
/>
>
AI合成
</Tag>
{/* 渲染 query_part每句带来源悬停提示 */}
<Text
as="span"
fontSize="sm"
color={PROFESSIONAL_COLORS.text.primary}
lineHeight="1.8"
>
{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>
</Tooltip>
@@ -339,8 +400,6 @@ const StockListItem = ({
setIsDescExpanded(!isDescExpanded);
}}
cursor="pointer"
px={3}
py={2}
bg={PROFESSIONAL_COLORS.background.secondary}
borderRadius="md"
_hover={{
@@ -350,7 +409,7 @@ const StockListItem = ({
position="relative"
>
{/* 去掉"关联描述"标题 */}
<Collapse in={isDescExpanded} startingHeight={36}>
<Collapse in={isDescExpanded} startingHeight={56}>
<Text
fontSize="xs"
color={nameColor}

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"

View File

@@ -32,10 +32,10 @@ const EventFollowButton = ({
size={size}
colorScheme="yellow"
variant="ghost"
bg="whiteAlpha.500"
bg="rgba(113, 128, 150, 0.6)"
boxShadow="sm"
_hover={{
bg: 'whiteAlpha.800',
bg: 'rgba(113, 128, 150, 0.8)',
boxShadow: 'md'
}}
icon={
@@ -47,8 +47,7 @@ const EventFollowButton = ({
) : (
<AiOutlineStar
size={iconSize}
color="#718096"
strokeWidth="1"
color="gold"
/>
)
}

View File

@@ -0,0 +1,36 @@
.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 {
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

@@ -0,0 +1,48 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Modal } from 'antd';
import { selectIsMobile } from '@store/slices/deviceSlice';
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
import './EventDetailModal.less';
interface EventDetailModalProps {
/** 是否打开弹窗 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 事件对象 */
event: any; // TODO: 后续可替换为具体的 Event 类型
}
/**
* 事件详情弹窗组件
*/
const EventDetailModal: React.FC<EventDetailModalProps> = ({
open,
onClose,
event,
}) => {
const isMobile = useSelector(selectIsMobile);
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
title={event?.title || '事件详情'}
width='100vw'
destroyOnClose
className="event-detail-modal"
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 },
}}
>
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
</Modal>
);
};
export default EventDetailModal;

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@
}
.hot-events-carousel {
padding: 0 40px; /* 增加左右padding箭头留出空间 */
padding: 0; /* 移除左右padding箭头使用绝对定位 */
position: relative;
}
@@ -65,13 +65,20 @@
color: #096dd9 !important;
}
/* 箭头位置 */
/* 箭头位置 - 绝对定位,悬浮在卡片边缘 */
.hot-events-carousel .slick-prev.custom-carousel-arrow {
left: 0 !important;
left: 8px !important;
position: absolute;
}
.hot-events-carousel .slick-next.custom-carousel-arrow {
right: 0 !important;
right: 8px !important;
position: absolute;
}
/* 隐藏可能重复的默认箭头 */
.hot-events-carousel .slick-arrow:not(.custom-carousel-arrow) {
display: none !important;
}
/* 禁用状态 */

View File

@@ -2,19 +2,11 @@
import React, { useState } from 'react';
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react';
import { useDisclosure } from '@chakra-ui/react';
import EventDetailModal from './EventDetailModal';
import dayjs from 'dayjs';
import './HotEvents.css';
import defaultEventImage from '../../../assets/img/default-event.jpg';
import DynamicNewsDetailPanel from './DynamicNewsDetail';
// 自定义箭头组件
const CustomArrow = ({ className, style, onClick, direction }) => {
@@ -196,21 +188,12 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
</Card>
)}
{/* 事件详情弹窗 - 使用 Chakra UI Modal与平铺模式一致 */}
{isModalOpen ? (
<Modal isOpen={isModalOpen} onClose={onModalClose} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent>
<ModalHeader>
{modalEvent?.title || '事件详情'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
</ModalBody>
</ModalContent>
</Modal>
): null}
{/* 事件详情弹窗 */}
<EventDetailModal
open={isModalOpen}
onClose={onModalClose}
event={modalEvent}
/>
</div>
);
};

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

@@ -19,9 +19,10 @@ import { logger } from '../../../../../utils/logger';
* @param {string} eventTime - 事件时间
* @param {Object} options - 配置选项
* @param {boolean} options.autoLoad - 是否自动加载数据默认true
* @param {boolean} options.autoLoadQuotes - 是否自动加载行情数据默认true设为false可延迟到展开时加载
* @returns {Object} 事件数据和加载状态
*/
export const useEventStocks = (eventId, eventTime, { autoLoad = true } = {}) => {
export const useEventStocks = (eventId, eventTime, { autoLoad = true, autoLoadQuotes = true } = {}) => {
const dispatch = useDispatch();
// 从 Redux 获取数据
@@ -122,12 +123,12 @@ export const useEventStocks = (eventId, eventTime, { autoLoad = true } = {}) =>
}
}, [eventId, autoLoad, loadAllData]); // 添加 loadAllData 依赖
// 自动加载行情数据
// 自动加载行情数据(可通过 autoLoadQuotes 参数控制)
useEffect(() => {
if (stocks.length > 0) {
if (stocks.length > 0 && autoLoadQuotes) {
refreshQuotes();
}
}, [stocks.length, eventId]); // 注意:这里不依赖 refreshQuotes避免重复请求
}, [stocks.length, eventId, autoLoadQuotes]); // 注意:这里不依赖 refreshQuotes避免重复请求
// 计算股票行情合并数据
const stocksWithQuotes = useMemo(() => {

View File

@@ -1,20 +1,25 @@
// src/views/Community/components/TradingTimeFilter.js
// 交易时段智能筛选组件
import React, { useState, useMemo, useEffect } from 'react';
import { Space, Button, Tag, Tooltip, DatePicker, Popover } from 'antd';
import { ClockCircleOutlined, CalendarOutlined } from '@ant-design/icons';
import { Space, Button, Tag, Tooltip, DatePicker, Popover, Select } from 'antd';
import { ClockCircleOutlined, CalendarOutlined, FilterOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import locale from 'antd/es/date-picker/locale/zh_CN';
import { logger } from '../../../utils/logger';
import { logger } from '@utils/logger';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
const { RangePicker } = DatePicker;
const { Option } = Select;
/**
* 交易时段筛选组件
* @param {string} value - 当前选中的 key受控
* @param {Function} onChange - 时间范围变化回调 (timeConfig) => void
* @param {boolean} compact - 是否使用紧凑模式PC 端搜索栏内使用)
* @param {boolean} mobile - 是否使用移动端模式(下拉选择)
*/
const TradingTimeFilter = ({ value, onChange }) => {
const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false }) => {
const [selectedKey, setSelectedKey] = useState(null);
const [customRangeVisible, setCustomRangeVisible] = useState(false);
const [customRange, setCustomRange] = useState(null);
@@ -266,7 +271,39 @@ const TradingTimeFilter = ({ value, onChange }) => {
}
};
// 渲染按钮
// 渲染紧凑模式按钮PC 端搜索栏内使用,文字按钮 + | 分隔符)
const renderCompactButton = (config, showDivider = true) => {
const isSelected = selectedKey === config.key;
const fullTooltip = config.timeHint ? `${config.tooltip} · ${config.timeHint}` : config.tooltip;
return (
<React.Fragment key={config.key}>
<Tooltip title={fullTooltip}>
<span
onClick={() => handleButtonClick(config)}
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '13px',
fontWeight: isSelected ? 600 : 400,
color: isSelected ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.text.secondary,
background: isSelected ? 'rgba(255, 195, 0, 0.15)' : 'transparent',
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
}}
>
{config.label}
</span>
</Tooltip>
{showDivider && (
<span style={{ color: 'rgba(255, 255, 255, 0.2)', margin: '0 2px' }}>|</span>
)}
</React.Fragment>
);
};
// 渲染按钮(默认模式)
const renderButton = (config) => {
const isSelected = selectedKey === config.key;
@@ -321,6 +358,98 @@ const TradingTimeFilter = ({ value, onChange }) => {
</div>
);
// 移动端模式:下拉选择器
if (mobile) {
const allButtons = [...timeRangeConfig.dynamic, ...timeRangeConfig.fixed];
const handleMobileSelect = (key) => {
if (key === selectedKey) {
// 取消选中
setSelectedKey(null);
onChange(null);
} else {
const config = allButtons.find(b => b.key === key);
if (config) {
handleButtonClick(config);
}
}
};
return (
<Select
value={selectedKey}
onChange={handleMobileSelect}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<FilterOutlined style={{ fontSize: 12 }} />
筛选
</span>
}
allowClear
onClear={() => {
setSelectedKey(null);
onChange(null);
}}
style={{ minWidth: 80 }}
className="transparent-select"
popupMatchSelectWidth={false}
>
{allButtons.map(config => (
<Option key={config.key} value={config.key}>
{config.label}
</Option>
))}
</Select>
);
}
// 紧凑模式PC 端搜索栏内的样式
if (compact) {
// 合并所有按钮配置
const allButtons = [...timeRangeConfig.dynamic, ...timeRangeConfig.fixed];
return (
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap' }}>
{allButtons.map((config, index) =>
renderCompactButton(config, index < allButtons.length - 1)
)}
{/* 更多时间 */}
<span style={{ color: 'rgba(255, 255, 255, 0.2)', margin: '0 2px' }}>|</span>
<Popover
content={customRangeContent}
title="选择自定义时间范围"
trigger="click"
open={customRangeVisible}
onOpenChange={setCustomRangeVisible}
placement="bottomLeft"
>
<Tooltip title="自定义时间范围">
<span
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '13px',
fontWeight: selectedKey === 'custom' ? 600 : 400,
color: selectedKey === 'custom' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.text.secondary,
background: selectedKey === 'custom' ? 'rgba(255, 195, 0, 0.15)' : 'transparent',
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<CalendarOutlined style={{ fontSize: 12 }} />
更多
</span>
</Tooltip>
</Popover>
</div>
);
}
// 默认模式:移动端/独立使用
return (
<Space wrap size={[8, 8]} style={{ display: 'flex', alignItems: 'flex-start' }}>
{/* 动态按钮(根据时段显示多个) */}

View File

@@ -29,10 +29,12 @@ import {
FaChartLine,
FaInfoCircle
} from 'react-icons/fa';
import { stockService } from '../../../services/eventService';
import { logger } from '../../../utils/logger';
import CitedContent from '../../../components/Citation/CitedContent';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
import { Tag } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
import CitedContent from '@components/Citation/CitedContent';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
const HistoricalEvents = ({
events = [],
@@ -244,7 +246,7 @@ const HistoricalEvents = ({
key={event.id}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderColor="gray.500"
borderRadius="lg"
position="relative"
overflow="visible"
@@ -267,16 +269,16 @@ const HistoricalEvents = ({
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={2} p={3}>
<VStack align="stretch" spacing={3} p={4}>
{/* 顶部区域:左侧(标题+时间) + 右侧(按钮) */}
<HStack align="flex-start" spacing={3}>
{/* 左侧:标题 + 时间信息(允许折行) */}
<VStack flex="1" align="flex-start" spacing={1}>
<VStack flex="1" align="flex-start" spacing={2}>
{/* 标题 */}
<Text
fontSize="md"
fontSize="lg"
fontWeight="bold"
color={useColorModeValue('blue.600', 'blue.400')}
color={useColorModeValue('blue.500', 'blue.300')}
lineHeight="1.4"
cursor="pointer"
onClick={(e) => {
@@ -290,27 +292,28 @@ const HistoricalEvents = ({
{/* 时间 + Badges允许折行 */}
<HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={textSecondary}>
<Text fontSize="sm" color="gray.300" fontWeight="medium">
{formatDate(getEventDate(event))}
</Text>
<Text fontSize="sm" color={textSecondary}>
<Text fontSize="sm" color="gray.400">
({getRelativeTime(getEventDate(event))})
</Text>
{event.importance && (
<Badge colorScheme={importanceColor} size="sm">
<Badge colorScheme={importanceColor} fontSize="xs" px={2}>
重要性: {event.importance}
</Badge>
)}
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
<Badge
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
size="sm"
fontSize="xs"
px={2}
>
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
</Badge>
)}
{event.similarity !== undefined && event.similarity !== null && (
<Badge colorScheme={getSimilarityColor(event.similarity)} size="sm">
<Badge colorScheme={getSimilarityColor(event.similarity)} fontSize="xs" px={2}>
相关度: {event.similarity}
</Badge>
)}
@@ -344,10 +347,9 @@ const HistoricalEvents = ({
data={content}
title=""
showAIBadge={true}
textColor={PROFESSIONAL_COLORS.text.primary}
textColor="#E2E8F0"
containerStyle={{
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'),
borderRadius: '8px',
backgroundColor: 'transparent',
padding: '0',
}}
/>

View File

@@ -1,18 +1,18 @@
import React, { useEffect, useState, useRef } from 'react';
import {
Box,
Button,
Flex,
Spinner,
Alert,
AlertIcon,
Text,
Stat,
StatLabel,
StatNumber,
HStack,
VStack,
Tag,
import {
Box,
Button,
Flex,
Spinner,
Alert,
AlertIcon,
Text,
Stat,
StatLabel,
StatNumber,
HStack,
VStack,
Tag,
Badge,
List,
ListItem,
@@ -28,9 +28,11 @@ import {
ModalCloseButton,
Icon,
useColorModeValue,
Tooltip
Tooltip,
Center
} from '@chakra-ui/react';
import { InfoIcon, ViewIcon } from '@chakra-ui/icons';
import { Share2, GitBranch, Inbox } from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { eventService } from '../../../services/eventService';
import CitedContent from '../../../components/Citation/CitedContent';
@@ -637,7 +639,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
};
return (
<Box p={6}>
<Box>
{/* 统计信息条 */}
<Box
mb={4}
@@ -647,56 +649,57 @@ const TransmissionChainAnalysis = ({ eventId }) => {
borderColor={PROFESSIONAL_COLORS.border.default}
bg={PROFESSIONAL_COLORS.background.secondary}
>
<HStack spacing={6} wrap="wrap">
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>总节点数</StatLabel>
<StatNumber color={PROFESSIONAL_COLORS.text.primary}>{stats.totalNodes}</StatNumber>
<Flex wrap="wrap" gap={{ base: 3, md: 6 }}>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">总节点数</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.totalNodes}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>涉及行业</StatLabel>
<StatNumber color={PROFESSIONAL_COLORS.text.primary}>{stats.involvedIndustries}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">涉及行业</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.involvedIndustries}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>相关公司</StatLabel>
<StatNumber color={PROFESSIONAL_COLORS.text.primary}>{stats.relatedCompanies}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">相关公司</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.relatedCompanies}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>正向影响</StatLabel>
<StatNumber color="#10B981">{stats.positiveImpact}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">正向影响</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#10B981">{stats.positiveImpact}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>负向影响</StatLabel>
<StatNumber color="#EF4444">{stats.negativeImpact}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">负向影响</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#EF4444">{stats.negativeImpact}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>循环效应</StatLabel>
<StatNumber color="#A855F7">{stats.circularEffect}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">循环效应</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#A855F7">{stats.circularEffect}</StatNumber>
</Stat>
</HStack>
</Flex>
</Box>
{/* 自定义图例 */}
<Box mb={4}>
<HStack spacing={4} wrap="wrap">
{Object.entries(NODE_STYLES).map(([type, style]) => (
<Tag
key={type}
size="md"
bg={PROFESSIONAL_COLORS.background.secondary}
color={PROFESSIONAL_COLORS.text.primary}
borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default}
>
<Box w={3} h={3} bg={style.color} borderRadius="sm" mr={2} />
{NODE_TYPE_LABELS[type] || type}
</Tag>
))}
</HStack>
</Box>
<Flex mb={4} wrap="wrap" gap={2}>
{Object.entries(NODE_STYLES).map(([type, style]) => (
<Tag
key={type}
size="sm"
px={2}
py={1}
bg={PROFESSIONAL_COLORS.background.secondary}
color={PROFESSIONAL_COLORS.text.primary}
borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default}
>
<Box w={2.5} h={2.5} bg={style.color} borderRadius="sm" mr={1.5} />
{NODE_TYPE_LABELS[type] || type}
</Tag>
))}
</Flex>
{/* 视图切换按钮 */}
<Flex mb={4} gap={2}>
<Button
leftIcon={<Icon as={Share2} boxSize={4} />}
bg={viewMode === 'graph' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary}
color={viewMode === 'graph' ? 'black' : PROFESSIONAL_COLORS.text.primary}
_hover={{
@@ -710,6 +713,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
力导向图
</Button>
<Button
leftIcon={<Icon as={GitBranch} boxSize={4} />}
bg={viewMode === 'sankey' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary}
color={viewMode === 'sankey' ? 'black' : PROFESSIONAL_COLORS.text.primary}
_hover={{
@@ -722,7 +726,6 @@ const TransmissionChainAnalysis = ({ eventId }) => {
>
桑基图
</Button>
</Flex>
{loading && (
@@ -748,86 +751,108 @@ const TransmissionChainAnalysis = ({ eventId }) => {
{!loading && !error && (
<Box>
{/* 提示信息 */}
<Alert
status="info"
mb={4}
borderRadius="md"
bg="rgba(59, 130, 246, 0.1)"
color="#3B82F6"
borderWidth="1px"
borderColor="#3B82F6"
>
<AlertIcon />
<Text fontSize="sm" color={PROFESSIONAL_COLORS.text.secondary}>
<Icon as={ViewIcon} mr={2} />
点击图表中的节点可以查看详细信息
</Text>
</Alert>
{/* 图表容器 */}
{/* 图表容器 - 宽高比 2:1H5 自适应 */}
<Box
h={viewMode === 'sankey' ? "600px" : "700px"}
position="relative"
w="100%"
pb={{ base: "75%", md: "50%" }}
border="1px solid"
borderColor={PROFESSIONAL_COLORS.border.default}
borderRadius="lg"
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
bg={PROFESSIONAL_COLORS.background.card}
p={4}
ref={containerRef}
>
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
p={4}
>
{/* 提示信息 - 固定在左上角 */}
<Text
position="absolute"
top={2}
left={3}
fontSize="xs"
color={PROFESSIONAL_COLORS.text.muted}
zIndex={1}
bg="rgba(0, 0, 0, 0.5)"
px={2}
py={1}
borderRadius="md"
>
<Icon as={ViewIcon} mr={1} boxSize={3} />
点击节点查看详情
</Text>
{chartReady && (
<>
{viewMode === 'graph' ? (
<ReactECharts
option={graphData ? getGraphOption(graphData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleGraphNodeClick
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
{/* 空状态提示 */}
{(viewMode === 'graph' && (!graphData || !graphData.nodes || graphData.nodes.length === 0)) ||
(viewMode === 'sankey' && (!sankeyData || !sankeyData.nodes || sankeyData.nodes.length === 0)) ? (
<Center h="100%" flexDirection="column">
<Icon as={Inbox} boxSize={12} color={PROFESSIONAL_COLORS.text.muted} />
<Text mt={4} color={PROFESSIONAL_COLORS.text.muted} fontSize="sm">
暂无传导链数据
</Text>
</Center>
) : (
<ReactECharts
option={sankeyData ? getSankeyOption(sankeyData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleSankeyNodeClick
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
<>
{viewMode === 'graph' ? (
<ReactECharts
option={graphData ? getGraphOption(graphData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleGraphNodeClick
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
) : (
<ReactECharts
option={sankeyData ? getSankeyOption(sankeyData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleSankeyNodeClick
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
)}
</>
)}
</>
)}
</Box>
</Box>
</Box>
)}
{/* 节点详情弹窗 */}
{isModalOpen && (
<Modal isOpen={isModalOpen} onClose={handleCloseModal} size="xl">
<ModalOverlay />
<ModalContent maxH="80vh" bg={modalBgColor}>
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor}>
<HStack justify="space-between">
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor} pr={12}>
<HStack justify="space-between" pr={2}>
<Text color={PROFESSIONAL_COLORS.text.primary}>{selectedNode ? '节点详情' : '传导链分析'}</Text>
{selectedNode && (
<Badge
@@ -841,7 +866,10 @@ const TransmissionChainAnalysis = ({ eventId }) => {
)}
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalCloseButton
color={PROFESSIONAL_COLORS.text.secondary}
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
/>
<ModalBody overflowY="auto">
{selectedNode ? (
@@ -1084,11 +1112,15 @@ const TransmissionChainAnalysis = ({ eventId }) => {
prefix="机制:"
prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }}
textColor={PROFESSIONAL_COLORS.text.primary}
containerStyle={{ marginTop: 8 }}
containerStyle={{
marginTop: 8,
backgroundColor: 'transparent',
padding: 0,
}}
showAIBadge={false}
/>
) : parent.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary}>
机制: {parent.transmission_mechanism}AI合成
</Text>
) : null}
@@ -1105,23 +1137,42 @@ const TransmissionChainAnalysis = ({ eventId }) => {
{/* 影响输出 */}
{(() => {
const targetsFromAPI = nodeDetail && nodeDetail.children && nodeDetail.children.length > 0;
if (targetsFromAPI) {
return (
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">
影响输出 ({nodeDetail.children.length})
<Text fontWeight="bold" mb={2} color={PROFESSIONAL_COLORS.gold[500]}>
影响输出 ({nodeDetail.children.length})AI合成
</Text>
<List spacing={2}>
{nodeDetail.children.map((child, index) => (
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="orange.300" position="relative">
<ListItem
key={index}
p={2}
bg={PROFESSIONAL_COLORS.background.secondary}
borderRadius="md"
borderLeft="3px solid"
borderColor="#FB923C"
position="relative"
>
{child.direction && (
<Box position="absolute" top={2} right={2} zIndex={1}>
<Badge
colorScheme={
child.direction === 'positive' ? 'green' :
child.direction === 'negative' ? 'red' :
'gray'
bg={
child.direction === 'positive' ? 'rgba(16, 185, 129, 0.15)' :
child.direction === 'negative' ? 'rgba(239, 68, 68, 0.15)' :
'rgba(107, 114, 128, 0.15)'
}
color={
child.direction === 'positive' ? '#10B981' :
child.direction === 'negative' ? '#EF4444' :
'#6B7280'
}
borderWidth="1px"
borderColor={
child.direction === 'positive' ? '#10B981' :
child.direction === 'negative' ? '#EF4444' :
'#6B7280'
}
size="sm"
>
@@ -1132,7 +1183,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
</Box>
)}
<VStack align="stretch" spacing={1}>
<Text fontWeight="bold" fontSize="sm" pr={child.direction ? 20 : 0}>{child.name}</Text>
<Text fontWeight="bold" fontSize="sm" color={PROFESSIONAL_COLORS.text.primary} pr={child.direction ? 20 : 0}>{child.name}</Text>
{child.transmission_mechanism?.data ? (
<CitedContent
data={child.transmission_mechanism}
@@ -1140,11 +1191,15 @@ const TransmissionChainAnalysis = ({ eventId }) => {
prefix="机制:"
prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }}
textColor={PROFESSIONAL_COLORS.text.primary}
containerStyle={{ marginTop: 8 }}
containerStyle={{
marginTop: 8,
backgroundColor: 'transparent',
padding: 0,
}}
showAIBadge={false}
/>
) : child.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary}>
机制: {child.transmission_mechanism}AI合成
</Text>
) : null}
@@ -1169,7 +1224,14 @@ const TransmissionChainAnalysis = ({ eventId }) => {
</ModalBody>
<ModalFooter borderTop="1px solid" borderColor={modalBorderColor}>
<Button onClick={handleCloseModal}>关闭</Button>
<Button
onClick={handleCloseModal}
variant="ghost"
color={PROFESSIONAL_COLORS.text.secondary}
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
>
关闭
</Button>
</ModalFooter>
</ModalContent>
</Modal>

View File

@@ -31,7 +31,6 @@ import {
TagLabel,
Wrap,
WrapItem,
IconButton,
useColorModeValue,
useToast,
Tooltip,
@@ -40,7 +39,7 @@ import {
AlertIcon,
} from '@chakra-ui/react';
import { formatTooltipText, getFormattedTextProps } from '../../../utils/textUtils';
import { SearchIcon, CalendarIcon, ViewIcon, ExternalLinkIcon, DownloadIcon } from '@chakra-ui/icons';
import { SearchIcon, CalendarIcon, DownloadIcon } from '@chakra-ui/icons';
// 高级搜索组件
export const AdvancedSearch = ({ onSearch, loading }) => {
@@ -138,7 +137,22 @@ export const AdvancedSearch = ({ onSearch, loading }) => {
<Box flex={1}>
<Text fontSize="sm" mb={2} fontWeight="bold">搜索模式</Text>
<Select value={searchMode} onChange={(e) => setSearchMode(e.target.value)}>
<Select
value={searchMode}
onChange={(e) => setSearchMode(e.target.value)}
bg="white"
_dark={{ bg: 'gray.700' }}
sx={{
'& option': {
bg: 'white',
color: 'gray.800',
_dark: {
bg: 'gray.700',
color: 'white'
}
}
}}
>
<option value="hybrid">智能搜索推荐</option>
<option value="text">精确匹配</option>
<option value="vector">语义搜索</option>
@@ -260,7 +274,7 @@ export const SearchResultsModal = ({ isOpen, onClose, searchResults, onStockClic
<Th>连板天数</Th>
<Th>涨停原因</Th>
<Th>所属板块</Th>
<Th>操作</Th>
<Th>K线</Th>
</Tr>
</Thead>
<Tbody>
@@ -329,25 +343,16 @@ export const SearchResultsModal = ({ isOpen, onClose, searchResults, onStockClic
</Wrap>
</Td>
<Td>
<HStack spacing={1}>
<Tooltip label="查看详情">
<IconButton
icon={<ViewIcon />}
size="sm"
variant="ghost"
colorScheme="blue"
onClick={() => onStockClick(stock)}
/>
</Tooltip>
<Tooltip label="查看K线">
<IconButton
icon={<ExternalLinkIcon />}
size="sm"
variant="ghost"
colorScheme="green"
/>
</Tooltip>
</HStack>
<Button
size="sm"
colorScheme="blue"
onClick={() => {
// 跳转到公司详情页面查看K线
window.open(`https://valuefrontier.cn/company?scode=${stock.scode}`, '_blank');
}}
>
查看
</Button>
</Td>
</Tr>
))}

View File

@@ -14,28 +14,22 @@ import {
AccordionButton,
AccordionPanel,
AccordionIcon,
IconButton,
Flex,
Circle,
Tag,
TagLabel,
Wrap,
WrapItem,
Button,
useColorModeValue,
Collapse,
useDisclosure,
Link,
} from '@chakra-ui/react';
import { StarIcon, ViewIcon, TimeIcon, ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
import { getFormattedTextProps } from '../../../utils/textUtils';
import { StarIcon, TimeIcon, ExternalLinkIcon } from '@chakra-ui/icons';
const SectorDetails = ({ sortedSectors, totalStocks, onStockClick }) => {
const SectorDetails = ({ sortedSectors, totalStocks }) => {
// 使用 useRef 来维持展开状态,避免重新渲染时重置
const expandedSectorsRef = useRef([]);
const [expandedSectors, setExpandedSectors] = useState([]);
const [isInitialized, setIsInitialized] = useState(false);
// 新增:管理每个股票涨停原因的展开状态
const [expandedStockReasons, setExpandedStockReasons] = useState({});
const cardBg = useColorModeValue('white', 'gray.800');
@@ -61,14 +55,6 @@ const SectorDetails = ({ sortedSectors, totalStocks, onStockClick }) => {
}
};
// 新增:切换股票涨停原因的展开状态
const toggleStockReason = (stockCode) => {
setExpandedStockReasons(prev => ({
...prev,
[stockCode]: !prev[stockCode]
}));
};
const getSectorColorScheme = (sector) => {
if (sector === '公告') return 'orange';
if (sector === '其他') return 'gray';
@@ -180,105 +166,86 @@ const SectorDetails = ({ sortedSectors, totalStocks, onStockClick }) => {
.map((stock, idx) => (
<Box
key={`${stock.scode}-${idx}`}
p={4}
p={3}
borderRadius="lg"
bg="white"
border="1px solid"
borderColor="gray.200"
borderLeft="4px solid"
borderLeftColor={`${colorScheme}.400`}
_hover={{
transform: 'translateX(5px)',
boxShadow: 'lg',
borderLeftColor: `${colorScheme}.600`,
bg: 'gray.50'
}}
transition="all 0.2s"
cursor="pointer"
onClick={() => onStockClick && onStockClick(stock)}
>
<Flex justify="space-between" align="start">
<VStack align="start" spacing={2} flex={1}>
<HStack spacing={2} wrap="wrap">
<Text fontWeight="bold" fontSize="lg">{stock.sname}</Text>
<Badge colorScheme="purple" fontSize="sm">{stock.scode}</Badge>
{stock.continuous_days && (
<Badge
colorScheme={getContinuousDaysBadgeColor(stock.continuous_days)}
variant="solid"
fontSize="sm"
>
{stock.continuous_days}
</Badge>
)}
</HStack>
<Collapse in={expandedStockReasons[stock.scode]}>
<Box mt={2} p={3} bg="gray.50" borderRadius="md" border="1px solid" borderColor="gray.200">
<Text fontSize="sm" color="gray.700" fontWeight="bold">
涨停原因:
</Text>
<Text
fontSize="sm"
color="gray.600"
noOfLines={3}
{...getFormattedTextProps(stock.brief || stock.summary || '暂无涨停原因').props}
>
{getFormattedTextProps(stock.brief || stock.summary || '暂无涨停原因').children}
</Text>
</Box>
</Collapse>
<HStack spacing={4} fontSize="xs" color="gray.500">
<HStack spacing={1}>
<TimeIcon boxSize={3} />
<Text>涨停: {formatStockTime(stock)}</Text>
</HStack>
{stock.first_time && (
<Text>首板: {stock.first_time.split(' ')[0]}</Text>
)}
{stock.change_pct && (
<Text color="red.500" fontWeight="bold">
涨幅: {stock.change_pct}%
</Text>
)}
</HStack>
{stock.core_sectors && stock.core_sectors.length > 0 && (
<Wrap spacing={1} mt={1}>
{stock.core_sectors.slice(0, 5).map((s, i) => (
<WrapItem key={`${s}-${i}`}>
<Tag
size="sm"
colorScheme={getSectorColorScheme(s)}
variant="subtle"
>
<TagLabel fontSize="xs">{s}</TagLabel>
</Tag>
</WrapItem>
))}
{stock.core_sectors.length > 5 && (
<WrapItem>
<Tag size="sm" colorScheme="gray">
<TagLabel fontSize="xs">
+{stock.core_sectors.length - 5}
</TagLabel>
</Tag>
</WrapItem>
)}
</Wrap>
<HStack justify="space-between" align="center" wrap="wrap" spacing={3}>
{/* 左侧:股票基本信息 */}
<HStack spacing={2} minW="200px">
<Link
href={`https://valuefrontier.cn/company?scode=${stock.scode}`}
isExternal
fontWeight="bold"
fontSize="md"
color="blue.600"
_hover={{
color: 'blue.500',
textDecoration: 'underline'
}}
onClick={(e) => e.stopPropagation()}
>
{stock.sname}
<ExternalLinkIcon mx="2px" boxSize={3} />
</Link>
<Badge colorScheme="purple" fontSize="xs">{stock.scode}</Badge>
{stock.continuous_days && (
<Badge
colorScheme={getContinuousDaysBadgeColor(stock.continuous_days)}
variant="solid"
fontSize="xs"
>
{stock.continuous_days}
</Badge>
)}
</VStack>
</HStack>
<IconButton
icon={expandedStockReasons[stock.scode] ? <ChevronUpIcon /> : <ChevronDownIcon />}
size="sm"
variant="ghost"
colorScheme={colorScheme}
aria-label={expandedStockReasons[stock.scode] ? "收起原因" : "展开原因"}
onClick={() => toggleStockReason(stock.scode)}
/>
</Flex>
{/* 中间:时间和涨幅信息 */}
<HStack spacing={4} fontSize="xs" color="gray.500" flex={1} justify="center">
<HStack spacing={1}>
<TimeIcon boxSize={3} />
<Text>涨停: {formatStockTime(stock)}</Text>
</HStack>
{stock.first_time && (
<Text>首板: {stock.first_time.split(' ')[0]}</Text>
)}
{stock.change_pct && (
<Text color="red.500" fontWeight="bold">
涨幅: {stock.change_pct}%
</Text>
)}
</HStack>
{/* 右侧:所属板块标签 */}
{stock.core_sectors && stock.core_sectors.length > 0 && (
<Wrap spacing={1} justify="flex-end" maxW="300px">
{stock.core_sectors.slice(0, 4).map((s, i) => (
<WrapItem key={`${s}-${i}`}>
<Tag
size="sm"
colorScheme={getSectorColorScheme(s)}
variant="subtle"
>
<TagLabel fontSize="xs">{s}</TagLabel>
</Tag>
</WrapItem>
))}
{stock.core_sectors.length > 4 && (
<WrapItem>
<Tag size="sm" colorScheme="gray">
<TagLabel fontSize="xs">
+{stock.core_sectors.length - 4}
</TagLabel>
</Tag>
</WrapItem>
)}
</Wrap>
)}
</HStack>
</Box>
))}
</VStack>

View File

@@ -40,7 +40,7 @@ const API_URL = process.env.NODE_ENV === 'production' ? '/report-api' : 'http://
// 恢复使用本页自带的轻量日历
import EnhancedCalendar from './components/EnhancedCalendar';
import SectorDetails from './components/SectorDetails';
import { DataAnalysis, StockDetailModal } from './components/DataVisualizationComponents';
import { DataAnalysis } from './components/DataVisualizationComponents';
import { AdvancedSearch, SearchResultsModal } from './components/SearchComponents';
// 导航栏已由 MainLayout 提供,无需在此导入
@@ -60,8 +60,6 @@ export default function LimitAnalyse() {
const [wordCloudData, setWordCloudData] = useState([]);
const [searchResults, setSearchResults] = useState(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedStock, setSelectedStock] = useState(null);
const [isStockDetailOpen, setIsStockDetailOpen] = useState(false);
const toast = useToast();
@@ -69,15 +67,7 @@ export default function LimitAnalyse() {
const {
trackDateSelected,
trackDailyStatsViewed,
trackSectorToggled,
trackSectorClicked,
trackLimitStockClicked,
trackSearchInitiated,
trackSearchResultClicked,
trackHighPositionStocksViewed,
trackSectorAnalysisViewed,
trackDataRefreshed,
trackStockDetailViewed,
} = useLimitAnalyseEvents();
const bgColor = useColorModeValue('gray.50', 'gray.900');
@@ -245,20 +235,6 @@ export default function LimitAnalyse() {
}
};
// 处理股票点击
const handleStockClick = (stock) => {
setSelectedStock(stock);
setIsStockDetailOpen(true);
// 🎯 追踪股票详情查看
trackStockDetailViewed(stock.scode, stock.sname, 'sector_details');
};
// 关闭股票详情弹窗
const handleCloseStockDetail = () => {
setIsStockDetailOpen(false);
setSelectedStock(null);
};
// 处理板块数据排序
const getSortedSectorData = () => {
if (!dailyData?.sector_data) return [];
@@ -478,6 +454,18 @@ export default function LimitAnalyse() {
{/* 高级搜索 */}
<AdvancedSearch onSearch={handleSearch} loading={loading} />
{/* 数据分析 - 移到板块详情上方 */}
{loading ? (
<Skeleton height="500px" borderRadius="xl" mb={6} />
) : (
<Box mb={6}>
<DataAnalysis
dailyData={dailyData}
wordCloudData={wordCloudData}
/>
</Box>
)}
{/* 板块详情 - 核心内容 */}
{loading ? (
<Skeleton height="600px" borderRadius="xl" mb={6} />
@@ -486,23 +474,12 @@ export default function LimitAnalyse() {
<SectorDetails
sortedSectors={getSortedSectorData()}
totalStocks={dailyData?.total_stocks || 0}
onStockClick={handleStockClick}
/>
</Box>
)}
{/* 高位股统计 */}
<HighPositionStocks dateStr={dateStr} />
{/* 数据分析 */}
{loading ? (
<Skeleton height="500px" borderRadius="xl" />
) : (
<DataAnalysis
dailyData={dailyData}
wordCloudData={wordCloudData}
/>
)}
</Container>
{/* 弹窗 */}
@@ -513,13 +490,6 @@ export default function LimitAnalyse() {
onStockClick={() => {}}
/>
{/* 股票详情弹窗 */}
<StockDetailModal
isOpen={isStockDetailOpen}
onClose={handleCloseStockDetail}
selectedStock={selectedStock}
/>
{/* 浮动按钮 */}
<Box position="fixed" bottom={8} right={8} zIndex={1000}>
<VStack spacing={3}>

295
test_quant_tools.py Normal file
View File

@@ -0,0 +1,295 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
量化工具测试脚本
测试 mcp_quant.py 中的 28 个量化因子工具是否正常工作
使用方法:
python test_quant_tools.py [股票代码]
示例:
python test_quant_tools.py 600519 # 测试贵州茅台
python test_quant_tools.py 000858 # 测试五粮液
python test_quant_tools.py # 默认使用 600519
"""
import asyncio
import sys
import time
import io
from datetime import datetime, timedelta
from typing import Dict, Any, List, Tuple
# 设置标准输出编码为 UTF-8
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
# 导入量化工具模块
try:
import mcp_quant as quant
except ImportError:
print("[X] Cannot import mcp_quant module, please run from project root")
sys.exit(1)
# 颜色输出 (Windows 兼容)
class Colors:
GREEN = '\033[92m'
RED = '\033[91m'
YELLOW = '\033[93m'
BLUE = '\033[94m'
CYAN = '\033[96m'
RESET = '\033[0m'
BOLD = '\033[1m'
def print_header(title: str):
"""打印标题"""
print(f"\n{Colors.BOLD}{Colors.CYAN}{'='*60}{Colors.RESET}")
print(f"{Colors.BOLD}{Colors.CYAN} {title}{Colors.RESET}")
print(f"{Colors.BOLD}{Colors.CYAN}{'='*60}{Colors.RESET}\n")
def print_section(title: str):
"""打印分节标题"""
print(f"\n{Colors.BOLD}{Colors.BLUE}>> {title}{Colors.RESET}")
print(f"{Colors.BLUE}{'-'*50}{Colors.RESET}")
def print_result(name: str, success: bool, description: str = "", time_ms: float = 0):
"""打印测试结果"""
status = f"{Colors.GREEN}[OK]{Colors.RESET}" if success else f"{Colors.RED}[FAIL]{Colors.RESET}"
time_str = f"{Colors.YELLOW}({time_ms:.0f}ms){Colors.RESET}" if time_ms > 0 else ""
print(f" {status} {name} {time_str}")
if description:
# 截断过长的描述
desc = description[:80] + "..." if len(description) > 80 else description
print(f" {Colors.CYAN}-> {desc}{Colors.RESET}")
async def test_tool(func, *args, **kwargs) -> Tuple[bool, str, float]:
"""
测试单个工具
返回: (是否成功, 描述信息, 耗时ms)
"""
start = time.time()
try:
result = await func(*args, **kwargs)
elapsed = (time.time() - start) * 1000
if result.get("success"):
desc = result.get("data", {}).get("description", "")
return True, desc, elapsed
else:
return False, result.get("error", "未知错误"), elapsed
except Exception as e:
elapsed = (time.time() - start) * 1000
return False, str(e), elapsed
async def run_tests(stock_code: str = "600519"):
"""运行所有量化工具测试"""
print_header(f"量化工具测试 - 股票代码: {stock_code}")
results: List[Tuple[str, bool, str, float]] = []
# ==================== 一、经典技术指标 ====================
print_section("一、经典技术指标 (4个)")
# 1. MACD信号
success, desc, ms = await test_tool(quant.get_macd_signal, stock_code)
print_result("get_macd_signal (MACD信号)", success, desc, ms)
results.append(("get_macd_signal", success, desc, ms))
# 2. RSI/KDJ指标
success, desc, ms = await test_tool(quant.check_oscillator_status, stock_code)
print_result("check_oscillator_status (RSI/KDJ)", success, desc, ms)
results.append(("check_oscillator_status", success, desc, ms))
# 3. 布林带分析
success, desc, ms = await test_tool(quant.analyze_bollinger_bands, stock_code)
print_result("analyze_bollinger_bands (布林带)", success, desc, ms)
results.append(("analyze_bollinger_bands", success, desc, ms))
# 4. ATR止损
success, desc, ms = await test_tool(quant.calc_stop_loss_atr, stock_code)
print_result("calc_stop_loss_atr (ATR止损)", success, desc, ms)
results.append(("calc_stop_loss_atr", success, desc, ms))
# ==================== 二、资金与情绪 ====================
print_section("二、资金与情绪 (3个)")
# 5. 市场热度
success, desc, ms = await test_tool(quant.analyze_market_heat, stock_code)
print_result("analyze_market_heat (市场热度)", success, desc, ms)
results.append(("analyze_market_heat", success, desc, ms))
# 6. 量价背离
success, desc, ms = await test_tool(quant.check_volume_price_divergence, stock_code)
print_result("check_volume_price_divergence (量价背离)", success, desc, ms)
results.append(("check_volume_price_divergence", success, desc, ms))
# 7. OBV能量潮
success, desc, ms = await test_tool(quant.analyze_obv_trend, stock_code)
print_result("analyze_obv_trend (OBV能量潮)", success, desc, ms)
results.append(("analyze_obv_trend", success, desc, ms))
# ==================== 三、形态与突破 ====================
print_section("三、形态与突破 (3个)")
# 8. 新高突破
success, desc, ms = await test_tool(quant.check_new_high_breakout, stock_code)
print_result("check_new_high_breakout (新高突破)", success, desc, ms)
results.append(("check_new_high_breakout", success, desc, ms))
# 9. K线形态
success, desc, ms = await test_tool(quant.identify_candlestick_pattern, stock_code)
print_result("identify_candlestick_pattern (K线形态)", success, desc, ms)
results.append(("identify_candlestick_pattern", success, desc, ms))
# 10. 跳空缺口
success, desc, ms = await test_tool(quant.find_price_gaps, stock_code)
print_result("find_price_gaps (跳空缺口)", success, desc, ms)
results.append(("find_price_gaps", success, desc, ms))
# ==================== 四、风险与估值 ====================
print_section("四、风险与估值 (3个)")
# 11. 最大回撤
success, desc, ms = await test_tool(quant.calc_max_drawdown, stock_code)
print_result("calc_max_drawdown (最大回撤)", success, desc, ms)
results.append(("calc_max_drawdown", success, desc, ms))
# 12. PE估值百分位
success, desc, ms = await test_tool(quant.check_valuation_rank, stock_code)
print_result("check_valuation_rank (PE估值)", success, desc, ms)
results.append(("check_valuation_rank", success, desc, ms))
# 13. Z-Score乖离率
success, desc, ms = await test_tool(quant.calc_price_zscore, stock_code)
print_result("calc_price_zscore (Z-Score)", success, desc, ms)
results.append(("calc_price_zscore", success, desc, ms))
# ==================== 五、分钟级高阶算子 ====================
print_section("五、分钟级高阶算子 (4个)")
print(f" (自动使用最近交易日数据)")
# 14. VPOC筹码峰
success, desc, ms = await test_tool(quant.calc_market_profile_vpoc, stock_code)
print_result("calc_market_profile_vpoc (VPOC)", success, desc, ms)
results.append(("calc_market_profile_vpoc", success, desc, ms))
# 15. 已实现波动率
success, desc, ms = await test_tool(quant.calc_realized_volatility, stock_code)
print_result("calc_realized_volatility (RV波动率)", success, desc, ms)
results.append(("calc_realized_volatility", success, desc, ms))
# 16. 买卖压力
success, desc, ms = await test_tool(quant.analyze_buying_pressure, stock_code)
print_result("analyze_buying_pressure (买卖压力)", success, desc, ms)
results.append(("analyze_buying_pressure", success, desc, ms))
# 17. 帕金森波动率
success, desc, ms = await test_tool(quant.calc_parkinson_volatility, stock_code)
print_result("calc_parkinson_volatility (帕金森波动率)", success, desc, ms)
results.append(("calc_parkinson_volatility", success, desc, ms))
# ==================== 六、高级趋势分析 ====================
print_section("六、高级趋势分析 (4个)")
# 18. 布林带挤压
success, desc, ms = await test_tool(quant.calc_bollinger_squeeze, stock_code)
print_result("calc_bollinger_squeeze (布林带挤压)", success, desc, ms)
results.append(("calc_bollinger_squeeze", success, desc, ms))
# 19. 趋势斜率
success, desc, ms = await test_tool(quant.calc_trend_slope, stock_code)
print_result("calc_trend_slope (趋势斜率)", success, desc, ms)
results.append(("calc_trend_slope", success, desc, ms))
# 20. Hurst指数
success, desc, ms = await test_tool(quant.calc_hurst_exponent, stock_code)
print_result("calc_hurst_exponent (Hurst指数)", success, desc, ms)
results.append(("calc_hurst_exponent", success, desc, ms))
# 21. 趋势分解
success, desc, ms = await test_tool(quant.decompose_trend_simple, stock_code)
print_result("decompose_trend_simple (趋势分解)", success, desc, ms)
results.append(("decompose_trend_simple", success, desc, ms))
# ==================== 七、流动性与统计 ====================
print_section("七、流动性与统计 (3个)")
# 22. Amihud流动性
success, desc, ms = await test_tool(quant.calc_amihud_illiquidity, stock_code)
print_result("calc_amihud_illiquidity (Amihud)", success, desc, ms)
results.append(("calc_amihud_illiquidity", success, desc, ms))
# 23. 价格熵值
success, desc, ms = await test_tool(quant.calc_price_entropy, stock_code)
print_result("calc_price_entropy (价格熵值)", success, desc, ms)
results.append(("calc_price_entropy", success, desc, ms))
# 24. RSI背离
success, desc, ms = await test_tool(quant.calc_rsi_divergence, stock_code)
print_result("calc_rsi_divergence (RSI背离)", success, desc, ms)
results.append(("calc_rsi_divergence", success, desc, ms))
# ==================== 八、配对与策略 ====================
print_section("八、配对与策略 (4个)")
# 25. 协整性测试 (需要两只股票)
success, desc, ms = await test_tool(quant.test_cointegration, stock_code, "000858")
print_result("test_cointegration (协整性测试)", success, desc, ms)
results.append(("test_cointegration", success, desc, ms))
# 26. 凯利仓位 (纯计算,不需要股票代码)
success, desc, ms = await test_tool(quant.calc_kelly_position, 0.55, 2.0)
print_result("calc_kelly_position (凯利仓位)", success, desc, ms)
results.append(("calc_kelly_position", success, desc, ms))
# 27. 相似K线检索
success, desc, ms = await test_tool(quant.search_similar_kline, stock_code)
print_result("search_similar_kline (相似K线)", success, desc, ms)
results.append(("search_similar_kline", success, desc, ms))
# 28. 综合技术分析
success, desc, ms = await test_tool(quant.get_comprehensive_analysis, stock_code)
print_result("get_comprehensive_analysis (综合分析)", success, desc, ms)
results.append(("get_comprehensive_analysis", success, desc, ms))
# ==================== 统计结果 ====================
print_header("测试结果统计")
passed = sum(1 for r in results if r[1])
failed = sum(1 for r in results if not r[1])
total = len(results)
total_time = sum(r[3] for r in results)
print(f" 总计: {total} 个工具")
print(f" {Colors.GREEN}通过: {passed}{Colors.RESET}")
print(f" {Colors.RED}失败: {failed}{Colors.RESET}")
print(f" 成功率: {passed/total*100:.1f}%")
print(f" 总耗时: {total_time/1000:.2f}")
print(f" 平均耗时: {total_time/total:.0f} ms/工具")
# 打印失败的工具
if failed > 0:
print(f"\n{Colors.RED}失败的工具:{Colors.RESET}")
for name, success, desc, ms in results:
if not success:
print(f" - {name}: {desc}")
print()
return passed == total
if __name__ == "__main__":
# 获取股票代码参数
stock_code = sys.argv[1] if len(sys.argv) > 1 else "600519"
# 运行测试
success = asyncio.run(run_tests(stock_code))
# 返回退出码
sys.exit(0 if success else 1)