Compare commits
92 Commits
dabedc1c0b
...
feature_20
| Author | SHA1 | Date | |
|---|---|---|---|
| 02d5311005 | |||
| 7fa3d26470 | |||
| 21eb1783e9 | |||
| ec31801ccd | |||
| ff9c68295b | |||
| a72978c200 | |||
| 2c4f5152e4 | |||
| 846e66fecb | |||
| ef6c58b247 | |||
| b753d29dbf | |||
| 455e1c1d32 | |||
| 7b65cac358 | |||
| 8843c81d8b | |||
| 6763151c57 | |||
| 9d9d3430b7 | |||
| 25c3d9d828 | |||
| 41368f82a7 | |||
| 608ac4a962 | |||
| 5a24cb9eec | |||
| 33a3c16421 | |||
| 2f8388ba41 | |||
| 4127e4c816 | |||
| 05aa0c89f0 | |||
| 14ab2f62f3 | |||
| fc738dc639 | |||
| 059275d1a2 | |||
| d14be2081d | |||
| 1676d69917 | |||
| 20b3d624f0 | |||
| 34323cc63d | |||
| 42fdb7d754 | |||
| 5526705254 | |||
| f6e8d673a8 | |||
| 547424fff6 | |||
| ec2978026a | |||
| 250d585b87 | |||
| 8cf2850660 | |||
| 9b7a221315 | |||
| 18f8f75116 | |||
| 56a7ca7eb3 | |||
| c1937b9e31 | |||
| 9c5900c7f5 | |||
| 007de2d76d | |||
| 49656e6e88 | |||
| bc6e993dec | |||
| 72a490c789 | |||
|
|
b88bfebcef | ||
|
|
cf4fdf6a68 | ||
|
|
34338373cd | ||
|
|
589e1c20f9 | ||
|
|
60e9a40a1f | ||
|
|
b8b24643fe | ||
|
|
e9e9ec9051 | ||
|
|
5b0e420770 | ||
|
|
93f43054fd | ||
|
|
101d042b0e | ||
|
|
a1aa6718e6 | ||
|
|
753727c1c0 | ||
|
|
afc92ee583 | ||
| 900aff17df | |||
|
|
d825e4fe59 | ||
|
|
62cf0a6c7d | ||
|
|
805d446775 | ||
|
|
24ddfcd4b5 | ||
|
|
a90158239b | ||
|
|
a8d4245595 | ||
|
|
5aedde7528 | ||
|
|
f5f89a1c72 | ||
|
|
e0b7f8c59d | ||
|
|
d22d75e761 | ||
|
|
30fc156474 | ||
|
|
572665199a | ||
|
|
a2831c82a8 | ||
|
|
217551b6ab | ||
|
|
022271947a | ||
|
|
cd6ffdbe68 | ||
|
|
9df725b748 | ||
|
|
64f8914951 | ||
|
|
506e5a448c | ||
|
|
e277352133 | ||
|
|
87437ed229 | ||
|
|
037471d880 | ||
|
|
0c482bc72c | ||
|
|
4aebb3bf4b | ||
|
|
ed241bd9c5 | ||
|
|
e6ede81c78 | ||
|
|
a0b688da80 | ||
|
|
6bd09b797d | ||
|
|
9c532b5f18 | ||
|
|
1d1d6c8169 | ||
|
|
3507cfe9f7 | ||
|
|
cc520893f8 |
BIN
__pycache__/mcp_database.cpython-310.pyc
Normal file
BIN
__pycache__/mcp_elasticsearch.cpython-310.pyc
Normal file
BIN
__pycache__/mcp_quant.cpython-310.pyc
Normal file
BIN
__pycache__/mcp_server.cpython-310.pyc
Normal file
523
app.py
@@ -3475,6 +3475,46 @@ def get_wechat_qrcode():
|
||||
}}), 200
|
||||
|
||||
|
||||
@app.route('/api/auth/wechat/h5-auth', methods=['POST'])
|
||||
def get_wechat_h5_auth_url():
|
||||
"""
|
||||
获取微信 H5 网页授权 URL
|
||||
用于手机浏览器跳转微信 App 授权
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
frontend_redirect = data.get('redirect_url', '/home')
|
||||
|
||||
# 生成唯一 state
|
||||
state = uuid.uuid4().hex
|
||||
|
||||
# 编码回调地址
|
||||
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
|
||||
|
||||
# 构建授权 URL(使用 snsapi_userinfo 获取用户信息,仅限微信内 H5 使用)
|
||||
auth_url = (
|
||||
f"https://open.weixin.qq.com/connect/oauth2/authorize?"
|
||||
f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}"
|
||||
f"&response_type=code&scope=snsapi_userinfo&state={state}"
|
||||
"#wechat_redirect"
|
||||
)
|
||||
|
||||
# 存储 session 信息
|
||||
wechat_qr_sessions[state] = {
|
||||
'status': 'waiting',
|
||||
'expires': time.time() + 300,
|
||||
'mode': 'h5', # 标记为 H5 模式
|
||||
'frontend_redirect': frontend_redirect,
|
||||
'user_info': None,
|
||||
'wechat_openid': None,
|
||||
'wechat_unionid': None
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'auth_url': auth_url,
|
||||
'state': state
|
||||
}), 200
|
||||
|
||||
|
||||
@app.route('/api/account/wechat/qrcode', methods=['GET'])
|
||||
def get_wechat_bind_qrcode():
|
||||
"""发起微信绑定二维码,会话标记为绑定模式"""
|
||||
@@ -3714,14 +3754,23 @@ def wechat_callback():
|
||||
# 更新微信session状态,供前端轮询检测
|
||||
if state in wechat_qr_sessions:
|
||||
session_item = wechat_qr_sessions[state]
|
||||
# 仅处理登录/注册流程,不处理绑定流程
|
||||
if not session_item.get('mode'):
|
||||
# 更新状态和用户信息
|
||||
mode = session_item.get('mode')
|
||||
|
||||
# H5 模式:重定向到前端回调页面
|
||||
if mode == 'h5':
|
||||
frontend_redirect = session_item.get('frontend_redirect', '/home/wechat-callback')
|
||||
# 清理 session
|
||||
del wechat_qr_sessions[state]
|
||||
print(f"✅ H5 微信登录成功,重定向到: {frontend_redirect}")
|
||||
return redirect(f"{frontend_redirect}?wechat_login=success")
|
||||
|
||||
# PC 扫码模式:更新状态供前端轮询
|
||||
if not mode:
|
||||
session_item['status'] = 'register_ready' if is_new_user else 'login_ready'
|
||||
session_item['user_info'] = {'user_id': user.id}
|
||||
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
|
||||
|
||||
# 直接跳转到首页
|
||||
# PC 模式直接跳转到首页
|
||||
return redirect('/home')
|
||||
|
||||
except Exception as e:
|
||||
@@ -4560,20 +4609,10 @@ def get_my_following_future_events():
|
||||
)
|
||||
|
||||
events = []
|
||||
# 所有返回的事件都是已关注的
|
||||
following_ids = set(future_event_ids)
|
||||
for row in result:
|
||||
event_data = {
|
||||
'id': row.data_id,
|
||||
'title': row.title,
|
||||
'type': row.type,
|
||||
'calendar_time': row.calendar_time.isoformat(),
|
||||
'star': row.star,
|
||||
'former': row.former,
|
||||
'forecast': row.forecast,
|
||||
'fact': row.fact,
|
||||
'is_following': True, # 这些都是已关注的
|
||||
'related_stocks': parse_json_field(row.related_stocks),
|
||||
'concepts': parse_json_field(row.concepts)
|
||||
}
|
||||
event_data = process_future_event_row(row, following_ids)
|
||||
events.append(event_data)
|
||||
|
||||
return jsonify({'success': True, 'data': events})
|
||||
@@ -5379,31 +5418,26 @@ def get_related_stocks(event_id):
|
||||
|
||||
stocks_data = []
|
||||
for stock in stocks:
|
||||
if stock.retrieved_sources is not None:
|
||||
stocks_data.append({
|
||||
'id': stock.id,
|
||||
'stock_code': stock.stock_code,
|
||||
'stock_name': stock.stock_name,
|
||||
'sector': stock.sector,
|
||||
'relation_desc': {"data":stock.retrieved_sources},
|
||||
'retrieved_sources': stock.retrieved_sources,
|
||||
'correlation': stock.correlation,
|
||||
'momentum': stock.momentum,
|
||||
'created_at': stock.created_at.isoformat() if stock.created_at else None,
|
||||
'updated_at': stock.updated_at.isoformat() if stock.updated_at else None
|
||||
})
|
||||
# 处理 relation_desc:只有当 retrieved_sources 是数组时才使用新格式
|
||||
if stock.retrieved_sources is not None and isinstance(stock.retrieved_sources, list):
|
||||
# retrieved_sources 是有效数组,使用新格式
|
||||
relation_desc_value = {"data": stock.retrieved_sources}
|
||||
else:
|
||||
stocks_data.append({
|
||||
'id': stock.id,
|
||||
'stock_code': stock.stock_code,
|
||||
'stock_name': stock.stock_name,
|
||||
'sector': stock.sector,
|
||||
'relation_desc': stock.relation_desc,
|
||||
'correlation': stock.correlation,
|
||||
'momentum': stock.momentum,
|
||||
'created_at': stock.created_at.isoformat() if stock.created_at else None,
|
||||
'updated_at': stock.updated_at.isoformat() if stock.updated_at else None
|
||||
})
|
||||
# retrieved_sources 不是数组(可能是 {"raw": "..."} 等异常格式),回退到原始文本
|
||||
relation_desc_value = stock.relation_desc
|
||||
|
||||
stocks_data.append({
|
||||
'id': stock.id,
|
||||
'stock_code': stock.stock_code,
|
||||
'stock_name': stock.stock_name,
|
||||
'sector': stock.sector,
|
||||
'relation_desc': relation_desc_value,
|
||||
'retrieved_sources': stock.retrieved_sources,
|
||||
'correlation': stock.correlation,
|
||||
'momentum': stock.momentum,
|
||||
'created_at': stock.created_at.isoformat() if stock.created_at else None,
|
||||
'updated_at': stock.updated_at.isoformat() if stock.updated_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -5779,18 +5813,29 @@ def get_stock_quotes():
|
||||
current_time = datetime.now()
|
||||
client = get_clickhouse_client()
|
||||
|
||||
# Get stock names from MySQL
|
||||
# Get stock names from MySQL(批量查询优化)
|
||||
stock_names = {}
|
||||
with engine.connect() as conn:
|
||||
for code in codes:
|
||||
codez = code.split('.')[0]
|
||||
# 提取不带后缀的股票代码
|
||||
base_codes = list(set([code.split('.')[0] for code in codes]))
|
||||
if base_codes:
|
||||
# 批量查询所有股票名称
|
||||
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
|
||||
params = {f'code{i}': code for i, code in enumerate(base_codes)}
|
||||
result = conn.execute(text(
|
||||
"SELECT SECNAME FROM ea_stocklist WHERE SECCODE = :code"
|
||||
), {"code": codez}).fetchone()
|
||||
if result:
|
||||
stock_names[code] = result[0]
|
||||
else:
|
||||
stock_names[code] = f"股票{codez}"
|
||||
f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})"
|
||||
), params).fetchall()
|
||||
|
||||
# 构建代码到名称的映射
|
||||
base_name_map = {row[0]: row[1] for row in result}
|
||||
|
||||
# 为每个完整代码(带后缀)分配名称
|
||||
for code in codes:
|
||||
base_code = code.split('.')[0]
|
||||
if base_code in base_name_map:
|
||||
stock_names[code] = base_name_map[base_code]
|
||||
else:
|
||||
stock_names[code] = f"股票{base_code}"
|
||||
|
||||
def get_trading_day_and_times(event_datetime):
|
||||
event_date = event_datetime.date()
|
||||
@@ -5862,65 +5907,111 @@ def get_stock_quotes():
|
||||
})
|
||||
|
||||
results = {}
|
||||
print(f"处理股票代码: {codes}, 交易日: {trading_day}, 时间范围: {start_datetime} - {end_datetime}")
|
||||
print(f"批量处理 {len(codes)} 只股票: {codes[:5]}{'...' if len(codes) > 5 else ''}, 交易日: {trading_day}, 时间范围: {start_datetime} - {end_datetime}")
|
||||
|
||||
for code in codes:
|
||||
try:
|
||||
print(f"正在查询股票 {code} 的价格数据...")
|
||||
# Get the first price and last price for the trading period
|
||||
data = client.execute("""
|
||||
WITH first_price AS (SELECT close
|
||||
FROM stock_minute
|
||||
WHERE code = %(code)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(end)s
|
||||
ORDER BY timestamp
|
||||
LIMIT 1
|
||||
),
|
||||
last_price AS (
|
||||
SELECT close
|
||||
FROM stock_minute
|
||||
WHERE code = %(code)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(end)s
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1
|
||||
)
|
||||
SELECT last_price.close as last_price,
|
||||
(last_price.close - first_price.close) / first_price.close * 100 as change
|
||||
FROM last_price
|
||||
CROSS JOIN first_price
|
||||
WHERE EXISTS (SELECT 1 FROM first_price)
|
||||
AND EXISTS (SELECT 1 FROM last_price)
|
||||
""", {
|
||||
'code': code,
|
||||
'start': start_datetime,
|
||||
'end': end_datetime
|
||||
})
|
||||
# ==================== 性能优化:批量查询所有股票数据 ====================
|
||||
# 使用 IN 子句一次查询所有股票,避免逐只循环查询
|
||||
try:
|
||||
# 批量查询价格和涨跌幅数据(使用窗口函数)
|
||||
batch_price_query = """
|
||||
WITH first_prices AS (
|
||||
SELECT
|
||||
code,
|
||||
close as first_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp ASC) as rn
|
||||
FROM stock_minute
|
||||
WHERE code IN %(codes)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(end)s
|
||||
),
|
||||
last_prices AS (
|
||||
SELECT
|
||||
code,
|
||||
close as last_price,
|
||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn
|
||||
FROM stock_minute
|
||||
WHERE code IN %(codes)s
|
||||
AND timestamp >= %(start)s
|
||||
AND timestamp <= %(end)s
|
||||
)
|
||||
SELECT
|
||||
fp.code,
|
||||
lp.last_price,
|
||||
(lp.last_price - fp.first_price) / fp.first_price * 100 as change_pct
|
||||
FROM first_prices fp
|
||||
INNER JOIN last_prices lp ON fp.code = lp.code
|
||||
WHERE fp.rn = 1 AND lp.rn = 1
|
||||
"""
|
||||
|
||||
print(f"股票 {code} 查询结果: {data}")
|
||||
if data and data[0] and data[0][0] is not None:
|
||||
price = float(data[0][0]) if data[0][0] is not None else None
|
||||
change = float(data[0][1]) if data[0][1] is not None else None
|
||||
batch_data = client.execute(batch_price_query, {
|
||||
'codes': codes,
|
||||
'start': start_datetime,
|
||||
'end': end_datetime
|
||||
})
|
||||
|
||||
print(f"批量查询返回 {len(batch_data)} 条价格数据")
|
||||
|
||||
# 解析批量查询结果
|
||||
price_data_map = {}
|
||||
for row in batch_data:
|
||||
code = row[0]
|
||||
last_price = float(row[1]) if row[1] is not None else None
|
||||
change_pct = float(row[2]) if row[2] is not None else None
|
||||
price_data_map[code] = {
|
||||
'price': last_price,
|
||||
'change': change_pct
|
||||
}
|
||||
|
||||
# 组装结果(所有股票)
|
||||
for code in codes:
|
||||
price_info = price_data_map.get(code)
|
||||
if price_info:
|
||||
results[code] = {
|
||||
'price': price,
|
||||
'change': change,
|
||||
'price': price_info['price'],
|
||||
'change': price_info['change'],
|
||||
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
|
||||
}
|
||||
else:
|
||||
# 批量查询没有返回的股票
|
||||
results[code] = {
|
||||
'price': None,
|
||||
'change': None,
|
||||
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error processing stock {code}: {e}")
|
||||
results[code] = {
|
||||
'price': None,
|
||||
'change': None,
|
||||
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"批量查询 ClickHouse 失败: {e},回退到逐只查询")
|
||||
# 降级方案:逐只股票查询(保持向后兼容)
|
||||
for code in codes:
|
||||
try:
|
||||
data = client.execute("""
|
||||
WITH first_price AS (
|
||||
SELECT close FROM stock_minute
|
||||
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
|
||||
ORDER BY timestamp LIMIT 1
|
||||
),
|
||||
last_price AS (
|
||||
SELECT close FROM stock_minute
|
||||
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
|
||||
ORDER BY timestamp DESC LIMIT 1
|
||||
)
|
||||
SELECT last_price.close as last_price,
|
||||
(last_price.close - first_price.close) / first_price.close * 100 as change
|
||||
FROM last_price CROSS JOIN first_price
|
||||
WHERE EXISTS (SELECT 1 FROM first_price) AND EXISTS (SELECT 1 FROM last_price)
|
||||
""", {'code': code, 'start': start_datetime, 'end': end_datetime})
|
||||
|
||||
if data and data[0] and data[0][0] is not None:
|
||||
results[code] = {
|
||||
'price': float(data[0][0]) if data[0][0] is not None else None,
|
||||
'change': float(data[0][1]) if data[0][1] is not None else None,
|
||||
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
|
||||
}
|
||||
else:
|
||||
results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')}
|
||||
except Exception as inner_e:
|
||||
print(f"Error processing stock {code}: {inner_e}")
|
||||
results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')}
|
||||
|
||||
# 返回标准格式
|
||||
return jsonify({'success': True, 'data': results})
|
||||
@@ -6045,17 +6136,9 @@ def account_calendar_events():
|
||||
|
||||
future_events = []
|
||||
if future_event_ids:
|
||||
# 使用 SELECT * 以便获取所有字段(包括新字段)
|
||||
base_sql = """
|
||||
SELECT data_id, \
|
||||
title, \
|
||||
type, \
|
||||
calendar_time, \
|
||||
star, \
|
||||
former, \
|
||||
forecast, \
|
||||
fact, \
|
||||
related_stocks, \
|
||||
concepts
|
||||
SELECT *
|
||||
FROM future_events
|
||||
WHERE data_id IN :event_ids \
|
||||
"""
|
||||
@@ -6073,12 +6156,24 @@ def account_calendar_events():
|
||||
|
||||
result = db.session.execute(text(base_sql), params)
|
||||
for row in result:
|
||||
# related_stocks 形如 [[code,name,reason,score], ...]
|
||||
rs = parse_json_field(row.related_stocks)
|
||||
# 使用新字段回退逻辑获取 former
|
||||
former_value = get_future_event_field(row, 'second_modified_text', 'former')
|
||||
|
||||
# 获取 related_stocks,优先使用 best_matches
|
||||
best_matches = getattr(row, 'best_matches', None) if hasattr(row, 'best_matches') else None
|
||||
if best_matches and str(best_matches).strip():
|
||||
rs = parse_best_matches(best_matches)
|
||||
else:
|
||||
rs = parse_json_field(getattr(row, 'related_stocks', None))
|
||||
|
||||
# 生成股票标签列表
|
||||
stock_tags = []
|
||||
try:
|
||||
for it in rs:
|
||||
if isinstance(it, (list, tuple)) and len(it) >= 2:
|
||||
if isinstance(it, dict):
|
||||
# 新结构
|
||||
stock_tags.append(f"{it.get('code', '')} {it.get('name', '')}")
|
||||
elif isinstance(it, (list, tuple)) and len(it) >= 2:
|
||||
stock_tags.append(f"{it[0]} {it[1]}")
|
||||
elif isinstance(it, str):
|
||||
stock_tags.append(it)
|
||||
@@ -6091,7 +6186,7 @@ def account_calendar_events():
|
||||
'event_date': (row.calendar_time.date().isoformat() if row.calendar_time else None),
|
||||
'type': 'future_event',
|
||||
'importance': int(row.star) if getattr(row, 'star', None) is not None else 3,
|
||||
'description': row.former or '',
|
||||
'description': former_value or '',
|
||||
'stocks': stock_tags,
|
||||
'is_following': True,
|
||||
'source': 'future'
|
||||
@@ -7499,47 +7594,8 @@ def get_calendar_events():
|
||||
user_following_ids = {f.future_event_id for f in follows}
|
||||
|
||||
for row in result:
|
||||
event_data = {
|
||||
'id': row.data_id,
|
||||
'title': row.title,
|
||||
'type': row.type,
|
||||
'calendar_time': row.calendar_time.isoformat(),
|
||||
'star': row.star,
|
||||
'former': row.former,
|
||||
'forecast': row.forecast,
|
||||
'fact': row.fact,
|
||||
'is_following': row.data_id in user_following_ids
|
||||
}
|
||||
|
||||
# 解析相关股票和概念
|
||||
if row.related_stocks:
|
||||
try:
|
||||
if isinstance(row.related_stocks, str):
|
||||
if row.related_stocks.startswith('['):
|
||||
event_data['related_stocks'] = json.loads(row.related_stocks)
|
||||
else:
|
||||
event_data['related_stocks'] = row.related_stocks.split(',')
|
||||
else:
|
||||
event_data['related_stocks'] = row.related_stocks
|
||||
except:
|
||||
event_data['related_stocks'] = []
|
||||
else:
|
||||
event_data['related_stocks'] = []
|
||||
|
||||
if row.concepts:
|
||||
try:
|
||||
if isinstance(row.concepts, str):
|
||||
if row.concepts.startswith('['):
|
||||
event_data['concepts'] = json.loads(row.concepts)
|
||||
else:
|
||||
event_data['concepts'] = row.concepts.split(',')
|
||||
else:
|
||||
event_data['concepts'] = row.concepts
|
||||
except:
|
||||
event_data['concepts'] = []
|
||||
else:
|
||||
event_data['concepts'] = []
|
||||
|
||||
# 使用统一的处理函数,支持新字段回退和 best_matches 解析
|
||||
event_data = process_future_event_row(row, user_following_ids)
|
||||
events.append(event_data)
|
||||
|
||||
return jsonify({
|
||||
@@ -7565,28 +7621,18 @@ def get_calendar_event_detail(event_id):
|
||||
'error': 'Event not found'
|
||||
}), 404
|
||||
|
||||
event_data = {
|
||||
'id': result.data_id,
|
||||
'title': result.title,
|
||||
'type': result.type,
|
||||
'calendar_time': result.calendar_time.isoformat(),
|
||||
'star': result.star,
|
||||
'former': result.former,
|
||||
'forecast': result.forecast,
|
||||
'fact': result.fact,
|
||||
'related_stocks': parse_json_field(result.related_stocks),
|
||||
'concepts': parse_json_field(result.concepts)
|
||||
}
|
||||
|
||||
# 检查当前用户是否关注了该未来事件
|
||||
user_following_ids = set()
|
||||
if 'user_id' in session:
|
||||
is_following = FutureEventFollow.query.filter_by(
|
||||
user_id=session['user_id'],
|
||||
future_event_id=event_id
|
||||
).first() is not None
|
||||
event_data['is_following'] = is_following
|
||||
else:
|
||||
event_data['is_following'] = False
|
||||
if is_following:
|
||||
user_following_ids.add(event_id)
|
||||
|
||||
# 使用统一的处理函数,支持新字段回退和 best_matches 解析
|
||||
event_data = process_future_event_row(result, user_following_ids)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -7678,6 +7724,147 @@ def parse_json_field(field_value):
|
||||
return []
|
||||
|
||||
|
||||
def get_future_event_field(row, new_field, old_field):
|
||||
"""
|
||||
获取 future_events 表字段值,支持新旧字段回退
|
||||
如果新字段存在且不为空,使用新字段;否则使用旧字段
|
||||
"""
|
||||
new_value = getattr(row, new_field, None) if hasattr(row, new_field) else None
|
||||
old_value = getattr(row, old_field, None) if hasattr(row, old_field) else None
|
||||
|
||||
# 如果新字段有值(不为空字符串),使用新字段
|
||||
if new_value is not None and str(new_value).strip():
|
||||
return new_value
|
||||
return old_value
|
||||
|
||||
|
||||
def parse_best_matches(best_matches_value):
|
||||
"""
|
||||
解析新的 best_matches 数据结构(含研报引用信息)
|
||||
|
||||
新结构示例:
|
||||
[
|
||||
{
|
||||
"stock_code": "300451.SZ",
|
||||
"company_name": "创业慧康",
|
||||
"original_description": "核心标的,医疗信息化...",
|
||||
"best_report_title": "报告标题",
|
||||
"best_report_author": "作者",
|
||||
"best_report_sentences": "相关内容",
|
||||
"best_report_match_score": "好",
|
||||
"best_report_match_ratio": 0.9285714285714286,
|
||||
"best_report_declare_date": "2023-04-25T00:00:00",
|
||||
"total_reports": 9,
|
||||
"high_score_reports": 6
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
返回统一格式的股票列表,兼容旧格式
|
||||
"""
|
||||
if not best_matches_value:
|
||||
return []
|
||||
|
||||
try:
|
||||
# 解析 JSON
|
||||
if isinstance(best_matches_value, str):
|
||||
data = json.loads(best_matches_value)
|
||||
else:
|
||||
data = best_matches_value
|
||||
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
|
||||
result = []
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
# 新结构:包含研报信息的字典
|
||||
stock_info = {
|
||||
'code': item.get('stock_code', ''),
|
||||
'name': item.get('company_name', ''),
|
||||
'description': item.get('original_description', ''),
|
||||
'score': item.get('best_report_match_ratio', 0),
|
||||
# 研报引用信息
|
||||
'report': {
|
||||
'title': item.get('best_report_title', ''),
|
||||
'author': item.get('best_report_author', ''),
|
||||
'sentences': item.get('best_report_sentences', ''),
|
||||
'match_score': item.get('best_report_match_score', ''),
|
||||
'match_ratio': item.get('best_report_match_ratio', 0),
|
||||
'declare_date': item.get('best_report_declare_date', ''),
|
||||
'total_reports': item.get('total_reports', 0),
|
||||
'high_score_reports': item.get('high_score_reports', 0)
|
||||
} if item.get('best_report_title') else None
|
||||
}
|
||||
result.append(stock_info)
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
# 旧结构:[code, name, description, score]
|
||||
result.append({
|
||||
'code': item[0],
|
||||
'name': item[1],
|
||||
'description': item[2] if len(item) > 2 else '',
|
||||
'score': item[3] if len(item) > 3 else 0,
|
||||
'report': None
|
||||
})
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"parse_best_matches error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def process_future_event_row(row, user_following_ids=None):
|
||||
"""
|
||||
统一处理 future_events 表的行数据
|
||||
支持新字段回退和 best_matches 解析
|
||||
"""
|
||||
if user_following_ids is None:
|
||||
user_following_ids = set()
|
||||
|
||||
# 获取字段值,支持新旧回退
|
||||
# second_modified_text -> former
|
||||
# second_modified_text.1 -> forecast (MySQL 中用反引号)
|
||||
former_value = get_future_event_field(row, 'second_modified_text', 'former')
|
||||
|
||||
# 处理 second_modified_text.1 字段(特殊字段名)
|
||||
forecast_new = None
|
||||
if hasattr(row, 'second_modified_text.1'):
|
||||
forecast_new = getattr(row, 'second_modified_text.1', None)
|
||||
# 尝试其他可能的属性名
|
||||
for attr_name in ['second_modified_text.1', 'second_modified_text_1']:
|
||||
if hasattr(row, attr_name):
|
||||
val = getattr(row, attr_name, None)
|
||||
if val and str(val).strip():
|
||||
forecast_new = val
|
||||
break
|
||||
forecast_value = forecast_new if (forecast_new and str(forecast_new).strip()) else getattr(row, 'forecast', None)
|
||||
|
||||
# best_matches -> related_stocks
|
||||
best_matches = getattr(row, 'best_matches', None) if hasattr(row, 'best_matches') else None
|
||||
if best_matches and str(best_matches).strip():
|
||||
related_stocks = parse_best_matches(best_matches)
|
||||
else:
|
||||
related_stocks = parse_json_field(getattr(row, 'related_stocks', None))
|
||||
|
||||
# 构建事件数据
|
||||
event_data = {
|
||||
'id': row.data_id,
|
||||
'title': row.title,
|
||||
'type': getattr(row, 'type', None),
|
||||
'calendar_time': row.calendar_time.isoformat() if row.calendar_time else None,
|
||||
'star': row.star,
|
||||
'former': former_value,
|
||||
'forecast': forecast_value,
|
||||
'fact': getattr(row, 'fact', None),
|
||||
'is_following': row.data_id in user_following_ids,
|
||||
'related_stocks': related_stocks,
|
||||
'concepts': parse_json_field(getattr(row, 'concepts', None)),
|
||||
'update_time': getattr(row, 'update_time', None).isoformat() if getattr(row, 'update_time', None) else None
|
||||
}
|
||||
|
||||
return event_data
|
||||
|
||||
|
||||
# ==================== 行业API ====================
|
||||
@app.route('/api/classifications', methods=['GET'])
|
||||
def get_classifications():
|
||||
|
||||
381
app_vx.py
@@ -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
166
gunicorn_config.py
Normal 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
|
||||
"""
|
||||
249
mcp_database.py
@@ -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)}
|
||||
|
||||
@@ -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
1635
mcp_server.py
@@ -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",
|
||||
|
||||
BIN
public/images/agent/simons.png
Normal file
|
After Width: | Height: | Size: 380 KiB |
BIN
public/images/agent/基金经理.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
public/images/agent/大空头.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
public/images/agent/巴菲特.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
public/images/agent/牢大.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 918 KiB |
|
Before Width: | Height: | Size: 795 KiB |
|
Before Width: | Height: | Size: 1017 KiB |
|
Before Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 640 KiB |
|
Before Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 479 KiB |
|
Before Width: | Height: | Size: 553 KiB |
|
Before Width: | Height: | Size: 556 KiB |
|
Before Width: | Height: | Size: 443 KiB |
|
Before Width: | Height: | Size: 607 KiB |
2089
report_zt_api.py
Normal file
120
src/App.js
@@ -27,19 +27,66 @@ import { PerformancePanel } from './components/PerformancePanel';
|
||||
import { useGlobalErrorHandler } from './hooks/useGlobalErrorHandler';
|
||||
|
||||
// Redux
|
||||
import { initializePostHog } from './store/slices/posthogSlice';
|
||||
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
|
||||
// import { initializePostHog } from './store/slices/posthogSlice';
|
||||
import { updateScreenSize } from './store/slices/deviceSlice';
|
||||
import { injectReducer } from './store';
|
||||
|
||||
// Utils
|
||||
import { logger } from './utils/logger';
|
||||
import { performanceMonitor } from './utils/performanceMonitor';
|
||||
|
||||
// PostHog 追踪
|
||||
import { trackEvent, trackEventAsync } from '@lib/posthog';
|
||||
// ⚡ PostHog 延迟加载:移除同步导入
|
||||
// import { trackEvent, trackEventAsync } from '@lib/posthog';
|
||||
|
||||
// Contexts
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
|
||||
// ⚡ PostHog 延迟加载模块(动态导入后缓存)
|
||||
let posthogModule = null;
|
||||
let posthogSliceModule = null;
|
||||
|
||||
/**
|
||||
* ⚡ 延迟加载 PostHog 模块
|
||||
* 返回 { trackEvent, trackEventAsync, initializePostHog, posthogReducer }
|
||||
*/
|
||||
const loadPostHogModules = async () => {
|
||||
if (posthogModule && posthogSliceModule) {
|
||||
return { posthogModule, posthogSliceModule };
|
||||
}
|
||||
|
||||
try {
|
||||
const [posthog, posthogSlice] = await Promise.all([
|
||||
import('@lib/posthog'),
|
||||
import('./store/slices/posthogSlice'),
|
||||
]);
|
||||
|
||||
posthogModule = posthog;
|
||||
posthogSliceModule = posthogSlice;
|
||||
|
||||
return { posthogModule, posthogSliceModule };
|
||||
} catch (error) {
|
||||
logger.error('App', 'PostHog 模块加载失败', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚡ 异步追踪事件(延迟加载 PostHog 后调用)
|
||||
* @param {string} eventName - 事件名称
|
||||
* @param {object} properties - 事件属性
|
||||
*/
|
||||
const trackEventLazy = async (eventName, properties = {}) => {
|
||||
// 等待模块加载完成
|
||||
if (!posthogModule) {
|
||||
const modules = await loadPostHogModules();
|
||||
if (!modules) return;
|
||||
}
|
||||
|
||||
// 使用异步追踪,不阻塞主线程
|
||||
posthogModule.trackEventAsync(eventName, properties);
|
||||
};
|
||||
|
||||
/**
|
||||
* AppContent - 应用核心内容
|
||||
* 负责 PostHog 初始化和渲染路由
|
||||
@@ -53,15 +100,45 @@ function AppContent() {
|
||||
const pageEnterTimeRef = useRef(Date.now());
|
||||
const currentPathRef = useRef(location.pathname);
|
||||
|
||||
// 🎯 PostHog Redux 初始化(延迟执行,不阻塞首屏)
|
||||
// 🎯 ⚡ PostHog 空闲时加载 + Redux 初始化(首屏不加载 ~180KB)
|
||||
useEffect(() => {
|
||||
// ⚡ 延迟 2 秒初始化 PostHog,确保首屏渲染不被阻塞
|
||||
const timer = setTimeout(() => {
|
||||
dispatch(initializePostHog());
|
||||
logger.info('App', 'PostHog Redux 初始化已触发(延迟 2 秒)');
|
||||
}, 2000);
|
||||
const initPostHogRedux = async () => {
|
||||
try {
|
||||
const modules = await loadPostHogModules();
|
||||
if (!modules) return;
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
const { posthogSliceModule } = modules;
|
||||
|
||||
// 动态注入 PostHog reducer
|
||||
injectReducer('posthog', posthogSliceModule.default);
|
||||
|
||||
// 初始化 PostHog
|
||||
dispatch(posthogSliceModule.initializePostHog());
|
||||
|
||||
// ⚡ 刷新注入前缓存的事件(避免丢失)
|
||||
const pendingEvents = posthogSliceModule.flushPendingEventsBeforeInjection();
|
||||
if (pendingEvents.length > 0) {
|
||||
logger.info('App', `刷新 ${pendingEvents.length} 个注入前缓存的事件`);
|
||||
pendingEvents.forEach(({ eventName, properties }) => {
|
||||
posthogModule.trackEventAsync(eventName, properties);
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('App', 'PostHog 模块空闲时加载完成,Redux 初始化已触发');
|
||||
} catch (error) {
|
||||
logger.error('App', 'PostHog 加载失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ⚡ 使用 requestIdleCallback 在浏览器空闲时加载,最长等待 3 秒
|
||||
if ('requestIdleCallback' in window) {
|
||||
const idleId = requestIdleCallback(initPostHogRedux, { timeout: 3000 });
|
||||
return () => cancelIdleCallback(idleId);
|
||||
} else {
|
||||
// 降级:Safari 等不支持 requestIdleCallback 的浏览器使用 setTimeout
|
||||
const timer = setTimeout(initPostHogRedux, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
// ⚡ 性能监控:标记 React 初始化完成
|
||||
@@ -90,22 +167,31 @@ function AppContent() {
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
// ✅ 首次访问追踪
|
||||
// ✅ 首次访问追踪(🔴 关键事件:立即加载模块,确保数据不丢失)
|
||||
useEffect(() => {
|
||||
const hasVisited = localStorage.getItem('has_visited');
|
||||
|
||||
if (!hasVisited) {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
// ⚡ 使用异步追踪,不阻塞页面渲染
|
||||
trackEventAsync('first_visit', {
|
||||
const eventData = {
|
||||
referrer: document.referrer || 'direct',
|
||||
utm_source: urlParams.get('utm_source'),
|
||||
utm_medium: urlParams.get('utm_medium'),
|
||||
utm_campaign: urlParams.get('utm_campaign'),
|
||||
landing_page: location.pathname,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
// 🔴 关键事件:立即加载 PostHog 模块并同步追踪(不使用 trackEventLazy)
|
||||
// 确保首次访问数据不会因用户快速离开而丢失
|
||||
(async () => {
|
||||
const modules = await loadPostHogModules();
|
||||
if (modules) {
|
||||
// 使用同步追踪(trackEvent),而非异步追踪(trackEventAsync)
|
||||
modules.posthogModule.trackEvent('first_visit', eventData);
|
||||
logger.info('App', '首次访问事件已同步追踪', eventData);
|
||||
}
|
||||
})();
|
||||
|
||||
localStorage.setItem('has_visited', 'true');
|
||||
}
|
||||
@@ -120,8 +206,8 @@ function AppContent() {
|
||||
|
||||
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
|
||||
if (duration > 1) {
|
||||
// ⚡ 使用异步追踪,不阻塞页面切换
|
||||
trackEventAsync('page_view_duration', {
|
||||
// ⚡ 使用延迟加载的异步追踪,不阻塞页面切换
|
||||
trackEventLazy('page_view_duration', {
|
||||
path: currentPathRef.current,
|
||||
duration_seconds: duration,
|
||||
is_authenticated: isAuthenticated,
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
// ⚡ 模块级变量:防止 React StrictMode 双重初始化
|
||||
let widgetInitialized = false;
|
||||
let idleCallbackId = null;
|
||||
|
||||
const BytedeskWidget = ({
|
||||
config,
|
||||
autoLoad = true,
|
||||
@@ -27,110 +31,151 @@ const BytedeskWidget = ({
|
||||
useEffect(() => {
|
||||
// 如果不自动加载或配置未设置,跳过
|
||||
if (!autoLoad || !config) {
|
||||
if (!config) {
|
||||
console.warn('[Bytedesk] 配置未设置,客服组件未加载');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Bytedesk] 开始加载客服Widget...', config);
|
||||
// ⚡ 防止重复初始化(React StrictMode 会双重调用 useEffect)
|
||||
if (widgetInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载Bytedesk Widget脚本
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
|
||||
script.async = true;
|
||||
script.id = 'bytedesk-web-script';
|
||||
// ⚡ 使用 requestIdleCallback 延迟加载,不阻塞首屏
|
||||
const loadWidget = () => {
|
||||
// 再次检查,防止竞态条件
|
||||
if (widgetInitialized) return;
|
||||
widgetInitialized = true;
|
||||
|
||||
script.onload = () => {
|
||||
console.log('[Bytedesk] Widget脚本加载成功');
|
||||
// 检查脚本是否已存在
|
||||
if (document.getElementById('bytedesk-web-script')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (window.BytedeskWeb) {
|
||||
console.log('[Bytedesk] 初始化Widget');
|
||||
const bytedesk = new window.BytedeskWeb(config);
|
||||
bytedesk.init();
|
||||
// 加载Bytedesk Widget脚本
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
|
||||
script.async = true;
|
||||
script.id = 'bytedesk-web-script';
|
||||
|
||||
widgetRef.current = bytedesk;
|
||||
console.log('[Bytedesk] Widget初始化成功');
|
||||
script.onload = () => {
|
||||
try {
|
||||
if (window.BytedeskWeb) {
|
||||
const bytedesk = new window.BytedeskWeb(config);
|
||||
bytedesk.init();
|
||||
widgetRef.current = bytedesk;
|
||||
|
||||
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能)
|
||||
// Bytedesk SDK 内部的 /stomp WebSocket 连接失败不影响核心客服功能
|
||||
// SDK 会自动降级使用 HTTP 轮询
|
||||
const originalConsoleError = console.error;
|
||||
console.error = function(...args) {
|
||||
const errorMsg = args.join(' ');
|
||||
// 忽略 /stomp 和 STOMP 相关错误
|
||||
if (errorMsg.includes('/stomp') ||
|
||||
errorMsg.includes('stomp onWebSocketError') ||
|
||||
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) {
|
||||
return; // 不输出日志
|
||||
// ⚡ H5 端样式适配:使用 MutationObserver 立即应用样式(避免闪烁)
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
const applyBytedeskStyles = () => {
|
||||
const allElements = document.querySelectorAll('body > div');
|
||||
allElements.forEach(el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
// 检查是否是右下角固定定位的元素(Bytedesk 按钮)
|
||||
if (style.position === 'fixed' && style.right && style.bottom) {
|
||||
const rightVal = parseInt(style.right);
|
||||
const bottomVal = parseInt(style.bottom);
|
||||
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
|
||||
// H5 端设置按钮尺寸为 48x48(只执行一次)
|
||||
if (isMobile && !el.dataset.bytedeskStyled) {
|
||||
el.dataset.bytedeskStyled = 'true';
|
||||
const button = el.querySelector('button');
|
||||
if (button) {
|
||||
button.style.width = '48px';
|
||||
button.style.height = '48px';
|
||||
button.style.minWidth = '48px';
|
||||
button.style.minHeight = '48px';
|
||||
}
|
||||
}
|
||||
// 提示框 3 秒后隐藏(查找白色气泡框)
|
||||
const children = el.querySelectorAll('div');
|
||||
children.forEach(child => {
|
||||
if (child.dataset.bytedeskTooltip) return; // 已处理过
|
||||
const childStyle = window.getComputedStyle(child);
|
||||
// 白色背景的提示框
|
||||
if (childStyle.backgroundColor === 'rgb(255, 255, 255)') {
|
||||
child.dataset.bytedeskTooltip = 'true';
|
||||
setTimeout(() => {
|
||||
child.style.transition = 'opacity 0.3s';
|
||||
child.style.opacity = '0';
|
||||
setTimeout(() => child.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 立即执行一次
|
||||
applyBytedeskStyles();
|
||||
|
||||
// 监听 DOM 变化,新元素出现时立即应用样式
|
||||
const observer = new MutationObserver(applyBytedeskStyles);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// 5 秒后停止监听(避免性能问题)
|
||||
setTimeout(() => observer.disconnect(), 5000);
|
||||
|
||||
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能)
|
||||
const originalConsoleError = console.error;
|
||||
console.error = function(...args) {
|
||||
const errorMsg = args.join(' ');
|
||||
if (errorMsg.includes('/stomp') ||
|
||||
errorMsg.includes('stomp onWebSocketError') ||
|
||||
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) {
|
||||
return;
|
||||
}
|
||||
originalConsoleError.apply(console, args);
|
||||
};
|
||||
|
||||
if (onLoad) {
|
||||
onLoad(bytedesk);
|
||||
}
|
||||
originalConsoleError.apply(console, args);
|
||||
};
|
||||
|
||||
if (onLoad) {
|
||||
onLoad(bytedesk);
|
||||
} else {
|
||||
throw new Error('BytedeskWeb对象未定义');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Bytedesk] 初始化失败:', error);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
} else {
|
||||
throw new Error('BytedeskWeb对象未定义');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Bytedesk] Widget初始化失败:', error);
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error('[Bytedesk] 脚本加载失败:', error);
|
||||
widgetInitialized = false; // 允许重试
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
scriptRef.current = script;
|
||||
};
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error('[Bytedesk] Widget脚本加载失败:', error);
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
};
|
||||
// ⚡ 使用 requestIdleCallback 在浏览器空闲时加载
|
||||
if ('requestIdleCallback' in window) {
|
||||
idleCallbackId = requestIdleCallback(loadWidget, { timeout: 3000 });
|
||||
} else {
|
||||
// 降级:使用 setTimeout
|
||||
idleCallbackId = setTimeout(loadWidget, 100);
|
||||
}
|
||||
|
||||
// 添加脚本到页面
|
||||
document.body.appendChild(script);
|
||||
scriptRef.current = script;
|
||||
|
||||
// 清理函数 - 增强错误处理,防止 React 18 StrictMode 双重清理报错
|
||||
// 清理函数
|
||||
return () => {
|
||||
console.log('[Bytedesk] 清理Widget');
|
||||
|
||||
// 移除脚本
|
||||
try {
|
||||
if (scriptRef.current && scriptRef.current.parentNode) {
|
||||
scriptRef.current.parentNode.removeChild(scriptRef.current);
|
||||
// 取消待执行的 idle callback
|
||||
if (idleCallbackId) {
|
||||
if ('cancelIdleCallback' in window) {
|
||||
cancelIdleCallback(idleCallbackId);
|
||||
} else {
|
||||
clearTimeout(idleCallbackId);
|
||||
}
|
||||
scriptRef.current = null;
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 移除脚本失败(可能已被移除):', error.message);
|
||||
idleCallbackId = null;
|
||||
}
|
||||
|
||||
// 移除Widget DOM元素
|
||||
try {
|
||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||
widgetElements.forEach(el => {
|
||||
try {
|
||||
if (el && el.parentNode && el.parentNode.contains(el)) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
} catch (err) {
|
||||
// 忽略单个元素移除失败(可能已被移除)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 清理Widget DOM元素失败:', error.message);
|
||||
}
|
||||
|
||||
// 清理全局对象
|
||||
try {
|
||||
if (window.BytedeskWeb) {
|
||||
delete window.BytedeskWeb;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 清理全局对象失败:', error.message);
|
||||
}
|
||||
// ⚠️ 不重置 widgetInitialized,保持单例
|
||||
// 不清理 DOM,因为客服 Widget 应该持久存在
|
||||
};
|
||||
}, [config, autoLoad, onLoad, onError]);
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function WechatRegister() {
|
||||
const isMountedRef = useRef(true); // 追踪组件挂载状态
|
||||
const containerRef = useRef(null); // 容器DOM引用
|
||||
const sessionIdRef = useRef(null); // 存储最新的 sessionId,避免闭包陷阱
|
||||
const wechatStatusRef = useRef(WECHAT_STATUS.NONE); // 存储最新的 wechatStatus,避免闭包陷阱
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
@@ -128,12 +129,8 @@ export default function WechatRegister() {
|
||||
*/
|
||||
const handleLoginSuccess = useCallback(async (sessionId, status) => {
|
||||
try {
|
||||
logger.info('WechatRegister', '开始调用登录接口', { sessionId: sessionId.substring(0, 8) + '...', status });
|
||||
|
||||
const response = await authService.loginWithWechat(sessionId);
|
||||
|
||||
logger.info('WechatRegister', '登录接口返回', { success: response?.success, hasUser: !!response?.user });
|
||||
|
||||
if (response?.success) {
|
||||
// 追踪微信登录成功
|
||||
authEvents.trackLoginSuccess(
|
||||
@@ -182,40 +179,28 @@ export default function WechatRegister() {
|
||||
const checkWechatStatus = useCallback(async () => {
|
||||
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId
|
||||
if (!isMountedRef.current || !sessionIdRef.current) {
|
||||
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
|
||||
isMounted: isMountedRef.current,
|
||||
hasSessionId: !!sessionIdRef.current
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
|
||||
|
||||
try {
|
||||
const response = await authService.checkWechatStatus(currentSessionId);
|
||||
|
||||
// 安全检查:确保 response 存在且包含 status
|
||||
if (!response || typeof response.status === 'undefined') {
|
||||
logger.warn('WechatRegister', '微信状态检查返回无效数据', { response });
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = response;
|
||||
logger.debug('WechatRegister', '微信状态', { status });
|
||||
|
||||
logger.debug('WechatRegister', '检测到微信状态', {
|
||||
sessionId: wechatSessionId.substring(0, 8) + '...',
|
||||
status,
|
||||
userInfo: response.user_info
|
||||
});
|
||||
|
||||
// 组件卸载后不再更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 追踪状态变化
|
||||
if (wechatStatus !== status) {
|
||||
authEvents.trackWechatStatusChanged(currentSessionId, wechatStatus, status);
|
||||
// 追踪状态变化(使用 ref 获取最新状态,避免闭包陷阱)
|
||||
const previousStatus = wechatStatusRef.current;
|
||||
if (previousStatus !== status) {
|
||||
authEvents.trackWechatStatusChanged(currentSessionId, previousStatus, status);
|
||||
|
||||
// 特别追踪扫码事件
|
||||
if (status === WECHAT_STATUS.SCANNED) {
|
||||
@@ -227,7 +212,6 @@ export default function WechatRegister() {
|
||||
|
||||
// 处理成功状态
|
||||
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
|
||||
logger.info('WechatRegister', '检测到登录成功状态,停止轮询', { status });
|
||||
clearTimers(); // 停止轮询
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
|
||||
@@ -277,6 +261,12 @@ export default function WechatRegister() {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理授权成功(AUTHORIZED)- 用户已在微信端确认授权,调用登录 API
|
||||
else if (status === WECHAT_STATUS.AUTHORIZED) {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
await handleLoginSuccess(currentSessionId, status);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
|
||||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||||
@@ -301,11 +291,6 @@ export default function WechatRegister() {
|
||||
* 启动轮询
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
logger.debug('WechatRegister', '启动轮询', {
|
||||
sessionId: sessionIdRef.current,
|
||||
interval: POLL_INTERVAL
|
||||
});
|
||||
|
||||
// 清理旧的定时器
|
||||
clearTimers();
|
||||
|
||||
@@ -316,7 +301,6 @@ export default function WechatRegister() {
|
||||
|
||||
// 设置超时
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
logger.debug('WechatRegister', '二维码超时');
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
setWechatStatus(WECHAT_STATUS.EXPIRED);
|
||||
@@ -368,11 +352,6 @@ export default function WechatRegister() {
|
||||
setWechatSessionId(response.data.session_id);
|
||||
setWechatStatus(WECHAT_STATUS.WAITING);
|
||||
|
||||
logger.debug('WechatRegister', '获取二维码成功', {
|
||||
sessionId: response.data.session_id,
|
||||
authUrl: response.data.auth_url
|
||||
});
|
||||
|
||||
// 启动轮询检查扫码状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
@@ -404,6 +383,14 @@ export default function WechatRegister() {
|
||||
}
|
||||
}, [getWechatQRCode]);
|
||||
|
||||
/**
|
||||
* 同步 wechatStatusRef 与 wechatStatus state
|
||||
* 确保 checkWechatStatus 回调中能获取到最新状态
|
||||
*/
|
||||
useEffect(() => {
|
||||
wechatStatusRef.current = wechatStatus;
|
||||
}, [wechatStatus]);
|
||||
|
||||
/**
|
||||
* 组件卸载时清理定时器和标记组件状态
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
})}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -2,95 +2,48 @@
|
||||
// 集中管理应用的全局组件
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectIsMobile } from '@/store/slices/deviceSlice';
|
||||
|
||||
// Global Components
|
||||
import AuthModalManager from './Auth/AuthModalManager';
|
||||
import NotificationContainer from './NotificationContainer';
|
||||
import ConnectionStatusBar from './ConnectionStatusBar';
|
||||
import ScrollToTop from './ScrollToTop';
|
||||
|
||||
// Bytedesk客服组件
|
||||
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
* 需要在 NotificationProvider 内部使用,所以在这里包装
|
||||
*/
|
||||
function ConnectionStatusBarWrapper() {
|
||||
const { connectionStatus, reconnectAttempt, maxReconnectAttempts, retryConnection } = useNotification();
|
||||
const [isDismissed, setIsDismissed] = React.useState(false);
|
||||
|
||||
// 监听连接状态变化
|
||||
React.useEffect(() => {
|
||||
// 重连成功后,清除 dismissed 状态
|
||||
if (connectionStatus === 'connected' && isDismissed) {
|
||||
setIsDismissed(false);
|
||||
// 从 localStorage 清除 dismissed 标记
|
||||
localStorage.removeItem('connection_status_dismissed');
|
||||
}
|
||||
|
||||
// 从 localStorage 恢复 dismissed 状态
|
||||
if (connectionStatus !== 'connected' && !isDismissed) {
|
||||
const dismissed = localStorage.getItem('connection_status_dismissed');
|
||||
if (dismissed === 'true') {
|
||||
setIsDismissed(true);
|
||||
}
|
||||
}
|
||||
}, [connectionStatus, isDismissed]);
|
||||
|
||||
const handleClose = () => {
|
||||
// 用户手动关闭,保存到 localStorage
|
||||
setIsDismissed(true);
|
||||
localStorage.setItem('connection_status_dismissed', 'true');
|
||||
logger.info('App', 'Connection status bar dismissed by user');
|
||||
};
|
||||
|
||||
return (
|
||||
<ConnectionStatusBar
|
||||
status={connectionStatus}
|
||||
reconnectAttempt={reconnectAttempt}
|
||||
maxReconnectAttempts={maxReconnectAttempts}
|
||||
onRetry={retryConnection}
|
||||
onClose={handleClose}
|
||||
isDismissed={isDismissed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GlobalComponents - 全局组件容器
|
||||
* 集中管理所有全局级别的组件,如弹窗、通知、状态栏等
|
||||
*
|
||||
* 包含的组件:
|
||||
* - ConnectionStatusBarWrapper: Socket 连接状态条
|
||||
* - ScrollToTop: 路由切换时自动滚动到顶部
|
||||
* - AuthModalManager: 认证弹窗管理器
|
||||
* - NotificationContainer: 通知容器
|
||||
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
|
||||
* - NotificationContainer: 通知容器(仅桌面端渲染)
|
||||
* - BytedeskWidget: Bytedesk在线客服
|
||||
*
|
||||
* 注意:
|
||||
* - ConnectionStatusBar 已移除(所有端)
|
||||
* - NotificationContainer 在移动端不渲染(通知功能已在 NotificationContext 层禁用)
|
||||
*/
|
||||
export function GlobalComponents() {
|
||||
const location = useLocation();
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
// ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载
|
||||
const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Socket 连接状态条 */}
|
||||
<ConnectionStatusBarWrapper />
|
||||
|
||||
{/* 路由切换时自动滚动到顶部 */}
|
||||
<ScrollToTop />
|
||||
|
||||
{/* 认证弹窗管理器 */}
|
||||
<AuthModalManager />
|
||||
|
||||
{/* 通知容器 */}
|
||||
<NotificationContainer />
|
||||
{/* 通知容器(仅桌面端渲染) */}
|
||||
{!isMobile && <NotificationContainer />}
|
||||
|
||||
{/* Bytedesk在线客服 - 使用缓存的配置对象 */}
|
||||
<BytedeskWidget
|
||||
|
||||
@@ -18,10 +18,8 @@ import {
|
||||
Link,
|
||||
Divider,
|
||||
Avatar,
|
||||
useColorMode,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import { SunIcon, MoonIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
@@ -46,7 +44,6 @@ const MobileDrawer = memo(({
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const userBgColor = useColorModeValue('gray.50', 'whiteAlpha.100');
|
||||
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
const emailTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
@@ -82,17 +79,6 @@ const MobileDrawer = memo(({
|
||||
</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<VStack spacing={4} align="stretch">
|
||||
{/* 移动端:日夜模式切换 */}
|
||||
<Button
|
||||
leftIcon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
variant="ghost"
|
||||
justifyContent="flex-start"
|
||||
onClick={toggleColorMode}
|
||||
size="sm"
|
||||
>
|
||||
切换到{colorMode === 'light' ? '深色' : '浅色'}模式
|
||||
</Button>
|
||||
|
||||
{/* 移动端用户信息 */}
|
||||
{isAuthenticated && user && (
|
||||
<>
|
||||
|
||||
@@ -57,13 +57,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/community', '/concepts']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/community', '/concepts']) ? 'blue.600' : 'inherit'}
|
||||
rightIcon={<ChevronDownIcon color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'} />}
|
||||
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
|
||||
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||
onMouseLeave={highFreqMenu.handleMouseLeave}
|
||||
onClick={highFreqMenu.handleClick}
|
||||
@@ -123,13 +124,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'inherit'}
|
||||
rightIcon={<ChevronDownIcon color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'} />}
|
||||
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.100' : 'gray.50' }}
|
||||
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||
onMouseLeave={marketReviewMenu.handleMouseLeave}
|
||||
onClick={marketReviewMenu.handleClick}
|
||||
@@ -198,13 +200,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'}
|
||||
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }}
|
||||
rightIcon={<ChevronDownIcon color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'} />}
|
||||
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
|
||||
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
onClick={agentCommunityMenu.handleClick}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,9 +5,58 @@ import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '@utils/logger';
|
||||
import { performanceMonitor } from '@utils/performanceMonitor';
|
||||
import { useNotification } from '@contexts/NotificationContext';
|
||||
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
|
||||
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
|
||||
// import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
|
||||
import { SPECIAL_EVENTS } from '@lib/constants';
|
||||
|
||||
// ⚡ PostHog 延迟加载模块(动态导入后缓存)
|
||||
let posthogModule = null;
|
||||
|
||||
/**
|
||||
* ⚡ 延迟加载 PostHog 模块
|
||||
*/
|
||||
const loadPostHogModule = async () => {
|
||||
if (posthogModule) return posthogModule;
|
||||
|
||||
try {
|
||||
posthogModule = await import('@lib/posthog');
|
||||
return posthogModule;
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'PostHog 模块加载失败', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚡ 延迟调用 identifyUser
|
||||
*/
|
||||
const identifyUserLazy = async (userId, userProperties) => {
|
||||
const module = await loadPostHogModule();
|
||||
if (module) {
|
||||
module.identifyUser(userId, userProperties);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚡ 延迟调用 resetUser
|
||||
*/
|
||||
const resetUserLazy = async () => {
|
||||
const module = await loadPostHogModule();
|
||||
if (module) {
|
||||
module.resetUser();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* ⚡ 延迟调用 trackEvent(使用异步版本)
|
||||
*/
|
||||
const trackEventLazy = async (eventName, properties) => {
|
||||
const module = await loadPostHogModule();
|
||||
if (module) {
|
||||
module.trackEventAsync(eventName, properties);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建认证上下文
|
||||
const AuthContext = createContext();
|
||||
|
||||
@@ -99,8 +148,8 @@ export const AuthProvider = ({ children }) => {
|
||||
return prevUser;
|
||||
}
|
||||
|
||||
// ✅ 识别用户身份到 PostHog
|
||||
identifyUser(data.user.id, {
|
||||
// ✅ 识别用户身份到 PostHog(延迟加载)
|
||||
identifyUserLazy(data.user.id, {
|
||||
email: data.user.email,
|
||||
username: data.user.username,
|
||||
subscription_tier: data.user.subscription_tier,
|
||||
@@ -354,8 +403,8 @@ export const AuthProvider = ({ children }) => {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份)
|
||||
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
|
||||
// ✅ 追踪登出事件(延迟加载,必须在 resetUser() 之前)
|
||||
trackEventLazy(SPECIAL_EVENTS.USER_LOGGED_OUT, {
|
||||
timestamp: new Date().toISOString(),
|
||||
user_id: user?.id || null,
|
||||
session_duration_minutes: user?.session_start
|
||||
@@ -363,8 +412,8 @@ export const AuthProvider = ({ children }) => {
|
||||
: null,
|
||||
});
|
||||
|
||||
// ✅ 重置 PostHog 用户会话
|
||||
resetUser();
|
||||
// ✅ 重置 PostHog 用户会话(延迟加载)
|
||||
resetUserLazy();
|
||||
|
||||
// 清除本地状态
|
||||
setUser(null);
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
|
||||
import { BellIcon } from '@chakra-ui/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectIsMobile } from '@/store/slices/deviceSlice';
|
||||
import { logger } from '../utils/logger';
|
||||
import socket from '../services/socket';
|
||||
import notificationSound from '../assets/sounds/notification.wav';
|
||||
@@ -44,6 +46,10 @@ export const useNotification = () => {
|
||||
|
||||
// 通知提供者组件
|
||||
export const NotificationProvider = ({ children }) => {
|
||||
// ⚡ 移动端检测(使用 Redux 状态)
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
// ========== 所有 Hooks 必须在条件判断之前调用(React 规则) ==========
|
||||
const toast = useToast();
|
||||
const [notifications, setNotifications] = useState([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
@@ -565,8 +571,8 @@ export const NotificationProvider = ({ children }) => {
|
||||
logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
|
||||
await requestBrowserPermission();
|
||||
}
|
||||
// 如果权限是denied(已拒绝),提供设置指引
|
||||
else if (browserPermission === 'denied') {
|
||||
// 如果权限是denied(已拒绝),提供设置指引(仅 PC 端显示)
|
||||
else if (browserPermission === 'denied' && !isMobile) {
|
||||
const toastId = 'browser-permission-denied-guide';
|
||||
if (!toast.isActive(toastId)) {
|
||||
toast({
|
||||
@@ -1009,6 +1015,39 @@ export const NotificationProvider = ({ children }) => {
|
||||
};
|
||||
}, [browserPermission, toast]);
|
||||
|
||||
// ⚡ 移动端禁用完整通知能力:返回空壳 Provider
|
||||
// 注意:此判断必须在所有 Hooks 之后(React 规则要求 Hooks 调用顺序一致)
|
||||
if (isMobile) {
|
||||
const emptyValue = {
|
||||
notifications: [],
|
||||
isConnected: false,
|
||||
soundEnabled: false,
|
||||
browserPermission: 'default',
|
||||
connectionStatus: CONNECTION_STATUS.DISCONNECTED,
|
||||
reconnectAttempt: 0,
|
||||
maxReconnectAttempts: 0,
|
||||
addNotification: () => null,
|
||||
removeNotification: () => {},
|
||||
clearAllNotifications: () => {},
|
||||
toggleSound: () => {},
|
||||
requestBrowserPermission: () => Promise.resolve('default'),
|
||||
trackNotificationClick: () => {},
|
||||
retryConnection: () => {},
|
||||
showWelcomeGuide: () => {},
|
||||
showCommunityGuide: () => {},
|
||||
showFirstFollowGuide: () => {},
|
||||
registerEventUpdateCallback: () => () => {},
|
||||
unregisterEventUpdateCallback: () => {},
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={emptyValue}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 桌面端:完整通知功能 ==========
|
||||
const value = {
|
||||
notifications,
|
||||
isConnected,
|
||||
|
||||
@@ -82,7 +82,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
...getBaseProperties(),
|
||||
source,
|
||||
});
|
||||
logger.debug('useAuthEvents', '💬 WeChat Login Initiated', { source });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 手机验证码流程 ====================
|
||||
@@ -186,7 +185,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
has_auth_url: Boolean(authUrl),
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔲 WeChat QR Code Displayed', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -198,7 +196,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
...getBaseProperties(),
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '📱 WeChat QR Code Scanned', { sessionId: sessionId?.substring(0, 8) });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -212,7 +209,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
time_elapsed: timeElapsed,
|
||||
});
|
||||
logger.debug('useAuthEvents', '⏰ WeChat QR Code Expired', { sessionId: sessionId?.substring(0, 8), timeElapsed });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -226,7 +222,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
old_session_id: oldSessionId?.substring(0, 8) + '...',
|
||||
new_session_id: newSessionId?.substring(0, 8) + '...',
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat QR Code Refreshed');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -242,7 +237,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
old_status: oldStatus,
|
||||
new_status: newStatus,
|
||||
});
|
||||
logger.debug('useAuthEvents', '🔄 WeChat Status Changed', { oldStatus, newStatus });
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
/**
|
||||
@@ -250,7 +244,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
|
||||
*/
|
||||
const trackWechatH5Redirect = useCallback(() => {
|
||||
track(ACTIVATION_EVENTS.WECHAT_H5_REDIRECT, getBaseProperties());
|
||||
logger.debug('useAuthEvents', '🔗 WeChat H5 Redirect');
|
||||
}, [track, getBaseProperties]);
|
||||
|
||||
// ==================== 登录/注册结果 ====================
|
||||
|
||||
119
src/index.js
@@ -7,8 +7,8 @@ import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import { performanceMonitor } from './utils/performanceMonitor';
|
||||
performanceMonitor.mark('app-start');
|
||||
|
||||
// 导入 Brainwave 样式(空文件,保留以避免错误)
|
||||
import './styles/brainwave.css';
|
||||
// ⚡ 已删除 brainwave.css(项目未安装 Tailwind CSS,该文件无效)
|
||||
// import './styles/brainwave.css';
|
||||
|
||||
// 导入 Select 下拉框颜色修复样式
|
||||
import './styles/select-fix.css';
|
||||
@@ -40,91 +40,25 @@ if (process.env.REACT_APP_ENABLE_DEBUG === 'true') {
|
||||
function registerServiceWorker() {
|
||||
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
|
||||
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
console.log(
|
||||
'%c[App] Mock 模式已启用,跳过通知 Service Worker 注册(避免与 MSW 冲突)',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅在支持 Service Worker 的浏览器中注册
|
||||
if ('serviceWorker' in navigator) {
|
||||
// 在页面加载完成后注册
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js')
|
||||
.then((registration) => {
|
||||
console.log('[App] ✅ Service Worker 注册成功');
|
||||
console.log('[App] Scope:', registration.scope);
|
||||
|
||||
// 检查当前激活状态
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log('[App] ✅ Service Worker 已激活并控制页面');
|
||||
} else {
|
||||
console.log('[App] ⏳ Service Worker 已注册,等待激活...');
|
||||
console.log('[App] 💡 刷新页面以激活 Service Worker');
|
||||
|
||||
// 监听 controller 变化(Service Worker 激活后触发)
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('[App] ✅ Service Worker 控制器已更新');
|
||||
});
|
||||
}
|
||||
|
||||
// 监听 Service Worker 更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
console.log('[App] 🔄 发现 Service Worker 更新');
|
||||
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
console.log(`[App] Service Worker 状态: ${newWorker.state}`);
|
||||
if (newWorker.state === 'activated') {
|
||||
console.log('[App] ✅ Service Worker 已激活');
|
||||
|
||||
// 如果有旧的 Service Worker 在控制页面,提示用户刷新
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log('[App] 💡 Service Worker 已更新,建议刷新页面');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[App] ❌ Service Worker 注册失败');
|
||||
console.error('[App] 错误类型:', error.name);
|
||||
console.error('[App] 错误信息:', error.message);
|
||||
console.error('[App] 完整错误:', error);
|
||||
|
||||
// 额外检查:验证文件是否可访问
|
||||
fetch('/service-worker.js', { method: 'HEAD' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
console.error('[App] Service Worker 文件存在但注册失败');
|
||||
console.error('[App] 💡 可能的原因:');
|
||||
console.error('[App] 1. Service Worker 文件有语法错误');
|
||||
console.error('[App] 2. 浏览器不支持某些 Service Worker 特性');
|
||||
console.error('[App] 3. HTTPS 证书问题(Service Worker 需要 HTTPS)');
|
||||
} else {
|
||||
console.error('[App] Service Worker 文件不存在(HTTP', response.status, ')');
|
||||
}
|
||||
})
|
||||
.catch(fetchError => {
|
||||
console.error('[App] 无法访问 Service Worker 文件:', fetchError.message);
|
||||
});
|
||||
console.error('[App] Service Worker 注册失败:', error.message);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.warn('[App] Service Worker is not supported in this browser');
|
||||
}
|
||||
}
|
||||
|
||||
// 启动应用(MSW 异步初始化,不阻塞首屏渲染)
|
||||
function startApp() {
|
||||
// Create root
|
||||
// 渲染应用
|
||||
function renderApp() {
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
// ✅ 先渲染应用,不等待 MSW
|
||||
// StrictMode 已启用(Chakra UI 2.10.9+ 已修复兼容性问题)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
@@ -139,33 +73,26 @@ function startApp() {
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// ✅ 后台异步启动 MSW(不阻塞首屏渲染)
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
const initMSW = async () => {
|
||||
try {
|
||||
const { startMockServiceWorker } = await import('./mocks/browser');
|
||||
await startMockServiceWorker();
|
||||
console.log(
|
||||
'%c[MSW] ✅ Mock Service Worker 已在后台启动',
|
||||
'color: #4CAF50; font-weight: bold;'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[MSW] ❌ Mock Service Worker 启动失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 requestIdleCallback 在浏览器空闲时初始化,不阻塞首屏
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => initMSW(), { timeout: 3000 });
|
||||
} else {
|
||||
// 降级:使用 setTimeout(0) 延迟执行
|
||||
setTimeout(initMSW, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 注册 Service Worker
|
||||
// 注册 Service Worker(非 Mock 模式)
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
async function startApp() {
|
||||
// ✅ 开发环境 Mock 模式:先启动 MSW,再渲染应用
|
||||
// 确保所有 API 请求(包括 AuthContext.checkSession)都被正确拦截
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
try {
|
||||
const { startMockServiceWorker } = await import('./mocks/browser');
|
||||
await startMockServiceWorker();
|
||||
} catch (error) {
|
||||
console.error('[MSW] 启动失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染应用
|
||||
renderApp();
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
startApp();
|
||||
@@ -35,7 +35,7 @@ export default function MainLayout() {
|
||||
<MemoizedHomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
||||
<Box flex="1" pt="72px">
|
||||
<Box flex="1" pt="60px">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
|
||||
@@ -47,18 +47,8 @@ export async function startMockServiceWorker() {
|
||||
});
|
||||
|
||||
isStarted = true;
|
||||
console.log(
|
||||
'%c[MSW] Mock Service Worker 已启动 🎭 (警告模式)',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
console.log(
|
||||
'%c警告模式:已定义 Mock → 返回假数据 | 未定义 Mock → 显示警告 ⚠️ | 允许 passthrough',
|
||||
'color: #FF9800; font-weight: bold; font-size: 12px;'
|
||||
);
|
||||
console.log(
|
||||
'%c查看 src/mocks/handlers/ 目录管理 Mock 接口',
|
||||
'color: #2196F3; font-size: 12px;'
|
||||
);
|
||||
// 精简日志:只保留一行启动提示
|
||||
console.log('%c[MSW] Mock 已启用 🎭', 'color: #4CAF50; font-weight: bold;');
|
||||
} catch (error) {
|
||||
console.error('[MSW] 启动失败:', error);
|
||||
} finally {
|
||||
|
||||
@@ -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- RSI:58,处于中性区域\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- RSI:58,处于中性区域\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',
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -326,25 +326,22 @@ export const authHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 获取微信 H5 授权 URL
|
||||
http.post('/api/auth/wechat/h5-auth-url', async ({ request }) => {
|
||||
// 6. 获取微信 H5 授权 URL(手机浏览器用)
|
||||
http.post('/api/auth/wechat/h5-auth', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
const { redirect_url } = body;
|
||||
|
||||
const state = generateWechatSessionId();
|
||||
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=mock&redirect_uri=${encodeURIComponent(redirect_url)}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`;
|
||||
// Mock 模式下直接返回前端回调地址(模拟授权成功)
|
||||
const authUrl = `${redirect_url}?wechat_login=success&state=${state}`;
|
||||
|
||||
console.log('[Mock] 生成微信 H5 授权 URL:', authUrl);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
message: '成功',
|
||||
data: {
|
||||
auth_url: authUrl,
|
||||
state
|
||||
}
|
||||
auth_url: authUrl,
|
||||
state
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -409,91 +406,3 @@ export const authHandlers = [
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
// ==================== Mock 调试工具(仅开发环境) ====================
|
||||
|
||||
/**
|
||||
* 暴露全局API,方便手动触发微信扫码模拟
|
||||
* 使用方式:
|
||||
* 1. 浏览器控制台输入:window.mockWechatScan()
|
||||
* 2. 或者在组件中调用:window.mockWechatScan(sessionId)
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
window.mockWechatScan = (sessionId) => {
|
||||
// 如果没有传入sessionId,尝试获取最新的session
|
||||
let targetSessionId = sessionId;
|
||||
|
||||
if (!targetSessionId) {
|
||||
// 获取最新创建的session
|
||||
const sessions = Array.from(mockWechatSessions.entries());
|
||||
if (sessions.length === 0) {
|
||||
console.warn('[Mock API] 没有活跃的微信session,请先获取二维码');
|
||||
return false;
|
||||
}
|
||||
// 按创建时间排序,获取最新的
|
||||
const latestSession = sessions.sort((a, b) => b[1].createdAt - a[1].createdAt)[0];
|
||||
targetSessionId = latestSession[0];
|
||||
}
|
||||
|
||||
const session = mockWechatSessions.get(targetSessionId);
|
||||
|
||||
if (!session) {
|
||||
console.error('[Mock API] Session不存在:', targetSessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.status !== 'waiting') {
|
||||
console.warn('[Mock API] Session状态不是waiting,当前状态:', session.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 立即触发扫码
|
||||
session.status = 'scanned';
|
||||
console.log(`[Mock API] ✅ 模拟扫码成功: ${targetSessionId}`);
|
||||
|
||||
// 1秒后自动确认登录
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(targetSessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与自动扫码流程保持一致
|
||||
session2.user = {
|
||||
id: 999,
|
||||
nickname: '微信测试用户',
|
||||
wechat_openid: 'mock_openid_' + targetSessionId,
|
||||
avatar_url: 'https://ui-avatars.com/api/?name=微信测试用户&size=150&background=4299e1&color=fff',
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
created_at: new Date().toISOString(),
|
||||
subscription_type: 'free',
|
||||
subscription_status: 'active',
|
||||
subscription_end_date: null,
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
|
||||
console.log(`[Mock API] ✅ 模拟确认登录: ${targetSessionId}`, session2.user);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 暴露获取当前sessions的方法(调试用)
|
||||
window.getMockWechatSessions = () => {
|
||||
const sessions = Array.from(mockWechatSessions.entries()).map(([id, session]) => ({
|
||||
sessionId: id,
|
||||
status: session.status,
|
||||
createdAt: new Date(session.createdAt).toLocaleString(),
|
||||
hasUser: !!session.user
|
||||
}));
|
||||
console.table(sessions);
|
||||
return sessions;
|
||||
};
|
||||
|
||||
console.log('%c[Mock API] 微信登录调试工具已加载', 'color: #00D084; font-weight: bold');
|
||||
console.log('%c使用方法:', 'color: #666');
|
||||
console.log(' window.mockWechatScan() - 触发最新session的扫码');
|
||||
console.log(' window.mockWechatScan(sessionId) - 触发指定session的扫码');
|
||||
console.log(' window.getMockWechatSessions() - 查看所有活跃的sessions');
|
||||
}
|
||||
|
||||
@@ -188,46 +188,3 @@ export const paymentHandlers = [
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
// ==================== Mock 调试工具(仅开发环境) ====================
|
||||
|
||||
/**
|
||||
* 暴露全局API,方便手动触发支付成功
|
||||
* 使用方式:window.mockPaymentSuccess(orderId)
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
window.mockPaymentSuccess = (orderId) => {
|
||||
const order = mockOrders.get(orderId);
|
||||
if (!order) {
|
||||
console.error('[Mock Payment] 订单不存在:', orderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status !== 'pending') {
|
||||
console.warn('[Mock Payment] 订单状态不是待支付:', order.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
order.status = 'paid';
|
||||
order.paid_at = new Date().toISOString();
|
||||
console.log('[Mock Payment] ✅ 支付成功:', orderId);
|
||||
return true;
|
||||
};
|
||||
|
||||
window.getMockOrders = () => {
|
||||
const orders = Array.from(mockOrders.entries()).map(([id, order]) => ({
|
||||
orderId: id,
|
||||
status: order.status,
|
||||
amount: order.amount,
|
||||
plan: `${order.plan_name} - ${order.billing_cycle}`,
|
||||
createdAt: new Date(order.created_at).toLocaleString()
|
||||
}));
|
||||
console.table(orders);
|
||||
return orders;
|
||||
};
|
||||
|
||||
console.log('%c[Mock Payment] 支付调试工具已加载', 'color: #00D084; font-weight: bold');
|
||||
console.log('%c使用方法:', 'color: #666');
|
||||
console.log(' window.mockPaymentSuccess(orderId) - 手动触发订单支付成功');
|
||||
console.log(' window.getMockOrders() - 查看所有模拟订单');
|
||||
}
|
||||
|
||||
@@ -56,17 +56,8 @@ class SocketService {
|
||||
|
||||
// 注册所有暂存的事件监听器(保留 pendingListeners,不清空)
|
||||
if (this.pendingListeners.length > 0) {
|
||||
console.log(`[socketService] 📦 注册 ${this.pendingListeners.length} 个暂存的事件监听器`);
|
||||
this.pendingListeners.forEach(({ event, callback }) => {
|
||||
// 直接在 Socket.IO 实例上注册(避免递归调用 this.on())
|
||||
const wrappedCallback = (...args) => {
|
||||
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
|
||||
console.log(`[socketService] 事件数据 (${event}):`, ...args);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
this.socket.on(event, wrappedCallback);
|
||||
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
|
||||
this.socket.on(event, callback);
|
||||
});
|
||||
// ⚠️ 重要:不清空 pendingListeners,保留用于重连
|
||||
}
|
||||
@@ -82,15 +73,8 @@ class SocketService {
|
||||
this.customReconnectTimer = null;
|
||||
}
|
||||
|
||||
logger.info('socketService', 'Socket.IO connected successfully', {
|
||||
socketId: this.socket.id,
|
||||
});
|
||||
|
||||
console.log(`%c[socketService] ✅ WebSocket 已连接`, 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[socketService] Socket ID:', this.socket.id);
|
||||
|
||||
logger.info('socketService', 'Socket.IO connected', { socketId: this.socket.id });
|
||||
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
|
||||
// this.subscribeToAllEvents();
|
||||
});
|
||||
|
||||
// 监听断开连接
|
||||
@@ -174,25 +158,12 @@ class SocketService {
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
logger.info('socketService', 'Socket not ready, queuing listener', { event });
|
||||
console.log(`[socketService] 📦 Socket 未初始化,暂存事件监听器: ${event}`);
|
||||
this.pendingListeners.push({ event, callback });
|
||||
} else {
|
||||
console.log(`[socketService] ⚠️ 监听器已存在,跳过: ${event}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 包装回调函数,添加日志
|
||||
const wrappedCallback = (...args) => {
|
||||
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
|
||||
console.log(`[socketService] 事件数据 (${event}):`, ...args);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
this.socket.on(event, wrappedCallback);
|
||||
logger.info('socketService', `Event listener added: ${event}`);
|
||||
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
|
||||
this.socket.on(event, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,8 +181,6 @@ class SocketService {
|
||||
} else {
|
||||
this.socket.off(event);
|
||||
}
|
||||
|
||||
logger.info('socketService', `Event listener removed: ${event}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,8 +200,6 @@ class SocketService {
|
||||
} else {
|
||||
this.socket.emit(event, data);
|
||||
}
|
||||
|
||||
logger.info('socketService', `Event emitted: ${event}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,65 +322,31 @@ class SocketService {
|
||||
* 执行订阅操作(内部方法)
|
||||
*/
|
||||
_doSubscribe(eventType, importance, onNewEvent, onSubscribed) {
|
||||
console.log('\n========== [SocketService DEBUG] 开始订阅 ==========');
|
||||
console.log('[SocketService DEBUG] 事件类型:', eventType);
|
||||
console.log('[SocketService DEBUG] 重要性:', importance);
|
||||
console.log('[SocketService DEBUG] Socket 连接状态:', this.connected);
|
||||
console.log('[SocketService DEBUG] Socket ID:', this.socket?.id);
|
||||
|
||||
// 发送订阅请求
|
||||
const subscribeData = {
|
||||
event_type: eventType,
|
||||
importance: importance,
|
||||
};
|
||||
console.log('[SocketService DEBUG] 准备发送 subscribe_events:', subscribeData);
|
||||
this.emit('subscribe_events', subscribeData);
|
||||
console.log('[SocketService DEBUG] ✓ 已发送 subscribe_events');
|
||||
|
||||
// 监听订阅确认
|
||||
this.socket.once('subscription_confirmed', (data) => {
|
||||
console.log('\n[SocketService DEBUG] ========== 收到订阅确认 ==========');
|
||||
console.log('[SocketService DEBUG] 订阅确认数据:', data);
|
||||
logger.info('socketService', 'Subscription confirmed', data);
|
||||
if (onSubscribed) {
|
||||
console.log('[SocketService DEBUG] 调用 onSubscribed 回调');
|
||||
onSubscribed(data);
|
||||
}
|
||||
console.log('[SocketService DEBUG] ========== 订阅确认处理完成 ==========\n');
|
||||
});
|
||||
|
||||
// 监听订阅错误
|
||||
this.socket.once('subscription_error', (error) => {
|
||||
console.error('\n[SocketService ERROR] ========== 订阅错误 ==========');
|
||||
console.error('[SocketService ERROR] 错误信息:', error);
|
||||
logger.error('socketService', 'Subscription error', error);
|
||||
console.error('[SocketService ERROR] ========== 订阅错误处理完成 ==========\n');
|
||||
});
|
||||
|
||||
// 监听新事件推送
|
||||
// ⚠️ 注意:不要移除其他地方注册的 new_event 监听器(如 NotificationContext)
|
||||
// 多个监听器可以共存,都会被触发
|
||||
if (onNewEvent) {
|
||||
console.log('[SocketService DEBUG] 设置 new_event 监听器');
|
||||
|
||||
// ⚠️ 已移除 this.socket.off('new_event'),允许多个监听器共存
|
||||
|
||||
// 添加新的监听器(与其他监听器共存)
|
||||
this.socket.on('new_event', (eventData) => {
|
||||
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
|
||||
console.log('[SocketService DEBUG] 事件数据:', eventData);
|
||||
console.log('[SocketService DEBUG] 事件 ID:', eventData?.id);
|
||||
console.log('[SocketService DEBUG] 事件标题:', eventData?.title);
|
||||
logger.info('socketService', 'New event received', eventData);
|
||||
console.log('[SocketService DEBUG] 准备调用 onNewEvent 回调');
|
||||
onNewEvent(eventData);
|
||||
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
|
||||
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
|
||||
});
|
||||
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置(与其他监听器共存)');
|
||||
}
|
||||
|
||||
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,11 +373,7 @@ class SocketService {
|
||||
|
||||
// 监听取消订阅确认
|
||||
this.socket.once('unsubscription_confirmed', (data) => {
|
||||
logger.info('socketService', 'Unsubscription confirmed', data);
|
||||
|
||||
// 移除新事件监听器
|
||||
this.socket.off('new_event');
|
||||
|
||||
if (onUnsubscribed) {
|
||||
onUnsubscribed(data);
|
||||
}
|
||||
@@ -462,22 +391,10 @@ class SocketService {
|
||||
* @returns {Function} 取消订阅的函数
|
||||
*/
|
||||
subscribeToAllEvents(onNewEvent) {
|
||||
console.log('%c[socketService] 🔔 自动订阅所有事件...', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
// 如果没有提供回调,添加一个默认的日志回调
|
||||
const defaultCallback = (event) => {
|
||||
console.log('%c[socketService] 📨 收到新事件(默认回调)', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[socketService] 事件数据:', event);
|
||||
};
|
||||
|
||||
this.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onNewEvent: onNewEvent || defaultCallback,
|
||||
onSubscribed: (data) => {
|
||||
console.log('%c[socketService] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[socketService] 订阅确认:', data);
|
||||
},
|
||||
onNewEvent: onNewEvent || (() => {}),
|
||||
});
|
||||
|
||||
// 返回取消订阅的清理函数
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
// src/store/index.js
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { configureStore, combineReducers } from '@reduxjs/toolkit';
|
||||
import communityDataReducer from './slices/communityDataSlice';
|
||||
import posthogReducer from './slices/posthogSlice';
|
||||
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
|
||||
// import posthogReducer from './slices/posthogSlice';
|
||||
// import posthogMiddleware from './middleware/posthogMiddleware';
|
||||
import industryReducer from './slices/industrySlice';
|
||||
import stockReducer from './slices/stockSlice';
|
||||
import authModalReducer from './slices/authModalSlice';
|
||||
import subscriptionReducer from './slices/subscriptionSlice';
|
||||
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
|
||||
import posthogMiddleware from './middleware/posthogMiddleware';
|
||||
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
|
||||
|
||||
// ⚡ 基础 reducers(首屏必需)
|
||||
const staticReducers = {
|
||||
communityData: communityDataReducer,
|
||||
industry: industryReducer, // ✅ 行业分类数据管理
|
||||
stock: stockReducer, // ✅ 股票和事件数据管理
|
||||
authModal: authModalReducer, // ✅ 认证弹窗状态管理
|
||||
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
|
||||
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
|
||||
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
|
||||
};
|
||||
|
||||
// ⚡ 动态 reducers 注册表
|
||||
const asyncReducers = {};
|
||||
|
||||
// ⚡ 创建根 reducer 的工厂函数
|
||||
const createRootReducer = () => combineReducers({
|
||||
...staticReducers,
|
||||
...asyncReducers,
|
||||
});
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
communityData: communityDataReducer,
|
||||
posthog: posthogReducer, // ✅ PostHog Redux 状态管理
|
||||
industry: industryReducer, // ✅ 行业分类数据管理
|
||||
stock: stockReducer, // ✅ 股票和事件数据管理
|
||||
authModal: authModalReducer, // ✅ 认证弹窗状态管理
|
||||
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
|
||||
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
|
||||
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
|
||||
},
|
||||
reducer: createRootReducer(),
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
@@ -28,14 +40,27 @@ export const store = configureStore({
|
||||
ignoredActions: [
|
||||
'communityData/fetchPopularKeywords/fulfilled',
|
||||
'communityData/fetchHotEvents/fulfilled',
|
||||
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪
|
||||
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪(延迟加载后仍需)
|
||||
'stock/fetchEventStocks/fulfilled',
|
||||
'stock/fetchStockQuotes/fulfilled',
|
||||
],
|
||||
},
|
||||
})
|
||||
.concat(posthogMiddleware) // ✅ PostHog 自动追踪中间件
|
||||
// ⚡ PostHog 中间件延迟加载,首屏不再需要
|
||||
.concat(eventsApi.middleware), // ✅ RTK Query 中间件(自动缓存、去重、重试)
|
||||
});
|
||||
|
||||
/**
|
||||
* ⚡ 动态注入 reducer(用于延迟加载模块)
|
||||
* @param {string} key - reducer 的键名
|
||||
* @param {Function} reducer - reducer 函数
|
||||
*/
|
||||
export const injectReducer = (key, reducer) => {
|
||||
if (asyncReducers[key]) {
|
||||
return; // 已注入,避免重复
|
||||
}
|
||||
asyncReducers[key] = reducer;
|
||||
store.replaceReducer(createRootReducer());
|
||||
};
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -12,6 +12,19 @@ import {
|
||||
} from '../../lib/posthog';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
// ⚡ 模块级缓存:存储 reducer 注入前的事件(避免丢失)
|
||||
let pendingEventsBeforeInjection = [];
|
||||
|
||||
/**
|
||||
* 获取并清空注入前缓存的事件
|
||||
* 在 App.js 中 reducer 注入后调用
|
||||
*/
|
||||
export const flushPendingEventsBeforeInjection = () => {
|
||||
const events = [...pendingEventsBeforeInjection];
|
||||
pendingEventsBeforeInjection = [];
|
||||
return events;
|
||||
};
|
||||
|
||||
// ==================== Initial State ====================
|
||||
|
||||
const initialState = {
|
||||
@@ -51,7 +64,15 @@ export const initializePostHog = createAsyncThunk(
|
||||
'posthog/initialize',
|
||||
async (_, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { config } = getState().posthog;
|
||||
const posthogState = getState().posthog;
|
||||
|
||||
// ⚡ 防御性检查:reducer 尚未注入
|
||||
if (!posthogState) {
|
||||
logger.warn('PostHog', 'PostHog reducer 尚未注入,跳过初始化');
|
||||
return { isInitialized: false, skipped: true };
|
||||
}
|
||||
|
||||
const { config } = posthogState;
|
||||
|
||||
if (!config.apiKey) {
|
||||
logger.warn('PostHog', '未配置 API Key,分析功能将被禁用');
|
||||
@@ -112,7 +133,20 @@ export const trackEvent = createAsyncThunk(
|
||||
'posthog/trackEvent',
|
||||
async ({ eventName, properties = {} }, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
const { isInitialized } = getState().posthog;
|
||||
const posthogState = getState().posthog;
|
||||
|
||||
// ⚡ reducer 尚未注入:缓存到模块级队列(不丢弃)
|
||||
if (!posthogState) {
|
||||
logger.debug('PostHog', 'PostHog reducer 尚未注入,事件已缓存', { eventName });
|
||||
pendingEventsBeforeInjection.push({
|
||||
eventName,
|
||||
properties,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return { eventName, properties, pendingInjection: true };
|
||||
}
|
||||
|
||||
const { isInitialized } = posthogState;
|
||||
|
||||
if (!isInitialized) {
|
||||
logger.warn('PostHog', 'PostHog 未初始化,事件将被缓存', { eventName });
|
||||
@@ -160,7 +194,14 @@ export const flushCachedEvents = createAsyncThunk(
|
||||
'posthog/flushCachedEvents',
|
||||
async (_, { getState, dispatch }) => {
|
||||
try {
|
||||
const { eventQueue, isInitialized } = getState().posthog;
|
||||
const posthogState = getState().posthog;
|
||||
|
||||
// ⚡ 防御性检查:reducer 尚未注入
|
||||
if (!posthogState) {
|
||||
return { flushed: 0, skipped: true };
|
||||
}
|
||||
|
||||
const { eventQueue, isInitialized } = posthogState;
|
||||
|
||||
if (!isInitialized || eventQueue.length === 0) {
|
||||
return { flushed: 0 };
|
||||
@@ -281,15 +322,16 @@ export const {
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectPostHog = (state) => state.posthog;
|
||||
export const selectIsInitialized = (state) => state.posthog.isInitialized;
|
||||
export const selectUser = (state) => state.posthog.user;
|
||||
export const selectFeatureFlags = (state) => state.posthog.featureFlags;
|
||||
export const selectEventQueue = (state) => state.posthog.eventQueue;
|
||||
export const selectStats = (state) => state.posthog.stats;
|
||||
// ⚡ 安全的 selectors(支持 reducer 未注入的情况)
|
||||
export const selectPostHog = (state) => state.posthog || initialState;
|
||||
export const selectIsInitialized = (state) => state.posthog?.isInitialized ?? false;
|
||||
export const selectUser = (state) => state.posthog?.user ?? null;
|
||||
export const selectFeatureFlags = (state) => state.posthog?.featureFlags ?? {};
|
||||
export const selectEventQueue = (state) => state.posthog?.eventQueue ?? [];
|
||||
export const selectStats = (state) => state.posthog?.stats ?? initialState.stats;
|
||||
|
||||
export const selectFeatureFlag = (flagKey) => (state) => {
|
||||
return state.posthog.featureFlags[flagKey] || posthogGetFeatureFlag(flagKey);
|
||||
return state.posthog?.featureFlags?.[flagKey] || posthogGetFeatureFlag(flagKey);
|
||||
};
|
||||
|
||||
export const selectIsOptedOut = () => posthogHasOptedOut();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getApiBase } from '../../utils/apiConfig';
|
||||
|
||||
/**
|
||||
* 异步 Thunk: 获取用户订阅信息
|
||||
* 使用 condition 选项防止同一时刻多个组件重复发起请求
|
||||
*/
|
||||
export const fetchSubscriptionInfo = createAsyncThunk(
|
||||
'subscription/fetchInfo',
|
||||
@@ -51,6 +52,21 @@ export const fetchSubscriptionInfo = createAsyncThunk(
|
||||
logger.error('subscriptionSlice', '加载订阅信息失败', error);
|
||||
return rejectWithValue(error.message);
|
||||
}
|
||||
},
|
||||
{
|
||||
// 防止重复请求:如果已加载或正在加载中,则跳过本次请求
|
||||
condition: (_, { getState }) => {
|
||||
const { subscription } = getState();
|
||||
// 如果正在加载或已加载完成,返回 false 阻止请求
|
||||
if (subscription.loading || subscription.loaded) {
|
||||
logger.debug('subscriptionSlice', '跳过重复请求', {
|
||||
loading: subscription.loading,
|
||||
loaded: subscription.loaded
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/* Tailwind CSS 入口文件 */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 自定义工具类 */
|
||||
@layer utilities {
|
||||
/* 毛玻璃效果 */
|
||||
.backdrop-blur-xl {
|
||||
backdrop-filter: blur(24px);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,34 @@ const isDevelopment =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.env.REACT_APP_ENABLE_DEBUG === 'true';
|
||||
|
||||
// ========== 日志级别配置 ==========
|
||||
// 日志级别:error < warn < info < debug
|
||||
// 默认级别:warn(只显示警告和错误)
|
||||
// 可通过 localStorage.setItem('LOG_LEVEL', 'debug') 开启详细日志
|
||||
const LOG_LEVELS = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
};
|
||||
|
||||
// 从 localStorage 读取日志级别(允许用户临时开启详细日志)
|
||||
const getLogLevel = () => {
|
||||
if (typeof window !== 'undefined' && window.localStorage) {
|
||||
const level = localStorage.getItem('LOG_LEVEL');
|
||||
if (level && LOG_LEVELS[level] !== undefined) {
|
||||
return LOG_LEVELS[level];
|
||||
}
|
||||
}
|
||||
// 默认只显示 warn 和 error
|
||||
return LOG_LEVELS.warn;
|
||||
};
|
||||
|
||||
// 检查是否应该输出指定级别的日志
|
||||
const shouldLogLevel = (level) => {
|
||||
return LOG_LEVELS[level] <= getLogLevel();
|
||||
};
|
||||
|
||||
// ========== 日志限流配置 ==========
|
||||
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次
|
||||
const recentLogs = new Map(); // 日志缓存,用于去重
|
||||
@@ -148,13 +176,13 @@ export const logger = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 调试日志(仅开发环境)
|
||||
* 调试日志(仅开发环境 + LOG_LEVEL=debug)
|
||||
* @param {string} component - 组件名称
|
||||
* @param {string} message - 调试信息
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
debug: (component, message, data = {}) => {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
if (isDevelopment && shouldLogLevel('debug') && shouldLog(component, message)) {
|
||||
console.group(`🐛 Debug: ${component}`);
|
||||
console.log('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
@@ -166,13 +194,13 @@ export const logger = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 信息日志(仅开发环境)
|
||||
* 信息日志(仅开发环境 + LOG_LEVEL>=info)
|
||||
* @param {string} component - 组件名称
|
||||
* @param {string} message - 信息内容
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
info: (component, message, data = {}) => {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
if (isDevelopment && shouldLogLevel('info') && shouldLog(component, message)) {
|
||||
console.group(`ℹ️ Info: ${component}`);
|
||||
console.log('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
@@ -181,6 +209,28 @@ export const logger = {
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
console.groupEnd();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置日志级别(方便调试)
|
||||
* @param {string} level - 日志级别 ('error' | 'warn' | 'info' | 'debug')
|
||||
*/
|
||||
setLevel: (level) => {
|
||||
if (LOG_LEVELS[level] !== undefined) {
|
||||
localStorage.setItem('LOG_LEVEL', level);
|
||||
console.log(`[Logger] 日志级别已设置为: ${level}`);
|
||||
console.log(`[Logger] 可用级别: error < warn < info < debug`);
|
||||
} else {
|
||||
console.error(`[Logger] 无效的日志级别: ${level}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前日志级别
|
||||
*/
|
||||
getLevel: () => {
|
||||
const levelNum = getLogLevel();
|
||||
return Object.keys(LOG_LEVELS).find(key => LOG_LEVELS[key] === levelNum) || 'warn';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -58,11 +58,6 @@ const performanceMeasures: Array<{ name: string; duration: number; startMark: st
|
||||
*/
|
||||
class PerformanceMonitor {
|
||||
private metrics: PerformanceMetrics = {};
|
||||
private isProduction: boolean;
|
||||
|
||||
constructor() {
|
||||
this.isProduction = process.env.NODE_ENV === 'production';
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记性能时间点
|
||||
@@ -70,12 +65,6 @@ class PerformanceMonitor {
|
||||
mark(name: string): void {
|
||||
const timestamp = performance.now();
|
||||
performanceMarks.set(name, timestamp);
|
||||
|
||||
if (!this.isProduction) {
|
||||
logger.debug('PerformanceMonitor', `⏱️ Mark: ${name}`, {
|
||||
time: `${timestamp.toFixed(2)}ms`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +95,6 @@ class PerformanceMonitor {
|
||||
endMark
|
||||
});
|
||||
|
||||
if (!this.isProduction) {
|
||||
logger.debug('PerformanceMonitor', `📊 Measure: ${measureName}`, {
|
||||
duration: `${duration.toFixed(2)}ms`
|
||||
});
|
||||
}
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
310
src/views/AgentChat/components/ChatArea/WelcomeScreen.js
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
127
src/views/AgentChat/components/LeftSidebar/DateGroup.js
Normal 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;
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 用户信息卡片 */}
|
||||
|
||||
@@ -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;
|
||||
303
src/views/AgentChat/components/MeetingRoom/MeetingRolePanel.js
Normal 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;
|
||||
294
src/views/AgentChat/components/MeetingRoom/MeetingWelcome.js
Normal 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;
|
||||
442
src/views/AgentChat/components/MeetingRoom/index.js
Normal 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;
|
||||
@@ -20,3 +20,4 @@ export * from './messageTypes';
|
||||
export * from './models';
|
||||
export * from './tools';
|
||||
export * from './quickQuestions';
|
||||
export * from './meetingRoles';
|
||||
|
||||
266
src/views/AgentChat/constants/meetingRoles.ts
Normal 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;
|
||||
}
|
||||
@@ -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 查找工具配置
|
||||
|
||||
@@ -32,3 +32,9 @@ export type {
|
||||
UseAgentChatParams,
|
||||
UseAgentChatReturn,
|
||||
} from './useAgentChat';
|
||||
|
||||
export { useInvestmentMeeting } from './useInvestmentMeeting';
|
||||
export type {
|
||||
UseInvestmentMeetingParams,
|
||||
UseInvestmentMeetingReturn,
|
||||
} from './useInvestmentMeeting';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
711
src/views/AgentChat/hooks/useInvestmentMeeting.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
80
src/views/Community/components/CompactSearchBox.css
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ import { usePagination } from './DynamicNewsCard/hooks/usePagination';
|
||||
import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './DynamicNewsCard/constants';
|
||||
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
|
||||
import { debounce } from '../../../utils/debounce';
|
||||
import { useDevice } from '@hooks/useDevice';
|
||||
|
||||
// 🔍 调试:渲染计数器
|
||||
let dynamicNewsCardRenderCount = 0;
|
||||
@@ -81,6 +82,7 @@ const DynamicNewsCardComponent = forwardRef(({
|
||||
|
||||
// 通知权限相关
|
||||
const { browserPermission, requestBrowserPermission } = useNotification();
|
||||
const { isMobile } = useDevice();
|
||||
|
||||
// Refs
|
||||
const cardHeaderRef = useRef(null);
|
||||
@@ -534,73 +536,53 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
position="relative"
|
||||
zIndex={1}
|
||||
pb={3}
|
||||
px={isMobile ? 2 : undefined}
|
||||
>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{/* 第一行:标题 + 通知开关 + 更新时间 */}
|
||||
{/* 第一行:标题 + 模式切换 + 通知开关 + 更新时间 */}
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧:标题 */}
|
||||
<Heading size="md" color={PROFESSIONAL_COLORS.text.primary}>
|
||||
<HStack spacing={2}>
|
||||
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
|
||||
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
{/* 左侧:标题 + 模式切换按钮 */}
|
||||
<HStack spacing={4}>
|
||||
<Heading size={isMobile ? "sm" : "md"} color={PROFESSIONAL_COLORS.text.primary}>
|
||||
<HStack spacing={2}>
|
||||
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
|
||||
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
{/* 模式切换按钮(移动端隐藏) */}
|
||||
{!isMobile && <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />}
|
||||
</HStack>
|
||||
|
||||
{/* 右侧:通知开关 + 更新时间 */}
|
||||
<HStack spacing={3}>
|
||||
{/* 通知开关 */}
|
||||
<Tooltip
|
||||
label={browserPermission === 'granted'
|
||||
? '浏览器通知已开启'
|
||||
: '开启实时推送通知'}
|
||||
placement="left"
|
||||
hasArrow
|
||||
>
|
||||
{/* 通知开关 - 移动端隐藏 */}
|
||||
{!isMobile && (
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="md"
|
||||
bg={browserPermission === 'granted'
|
||||
? useColorModeValue('green.50', 'green.900')
|
||||
: useColorModeValue('gray.50', 'gray.700')}
|
||||
borderWidth="1px"
|
||||
borderColor={browserPermission === 'granted'
|
||||
? useColorModeValue('green.200', 'green.700')
|
||||
: useColorModeValue('gray.200', 'gray.600')}
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
borderColor: browserPermission === 'granted'
|
||||
? useColorModeValue('green.300', 'green.600')
|
||||
: useColorModeValue('blue.300', 'blue.600'),
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
onClick={handleNotificationToggle}
|
||||
_hover={{ opacity: 0.8 }}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
<Icon
|
||||
as={BellIcon}
|
||||
boxSize={3.5}
|
||||
color={browserPermission === 'granted'
|
||||
? useColorModeValue('green.600', 'green.300')
|
||||
: useColorModeValue('gray.500', 'gray.400')}
|
||||
color={PROFESSIONAL_COLORS.gold[500]}
|
||||
/>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
color={browserPermission === 'granted'
|
||||
? useColorModeValue('green.700', 'green.200')
|
||||
: useColorModeValue('gray.600', 'gray.300')}
|
||||
color={PROFESSIONAL_COLORS.text.secondary}
|
||||
>
|
||||
{browserPermission === 'granted' ? '已开启' : '开启通知'}
|
||||
实时消息推送:{browserPermission === 'granted' ? '已开启' : '未开启'}
|
||||
</Text>
|
||||
<Switch
|
||||
size="sm"
|
||||
isChecked={browserPermission === 'granted'}
|
||||
pointerEvents="none"
|
||||
colorScheme="green"
|
||||
colorScheme="yellow"
|
||||
/>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* 更新时间 */}
|
||||
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">
|
||||
@@ -618,6 +600,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
mode={mode}
|
||||
pageSize={pageSize}
|
||||
trackingFunctions={trackingFunctions}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
@@ -627,41 +610,14 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
<CardBody
|
||||
ref={cardBodyRef}
|
||||
position="relative"
|
||||
pt={4}
|
||||
pt={0}
|
||||
px={0}
|
||||
mx={0}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
overflow="visible"
|
||||
zIndex={1}
|
||||
>
|
||||
{/* 顶部控制栏:模式切换按钮 + 分页控制器(滚动时固定在顶部) */}
|
||||
<Box
|
||||
position="sticky"
|
||||
top="0"
|
||||
zIndex={10}
|
||||
bg={cardBg}
|
||||
py={2}
|
||||
mb={2}
|
||||
borderBottom="1px solid"
|
||||
borderColor={borderColor}
|
||||
mx={-6}
|
||||
px={6}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧:模式切换按钮 */}
|
||||
<ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />
|
||||
|
||||
{/* 右侧:分页控制器(仅在纵向模式显示) */}
|
||||
{mode === 'vertical' && totalPages > 1 && (
|
||||
<PaginationControl
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChangeWithScroll}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
{/* 内容区域 - 撑满剩余高度 */}
|
||||
<Box flex="1" minH={0} position="relative">
|
||||
{/* Loading 蒙层 - 数据请求时显示 */}
|
||||
|
||||
@@ -87,7 +87,7 @@ const EventScrollList = React.memo(({
|
||||
h="100%"
|
||||
pt={0}
|
||||
pb={4}
|
||||
px={mode === 'four-row' ? 0 : 2}
|
||||
px={mode === 'four-row' ? 0 : { base: 0, md: 2 }}
|
||||
position="relative"
|
||||
data-scroll-container="true"
|
||||
css={{
|
||||
@@ -141,6 +141,9 @@ const EventScrollList = React.memo(({
|
||||
onToggleFollow={onToggleFollow}
|
||||
getTimelineBoxStyle={getTimelineBoxStyle}
|
||||
borderColor={borderColor}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
|
||||
colorScheme="blue"
|
||||
variant={mode === 'vertical' ? 'solid' : 'outline'}
|
||||
>
|
||||
纵向
|
||||
列表
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onModeChange('four-row')}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -243,7 +243,7 @@ const RelatedConceptsSection = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '收起' : '查看详细描述'}
|
||||
{isExpanded ? '收起' : '查看详细'}
|
||||
</Button>
|
||||
</Flex>
|
||||
{/* 第二行:交易日期信息 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
|
||||
import { useDevice } from '@hooks/useDevice';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// 导入子组件
|
||||
import ImportanceStamp from './ImportanceStamp';
|
||||
@@ -54,6 +56,7 @@ const HorizontalDynamicNewsEventCard = React.memo(({
|
||||
layout = 'vertical',
|
||||
}) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const { isMobile } = useDevice();
|
||||
|
||||
// 专业配色 - 黑色、灰色、金色主题
|
||||
const cardBg = PROFESSIONAL_COLORS.background.card;
|
||||
@@ -67,8 +70,8 @@ const HorizontalDynamicNewsEventCard = React.memo(({
|
||||
const showTimeline = useBreakpointValue({ base: false, md: true }); // 移动端隐藏时间轴
|
||||
const cardPadding = useBreakpointValue({ base: 2, md: 3 }); // 移动端减小内边距
|
||||
const titleFontSize = useBreakpointValue({ base: 'sm', md: 'md' }); // 移动端减小标题字体
|
||||
const titlePaddingRight = useBreakpointValue({ base: '80px', md: '120px' }); // 为关键词留空间
|
||||
const spacing = useBreakpointValue({ base: 2, md: 3 }); // 间距
|
||||
const titlePaddingRight = useBreakpointValue({ base: '16px', md: '120px' }); // 桌面端为关键词留空间,移动端不显示关键词
|
||||
const spacing = useBreakpointValue({ base: 1, md: 3 }); // 间距(移动端更紧凑)
|
||||
|
||||
/**
|
||||
* 根据平均涨幅计算背景色(专业配色 - 深色主题)
|
||||
@@ -159,6 +162,33 @@ const HorizontalDynamicNewsEventCard = React.memo(({
|
||||
onClick={() => onEventClick?.(event)}
|
||||
>
|
||||
<CardBody p={cardPadding} pb={2}>
|
||||
{/* 左上角:移动端时间显示 */}
|
||||
{isMobile && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={1}
|
||||
left={1}
|
||||
zIndex={2}
|
||||
{...(timelineStyle.bgGradient ? { bgGradient: timelineStyle.bgGradient } : { bg: timelineStyle.bg })}
|
||||
borderWidth={timelineStyle.borderWidth}
|
||||
borderColor={timelineStyle.borderColor}
|
||||
borderRadius="md"
|
||||
px={1.5}
|
||||
py={1}
|
||||
textAlign="center"
|
||||
boxShadow={timelineStyle.boxShadow}
|
||||
>
|
||||
<Text
|
||||
fontSize="9px"
|
||||
fontWeight="bold"
|
||||
color={timelineStyle.textColor}
|
||||
lineHeight="1.2"
|
||||
>
|
||||
{dayjs(event.created_at).format('MM-DD HH:mm')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 右上角:关注按钮 */}
|
||||
<Box position="absolute" top={{ base: 1, md: 2 }} right={{ base: 1, md: 2 }} zIndex={2}>
|
||||
<EventFollowButton
|
||||
@@ -170,8 +200,8 @@ const HorizontalDynamicNewsEventCard = React.memo(({
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Keywords梦幻轮播 - 绝对定位在卡片右侧空白处 */}
|
||||
{event.keywords && event.keywords.length > 0 && (
|
||||
{/* Keywords梦幻轮播 - 绝对定位在卡片右侧空白处(移动端隐藏) */}
|
||||
{!isMobile && event.keywords && event.keywords.length > 0 && (
|
||||
<KeywordsCarousel
|
||||
keywords={event.keywords}
|
||||
interval={4000}
|
||||
@@ -200,6 +230,7 @@ const HorizontalDynamicNewsEventCard = React.memo(({
|
||||
onClick={(e) => onTitleClick?.(e, event)}
|
||||
mt={1}
|
||||
paddingRight={titlePaddingRight}
|
||||
paddingLeft={isMobile ? '70px' : undefined}
|
||||
>
|
||||
<Text
|
||||
fontSize={titleFontSize}
|
||||
|
||||
36
src/views/Community/components/EventDetailModal.less
Normal 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;
|
||||
}
|
||||
}
|
||||
48
src/views/Community/components/EventDetailModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 禁用状态 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||