Compare commits
31 Commits
06beeeaee4
...
feature_20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdca889083 | ||
|
|
c0d8bf20a3 | ||
|
|
662d140439 | ||
| c136c2aed8 | |||
| ea1adcb2ca | |||
| 43f32c5af2 | |||
| 6c69ad407d | |||
| 2e7ed4b899 | |||
| be496290bb | |||
| 51ed56726c | |||
| 9a6230e51e | |||
| 5042d1ee46 | |||
| 01d0a06f6a | |||
| dd975a65b2 | |||
| ae9904cd03 | |||
| 368af3f498 | |||
| 03d0a6514c | |||
| f7f9774caa | |||
| 1f592b6775 | |||
| 2f580c3c1f | |||
| 259b298ea6 | |||
| 5ff68d0790 | |||
| a14313fdbd | |||
| 4ba6fd34ff | |||
| 642de62566 | |||
| 4ea1ef08f4 | |||
| 2b3700369f | |||
| f60c6a8ae9 | |||
| f24f37c50d | |||
|
|
0dfbac7248 | ||
|
|
143933b480 |
437
app.py
437
app.py
@@ -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'
|
||||
}
|
||||
|
||||
@@ -35,6 +35,13 @@ export const bytedeskConfig = {
|
||||
subtitle: '点击咨询', // 副标题
|
||||
},
|
||||
|
||||
// 按钮大小配置
|
||||
buttonConfig: {
|
||||
show: true,
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
|
||||
// 主题配置
|
||||
theme: {
|
||||
mode: 'system', // light | dark | system
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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. 获取投资计划列表
|
||||
|
||||
@@ -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个交易日
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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模块 ====================
|
||||
{
|
||||
|
||||
@@ -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`);
|
||||
},
|
||||
|
||||
@@ -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 秒后自动消失 ========== */
|
||||
/* 提示框("在线客服 点击咨询"气泡)- 扩展选择器 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
// 静默处理错误,预加载失败不影响用户体验
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -179,7 +179,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
if (propStockCode && propStockCode !== stockCode) {
|
||||
setStockCode(propStockCode);
|
||||
}
|
||||
}, [propStockCode]);
|
||||
}, [propStockCode, stockCode]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,7 +27,7 @@ const ForecastReport = ({ stockCode: propStockCode }) => {
|
||||
if (propStockCode && propStockCode !== code) {
|
||||
setCode(propStockCode);
|
||||
}
|
||||
}, [propStockCode]);
|
||||
}, [propStockCode, code]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
|
||||
@@ -411,7 +411,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
if (propStockCode && propStockCode !== stockCode) {
|
||||
setStockCode(propStockCode);
|
||||
}
|
||||
}, [propStockCode]);
|
||||
}, [propStockCode, stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stockCode) {
|
||||
|
||||
@@ -91,7 +91,7 @@ const CompanyIndex = () => {
|
||||
setStockCode(scode);
|
||||
setInputCode(scode);
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams, stockCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWatchlistStatus();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user