Compare commits

..

31 Commits

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

437
app.py
View File

@@ -5601,24 +5601,31 @@ def get_historical_event_stocks(event_id):
if event_trading_date:
try:
# 查询股票在事件对应交易日的数据
# ea_trade 表字段F007N=最近成交价(收盘价), F010N=涨跌幅
base_stock_code = stock.stock_code.split('.')[0] if stock.stock_code else ''
# 日期格式转换为 YYYYMMDD 整数ea_trade.TRADEDATE 是 int 类型)
if hasattr(event_trading_date, 'strftime'):
trade_date_int = int(event_trading_date.strftime('%Y%m%d'))
else:
trade_date_int = int(str(event_trading_date).replace('-', ''))
with engine.connect() as conn:
query = text("""
SELECT close_price, change_pct
FROM ea_dailyline
WHERE seccode = :stock_code
AND date = :trading_date
ORDER BY date DESC
LIMIT 1
SELECT F007N as close_price, F010N as change_pct
FROM ea_trade
WHERE SECCODE = :stock_code
AND TRADEDATE = :trading_date
LIMIT 1
""")
result = conn.execute(query, {
'stock_code': stock.stock_code,
'trading_date': event_trading_date
'stock_code': base_stock_code,
'trading_date': trade_date_int
}).fetchone()
if result:
stock_data['event_day_close'] = float(result[0]) if result[0] else None
stock_data['event_day_change_pct'] = float(result[1]) if result[1] else None
print(f"[DEBUG] 股票{base_stock_code}{trade_date_int}: close={result[0]}, change_pct={result[1]}")
else:
stock_data['event_day_close'] = None
stock_data['event_day_change_pct'] = None
@@ -5801,6 +5808,23 @@ def get_stock_quotes():
if not codes:
return jsonify({'success': False, 'error': '请提供股票代码'}), 400
# 标准化股票代码(确保带后缀,用于 ClickHouse 查询)
def normalize_stock_code(code):
"""将股票代码标准化为带后缀格式(如 300274.SZ"""
if '.' in code:
return code # 已经带后缀
# 根据代码规则添加后缀6/0/3开头为深圳其他为上海
if code.startswith(('6',)):
return f"{code}.SH"
else:
return f"{code}.SZ"
# 保留原始代码用于返回结果,同时创建标准化代码用于 ClickHouse 查询
original_codes = codes
normalized_codes = [normalize_stock_code(code) for code in codes]
# 创建原始代码到标准化代码的映射
code_mapping = dict(zip(original_codes, normalized_codes))
# 处理事件时间
if event_time_str:
try:
@@ -5829,13 +5853,12 @@ def get_stock_quotes():
# 构建代码到名称的映射
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}"
# 为原始代码和标准化代码都分配名称
for orig_code, norm_code in code_mapping.items():
base_code = orig_code.split('.')[0]
name = base_name_map.get(base_code, f"股票{base_code}")
stock_names[orig_code] = name
stock_names[norm_code] = name
def get_trading_day_and_times(event_datetime):
event_date = event_datetime.date()
@@ -5897,6 +5920,21 @@ def get_stock_quotes():
start_datetime = datetime.combine(trading_day, start_time)
end_datetime = datetime.combine(trading_day, end_time)
# 获取前一个交易日(用于计算涨跌幅基准)
prev_trading_day = None
with engine.connect() as conn:
result = conn.execute(text("""
SELECT EXCHANGE_DATE
FROM trading_days
WHERE EXCHANGE_DATE < :date
ORDER BY EXCHANGE_DATE DESC
LIMIT 1
"""), {"date": trading_day}).fetchone()
if result:
prev_trading_day = result[0].date() if hasattr(result[0], 'date') else result[0]
print(f"当前交易日: {trading_day}, 前一交易日: {prev_trading_day}")
# If the trading day is in the future relative to current time,
# return only names without data
if trading_day > current_time.date():
@@ -5912,19 +5950,38 @@ def get_stock_quotes():
# ==================== 性能优化:批量查询所有股票数据 ====================
# 使用 IN 子句一次查询所有股票,避免逐只循环查询
try:
# 批量查询价格和涨跌幅数据(使用窗口函数
# 先从 MySQL ea_trade 表查询前一交易日的收盘价(日线数据,查询更快
prev_close_map = {}
if prev_trading_day:
with engine.connect() as conn:
# 提取不带后缀的股票代码用于查询
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)}
params['trade_date'] = prev_trading_day
prev_close_result = conn.execute(text(f"""
SELECT SECCODE, F007N as close_price
FROM ea_trade
WHERE SECCODE IN ({placeholders})
AND TRADEDATE = :trade_date
"""), params).fetchall()
# 构建代码到收盘价的映射(需要匹配完整代码格式)
base_close_map = {row[0]: float(row[1]) if row[1] else None for row in prev_close_result}
# 为每个标准化代码(带后缀)分配收盘价,用于 ClickHouse 查询结果匹配
for norm_code in normalized_codes:
base_code = norm_code.split('.')[0]
if base_code in base_close_map:
prev_close_map[norm_code] = base_close_map[base_code]
print(f"前一交易日({prev_trading_day})收盘价查询返回 {len(prev_close_result)} 条数据")
# 批量查询当前价格数据
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 (
WITH last_prices AS (
SELECT
code,
close as last_price,
@@ -5934,84 +5991,95 @@ def get_stock_quotes():
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
SELECT code, last_price
FROM last_prices
WHERE rn = 1
"""
batch_data = client.execute(batch_price_query, {
'codes': codes,
'codes': normalized_codes, # 使用标准化后的代码查询 ClickHouse
'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
prev_close = prev_close_map.get(code)
# 计算涨跌幅:(当前价 - 前一交易日收盘价) / 前一交易日收盘价 * 100
change_pct = None
if last_price is not None and prev_close is not None and prev_close > 0:
change_pct = (last_price - prev_close) / prev_close * 100
price_data_map[code] = {
'price': last_price,
'change': change_pct
}
# 组装结果(所有股票)
for code in codes:
price_info = price_data_map.get(code)
# 组装结果(所有股票)- 使用原始代码作为 key 返回
for orig_code in original_codes:
norm_code = code_mapping[orig_code]
price_info = price_data_map.get(norm_code)
if price_info:
results[code] = {
results[orig_code] = {
'price': price_info['price'],
'change': price_info['change'],
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
'name': stock_names.get(orig_code, stock_names.get(norm_code, f'股票{orig_code.split(".")[0]}'))
}
else:
# 批量查询没有返回的股票
results[code] = {
results[orig_code] = {
'price': None,
'change': None,
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
'name': stock_names.get(orig_code, stock_names.get(norm_code, f'股票{orig_code.split(".")[0]}'))
}
except Exception as e:
print(f"批量查询 ClickHouse 失败: {e},回退到逐只查询")
# 降级方案:逐只股票查询(保持向后兼容
for code in codes:
# 降级方案:逐只股票查询(使用前一交易日收盘价计算涨跌幅
for orig_code in original_codes:
norm_code = code_mapping[orig_code]
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})
# 查询当前价格(使用标准化代码查询 ClickHouse
current_data = client.execute("""
SELECT close FROM stock_minute
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
ORDER BY timestamp DESC LIMIT 1
""", {'code': norm_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]}')}
last_price = float(current_data[0][0]) if current_data and current_data[0] and current_data[0][0] else None
# 从 MySQL ea_trade 表查询前一交易日收盘价
prev_close = None
if prev_trading_day and last_price is not None:
base_code = orig_code.split('.')[0]
with engine.connect() as conn:
prev_result = conn.execute(text("""
SELECT F007N as close_price
FROM ea_trade
WHERE SECCODE = :code AND TRADEDATE = :trade_date
"""), {'code': base_code, 'trade_date': prev_trading_day}).fetchone()
prev_close = float(prev_result[0]) if prev_result and prev_result[0] else None
# 计算涨跌幅
change_pct = None
if last_price is not None and prev_close is not None and prev_close > 0:
change_pct = (last_price - prev_close) / prev_close * 100
# 使用原始代码作为 key 返回
results[orig_code] = {
'price': last_price,
'change': change_pct,
'name': stock_names.get(orig_code, f'股票{orig_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]}')}
print(f"Error processing stock {orig_code}: {inner_e}")
results[orig_code] = {'price': None, 'change': None, 'name': stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}')}
# 返回标准格式
return jsonify({'success': True, 'data': results})
@@ -6243,6 +6311,228 @@ def get_stock_kline(stock_code):
return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400
@app.route('/api/stock/batch-kline', methods=['POST'])
def get_batch_kline_data():
"""批量获取多只股票的K线/分时数据
请求体:{
codes: string[],
type: 'timeline'|'daily',
event_time?: string,
days_before?: number, # 查询事件日期前多少天的数据默认60最大365
end_date?: string # 分页加载时指定结束日期(用于加载更早的数据)
}
返回:{ success: true, data: { [code]: { data: [], trade_date: '', ... } }, has_more: boolean }
"""
try:
data = request.json
codes = data.get('codes', [])
chart_type = data.get('type', 'timeline')
event_time = data.get('event_time')
days_before = min(int(data.get('days_before', 60)), 365) # 默认60天最多365天
custom_end_date = data.get('end_date') # 用于分页加载更早数据
if not codes:
return jsonify({'success': False, 'error': '请提供股票代码列表'}), 400
if len(codes) > 50:
return jsonify({'success': False, 'error': '单次最多查询50只股票'}), 400
# 标准化股票代码(确保带后缀,用于 ClickHouse 查询)
def normalize_stock_code(code):
"""将股票代码标准化为带后缀格式(如 300274.SZ"""
if '.' in code:
return code # 已经带后缀
# 根据代码规则添加后缀6开头为上海其他为深圳
if code.startswith(('6',)):
return f"{code}.SH"
else:
return f"{code}.SZ"
# 保留原始代码用于返回结果,同时创建标准化代码用于 ClickHouse 查询
original_codes = codes
normalized_codes = [normalize_stock_code(code) for code in codes]
code_mapping = dict(zip(original_codes, normalized_codes))
reverse_mapping = dict(zip(normalized_codes, original_codes))
try:
event_datetime = datetime.fromisoformat(event_time) if event_time else datetime.now()
except ValueError:
return jsonify({'success': False, 'error': 'Invalid event_time format'}), 400
client = get_clickhouse_client()
# 批量获取股票名称
stock_names = {}
with engine.connect() as conn:
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(
f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})"
), params).fetchall()
for row in result:
stock_names[row[0]] = row[1]
# 确定目标交易日
target_date = get_trading_day_near_date(event_datetime.date())
is_after_market = event_datetime.time() > dt_time(15, 0)
if target_date and is_after_market:
next_trade_date = get_trading_day_near_date(target_date + timedelta(days=1))
if next_trade_date:
target_date = next_trade_date
if not target_date:
# 返回空数据(使用原始代码作为 key
return jsonify({
'success': True,
'data': {code: {'data': [], 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'type': chart_type} for code in original_codes}
})
start_time = datetime.combine(target_date, dt_time(9, 30))
end_time = datetime.combine(target_date, dt_time(15, 0))
results = {}
if chart_type == 'timeline':
# 批量查询分时数据(使用标准化代码查询 ClickHouse
batch_data = client.execute("""
SELECT code, timestamp, close, volume
FROM stock_minute
WHERE code IN %(codes)s
AND timestamp BETWEEN %(start)s AND %(end)s
ORDER BY code, timestamp
""", {
'codes': normalized_codes, # 使用标准化代码
'start': start_time,
'end': end_time
})
# 按股票代码分组(标准化代码 -> 数据列表)
stock_data = {}
for row in batch_data:
norm_code = row[0]
if norm_code not in stock_data:
stock_data[norm_code] = []
stock_data[norm_code].append({
'time': row[1].strftime('%H:%M'),
'price': float(row[2]),
'volume': float(row[3])
})
# 组装结果(使用原始代码作为 key 返回)
for orig_code in original_codes:
norm_code = code_mapping[orig_code]
base_code = orig_code.split('.')[0]
stock_name = stock_names.get(base_code, f'股票{base_code}')
data_list = stock_data.get(norm_code, [])
results[orig_code] = {
'code': orig_code,
'name': stock_name,
'data': data_list,
'trade_date': target_date.strftime('%Y-%m-%d'),
'type': 'timeline'
}
elif chart_type == 'daily':
# 批量查询日线数据从MySQL ea_trade表
with engine.connect() as conn:
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)}
# 确定查询的日期范围
# 如果指定了 custom_end_date用于分页加载更早的数据
if custom_end_date:
try:
end_date_obj = datetime.strptime(custom_end_date, '%Y-%m-%d').date()
except ValueError:
end_date_obj = target_date
else:
end_date_obj = target_date
# TRADEDATE 是整数格式 YYYYMMDD需要转换日期格式
start_date = end_date_obj - timedelta(days=days_before)
params['start_date'] = int(start_date.strftime('%Y%m%d'))
params['end_date'] = int(end_date_obj.strftime('%Y%m%d'))
daily_result = conn.execute(text(f"""
SELECT SECCODE, TRADEDATE, F003N as open, F005N as high, F006N as low, F007N as close, F004N as volume
FROM ea_trade
WHERE SECCODE IN ({placeholders})
AND TRADEDATE BETWEEN :start_date AND :end_date
ORDER BY SECCODE, TRADEDATE
"""), params).fetchall()
# 按股票代码分组
stock_data = {}
for row in daily_result:
code_base = row[0]
if code_base not in stock_data:
stock_data[code_base] = []
# 日期格式处理TRADEDATE 可能是 datetime 或 int(YYYYMMDD)
trade_date_val = row[1]
if hasattr(trade_date_val, 'strftime'):
date_str = trade_date_val.strftime('%Y-%m-%d')
elif isinstance(trade_date_val, int):
# 整数格式 YYYYMMDD -> YYYY-MM-DD
date_str = f"{str(trade_date_val)[:4]}-{str(trade_date_val)[4:6]}-{str(trade_date_val)[6:8]}"
else:
date_str = str(trade_date_val)
stock_data[code_base].append({
'time': date_str, # 统一使用 time 字段,与前端期望一致
'open': float(row[2]) if row[2] else 0,
'high': float(row[3]) if row[3] else 0,
'low': float(row[4]) if row[4] else 0,
'close': float(row[5]) if row[5] else 0,
'volume': float(row[6]) if row[6] else 0
})
# 组装结果(使用原始代码作为 key 返回)
# 同时计算最早日期,用于判断是否还有更多数据
earliest_dates = {}
for orig_code in original_codes:
base_code = orig_code.split('.')[0]
stock_name = stock_names.get(base_code, f'股票{base_code}')
data_list = stock_data.get(base_code, [])
# 记录每只股票的最早日期
if data_list:
earliest_dates[orig_code] = data_list[0]['time']
results[orig_code] = {
'code': orig_code,
'name': stock_name,
'data': data_list,
'trade_date': target_date.strftime('%Y-%m-%d'),
'type': 'daily',
'earliest_date': data_list[0]['time'] if data_list else None
}
# 计算是否还有更多历史数据基于事件日期往前推365天
event_date = event_datetime.date()
one_year_ago = event_date - timedelta(days=365)
# 如果当前查询的起始日期还没到一年前,则还有更多数据
has_more = start_date > one_year_ago if chart_type == 'daily' else False
print(f"批量K线查询完成: {len(codes)} 只股票, 类型: {chart_type}, 交易日: {target_date}, days_before: {days_before}, has_more: {has_more}")
return jsonify({
'success': True,
'data': results,
'has_more': has_more,
'query_start_date': start_date.strftime('%Y-%m-%d') if chart_type == 'daily' else None,
'query_end_date': end_date_obj.strftime('%Y-%m-%d') if chart_type == 'daily' else None
})
except Exception as e:
print(f"批量K线查询错误: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stock/<stock_code>/latest-minute', methods=['GET'])
def get_latest_minute_data(stock_code):
"""获取最新交易日的分钟频数据"""
@@ -8331,6 +8621,7 @@ def api_get_events():
'related_week_chg': event.related_week_chg,
'invest_score': event.invest_score,
'trending_score': event.trending_score,
'expectation_surprise_score': event.expectation_surprise_score,
})
if include_creator:
event_dict['creator'] = {
@@ -8417,6 +8708,8 @@ def get_hot_events():
'importance': event.importance,
'created_at': event.created_at.isoformat() if event.created_at else None,
'related_avg_chg': event.related_avg_chg,
'related_max_chg': event.related_max_chg,
'expectation_surprise_score': event.expectation_surprise_score,
'creator': {
'username': event.creator.username if event.creator else 'Anonymous'
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -263,6 +263,26 @@ export const accountHandlers = [
});
}),
// 10. 获取事件帖子(用户发布的评论/帖子)
http.get('/api/account/events/posts', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
console.log('[Mock] 获取事件帖子');
return HttpResponse.json({
success: true,
data: mockEventComments // 复用 mockEventComments 数据
});
}),
// ==================== 投资计划与复盘 ====================
// 10. 获取投资计划列表

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,10 @@ iframe[src*="/chat/"],
iframe[src*="/visitor/"] {
position: fixed !important;
z-index: 999999 !important;
max-height: 80vh !important; /* 限制最大高度为视口的80% */
max-width: 40vh !important; /* 限制最大高度为视口的80% */
bottom: 10px !important; /* 确保底部有足够空间 */
right: 10px !important; /* 右侧边距 */
}
/* Bytedesk 覆盖层(如果存在) */
@@ -37,16 +41,6 @@ iframe[src*="/visitor/"] {
z-index: 1000000 !important;
}
/* ========== H5 端客服组件整体缩小 ========== */
@media (max-width: 768px) {
/* 整个客服容器缩小(包括按钮和提示框) */
[class*="bytedesk"],
[id*="bytedesk"],
[class*="BytedeskWeb"] {
transform: scale(0.7) !important;
transform-origin: bottom right !important;
}
}
/* ========== 提示框 3 秒后自动消失 ========== */
/* 提示框("在线客服 点击咨询"气泡)- 扩展选择器 */

View File

@@ -8,6 +8,7 @@ import {
Card,
CardBody,
VStack,
HStack,
Text,
Spinner,
Center,
@@ -77,7 +78,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
// 使用 Hook 获取实时数据
// - autoLoad: false - 禁用自动加载所有数据,改为手动触发
// - autoLoadQuotes: false - 禁用自动加载行情,延迟到展开时加载(减少请求
// - autoLoadQuotes: true - 股票数据加载后自动加载行情(相关股票默认展开
const {
stocks,
quotes,
@@ -89,7 +90,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
loadHistoricalData,
loadChainAnalysis,
refreshQuotes
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false, autoLoadQuotes: false });
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false, autoLoadQuotes: true });
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
const loadEventDetail = useCallback(async () => {
@@ -122,8 +123,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪
// 相关股票默认折叠,只显示数量吸引点击
const [isStocksOpen, setIsStocksOpen] = useState(false);
// 相关股票默认展开
const [isStocksOpen, setIsStocksOpen] = useState(true);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
@@ -225,12 +226,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
setHasLoadedHistorical(false);
setHasLoadedTransmission(false);
// 相关股票默认折叠,但预加载股票列表(显示数量吸引点击)
setIsStocksOpen(false);
// 相关股票默认展开,预加载股票列表和行情数据
setIsStocksOpen(true);
if (canAccessStocks) {
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);
// 由于默认展开,直接加载行情数据
setHasLoadedQuotes(true);
}
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)

View File

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

View File

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

View File

@@ -39,13 +39,21 @@ import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
* @param {string} props.eventTime - 事件时间(可选)
* @param {boolean} props.isInWatchlist - 是否在自选股中
* @param {Function} props.onWatchlistToggle - 切换自选股回调
* @param {Array} props.timelineData - 预加载的分时图数据(可选,由父组件批量加载后传入)
* @param {boolean} props.timelineLoading - 分时图数据加载状态
* @param {Array} props.dailyData - 预加载的日K线数据可选由父组件批量加载后传入
* @param {boolean} props.dailyLoading - 日K线数据加载状态
*/
const StockListItem = ({
stock,
quote = null,
eventTime = null,
isInWatchlist = false,
onWatchlistToggle
onWatchlistToggle,
timelineData,
timelineLoading = false,
dailyData,
dailyLoading = false
}) => {
const isMobile = useSelector(selectIsMobile);
const cardBg = PROFESSIONAL_COLORS.background.card;
@@ -187,12 +195,13 @@ const StockListItem = ({
{onWatchlistToggle && (
<IconButton
size="xs"
variant={isInWatchlist ? 'solid' : 'ghost'}
variant={isInWatchlist ? 'solid' : 'outline'}
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
icon={<StarIcon />}
icon={<StarIcon color={isInWatchlist ? undefined : 'gray.400'} />}
onClick={handleWatchlistClick}
aria-label={isInWatchlist ? '已关注' : '加自选'}
borderRadius="full"
borderColor={isInWatchlist ? undefined : 'gray.300'}
/>
)}
</HStack>
@@ -236,6 +245,8 @@ const StockListItem = ({
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
preloadedData={timelineData}
loading={timelineLoading}
/>
</Box>
</VStack>
@@ -278,6 +289,8 @@ const StockListItem = ({
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
preloadedData={dailyData}
loading={dailyLoading}
/>
</Box>
</VStack>

View File

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

View File

@@ -303,7 +303,7 @@ const DynamicNewsEventCard = React.memo(({
<StockChangeIndicators
avgChange={event.related_avg_chg}
maxChange={event.related_max_chg}
weekChange={event.related_week_chg}
expectationScore={event.expectation_surprise_score}
/>
</Box>
</VStack>

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
// src/views/Community/components/StockDetailPanel/components/StockTable.js
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { Table, Button } from 'antd';
import { StarFilled, StarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import MiniTimelineChart from './MiniTimelineChart';
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '../utils/klineDataCache';
import { logger } from '../../../../../utils/logger';
/**
@@ -28,12 +29,92 @@ const StockTable = ({
}) => {
// 展开/收缩的行
const [expandedRows, setExpandedRows] = useState(new Set());
// K线数据状态{ [stockCode]: data[] }
const [klineDataMap, setKlineDataMap] = useState({});
const [klineLoading, setKlineLoading] = useState(false);
// 用于追踪当前正在加载的 stocksKey解决时序问题
const [loadingStocksKey, setLoadingStocksKey] = useState('');
// 稳定的事件时间,避免重复渲染
const stableEventTime = useMemo(() => {
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]);
// 批量加载K线数据
// 使用 stocks 的 JSON 字符串作为依赖项的 key避免引用变化导致重复加载
const stocksKey = useMemo(() => {
return stocks.map(s => s.stock_code).sort().join(',');
}, [stocks]);
// 计算是否应该显示 loading当前 stocksKey 和 loadingStocksKey 不匹配,或者正在加载
// 这样可以在 stocks 变化时立即显示 loading不需要等 useEffect
const shouldShowLoading = useMemo(() => {
if (stocks.length === 0) return false;
// 如果 stocksKey 变化了但 klineDataMap 还没更新,说明需要加载
const currentDataKeys = Object.keys(klineDataMap).sort().join(',');
if (stocksKey !== currentDataKeys && stocksKey !== loadingStocksKey) {
return true;
}
return klineLoading;
}, [stocks.length, stocksKey, klineDataMap, loadingStocksKey, klineLoading]);
useEffect(() => {
if (stocks.length === 0) {
setKlineDataMap({});
setKlineLoading(false);
setLoadingStocksKey('');
return;
}
// 立即设置 loading 状态和正在加载的 key
setKlineLoading(true);
setLoadingStocksKey(stocksKey);
const stockCodes = stocks.map(s => s.stock_code);
// 先检查缓存,只请求未缓存的
const cachedData = {};
const uncachedCodes = [];
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, stableEventTime, 'timeline');
const cached = klineDataCache.get(cacheKey);
if (cached !== undefined) {
cachedData[code] = cached;
} else {
uncachedCodes.push(code);
}
});
// 如果全部缓存命中,直接使用
if (uncachedCodes.length === 0) {
setKlineDataMap(cachedData);
setKlineLoading(false);
logger.debug('StockTable', 'K线数据全部来自缓存', { stockCount: stockCodes.length });
return;
}
logger.debug('StockTable', '批量加载K线数据', {
totalCount: stockCodes.length,
cachedCount: Object.keys(cachedData).length,
uncachedCount: uncachedCodes.length,
eventTime: stableEventTime
});
// 批量请求未缓存的数据
fetchBatchKlineData(stockCodes, stableEventTime, 'timeline')
.then((batchData) => {
// 合并缓存数据和新数据
setKlineDataMap({ ...cachedData, ...batchData });
setKlineLoading(false);
})
.catch((error) => {
logger.error('StockTable', '批量加载K线数据失败', error);
// 失败时使用已有的缓存数据
setKlineDataMap(cachedData);
setKlineLoading(false);
});
}, [stocksKey, stableEventTime]); // 使用 stocksKey 而非 stocks 对象引用
// 切换行展开状态
const toggleRowExpand = useCallback((stockCode) => {
setExpandedRows(prev => {
@@ -157,6 +238,8 @@ const StockTable = ({
<MiniTimelineChart
stockCode={record.stock_code}
eventTime={stableEventTime}
preloadedData={klineDataMap[record.stock_code]}
loading={shouldShowLoading && !klineDataMap[record.stock_code]}
/>
),
},
@@ -207,7 +290,7 @@ const StockTable = ({
);
},
},
], [quotes, stableEventTime, expandedRows, toggleRowExpand, watchlistSet, onWatchlistToggle]);
], [quotes, stableEventTime, expandedRows, toggleRowExpand, watchlistSet, onWatchlistToggle, klineDataMap, shouldShowLoading]);
return (
<div style={{ position: 'relative' }}>

View File

@@ -126,9 +126,14 @@ export const useEventStocks = (eventId, eventTime, { autoLoad = true, autoLoadQu
// 自动加载行情数据(可通过 autoLoadQuotes 参数控制)
useEffect(() => {
if (stocks.length > 0 && autoLoadQuotes) {
refreshQuotes();
const codes = stocks.map(s => s.stock_code);
logger.debug('useEventStocks', '自动加载行情数据', {
stockCount: codes.length,
eventTime
});
dispatch(fetchStockQuotes({ codes, eventTime }));
}
}, [stocks.length, eventId, autoLoadQuotes]); // 注意:这里不依赖 refreshQuotes,避免重复请求
}, [stocks, eventTime, autoLoadQuotes, dispatch]); // 直接使用 stocks 而不是 refreshQuotes
// 计算股票行情合并数据
const stocksWithQuotes = useMemo(() => {

View File

@@ -4,9 +4,10 @@ import { stockService } from '../../../../../services/eventService';
import { logger } from '../../../../../utils/logger';
// ================= 全局缓存和请求管理 =================
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}` -> data
export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}` -> Promise
export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}` -> timestamp
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}|${chartType}` -> data
export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}|${chartType}` -> Promise
export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}|${chartType}` -> timestamp
export const batchPendingRequests = new Map(); // 批量请求的 Promise: key = `${eventTime}|${chartType}` -> Promise
// 请求间隔限制(毫秒)
const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数据
@@ -157,3 +158,131 @@ export const getCacheStats = () => {
cacheKeys: Array.from(klineDataCache.keys())
};
};
/**
* 批量获取多只股票的K线数据一次API请求
* @param {string[]} stockCodes - 股票代码数组
* @param {string} eventTime - 事件时间
* @param {string} chartType - 图表类型timeline/daily
* @returns {Promise<Object>} 股票代码到K线数据的映射 { [stockCode]: data[] }
*/
export const fetchBatchKlineData = async (stockCodes, eventTime, chartType = 'timeline') => {
if (!stockCodes || stockCodes.length === 0) {
return {};
}
const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
const batchKey = `${normalizedEventTime || 'today'}|${chartType}`;
// 过滤出未缓存的股票
const uncachedCodes = stockCodes.filter(code => {
const cacheKey = getCacheKey(code, eventTime, chartType);
return !klineDataCache.has(cacheKey) || shouldRefreshData(cacheKey);
});
logger.debug('klineDataCache', '批量请求分析', {
totalCodes: stockCodes.length,
uncachedCodes: uncachedCodes.length,
cachedCodes: stockCodes.length - uncachedCodes.length
});
// 如果所有股票都有缓存,直接返回缓存数据
if (uncachedCodes.length === 0) {
const result = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, eventTime, chartType);
result[code] = klineDataCache.get(cacheKey) || [];
});
logger.debug('klineDataCache', '所有股票数据来自缓存', { stockCount: stockCodes.length });
return result;
}
// 检查是否有正在进行的批量请求
if (batchPendingRequests.has(batchKey)) {
logger.debug('klineDataCache', '等待进行中的批量请求', { batchKey });
return batchPendingRequests.get(batchKey);
}
// 发起批量请求
logger.debug('klineDataCache', '发起批量K线数据请求', {
batchKey,
stockCount: uncachedCodes.length,
chartType
});
const requestPromise = stockService
.getBatchKlineData(uncachedCodes, chartType, normalizedEventTime)
.then((response) => {
const batchData = response?.data || {};
const now = Date.now();
// 将批量数据存入缓存
Object.entries(batchData).forEach(([code, stockData]) => {
const data = Array.isArray(stockData?.data) ? stockData.data : [];
const cacheKey = getCacheKey(code, eventTime, chartType);
klineDataCache.set(cacheKey, data);
lastRequestTime.set(cacheKey, now);
});
// 对于请求中没有返回数据的股票,设置空数组
uncachedCodes.forEach(code => {
if (!batchData[code]) {
const cacheKey = getCacheKey(code, eventTime, chartType);
if (!klineDataCache.has(cacheKey)) {
klineDataCache.set(cacheKey, []);
lastRequestTime.set(cacheKey, now);
}
}
});
// 清除批量请求状态
batchPendingRequests.delete(batchKey);
logger.debug('klineDataCache', '批量K线数据请求完成', {
batchKey,
stockCount: Object.keys(batchData).length
});
// 返回所有请求股票的数据(包括之前缓存的)
const result = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, eventTime, chartType);
result[code] = klineDataCache.get(cacheKey) || [];
});
return result;
})
.catch((error) => {
logger.error('klineDataCache', 'fetchBatchKlineData', error, {
stockCount: uncachedCodes.length,
chartType
});
// 清除批量请求状态
batchPendingRequests.delete(batchKey);
// 返回已缓存的数据
const result = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, eventTime, chartType);
result[code] = klineDataCache.get(cacheKey) || [];
});
return result;
});
// 保存批量请求
batchPendingRequests.set(batchKey, requestPromise);
return requestPromise;
};
/**
* 预加载多只股票的K线数据后台执行不阻塞UI
* @param {string[]} stockCodes - 股票代码数组
* @param {string} eventTime - 事件时间
* @param {string} chartType - 图表类型timeline/daily
*/
export const preloadBatchKlineData = (stockCodes, eventTime, chartType = 'timeline') => {
// 异步执行不返回Promise不阻塞调用方
fetchBatchKlineData(stockCodes, eventTime, chartType).catch(() => {
// 静默处理错误,预加载失败不影响用户体验
});
};

View File

@@ -868,6 +868,13 @@ const ShareholderTypeBadge = ({ type }) => {
const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => {
const [stockCode, setStockCode] = useState(propStockCode || '000001');
const [loading, setLoading] = useState(false);
// 监听props中的stockCode变化
useEffect(() => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
}
}, [propStockCode, stockCode]);
// 企业深度分析数据
const [comprehensiveData, setComprehensiveData] = useState(null);

View File

@@ -179,7 +179,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
}
}, [propStockCode]);
}, [propStockCode, stockCode]);
// 初始加载
useEffect(() => {

View File

@@ -27,7 +27,7 @@ const ForecastReport = ({ stockCode: propStockCode }) => {
if (propStockCode && propStockCode !== code) {
setCode(propStockCode);
}
}, [propStockCode]);
}, [propStockCode, code]);
// 加载数据
useEffect(() => {

View File

@@ -411,7 +411,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
if (propStockCode && propStockCode !== stockCode) {
setStockCode(propStockCode);
}
}, [propStockCode]);
}, [propStockCode, stockCode]);
useEffect(() => {
if (stockCode) {

View File

@@ -91,7 +91,7 @@ const CompanyIndex = () => {
setStockCode(scode);
setInputCode(scode);
}
}, [searchParams]);
}, [searchParams, stockCode]);
useEffect(() => {
loadWatchlistStatus();

View File

@@ -69,9 +69,8 @@ export default function CenterDashboard() {
const navigate = useNavigate();
const toast = useToast();
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
// ⚡ 提取 userId 为独立变量
const userId = user?.id;
const prevUserIdRef = React.useRef(userId);
// 🎯 初始化Dashboard埋点Hook
const dashboardEvents = useDashboardEvents({
@@ -99,11 +98,13 @@ export default function CenterDashboard() {
try {
const base = getApiBase();
const ts = Date.now();
const [w, e, c] = await Promise.all([
fetch(base + `/api/account/watchlist?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/account/events/following?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
]);
const jw = await w.json();
const je = await e.json();
const jc = await c.json();
@@ -217,26 +218,35 @@ export default function CenterDashboard() {
return 'green';
};
// 🔧 使用 ref 跟踪是否已经加载过数据(首次加载标记)
const hasLoadedRef = React.useRef(false);
useEffect(() => {
const userIdChanged = prevUserIdRef.current !== userId;
const isOnCenterPage = location.pathname.includes('/home/center');
if (userIdChanged) {
prevUserIdRef.current = userId;
}
// 只在 userId 真正变化或路径变化时加载数据
if ((userIdChanged || !prevUserIdRef.current) && user && location.pathname.includes('/home/center')) {
// 首次进入页面且有用户时加载数据
if (user && isOnCenterPage && !hasLoadedRef.current) {
console.log('[Center] 🚀 首次加载数据');
hasLoadedRef.current = true;
loadData();
}
const onVis = () => {
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
console.log('[Center] 👁️ visibilitychange 触发 loadData');
loadData();
}
};
document.addEventListener('visibilitychange', onVis);
return () => document.removeEventListener('visibilitychange', onVis);
}, [userId, location.pathname, loadData, user]); // ⚡ 使用 userId防重复通过 ref 判断
}, [userId, location.pathname, loadData, user]);
// 当用户登出再登入userId 变化)时,重置加载标记
useEffect(() => {
if (!user) {
hasLoadedRef.current = false;
}
}, [user]);
// 定时刷新实时行情(每分钟一次)
useEffect(() => {

View File

@@ -1,6 +1,5 @@
// src/views/EventDetail/components/HistoricalEvents.js
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
VStack,
@@ -23,7 +22,9 @@ import {
ModalBody,
Link,
Flex,
Collapse
Collapse,
IconButton,
Tooltip
} from '@chakra-ui/react';
import {
FaChartLine,
@@ -35,6 +36,7 @@ import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
import CitedContent from '@components/Citation/CitedContent';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import KLineChartModal from '@components/StockChart/KLineChartModal';
const HistoricalEvents = ({
events = [],
@@ -42,8 +44,6 @@ const HistoricalEvents = ({
loading = false,
error = null
}) => {
const navigate = useNavigate();
// 状态管理
const [selectedEventForStocks, setSelectedEventForStocks] = useState(null);
const [stocksModalOpen, setStocksModalOpen] = useState(false);
@@ -117,10 +117,10 @@ const HistoricalEvents = ({
setSelectedEventForStocks(null);
};
// 处理卡片点击跳转到事件详情页
const handleCardClick = (event) => {
navigate(`/event-detail/${event.id}`);
};
// 历史事件卡片不需要点击跳转历史事件ID与主事件不同链接无效
// const handleCardClick = (event) => {
// navigate(`/event-detail/${event.id}`);
// };
// 获取重要性颜色
const getImportanceColor = (importance) => {
@@ -250,8 +250,6 @@ const HistoricalEvents = ({
borderRadius="lg"
position="relative"
overflow="visible"
cursor="pointer"
onClick={() => handleCardClick(event)}
_before={{
content: '""',
position: 'absolute',
@@ -263,10 +261,6 @@ const HistoricalEvents = ({
borderTopLeftRadius: 'lg',
borderTopRightRadius: 'lg',
}}
_hover={{
boxShadow: 'lg',
borderColor: 'blue.400',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3} p={4}>
@@ -280,12 +274,6 @@ const HistoricalEvents = ({
fontWeight="bold"
color={useColorModeValue('blue.500', 'blue.300')}
lineHeight="1.4"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
handleCardClick(event);
}}
_hover={{ textDecoration: 'underline' }}
>
{event.title || '未命名事件'}
</Text>
@@ -411,6 +399,8 @@ const HistoricalEvents = ({
// 股票列表子组件(卡片式布局)
const StocksList = ({ stocks, eventTradingDate }) => {
const [expandedStocks, setExpandedStocks] = useState(new Set());
const [selectedStock, setSelectedStock] = useState(null);
const [isKLineModalOpen, setIsKLineModalOpen] = useState(false);
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
@@ -418,6 +408,12 @@ const StocksList = ({ stocks, eventTradingDate }) => {
const textSecondary = useColorModeValue('gray.600', 'gray.400');
const nameColor = useColorModeValue('gray.700', 'gray.300');
// 打开K线弹窗
const handleOpenKLine = (stock) => {
setSelectedStock(stock);
setIsKLineModalOpen(true);
};
// 处理关联描述字段的辅助函数
const getRelationDesc = (relationDesc) => {
// 处理空值
@@ -536,13 +532,24 @@ const StocksList = ({ stocks, eventTradingDate }) => {
</Text>
</VStack>
<Text
fontSize="lg"
fontWeight="bold"
color={getChangeColor(stock.event_day_change_pct)}
>
{formatChange(stock.event_day_change_pct)}
</Text>
<Tooltip label="点击查看日K线" hasArrow>
<Button
size="sm"
variant="ghost"
colorScheme={stock.event_day_change_pct > 0 ? 'red' : stock.event_day_change_pct < 0 ? 'green' : 'gray'}
onClick={() => handleOpenKLine(stock)}
rightIcon={<Icon as={FaChartLine} boxSize={3} />}
px={2}
>
<Text
fontSize="lg"
fontWeight="bold"
color={getChangeColor(stock.event_day_change_pct)}
>
{formatChange(stock.event_day_change_pct)}
</Text>
</Button>
</Tooltip>
</Flex>
{/* 分隔线 */}
@@ -600,6 +607,16 @@ const StocksList = ({ stocks, eventTradingDate }) => {
);
})}
</SimpleGrid>
{/* K线图弹窗 */}
{isKLineModalOpen && selectedStock && (
<KLineChartModal
isOpen={isKLineModalOpen}
onClose={() => setIsKLineModalOpen(false)}
stock={selectedStock}
eventTime={eventTradingDate}
/>
)}
</>
);
};

View File

@@ -1,7 +1,7 @@
// src/views/Home/HomePage.tsx
// 首页 - 专业投资分析平台
import React, { useEffect, useCallback, useState } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { Box, Container, VStack, SimpleGrid } from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
@@ -11,7 +11,6 @@ import { ACQUISITION_EVENTS } from '@/lib/constants';
import { CORE_FEATURES } from '@/constants/homeFeatures';
import { performanceMonitor } from '@/utils/performanceMonitor';
import type { Feature } from '@/types/home';
import { HeroBackground } from './components/HeroBackground';
import { HeroHeader } from './components/HeroHeader';
import { FeaturedFeatureCard } from './components/FeaturedFeatureCard';
import { FeatureCard } from './components/FeatureCard';
@@ -25,7 +24,13 @@ const HomePage: React.FC = () => {
const { user, isAuthenticated } = useAuth();
const navigate = useNavigate();
const { track } = usePostHogTrack();
const [imageLoaded, setImageLoaded] = useState(false);
// ⚡ 性能标记:渲染开始(组件函数执行时,使用 ref 避免严格模式下重复标记)
const hasMarkedStart = useRef(false);
if (!hasMarkedStart.current) {
performanceMonitor.mark('homepage-render-start');
hasMarkedStart.current = true;
}
// 响应式配置
const {
@@ -34,12 +39,11 @@ const HomePage: React.FC = () => {
headingLetterSpacing,
heroTextSize,
containerPx,
showDecorations
} = useHomeResponsive();
// ⚡ 性能标记:首页组件挂载 = 渲染开始
// ⚡ 性能标记:渲染完成DOM 已挂载)
useEffect(() => {
performanceMonitor.mark('homepage-render-start');
performanceMonitor.mark('homepage-render-end');
}, []);
// PostHog 追踪:页面浏览
@@ -70,13 +74,6 @@ const HomePage: React.FC = () => {
}
}, [track, navigate]);
// 背景图片加载完成回调
const handleImageLoad = useCallback(() => {
setImageLoaded(true);
// ⚡ 性能标记:首页渲染完成(背景图片加载完成 = 首屏视觉完整)
performanceMonitor.mark('homepage-render-end');
}, []);
// 特色功能(第一个)
const featuredFeature = CORE_FEATURES[0];
// 其他功能
@@ -91,12 +88,6 @@ const HomePage: React.FC = () => {
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
overflow="hidden"
>
{/* 背景装饰 */}
<HeroBackground
imageLoaded={imageLoaded}
onImageLoad={handleImageLoad}
showDecorations={showDecorations}
/>
<Container maxW="7xl" position="relative" zIndex={30} px={containerPx}>
<VStack

View File

@@ -1,87 +0,0 @@
// src/views/Home/components/HeroBackground.tsx
// 首页英雄区背景装饰组件
import React from 'react';
import { Box } from '@chakra-ui/react';
import heroBg from '@assets/img/BackgroundCard1.png';
interface HeroBackgroundProps {
imageLoaded: boolean;
onImageLoad: () => void;
showDecorations: boolean | undefined;
}
/**
* 首页英雄区背景组件
* 包含背景图片和装饰性几何图形
*/
export const HeroBackground: React.FC<HeroBackgroundProps> = ({
imageLoaded,
onImageLoad,
showDecorations
}) => {
return (
<>
{/* 背景图片 */}
<Box
position="absolute"
top="0"
right="0"
w="50%"
h="100%"
bgImage={imageLoaded ? `url(${heroBg})` : 'none'}
bgSize="cover"
bgPosition="center"
opacity={imageLoaded ? 0.3 : 0}
transition="opacity 0.5s ease-in"
_after={{
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(90deg, rgba(14, 12, 21, 0.9) 0%, rgba(14, 12, 21, 0.3) 100%)'
}}
/>
{/* 预加载背景图片 */}
<Box display="none">
<img
src={heroBg}
alt=""
onLoad={onImageLoad}
onError={onImageLoad}
/>
</Box>
{/* 装饰性几何图形 - 移动端隐藏 */}
{showDecorations && (
<>
<Box
position="absolute"
top="20%"
left="10%"
w={{ base: '100px', md: '150px', lg: '200px' }}
h={{ base: '100px', md: '150px', lg: '200px' }}
borderRadius="50%"
bg="rgba(255, 215, 0, 0.1)"
filter="blur(80px)"
className="float-animation"
/>
<Box
position="absolute"
bottom="30%"
right="20%"
w={{ base: '80px', md: '120px', lg: '150px' }}
h={{ base: '80px', md: '120px', lg: '150px' }}
borderRadius="50%"
bg="rgba(138, 43, 226, 0.1)"
filter="blur(60px)"
className="float-animation-reverse"
/>
</>
)}
</>
);
};

View File

@@ -42,7 +42,6 @@ import {
useDisclosure,
Image,
Fade,
ScaleFade,
Collapse,
Stack,
Progress,
@@ -58,25 +57,12 @@ import {
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react';
import * as echarts from 'echarts';
import { logger } from '../../utils/logger';
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
// Navigation bar now provided by MainLayout
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
// 动画定义
const pulseAnimation = keyframes`
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
`;
const floatAnimation = keyframes`
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
`;
const StockOverview = () => {
const navigate = useNavigate();
const toast = useToast();
@@ -622,7 +608,7 @@ const StockOverview = () => {
<Container maxW="container.xl" position="relative">
<VStack spacing={8} align="center">
<VStack spacing={4} textAlign="center" maxW="3xl">
<HStack spacing={3} animation={`${floatAnimation} 3s ease-in-out infinite`}>
<HStack spacing={3}>
<Icon as={BsGraphUp} boxSize={12} color={colorMode === 'dark' ? goldColor : 'white'} />
<Heading
as="h1"
@@ -922,8 +908,8 @@ const StockOverview = () => {
) : (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{topConcepts.map((concept, index) => (
<ScaleFade in={true} initialScale={0.9} key={concept.concept_id}>
<Card
key={concept.concept_id}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
@@ -964,7 +950,6 @@ const StockOverview = () => {
px={3}
py={1}
borderRadius="full"
animation={Math.abs(concept.change_percent) > 5 ? `${pulseAnimation} 2s infinite` : 'none'}
border={colorMode === 'dark' ? '1px solid' : 'none'}
borderColor={colorMode === 'dark' ? concept.change_percent > 0 ? '#ff4d4d' : '#22c55e' : 'transparent'}
>
@@ -1039,7 +1024,6 @@ const StockOverview = () => {
</VStack>
</CardBody>
</Card>
</ScaleFade>
))}
</SimpleGrid>
)}