Compare commits
67 Commits
feature_20
...
7c1fe55a5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c1fe55a5f | ||
|
|
1d5d06c567 | ||
|
|
f64c1ffb19 | ||
|
|
6cf92b6851 | ||
|
|
ae42024ec0 | ||
|
|
dafeab0fa3 | ||
|
|
846ed816e5 | ||
|
|
4a97f87ee5 | ||
|
|
b5d054d89f | ||
|
|
b66c1585f7 | ||
|
|
5efd598694 | ||
|
|
b1d5b217d3 | ||
|
|
5f6b933172 | ||
|
|
0c291de182 | ||
|
|
61ed1510c2 | ||
|
|
0edc6a5e00 | ||
|
|
a569a63a85 | ||
|
|
77af61a93a | ||
|
|
999fd9b0a3 | ||
|
|
8d3e92dfaf | ||
|
|
e8c21f7863 | ||
|
|
3f518def09 | ||
|
|
f521b89c27 | ||
|
|
ac421011eb | ||
|
|
6628ddc7b2 | ||
|
|
5dc480f5f4 | ||
|
|
99f102a213 | ||
|
|
9f6c98135f | ||
|
|
f0074bca42 | ||
|
|
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 | ||
| 06beeeaee4 | |||
| d1a222d9e9 | |||
| bd86ccce85 | |||
| ed14031d65 | |||
| 9b16d9d162 | |||
| 7708cb1a69 | |||
| 2395d92b17 |
Binary file not shown.
604
app.py
604
app.py
@@ -1510,8 +1510,8 @@ def initialize_subscription_plans_safe():
|
||||
|
||||
pro_plan = SubscriptionPlan(
|
||||
name='pro',
|
||||
display_name='Pro版本',
|
||||
description='适合个人投资者的基础功能套餐',
|
||||
display_name='Pro 专业版',
|
||||
description='事件关联股票深度分析 | 历史事件智能对比复盘 | 事件概念关联与挖掘 | 概念板块个股追踪 | 概念深度研报与解读 | 个股异动实时预警',
|
||||
monthly_price=0.01,
|
||||
yearly_price=0.08,
|
||||
features=json.dumps([
|
||||
@@ -1526,8 +1526,8 @@ def initialize_subscription_plans_safe():
|
||||
|
||||
max_plan = SubscriptionPlan(
|
||||
name='max',
|
||||
display_name='Max版本',
|
||||
description='适合专业投资者的全功能套餐',
|
||||
display_name='Max 旗舰版',
|
||||
description='包含Pro版全部功能 | 事件传导链路智能分析 | 概念演变时间轴追溯 | 个股全方位深度研究 | 价小前投研助手无限使用 | 新功能优先体验权 | 专属客服一对一服务',
|
||||
monthly_price=0.1,
|
||||
yearly_price=0.8,
|
||||
features=json.dumps([
|
||||
@@ -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):
|
||||
"""获取最新交易日的分钟频数据"""
|
||||
@@ -7289,6 +7579,135 @@ def get_timeline_data(stock_code, event_datetime, stock_name):
|
||||
|
||||
|
||||
# ==================== 指数行情API(与股票逻辑一致,数据表为 index_minute) ====================
|
||||
|
||||
@app.route('/api/index/<index_code>/realtime')
|
||||
def get_index_realtime(index_code):
|
||||
"""
|
||||
获取指数实时行情(用于交易时间内的行情更新)
|
||||
从 index_minute 表获取最新的分钟数据
|
||||
返回: 最新价、涨跌幅、涨跌额、开盘价、最高价、最低价、昨收价
|
||||
"""
|
||||
# 确保指数代码包含后缀(ClickHouse 中存储的是带后缀的代码)
|
||||
# 上证指数: 000xxx.SH, 深证指数: 399xxx.SZ
|
||||
if '.' not in index_code:
|
||||
if index_code.startswith('399'):
|
||||
index_code = f"{index_code}.SZ"
|
||||
else:
|
||||
# 000开头的上证指数,以及其他指数默认上海
|
||||
index_code = f"{index_code}.SH"
|
||||
|
||||
client = get_clickhouse_client()
|
||||
today = date.today()
|
||||
|
||||
# 判断今天是否是交易日
|
||||
if today not in trading_days_set:
|
||||
# 非交易日,获取最近一个交易日的收盘数据
|
||||
target_date = get_trading_day_near_date(today)
|
||||
if not target_date:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No trading day found',
|
||||
'data': None
|
||||
})
|
||||
is_trading = False
|
||||
else:
|
||||
target_date = today
|
||||
# 判断是否在交易时间内
|
||||
now = datetime.now()
|
||||
current_minutes = now.hour * 60 + now.minute
|
||||
# 9:30-11:30 = 570-690, 13:00-15:00 = 780-900
|
||||
is_trading = (570 <= current_minutes <= 690) or (780 <= current_minutes <= 900)
|
||||
|
||||
try:
|
||||
# 获取当天/最近交易日的第一条数据(开盘价)和最后一条数据(最新价)
|
||||
# 同时获取最高价和最低价
|
||||
data = client.execute(
|
||||
"""
|
||||
SELECT
|
||||
min(open) as first_open,
|
||||
max(high) as day_high,
|
||||
min(low) as day_low,
|
||||
argMax(close, timestamp) as latest_close,
|
||||
argMax(timestamp, timestamp) as latest_time
|
||||
FROM index_minute
|
||||
WHERE code = %(code)s
|
||||
AND toDate(timestamp) = %(date)s
|
||||
""",
|
||||
{
|
||||
'code': index_code,
|
||||
'date': target_date,
|
||||
}
|
||||
)
|
||||
|
||||
if not data or not data[0] or data[0][3] is None:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No data available',
|
||||
'data': None
|
||||
})
|
||||
|
||||
row = data[0]
|
||||
first_open = float(row[0]) if row[0] else None
|
||||
day_high = float(row[1]) if row[1] else None
|
||||
day_low = float(row[2]) if row[2] else None
|
||||
latest_close = float(row[3]) if row[3] else None
|
||||
latest_time = row[4]
|
||||
|
||||
# 获取昨收价(从 MySQL ea_exchangetrade 表)
|
||||
code_no_suffix = index_code.split('.')[0]
|
||||
prev_close = None
|
||||
|
||||
with engine.connect() as conn:
|
||||
# 获取前一个交易日的收盘价
|
||||
prev_result = conn.execute(text(
|
||||
"""
|
||||
SELECT F006N
|
||||
FROM ea_exchangetrade
|
||||
WHERE INDEXCODE = :code
|
||||
AND TRADEDATE < :today
|
||||
ORDER BY TRADEDATE DESC LIMIT 1
|
||||
"""
|
||||
), {
|
||||
'code': code_no_suffix,
|
||||
'today': datetime.combine(target_date, dt_time(0, 0, 0))
|
||||
}).fetchone()
|
||||
|
||||
if prev_result and prev_result[0]:
|
||||
prev_close = float(prev_result[0])
|
||||
|
||||
# 计算涨跌额和涨跌幅
|
||||
change_amount = None
|
||||
change_pct = None
|
||||
if latest_close is not None and prev_close is not None and prev_close > 0:
|
||||
change_amount = latest_close - prev_close
|
||||
change_pct = (change_amount / prev_close) * 100
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'code': index_code,
|
||||
'price': latest_close,
|
||||
'open': first_open,
|
||||
'high': day_high,
|
||||
'low': day_low,
|
||||
'prev_close': prev_close,
|
||||
'change': change_amount,
|
||||
'change_pct': change_pct,
|
||||
'update_time': latest_time.strftime('%H:%M:%S') if latest_time else None,
|
||||
'trade_date': target_date.strftime('%Y-%m-%d'),
|
||||
'is_trading': is_trading,
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取指数实时行情失败: {index_code}, 错误: {str(e)}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'data': None
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/index/<index_code>/kline')
|
||||
def get_index_kline(index_code):
|
||||
chart_type = request.args.get('type', 'minute')
|
||||
@@ -8202,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'] = {
|
||||
@@ -8288,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'
|
||||
}
|
||||
@@ -11810,12 +12232,19 @@ def get_market_statistics():
|
||||
|
||||
available_dates = [str(row.TRADEDATE) for row in available_dates_result]
|
||||
|
||||
# 格式化日期为 YYYY-MM-DD
|
||||
formatted_trade_date = trade_date.strftime('%Y-%m-%d') if hasattr(trade_date, 'strftime') else str(trade_date).split(' ')[0][:10]
|
||||
formatted_available_dates = [
|
||||
d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d).split(' ')[0][:10]
|
||||
for d in [row.TRADEDATE for row in available_dates_result]
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'trade_date': str(trade_date),
|
||||
'trade_date': formatted_trade_date,
|
||||
'summary': summary,
|
||||
'details': list(statistics.values()),
|
||||
'available_dates': available_dates
|
||||
'available_dates': formatted_available_dates
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
@@ -11855,19 +12284,30 @@ def get_daily_top_concepts():
|
||||
top_concepts = []
|
||||
|
||||
for concept in data.get('results', []):
|
||||
# 保持与 /concept-api/search 相同的字段结构
|
||||
top_concepts.append({
|
||||
'concept_id': concept.get('concept_id'),
|
||||
'concept_name': concept.get('concept'),
|
||||
'concept': concept.get('concept'), # 原始字段名
|
||||
'concept_name': concept.get('concept'), # 兼容旧字段名
|
||||
'description': concept.get('description'),
|
||||
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0),
|
||||
'stock_count': concept.get('stock_count', 0),
|
||||
'stocks': concept.get('stocks', [])[:5] # 只返回前5只股票
|
||||
'score': concept.get('score'),
|
||||
'match_type': concept.get('match_type'),
|
||||
'price_info': concept.get('price_info', {}), # 完整的价格信息
|
||||
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 兼容旧字段
|
||||
'happened_times': concept.get('happened_times', []), # 历史触发时间
|
||||
'stocks': concept.get('stocks', []), # 返回完整股票列表
|
||||
'hot_score': concept.get('hot_score')
|
||||
})
|
||||
|
||||
# 格式化日期为 YYYY-MM-DD
|
||||
price_date = data.get('price_date', '')
|
||||
formatted_date = str(price_date).split(' ')[0][:10] if price_date else ''
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': top_concepts,
|
||||
'trade_date': data.get('price_date'),
|
||||
'trade_date': formatted_date,
|
||||
'count': len(top_concepts)
|
||||
})
|
||||
else:
|
||||
|
||||
643
app_vx.py
643
app_vx.py
@@ -559,7 +559,86 @@ app.config['COMPRESS_MIMETYPES'] = [
|
||||
'application/javascript',
|
||||
'application/x-javascript'
|
||||
]
|
||||
user_tokens = {}
|
||||
|
||||
# ===================== Token 存储(支持多 worker 共享) =====================
|
||||
class TokenStore:
|
||||
"""
|
||||
Token 存储类 - 支持 Redis(多 worker 共享)或内存(单 worker)
|
||||
"""
|
||||
def __init__(self):
|
||||
self._redis_client = None
|
||||
self._memory_store = {}
|
||||
self._prefix = 'vf_token:'
|
||||
self._initialized = False
|
||||
|
||||
def _ensure_initialized(self):
|
||||
"""延迟初始化,确保在 fork 后才连接 Redis"""
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
try:
|
||||
import redis
|
||||
self._redis_client = redis.from_url(redis_url)
|
||||
self._redis_client.ping()
|
||||
logger.info(f"✅ Token 存储: Redis ({redis_url})")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Redis 不可用 ({e}),Token 使用内存存储(多 worker 模式下会有问题!)")
|
||||
self._redis_client = None
|
||||
|
||||
def get(self, token):
|
||||
"""获取 token 数据"""
|
||||
self._ensure_initialized()
|
||||
if self._redis_client:
|
||||
try:
|
||||
data = self._redis_client.get(f"{self._prefix}{token}")
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Redis get error: {e}")
|
||||
return self._memory_store.get(token)
|
||||
return self._memory_store.get(token)
|
||||
|
||||
def set(self, token, data, expire_seconds=30*24*3600):
|
||||
"""设置 token 数据"""
|
||||
self._ensure_initialized()
|
||||
if self._redis_client:
|
||||
try:
|
||||
# 将 datetime 转为字符串存储
|
||||
store_data = data.copy()
|
||||
if 'expires' in store_data and isinstance(store_data['expires'], datetime):
|
||||
store_data['expires'] = store_data['expires'].isoformat()
|
||||
self._redis_client.setex(
|
||||
f"{self._prefix}{token}",
|
||||
expire_seconds,
|
||||
json.dumps(store_data)
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Redis set error: {e}")
|
||||
self._memory_store[token] = data
|
||||
|
||||
def delete(self, token):
|
||||
"""删除 token"""
|
||||
self._ensure_initialized()
|
||||
if self._redis_client:
|
||||
try:
|
||||
self._redis_client.delete(f"{self._prefix}{token}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Redis delete error: {e}")
|
||||
self._memory_store.pop(token, None)
|
||||
|
||||
def __contains__(self, token):
|
||||
"""支持 'in' 操作符"""
|
||||
return self.get(token) is not None
|
||||
|
||||
|
||||
# 使用 TokenStore 替代内存字典
|
||||
user_tokens = TokenStore()
|
||||
|
||||
app.config['SECRET_KEY'] = 'vf7891574233241'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
@@ -597,14 +676,36 @@ JWT_SECRET_KEY = 'vfllmgreat33818!' # 请修改为安全的密钥
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
JWT_EXPIRATION_HOURS = 24 * 7 # Token有效期7天
|
||||
|
||||
# Session 配置 - 使用文件系统存储(替代 Redis)
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
app.config['SESSION_FILE_DIR'] = os.path.join(os.path.dirname(__file__), 'flask_session')
|
||||
# Session 配置
|
||||
# 优先使用 Redis(支持多 worker 共享),否则回退到文件系统
|
||||
_REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
_USE_REDIS_SESSION = os.environ.get('USE_REDIS_SESSION', 'true').lower() == 'true'
|
||||
|
||||
try:
|
||||
if _USE_REDIS_SESSION:
|
||||
import redis
|
||||
# 测试 Redis 连接
|
||||
_redis_client = redis.from_url(_REDIS_URL)
|
||||
_redis_client.ping()
|
||||
|
||||
app.config['SESSION_TYPE'] = 'redis'
|
||||
app.config['SESSION_REDIS'] = _redis_client
|
||||
app.config['SESSION_KEY_PREFIX'] = 'vf_session:'
|
||||
logger.info(f"✅ Session 存储: Redis ({_REDIS_URL})")
|
||||
else:
|
||||
raise Exception("Redis session disabled by config")
|
||||
except Exception as e:
|
||||
# Redis 不可用,回退到文件系统
|
||||
logger.warning(f"⚠️ Redis 不可用 ({e}),使用文件系统 session(多 worker 模式下可能不稳定)")
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
app.config['SESSION_FILE_DIR'] = os.path.join(os.path.dirname(__file__), 'flask_session')
|
||||
os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True)
|
||||
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session 有效期 7 天
|
||||
|
||||
# 确保 session 目录存在
|
||||
os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True)
|
||||
app.config['SESSION_COOKIE_SECURE'] = False # 生产环境 HTTPS 时设为 True
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
|
||||
# Cache directory setup
|
||||
CACHE_DIR = Path('cache')
|
||||
@@ -661,9 +762,12 @@ def token_required(f):
|
||||
if not token_data:
|
||||
return jsonify({'message': 'Token无效', 'code': 401}), 401
|
||||
|
||||
# 检查是否过期
|
||||
if token_data['expires'] < datetime.now():
|
||||
del user_tokens[token]
|
||||
# 检查是否过期(expires 可能是字符串或 datetime)
|
||||
expires = token_data['expires']
|
||||
if isinstance(expires, str):
|
||||
expires = datetime.fromisoformat(expires)
|
||||
if expires < datetime.now():
|
||||
user_tokens.delete(token)
|
||||
return jsonify({'message': 'Token已过期'}), 401
|
||||
|
||||
# 获取用户对象并添加到请求上下文
|
||||
@@ -3438,7 +3542,7 @@ def logout_with_token():
|
||||
token = data.get('token') if data else None
|
||||
|
||||
if token and token in user_tokens:
|
||||
del user_tokens[token]
|
||||
user_tokens.delete(token)
|
||||
|
||||
# 清除session
|
||||
session.clear()
|
||||
@@ -3595,10 +3699,10 @@ def login_with_phone():
|
||||
token = generate_token(32)
|
||||
|
||||
# 存储token映射(30天有效期)
|
||||
user_tokens[token] = {
|
||||
user_tokens.set(token, {
|
||||
'user_id': user.id,
|
||||
'expires': datetime.now() + timedelta(days=30)
|
||||
}
|
||||
})
|
||||
|
||||
# 清除验证码
|
||||
del verification_codes[phone]
|
||||
@@ -3648,9 +3752,12 @@ def verify_token():
|
||||
if not token_data:
|
||||
return jsonify({'valid': False, 'message': 'Token无效', 'code': 401}), 401
|
||||
|
||||
# 检查是否过期
|
||||
if token_data['expires'] < datetime.now():
|
||||
del user_tokens[token]
|
||||
# 检查是否过期(expires 可能是字符串或 datetime)
|
||||
expires = token_data['expires']
|
||||
if isinstance(expires, str):
|
||||
expires = datetime.fromisoformat(expires)
|
||||
if expires < datetime.now():
|
||||
user_tokens.delete(token)
|
||||
return jsonify({'valid': False, 'message': 'Token已过期'}), 401
|
||||
|
||||
# 获取用户信息
|
||||
@@ -3883,10 +3990,10 @@ def api_login_wechat():
|
||||
token = generate_token(32) # 使用相同的随机字符串生成器
|
||||
|
||||
# 存储token映射(与手机登录保持一致)
|
||||
user_tokens[token] = {
|
||||
user_tokens.set(token, {
|
||||
'user_id': user.id,
|
||||
'expires': datetime.now() + timedelta(days=30) # 30天有效期
|
||||
}
|
||||
})
|
||||
|
||||
# 设置session(可选,保持与手机登录一致)
|
||||
session.permanent = True
|
||||
@@ -5274,6 +5381,114 @@ def get_comment_replies(comment_id):
|
||||
}), 500
|
||||
|
||||
|
||||
# 工具函数:解析JSON字段
|
||||
def parse_json_field(field_value):
|
||||
"""解析JSON字段"""
|
||||
if not field_value:
|
||||
return []
|
||||
try:
|
||||
if isinstance(field_value, str):
|
||||
if field_value.startswith('['):
|
||||
return json.loads(field_value)
|
||||
else:
|
||||
return field_value.split(',')
|
||||
else:
|
||||
return field_value
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
# 工具函数:获取 future_events 表字段值,支持新旧字段回退
|
||||
def get_future_event_field(row, new_field, old_field):
|
||||
"""
|
||||
获取 future_events 表字段值,支持新旧字段回退
|
||||
如果新字段存在且不为空,使用新字段;否则使用旧字段
|
||||
"""
|
||||
new_value = getattr(row, new_field, None) if hasattr(row, new_field) else None
|
||||
old_value = getattr(row, old_field, None) if hasattr(row, old_field) else None
|
||||
|
||||
# 如果新字段有值(不为空字符串),使用新字段
|
||||
if new_value is not None and str(new_value).strip():
|
||||
return new_value
|
||||
return old_value
|
||||
|
||||
|
||||
# 工具函数:解析新的 best_matches 数据结构(含研报引用信息)
|
||||
def parse_best_matches(best_matches_value):
|
||||
"""
|
||||
解析新的 best_matches 数据结构(含研报引用信息)
|
||||
|
||||
新结构示例:
|
||||
[
|
||||
{
|
||||
"stock_code": "300451.SZ",
|
||||
"company_name": "创业慧康",
|
||||
"original_description": "核心标的,医疗信息化...",
|
||||
"best_report_title": "报告标题",
|
||||
"best_report_author": "作者",
|
||||
"best_report_sentences": "相关内容",
|
||||
"best_report_match_score": "好",
|
||||
"best_report_match_ratio": 0.9285714285714286,
|
||||
"best_report_declare_date": "2023-04-25T00:00:00",
|
||||
"total_reports": 9,
|
||||
"high_score_reports": 6
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
返回统一格式的股票列表,兼容旧格式
|
||||
"""
|
||||
if not best_matches_value:
|
||||
return []
|
||||
|
||||
try:
|
||||
# 解析 JSON
|
||||
if isinstance(best_matches_value, str):
|
||||
data = json.loads(best_matches_value)
|
||||
else:
|
||||
data = best_matches_value
|
||||
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
|
||||
result = []
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
# 新结构:包含研报信息的字典
|
||||
stock_info = {
|
||||
'code': item.get('stock_code', ''),
|
||||
'name': item.get('company_name', ''),
|
||||
'description': item.get('original_description', ''),
|
||||
'score': item.get('best_report_match_ratio', 0),
|
||||
# 研报引用信息
|
||||
'report': {
|
||||
'title': item.get('best_report_title', ''),
|
||||
'author': item.get('best_report_author', ''),
|
||||
'sentences': item.get('best_report_sentences', ''),
|
||||
'match_score': item.get('best_report_match_score', ''),
|
||||
'match_ratio': item.get('best_report_match_ratio', 0),
|
||||
'declare_date': item.get('best_report_declare_date', ''),
|
||||
'total_reports': item.get('total_reports', 0),
|
||||
'high_score_reports': item.get('high_score_reports', 0)
|
||||
} if item.get('best_report_title') else None
|
||||
}
|
||||
result.append(stock_info)
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
# 旧结构:[code, name, description, score]
|
||||
result.append({
|
||||
'code': item[0],
|
||||
'name': item[1],
|
||||
'description': item[2] if len(item) > 2 else '',
|
||||
'score': item[3] if len(item) > 3 else 0,
|
||||
'report': None
|
||||
})
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"parse_best_matches error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# 工具函数:处理转义字符,保留 Markdown 格式
|
||||
def unescape_markdown_text(text):
|
||||
"""
|
||||
@@ -5363,6 +5578,7 @@ def api_calendar_events():
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# 构建基础查询 - 使用 future_events 表
|
||||
# 添加新字段 second_modified_text, `second_modified_text.1`, best_matches 支持新旧回退
|
||||
query = """
|
||||
SELECT data_id, \
|
||||
calendar_time, \
|
||||
@@ -5374,7 +5590,10 @@ def api_calendar_events():
|
||||
fact, \
|
||||
related_stocks, \
|
||||
concepts, \
|
||||
inferred_tag
|
||||
inferred_tag, \
|
||||
second_modified_text, \
|
||||
`second_modified_text.1` as second_modified_text_1, \
|
||||
best_matches
|
||||
FROM future_events
|
||||
WHERE 1 = 1 \
|
||||
"""
|
||||
@@ -5445,90 +5664,114 @@ def api_calendar_events():
|
||||
|
||||
events_data = []
|
||||
for event in events:
|
||||
# 解析相关股票
|
||||
# 使用新字段回退机制获取 former 和 forecast
|
||||
# second_modified_text -> former
|
||||
former_value = get_future_event_field(event, 'second_modified_text', 'former')
|
||||
# second_modified_text.1 -> forecast
|
||||
forecast_new = getattr(event, 'second_modified_text_1', None)
|
||||
forecast_value = forecast_new if (forecast_new and str(forecast_new).strip()) else getattr(event, 'forecast', None)
|
||||
|
||||
# 解析相关股票 - 优先使用 best_matches,回退到 related_stocks
|
||||
related_stocks_list = []
|
||||
related_avg_chg = 0
|
||||
related_max_chg = 0
|
||||
related_week_chg = 0
|
||||
|
||||
# 处理相关股票数据
|
||||
if event.related_stocks:
|
||||
# 优先使用 best_matches(新结构,含研报引用)
|
||||
best_matches = getattr(event, 'best_matches', None)
|
||||
if best_matches and str(best_matches).strip():
|
||||
# 使用新的 parse_best_matches 函数解析
|
||||
parsed_stocks = parse_best_matches(best_matches)
|
||||
else:
|
||||
# 回退到旧的 related_stocks 处理
|
||||
parsed_stocks = []
|
||||
if event.related_stocks:
|
||||
try:
|
||||
import ast
|
||||
if isinstance(event.related_stocks, str):
|
||||
try:
|
||||
stock_data = json.loads(event.related_stocks)
|
||||
except:
|
||||
stock_data = ast.literal_eval(event.related_stocks)
|
||||
else:
|
||||
stock_data = event.related_stocks
|
||||
|
||||
if stock_data:
|
||||
for stock_info in stock_data:
|
||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
||||
parsed_stocks.append({
|
||||
'code': stock_info[0],
|
||||
'name': stock_info[1],
|
||||
'description': stock_info[2] if len(stock_info) > 2 else '',
|
||||
'score': stock_info[3] if len(stock_info) > 3 else 0,
|
||||
'report': None
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error parsing related_stocks for event {event.data_id}: {e}")
|
||||
|
||||
# 处理解析后的股票数据,获取交易信息
|
||||
if parsed_stocks:
|
||||
try:
|
||||
import json
|
||||
import ast
|
||||
daily_changes = []
|
||||
week_changes = []
|
||||
|
||||
# 使用与detail接口相同的解析逻辑
|
||||
if isinstance(event.related_stocks, str):
|
||||
try:
|
||||
stock_data = json.loads(event.related_stocks)
|
||||
except:
|
||||
stock_data = ast.literal_eval(event.related_stocks)
|
||||
else:
|
||||
stock_data = event.related_stocks
|
||||
for stock_info in parsed_stocks:
|
||||
stock_code = stock_info.get('code', '')
|
||||
stock_name = stock_info.get('name', '')
|
||||
description = stock_info.get('description', '')
|
||||
score = stock_info.get('score', 0)
|
||||
report = stock_info.get('report', None)
|
||||
|
||||
if stock_data:
|
||||
daily_changes = []
|
||||
week_changes = []
|
||||
if stock_code:
|
||||
# 规范化股票代码,移除后缀
|
||||
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
|
||||
|
||||
# 处理正确的数据格式 [股票代码, 股票名称, 描述, 分数]
|
||||
for stock_info in stock_data:
|
||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
||||
stock_code = stock_info[0] # 股票代码
|
||||
stock_name = stock_info[1] # 股票名称
|
||||
description = stock_info[2] if len(stock_info) > 2 else ''
|
||||
score = stock_info[3] if len(stock_info) > 3 else 0
|
||||
else:
|
||||
continue
|
||||
# 使用模糊匹配查询真实的交易数据
|
||||
trade_query = """
|
||||
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
|
||||
FROM ea_trade
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
ORDER BY TRADEDATE DESC LIMIT 7 \
|
||||
"""
|
||||
trade_result = db.session.execute(text(trade_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
trade_data = trade_result.fetchall()
|
||||
|
||||
if stock_code:
|
||||
# 规范化股票代码,移除后缀
|
||||
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
|
||||
daily_chg = 0
|
||||
week_chg = 0
|
||||
|
||||
# 使用模糊匹配查询真实的交易数据
|
||||
trade_query = """
|
||||
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
|
||||
FROM ea_trade
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
ORDER BY TRADEDATE DESC LIMIT 7 \
|
||||
"""
|
||||
trade_result = db.session.execute(text(trade_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
trade_data = trade_result.fetchall()
|
||||
if trade_data:
|
||||
# 日涨跌幅(当日)
|
||||
daily_chg = float(trade_data[0].change_pct or 0)
|
||||
|
||||
daily_chg = 0
|
||||
week_chg = 0
|
||||
# 周涨跌幅(5个交易日)
|
||||
if len(trade_data) >= 5:
|
||||
current_price = float(trade_data[0].close_price or 0)
|
||||
week_ago_price = float(trade_data[4].close_price or 0)
|
||||
if week_ago_price > 0:
|
||||
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
|
||||
|
||||
if trade_data:
|
||||
# 日涨跌幅(当日)
|
||||
daily_chg = float(trade_data[0].change_pct or 0)
|
||||
# 收集涨跌幅数据
|
||||
daily_changes.append(daily_chg)
|
||||
week_changes.append(week_chg)
|
||||
|
||||
# 周涨跌幅(5个交易日)
|
||||
if len(trade_data) >= 5:
|
||||
current_price = float(trade_data[0].close_price or 0)
|
||||
week_ago_price = float(trade_data[4].close_price or 0)
|
||||
if week_ago_price > 0:
|
||||
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
|
||||
related_stocks_list.append({
|
||||
'code': stock_code,
|
||||
'name': stock_name,
|
||||
'description': description,
|
||||
'score': score,
|
||||
'daily_chg': daily_chg,
|
||||
'week_chg': week_chg,
|
||||
'report': report # 添加研报引用信息
|
||||
})
|
||||
|
||||
# 收集涨跌幅数据
|
||||
daily_changes.append(daily_chg)
|
||||
week_changes.append(week_chg)
|
||||
# 计算平均收益率
|
||||
if daily_changes:
|
||||
related_avg_chg = round(sum(daily_changes) / len(daily_changes), 4)
|
||||
related_max_chg = round(max(daily_changes), 4)
|
||||
|
||||
related_stocks_list.append({
|
||||
'code': stock_code,
|
||||
'name': stock_name,
|
||||
'description': description,
|
||||
'score': score,
|
||||
'daily_chg': daily_chg,
|
||||
'week_chg': week_chg
|
||||
})
|
||||
|
||||
# 计算平均收益率
|
||||
if daily_changes:
|
||||
related_avg_chg = round(sum(daily_changes) / len(daily_changes), 4)
|
||||
related_max_chg = round(max(daily_changes), 4)
|
||||
|
||||
if week_changes:
|
||||
related_week_chg = round(sum(week_changes) / len(week_changes), 4)
|
||||
if week_changes:
|
||||
related_week_chg = round(sum(week_changes) / len(week_changes), 4)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing related stocks for event {event.data_id}: {e}")
|
||||
@@ -5553,8 +5796,9 @@ def api_calendar_events():
|
||||
highlight_match = 'concepts'
|
||||
|
||||
# 将转义的换行符转换为真正的换行符,保留 Markdown 格式
|
||||
cleaned_former = unescape_markdown_text(event.former)
|
||||
cleaned_forecast = unescape_markdown_text(event.forecast)
|
||||
# 使用新字段回退后的值(former_value, forecast_value)
|
||||
cleaned_former = unescape_markdown_text(former_value)
|
||||
cleaned_forecast = unescape_markdown_text(forecast_value)
|
||||
cleaned_fact = unescape_markdown_text(event.fact)
|
||||
|
||||
event_dict = {
|
||||
@@ -5800,6 +6044,7 @@ def api_future_event_detail(item_id):
|
||||
"""未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员"""
|
||||
try:
|
||||
# 从 future_events 表查询事件详情
|
||||
# 添加新字段 second_modified_text, `second_modified_text.1`, best_matches 支持新旧回退
|
||||
query = """
|
||||
SELECT data_id, \
|
||||
calendar_time, \
|
||||
@@ -5810,7 +6055,10 @@ def api_future_event_detail(item_id):
|
||||
forecast, \
|
||||
fact, \
|
||||
related_stocks, \
|
||||
concepts
|
||||
concepts, \
|
||||
second_modified_text, \
|
||||
`second_modified_text.1` as second_modified_text_1, \
|
||||
best_matches
|
||||
FROM future_events
|
||||
WHERE data_id = :item_id \
|
||||
"""
|
||||
@@ -5825,6 +6073,13 @@ def api_future_event_detail(item_id):
|
||||
'data': None
|
||||
}), 404
|
||||
|
||||
# 使用新字段回退机制获取 former 和 forecast
|
||||
# second_modified_text -> former
|
||||
former_value = get_future_event_field(event, 'second_modified_text', 'former')
|
||||
# second_modified_text.1 -> forecast
|
||||
forecast_new = getattr(event, 'second_modified_text_1', None)
|
||||
forecast_value = forecast_new if (forecast_new and str(forecast_new).strip()) else getattr(event, 'forecast', None)
|
||||
|
||||
extracted_concepts = extract_concepts_from_concepts_field(event.concepts)
|
||||
|
||||
# 解析相关股票
|
||||
@@ -5868,136 +6123,150 @@ def api_future_event_detail(item_id):
|
||||
'环保': '公共产业板块', '综合': '公共产业板块'
|
||||
}
|
||||
|
||||
# 处理相关股票
|
||||
# 处理相关股票 - 优先使用 best_matches,回退到 related_stocks
|
||||
related_avg_chg = 0
|
||||
related_max_chg = 0
|
||||
related_week_chg = 0
|
||||
|
||||
if event.related_stocks:
|
||||
# 优先使用 best_matches(新结构,含研报引用)
|
||||
best_matches = getattr(event, 'best_matches', None)
|
||||
if best_matches and str(best_matches).strip():
|
||||
# 使用新的 parse_best_matches 函数解析
|
||||
parsed_stocks = parse_best_matches(best_matches)
|
||||
else:
|
||||
# 回退到旧的 related_stocks 处理
|
||||
parsed_stocks = []
|
||||
if event.related_stocks:
|
||||
try:
|
||||
import ast
|
||||
if isinstance(event.related_stocks, str):
|
||||
try:
|
||||
stock_data = json.loads(event.related_stocks)
|
||||
except:
|
||||
stock_data = ast.literal_eval(event.related_stocks)
|
||||
else:
|
||||
stock_data = event.related_stocks
|
||||
|
||||
if stock_data:
|
||||
for stock_info in stock_data:
|
||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
||||
parsed_stocks.append({
|
||||
'code': stock_info[0],
|
||||
'name': stock_info[1],
|
||||
'description': stock_info[2] if len(stock_info) > 2 else '',
|
||||
'score': stock_info[3] if len(stock_info) > 3 else 0,
|
||||
'report': None
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error parsing related_stocks for event {event.data_id}: {e}")
|
||||
|
||||
# 处理解析后的股票数据
|
||||
if parsed_stocks:
|
||||
try:
|
||||
import json
|
||||
import ast
|
||||
daily_changes = []
|
||||
week_changes = []
|
||||
|
||||
# **修正:正确解析related_stocks数据结构**
|
||||
if isinstance(event.related_stocks, str):
|
||||
try:
|
||||
# 先尝试JSON解析
|
||||
stock_data = json.loads(event.related_stocks)
|
||||
except:
|
||||
# 如果JSON解析失败,尝试ast.literal_eval解析
|
||||
stock_data = ast.literal_eval(event.related_stocks)
|
||||
else:
|
||||
stock_data = event.related_stocks
|
||||
for stock_info in parsed_stocks:
|
||||
stock_code = stock_info.get('code', '')
|
||||
stock_name = stock_info.get('name', '')
|
||||
description = stock_info.get('description', '')
|
||||
score = stock_info.get('score', 0)
|
||||
report = stock_info.get('report', None)
|
||||
|
||||
print(f"Parsed stock_data: {stock_data}") # 调试输出
|
||||
if stock_code:
|
||||
# 规范化股票代码,移除后缀
|
||||
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
|
||||
|
||||
if stock_data:
|
||||
daily_changes = []
|
||||
week_changes = []
|
||||
print(f"Processing stock: {clean_code} - {stock_name}") # 调试输出
|
||||
|
||||
# **修正:处理正确的数据格式 [股票代码, 股票名称, 描述, 分数]**
|
||||
for stock_info in stock_data:
|
||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
||||
stock_code = stock_info[0] # 第一个元素是股票代码
|
||||
stock_name = stock_info[1] # 第二个元素是股票名称
|
||||
description = stock_info[2] if len(stock_info) > 2 else ''
|
||||
score = stock_info[3] if len(stock_info) > 3 else 0
|
||||
else:
|
||||
continue # 跳过格式不正确的数据
|
||||
# 使用模糊匹配LIKE查询申万一级行业F004V
|
||||
sector_query = """
|
||||
SELECT F004V as sw_primary_sector
|
||||
FROM ea_sector
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
AND F002V = '申银万国行业分类' LIMIT 1 \
|
||||
"""
|
||||
sector_result = db.session.execute(text(sector_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
sector_row = sector_result.fetchone()
|
||||
|
||||
if stock_code:
|
||||
# 规范化股票代码,移除后缀
|
||||
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
|
||||
# 根据申万一级行业(F004V)映射到主板块
|
||||
sw_primary_sector = sector_row.sw_primary_sector if sector_row else None
|
||||
primary_sector = sector_map.get(sw_primary_sector, '其他') if sw_primary_sector else '其他'
|
||||
|
||||
print(f"Processing stock: {clean_code} - {stock_name}") # 调试输出
|
||||
print(
|
||||
f"Stock: {clean_code}, SW Primary: {sw_primary_sector}, Primary Sector: {primary_sector}")
|
||||
|
||||
# 使用模糊匹配LIKE查询申万一级行业F004V
|
||||
sector_query = """
|
||||
SELECT F004V as sw_primary_sector
|
||||
FROM ea_sector
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
AND F002V = '申银万国行业分类' LIMIT 1 \
|
||||
"""
|
||||
sector_result = db.session.execute(text(sector_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
sector_row = sector_result.fetchone()
|
||||
# 通过SQL查询获取真实的日涨跌幅和周涨跌幅
|
||||
trade_query = """
|
||||
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
|
||||
FROM ea_trade
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
ORDER BY TRADEDATE DESC LIMIT 7 \
|
||||
"""
|
||||
trade_result = db.session.execute(text(trade_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
trade_data = trade_result.fetchall()
|
||||
|
||||
# 根据申万一级行业(F004V)映射到主板块
|
||||
sw_primary_sector = sector_row.sw_primary_sector if sector_row else None
|
||||
primary_sector = sector_map.get(sw_primary_sector, '其他') if sw_primary_sector else '其他'
|
||||
daily_chg = 0
|
||||
week_chg = 0
|
||||
|
||||
print(
|
||||
f"Stock: {clean_code}, SW Primary: {sw_primary_sector}, Primary Sector: {primary_sector}")
|
||||
if trade_data:
|
||||
# 日涨跌幅(当日)
|
||||
daily_chg = float(trade_data[0].change_pct or 0)
|
||||
|
||||
# 通过SQL查询获取真实的日涨跌幅和周涨跌幅
|
||||
trade_query = """
|
||||
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
|
||||
FROM ea_trade
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
ORDER BY TRADEDATE DESC LIMIT 7 \
|
||||
"""
|
||||
trade_result = db.session.execute(text(trade_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
trade_data = trade_result.fetchall()
|
||||
# 周涨跌幅(5个交易日)
|
||||
if len(trade_data) >= 5:
|
||||
current_price = float(trade_data[0].close_price or 0)
|
||||
week_ago_price = float(trade_data[4].close_price or 0)
|
||||
if week_ago_price > 0:
|
||||
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
|
||||
|
||||
daily_chg = 0
|
||||
week_chg = 0
|
||||
print(
|
||||
f"Trade data found: {len(trade_data) if trade_data else 0} records, daily_chg: {daily_chg}")
|
||||
|
||||
if trade_data:
|
||||
# 日涨跌幅(当日)
|
||||
daily_chg = float(trade_data[0].change_pct or 0)
|
||||
# 统计各分类数量
|
||||
sector_stats['全部股票'] += 1
|
||||
sector_stats[primary_sector] += 1
|
||||
|
||||
# 周涨跌幅(5个交易日)
|
||||
if len(trade_data) >= 5:
|
||||
current_price = float(trade_data[0].close_price or 0)
|
||||
week_ago_price = float(trade_data[4].close_price or 0)
|
||||
if week_ago_price > 0:
|
||||
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
|
||||
# 收集涨跌幅数据
|
||||
daily_changes.append(daily_chg)
|
||||
week_changes.append(week_chg)
|
||||
|
||||
print(
|
||||
f"Trade data found: {len(trade_data) if trade_data else 0} records, daily_chg: {daily_chg}")
|
||||
related_stocks_list.append({
|
||||
'code': stock_code, # 原始股票代码
|
||||
'name': stock_name, # 股票名称
|
||||
'description': description, # 关联描述
|
||||
'score': score, # 关联分数
|
||||
'sw_primary_sector': sw_primary_sector, # 申万一级行业(F004V)
|
||||
'primary_sector': primary_sector, # 主板块分类
|
||||
'daily_change': daily_chg, # 真实的日涨跌幅
|
||||
'week_change': week_chg, # 真实的周涨跌幅
|
||||
'report': report # 研报引用信息(新字段)
|
||||
})
|
||||
|
||||
# 统计各分类数量
|
||||
sector_stats['全部股票'] += 1
|
||||
sector_stats[primary_sector] += 1
|
||||
# 计算平均收益率
|
||||
if daily_changes:
|
||||
related_avg_chg = sum(daily_changes) / len(daily_changes)
|
||||
related_max_chg = max(daily_changes)
|
||||
|
||||
# 收集涨跌幅数据
|
||||
daily_changes.append(daily_chg)
|
||||
week_changes.append(week_chg)
|
||||
|
||||
related_stocks_list.append({
|
||||
'code': stock_code, # 原始股票代码
|
||||
'name': stock_name, # 股票名称
|
||||
'description': description, # 关联描述
|
||||
'score': score, # 关联分数
|
||||
'sw_primary_sector': sw_primary_sector, # 申万一级行业(F004V)
|
||||
'primary_sector': primary_sector, # 主板块分类
|
||||
'daily_change': daily_chg, # 真实的日涨跌幅
|
||||
'week_change': week_chg # 真实的周涨跌幅
|
||||
})
|
||||
|
||||
# 计算平均收益率
|
||||
if daily_changes:
|
||||
related_avg_chg = sum(daily_changes) / len(daily_changes)
|
||||
related_max_chg = max(daily_changes)
|
||||
|
||||
if week_changes:
|
||||
related_week_chg = sum(week_changes) / len(week_changes)
|
||||
if week_changes:
|
||||
related_week_chg = sum(week_changes) / len(week_changes)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing related stocks: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 构建返回数据
|
||||
# 构建返回数据,使用新字段回退后的值
|
||||
detail_data = {
|
||||
'id': event.data_id,
|
||||
'title': event.title,
|
||||
'type': event.type,
|
||||
'star': event.star,
|
||||
'calendar_time': event.calendar_time.isoformat() if event.calendar_time else None,
|
||||
'former': event.former,
|
||||
'forecast': event.forecast,
|
||||
'former': former_value, # 使用回退后的值(优先 second_modified_text)
|
||||
'forecast': forecast_value, # 使用回退后的值(优先 second_modified_text.1)
|
||||
'fact': event.fact,
|
||||
'concepts': event.concepts,
|
||||
'extracted_concepts': extracted_concepts,
|
||||
|
||||
1176
concept_hierarchy.json
Normal file
1176
concept_hierarchy.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ Flask-Compress==1.14
|
||||
Flask-SocketIO==5.3.6
|
||||
Flask-Mail==0.9.1
|
||||
Flask-Migrate==4.0.5
|
||||
Flask-Session==0.5.0
|
||||
redis==5.0.1
|
||||
pandas==2.0.3
|
||||
numpy==1.24.3
|
||||
requests==2.31.0
|
||||
|
||||
@@ -75,9 +75,11 @@ const BytedeskWidget = ({
|
||||
const rightVal = parseInt(style.right);
|
||||
const bottomVal = parseInt(style.bottom);
|
||||
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
|
||||
// H5 端设置按钮尺寸为 48x48(只执行一次)
|
||||
// H5 端设置按钮尺寸为 48x48 并降低 z-index(只执行一次)
|
||||
if (isMobile && !el.dataset.bytedeskStyled) {
|
||||
el.dataset.bytedeskStyled = 'true';
|
||||
// 降低 z-index,避免遮挡页面内的发布按钮等交互元素
|
||||
el.style.zIndex = 10;
|
||||
const button = el.querySelector('button');
|
||||
if (button) {
|
||||
button.style.width = '48px';
|
||||
|
||||
@@ -35,6 +35,13 @@ export const bytedeskConfig = {
|
||||
subtitle: '点击咨询', // 副标题
|
||||
},
|
||||
|
||||
// 按钮大小配置
|
||||
buttonConfig: {
|
||||
show: true,
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
|
||||
// 主题配置
|
||||
theme: {
|
||||
mode: 'system', // light | dark | system
|
||||
@@ -48,10 +55,17 @@ export const bytedeskConfig = {
|
||||
t: '1', // 类型: 1=人工客服, 2=机器人
|
||||
sid: 'df_wg_uid', // 工作组ID
|
||||
},
|
||||
|
||||
window: window.innerWidth <= 768 ? {
|
||||
width: window.innerWidth - 1,
|
||||
height: Math.min(window.innerWidth * 640/380, window.innerHeight - 200)
|
||||
} : { width: 380, height: 640 }
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取Bytedesk配置(根据环境自动切换)
|
||||
* - H5 端:宽度占满,高度根据宽度等比缩放
|
||||
* - PC 端:固定宽高 380x640
|
||||
*
|
||||
* @returns {Object} Bytedesk配置对象
|
||||
*/
|
||||
|
||||
239
src/components/ConceptStocksModal/index.tsx
Normal file
239
src/components/ConceptStocksModal/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
ModalBody,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
TableContainer,
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Icon,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaTable } from 'react-icons/fa';
|
||||
import marketService from '@services/marketService';
|
||||
import { logger } from '@utils/logger';
|
||||
|
||||
// 股票信息类型
|
||||
interface StockInfo {
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
reason?: string;
|
||||
change_pct?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// 概念信息类型
|
||||
export interface ConceptInfo {
|
||||
concept_id?: string;
|
||||
concept_name: string;
|
||||
stock_count?: number;
|
||||
stocks?: StockInfo[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// 行情数据类型
|
||||
interface MarketData {
|
||||
stock_code: string;
|
||||
close?: number;
|
||||
change_percent?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ConceptStocksModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
concept: ConceptInfo | null;
|
||||
}
|
||||
|
||||
const ConceptStocksModal: React.FC<ConceptStocksModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
concept,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 状态
|
||||
const [stockMarketData, setStockMarketData] = useState<Record<string, MarketData>>({});
|
||||
const [loadingStockData, setLoadingStockData] = useState(false);
|
||||
|
||||
// 颜色主题
|
||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||
const hoverBg = useColorModeValue('gray.50', '#2a2a2a');
|
||||
|
||||
// 响应式配置 - 添加 fallback 避免首次渲染时返回 undefined 导致弹窗异常
|
||||
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
|
||||
// H5 使用 xl 而非 full,配合 maxH 限制高度
|
||||
const modalSize = useBreakpointValue({ base: 'xl', md: '4xl' }, { fallback: 'md' });
|
||||
const tableMaxH = useBreakpointValue({ base: '45vh', md: '60vh' }, { fallback: 'md' });
|
||||
|
||||
// 批量获取股票行情数据
|
||||
const fetchStockMarketData = useCallback(async (stocks: StockInfo[]) => {
|
||||
if (!stocks || stocks.length === 0) return;
|
||||
|
||||
setLoadingStockData(true);
|
||||
const newMarketData: Record<string, MarketData> = {};
|
||||
|
||||
try {
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < stocks.length; i += batchSize) {
|
||||
const batch = stocks.slice(i, i + batchSize);
|
||||
const promises = batch.map(async (stock) => {
|
||||
if (!stock.stock_code) return null;
|
||||
const seccode = stock.stock_code.substring(0, 6);
|
||||
try {
|
||||
const response = await marketService.getTradeData(seccode, 1);
|
||||
if (response.success && response.data?.length > 0) {
|
||||
const latestData = response.data[response.data.length - 1];
|
||||
return { stock_code: stock.stock_code, ...latestData };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('ConceptStocksModal', '获取股票行情失败', { stockCode: seccode });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
results.forEach((result) => {
|
||||
if (result) newMarketData[result.stock_code] = result;
|
||||
});
|
||||
}
|
||||
setStockMarketData(newMarketData);
|
||||
} catch (error) {
|
||||
logger.error('ConceptStocksModal', 'fetchStockMarketData', error);
|
||||
} finally {
|
||||
setLoadingStockData(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 弹窗打开时加载数据
|
||||
React.useEffect(() => {
|
||||
if (isOpen && concept?.stocks) {
|
||||
setStockMarketData({});
|
||||
fetchStockMarketData(concept.stocks);
|
||||
}
|
||||
}, [isOpen, concept, fetchStockMarketData]);
|
||||
|
||||
// 点击股票行
|
||||
const handleStockClick = (stockCode: string) => {
|
||||
navigate(`/company?scode=${stockCode}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const stocks = concept?.stocks || [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={modalSize}
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent bg={cardBg} maxH={isMobile ? '70vh' : undefined}>
|
||||
<ModalHeader bg="purple.500" color="white" borderTopRadius="md">
|
||||
<HStack>
|
||||
<Icon as={FaTable} />
|
||||
<Text>{concept?.concept_name} - 相关个股</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="white" />
|
||||
|
||||
<ModalBody py={6}>
|
||||
{stocks.length === 0 ? (
|
||||
<Text color="gray.500" textAlign="center">暂无相关股票数据</Text>
|
||||
) : (
|
||||
<Box>
|
||||
{loadingStockData && (
|
||||
<HStack justify="center" mb={4}>
|
||||
<Spinner size="sm" color="purple.500" />
|
||||
<Text fontSize="sm" color="gray.500">正在获取行情数据...</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<TableContainer maxH={tableMaxH} overflowY="auto" overflowX="auto">
|
||||
<Table variant="simple" size="sm" minW={isMobile ? '600px' : undefined}>
|
||||
<Thead position="sticky" top={0} bg={cardBg} zIndex={1}>
|
||||
<Tr>
|
||||
<Th whiteSpace="nowrap">股票名称</Th>
|
||||
<Th whiteSpace="nowrap">股票代码</Th>
|
||||
<Th isNumeric whiteSpace="nowrap">现价</Th>
|
||||
<Th isNumeric whiteSpace="nowrap">当日涨跌幅</Th>
|
||||
<Th whiteSpace="nowrap" minW="200px">板块原因</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{stocks.map((stock, idx) => {
|
||||
const marketData = stockMarketData[stock.stock_code];
|
||||
const changePercent = marketData?.change_percent;
|
||||
|
||||
return (
|
||||
<Tr
|
||||
key={idx}
|
||||
_hover={{ bg: hoverBg }}
|
||||
cursor="pointer"
|
||||
onClick={() => handleStockClick(stock.stock_code)}
|
||||
>
|
||||
<Td color="blue.500" fontWeight="medium">
|
||||
{stock.stock_name}
|
||||
</Td>
|
||||
<Td>{stock.stock_code}</Td>
|
||||
<Td isNumeric>
|
||||
{loadingStockData ? (
|
||||
<Spinner size="xs" />
|
||||
) : marketData?.close ? (
|
||||
`¥${marketData.close.toFixed(2)}`
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Td>
|
||||
<Td
|
||||
isNumeric
|
||||
fontWeight="bold"
|
||||
color={
|
||||
changePercent && changePercent > 0
|
||||
? 'red.500'
|
||||
: changePercent && changePercent < 0
|
||||
? 'green.500'
|
||||
: 'gray.500'
|
||||
}
|
||||
>
|
||||
{loadingStockData ? (
|
||||
<Spinner size="xs" />
|
||||
) : changePercent !== undefined ? (
|
||||
`${changePercent > 0 ? '+' : ''}${changePercent.toFixed(2)}%`
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Td>
|
||||
<Td fontSize="xs" color="gray.600" maxW="300px">
|
||||
<Text noOfLines={2}>{stock.reason || '-'}</Text>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptStocksModal;
|
||||
138
src/components/ErrorPage/index.tsx
Normal file
138
src/components/ErrorPage/index.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* ErrorPage - 通用错误页面组件
|
||||
* 用于显示加载失败、网络错误等异常状态
|
||||
* 设计风格:黑色背景 + 金色边框
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
Circle,
|
||||
Text,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Icon,
|
||||
} from '@chakra-ui/react';
|
||||
import { WarningIcon } from '@chakra-ui/icons';
|
||||
|
||||
// 主题色
|
||||
const GOLD_COLOR = '#D4A574';
|
||||
const BG_COLOR = '#1A202C'; // 与页面背景一致
|
||||
|
||||
interface ErrorPageProps {
|
||||
/** 错误标题,默认"加载失败" */
|
||||
title?: string;
|
||||
/** 错误描述信息 */
|
||||
description?: string;
|
||||
/** 详细信息(如事件ID、订单号等) */
|
||||
detail?: string;
|
||||
/** 详细信息标签,默认"ID" */
|
||||
detailLabel?: string;
|
||||
/** 是否显示重试按钮 */
|
||||
showRetry?: boolean;
|
||||
/** 重试回调函数 */
|
||||
onRetry?: () => void;
|
||||
/** 是否显示返回按钮 */
|
||||
showBack?: boolean;
|
||||
/** 返回回调函数 */
|
||||
onBack?: () => void;
|
||||
/** 是否全屏显示,默认 true */
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
const ErrorPage: React.FC<ErrorPageProps> = ({
|
||||
title = '加载失败',
|
||||
description,
|
||||
detail,
|
||||
detailLabel = 'ID',
|
||||
showRetry = false,
|
||||
onRetry,
|
||||
showBack = false,
|
||||
onBack,
|
||||
fullScreen = true,
|
||||
}) => {
|
||||
const hasButtons = (showRetry && onRetry) || (showBack && onBack);
|
||||
|
||||
return (
|
||||
<Box
|
||||
h={fullScreen ? '100vh' : '60vh'}
|
||||
w="100%"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Box
|
||||
bg={BG_COLOR}
|
||||
border="1px solid"
|
||||
borderColor={GOLD_COLOR}
|
||||
borderRadius="lg"
|
||||
p={8}
|
||||
maxW="400px"
|
||||
w="90%"
|
||||
textAlign="center"
|
||||
>
|
||||
{/* 金色圆形图标 + 黑色感叹号 */}
|
||||
<Circle size="50px" bg={GOLD_COLOR} mx="auto" mb={4}>
|
||||
<Icon as={WarningIcon} color={BG_COLOR} boxSize={5} />
|
||||
</Circle>
|
||||
|
||||
{/* 金色标题 */}
|
||||
<Text color={GOLD_COLOR} fontSize="lg" fontWeight="medium" mb={2}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
{/* 描述信息 */}
|
||||
{description && (
|
||||
<Text color="gray.400" fontSize="sm" mb={2}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 详情 */}
|
||||
{detail && (
|
||||
<Text color="gray.500" fontSize="sm" mb={4}>
|
||||
{detailLabel}: {detail}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 按钮组 */}
|
||||
{hasButtons && (
|
||||
<HStack justify="center" spacing={3} mt={4}>
|
||||
{showBack && onBack && (
|
||||
<Button
|
||||
variant="outline"
|
||||
borderColor={GOLD_COLOR}
|
||||
color={GOLD_COLOR}
|
||||
size="sm"
|
||||
px={6}
|
||||
_hover={{ bg: GOLD_COLOR, color: 'black' }}
|
||||
onClick={onBack}
|
||||
>
|
||||
返回
|
||||
</Button>
|
||||
)}
|
||||
{showRetry && onRetry && (
|
||||
<Button
|
||||
bg={GOLD_COLOR}
|
||||
color={BG_COLOR}
|
||||
borderColor={GOLD_COLOR}
|
||||
border="1px solid"
|
||||
size="sm"
|
||||
px={6}
|
||||
fontWeight="medium"
|
||||
_hover={{ bg: '#C49A6C' }}
|
||||
onClick={onRetry}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
@@ -161,7 +161,7 @@ export default function HomeNavbar() {
|
||||
borderColor={navbarBorder}
|
||||
py={{ base: 2, md: 3 }}
|
||||
>
|
||||
<Container maxW="container.xl" px={{ base: 3, md: 4 }}>
|
||||
<Container maxW="container.xl" px={{ base: 3, md: 4 }} style={{ paddingRight: 'max(16px, env(safe-area-inset-right))' }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* Logo - 价小前投研 */}
|
||||
<BrandLogo />
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { FiCalendar } from 'react-icons/fi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useFollowingEvents } from '../../../../hooks/useFollowingEvents';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
|
||||
/**
|
||||
* 关注事件下拉菜单组件
|
||||
@@ -86,7 +87,7 @@ const FollowingEventsMenu = memo(() => {
|
||||
<MenuItem
|
||||
key={ev.id}
|
||||
_hover={{ bg: 'gray.50' }}
|
||||
onClick={() => navigate(`/event-detail/${ev.id}`)}
|
||||
onClick={() => navigate(getEventDetailUrl(ev.id))}
|
||||
>
|
||||
<HStack justify="space-between" w="100%">
|
||||
<Box flex={1} minW={0}>
|
||||
|
||||
@@ -61,8 +61,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
|
||||
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
border={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
||||
borderColor={isActive(['/community', '/concepts']) ? 'blue.300' : 'transparent'}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||
@@ -128,8 +128,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
|
||||
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
border={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
|
||||
borderColor={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.300' : 'transparent'}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||
@@ -204,8 +204,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
|
||||
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
|
||||
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
|
||||
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
|
||||
borderColor="white"
|
||||
border={isActive(['/agent-chat', '/value-forum']) ? '2px solid' : 'none'}
|
||||
borderColor={isActive(['/agent-chat', '/value-forum']) ? 'blue.300' : 'transparent'}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-
|
||||
import { FaCrown } from 'react-icons/fa';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import UserAvatar from './UserAvatar';
|
||||
import SubscriptionModal from '../../../Subscription/SubscriptionModal';
|
||||
import { useSubscription } from '../../../../hooks/useSubscription';
|
||||
|
||||
/**
|
||||
@@ -38,12 +37,7 @@ const TabletUserMenu = memo(({
|
||||
followingEvents
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
subscriptionInfo,
|
||||
isSubscriptionModalOpen,
|
||||
openSubscriptionModal,
|
||||
closeSubscriptionModal
|
||||
} = useSubscription();
|
||||
const { subscriptionInfo } = useSubscription();
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
@@ -90,8 +84,8 @@ const TabletUserMenu = memo(({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 订阅管理 */}
|
||||
<MenuItem icon={<FaCrown />} onClick={openSubscriptionModal}>
|
||||
{/* 订阅管理 - 移动端导航到订阅页面 */}
|
||||
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text>订阅管理</Text>
|
||||
<Badge colorScheme={getSubscriptionBadgeColor()}>
|
||||
@@ -149,14 +143,6 @@ const TabletUserMenu = memo(({
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
{/* 订阅弹窗 */}
|
||||
{isSubscriptionModalOpen && (
|
||||
<SubscriptionModal
|
||||
isOpen={isSubscriptionModalOpen}
|
||||
onClose={closeSubscriptionModal}
|
||||
subscriptionInfo={subscriptionInfo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,8 +1,11 @@
|
||||
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import * as echarts from 'echarts';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '@services/eventService';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
|
||||
/**
|
||||
* 股票信息
|
||||
@@ -40,6 +43,31 @@ interface KLineDataPoint {
|
||||
volume: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量K线API响应
|
||||
*/
|
||||
interface BatchKlineResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
[stockCode: string]: {
|
||||
code: string;
|
||||
name: string;
|
||||
data: KLineDataPoint[];
|
||||
trade_date: string;
|
||||
type: string;
|
||||
earliest_date?: string;
|
||||
};
|
||||
};
|
||||
has_more: boolean;
|
||||
query_start_date?: string;
|
||||
query_end_date?: string;
|
||||
}
|
||||
|
||||
// 每次加载的天数
|
||||
const DAYS_PER_LOAD = 60;
|
||||
// 最大加载天数(一年)
|
||||
const MAX_DAYS = 365;
|
||||
|
||||
const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -50,8 +78,15 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
const chartRef = useRef<HTMLDivElement>(null);
|
||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<KLineDataPoint[]>([]);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [earliestDate, setEarliestDate] = useState<string | null>(null);
|
||||
const [totalDaysLoaded, setTotalDaysLoaded] = useState(0);
|
||||
|
||||
// H5 响应式适配
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
// 调试日志
|
||||
console.log('[KLineChartModal] 渲染状态:', {
|
||||
@@ -60,38 +95,102 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
eventTime,
|
||||
dataLength: data.length,
|
||||
loading,
|
||||
error
|
||||
loadingMore,
|
||||
hasMore,
|
||||
earliestDate
|
||||
});
|
||||
|
||||
// 加载K线数据
|
||||
const loadData = async () => {
|
||||
// 加载更多历史数据
|
||||
const loadMoreData = useCallback(async () => {
|
||||
if (!stock?.stock_code || !hasMore || loadingMore || !earliestDate) return;
|
||||
|
||||
console.log('[KLineChartModal] 加载更多历史数据, earliestDate:', earliestDate);
|
||||
setLoadingMore(true);
|
||||
|
||||
try {
|
||||
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
|
||||
// 请求更早的数据,end_date 设置为当前最早日期的前一天
|
||||
const endDate = dayjs(earliestDate).subtract(1, 'day').format('YYYY-MM-DD');
|
||||
|
||||
const response = await stockService.getBatchKlineData(
|
||||
[stock.stock_code],
|
||||
'daily',
|
||||
stableEventTime,
|
||||
{ days_before: DAYS_PER_LOAD, end_date: endDate }
|
||||
) as BatchKlineResponse;
|
||||
|
||||
if (response?.success && response.data) {
|
||||
const stockData = response.data[stock.stock_code];
|
||||
const newData = stockData?.data || [];
|
||||
|
||||
if (newData.length > 0) {
|
||||
// 将新数据添加到现有数据的前面
|
||||
setData(prevData => [...newData, ...prevData]);
|
||||
setEarliestDate(newData[0].time);
|
||||
setTotalDaysLoaded(prev => prev + DAYS_PER_LOAD);
|
||||
console.log('[KLineChartModal] 加载了更多数据:', newData.length, '条');
|
||||
}
|
||||
|
||||
// 检查是否还有更多数据
|
||||
const noMoreData = !response.has_more || totalDaysLoaded + DAYS_PER_LOAD >= MAX_DAYS || newData.length === 0;
|
||||
setHasMore(!noMoreData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[KLineChartModal] 加载更多数据失败:', err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [stock?.stock_code, hasMore, loadingMore, earliestDate, eventTime, totalDaysLoaded]);
|
||||
|
||||
// 初始加载K线数据
|
||||
const loadData = useCallback(async () => {
|
||||
if (!stock?.stock_code) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setData([]);
|
||||
setHasMore(true);
|
||||
setEarliestDate(null);
|
||||
setTotalDaysLoaded(0);
|
||||
|
||||
try {
|
||||
const response = await stockService.getKlineData(
|
||||
stock.stock_code,
|
||||
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
|
||||
// 使用新的带分页参数的接口
|
||||
const response = await stockService.getBatchKlineData(
|
||||
[stock.stock_code],
|
||||
'daily',
|
||||
eventTime || undefined
|
||||
);
|
||||
stableEventTime,
|
||||
{ days_before: DAYS_PER_LOAD, end_date: '' }
|
||||
) as BatchKlineResponse;
|
||||
|
||||
console.log('[KLineChartModal] API响应:', response);
|
||||
if (response?.success && response.data) {
|
||||
const stockData = response.data[stock.stock_code];
|
||||
const klineData = stockData?.data || [];
|
||||
|
||||
if (!response || !response.data || response.data.length === 0) {
|
||||
throw new Error('暂无K线数据');
|
||||
if (klineData.length === 0) {
|
||||
throw new Error('暂无K线数据');
|
||||
}
|
||||
|
||||
console.log('[KLineChartModal] 初始数据条数:', klineData.length);
|
||||
setData(klineData);
|
||||
setEarliestDate(klineData[0]?.time || null);
|
||||
setTotalDaysLoaded(DAYS_PER_LOAD);
|
||||
setHasMore(response.has_more !== false);
|
||||
} else {
|
||||
throw new Error('数据加载失败');
|
||||
}
|
||||
|
||||
console.log('[KLineChartModal] 数据条数:', response.data.length);
|
||||
setData(response.data);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
||||
setError(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [stock?.stock_code, eventTime]);
|
||||
|
||||
// 用于防抖的 ref
|
||||
const loadMoreDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 初始化图表
|
||||
useEffect(() => {
|
||||
@@ -124,6 +223,9 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
if (loadMoreDebounceRef.current) {
|
||||
clearTimeout(loadMoreDebounceRef.current);
|
||||
}
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.dispose();
|
||||
chartInstance.current = null;
|
||||
@@ -131,6 +233,35 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// 监听 dataZoom 事件,当滑到左边界时加载更多数据
|
||||
useEffect(() => {
|
||||
if (!chartInstance.current || !hasMore || loadingMore) return;
|
||||
|
||||
const handleDataZoom = (params: any) => {
|
||||
// 获取当前 dataZoom 的 start 值
|
||||
const start = params.start ?? params.batch?.[0]?.start ?? 0;
|
||||
|
||||
// 当 start 接近 0(左边界)时,触发加载更多
|
||||
if (start <= 5 && hasMore && !loadingMore) {
|
||||
console.log('[KLineChartModal] 检测到滑动到左边界,准备加载更多数据');
|
||||
|
||||
// 防抖处理
|
||||
if (loadMoreDebounceRef.current) {
|
||||
clearTimeout(loadMoreDebounceRef.current);
|
||||
}
|
||||
loadMoreDebounceRef.current = setTimeout(() => {
|
||||
loadMoreData();
|
||||
}, 300);
|
||||
}
|
||||
};
|
||||
|
||||
chartInstance.current.on('datazoom', handleDataZoom);
|
||||
|
||||
return () => {
|
||||
chartInstance.current?.off('datazoom', handleDataZoom);
|
||||
};
|
||||
}, [hasMore, loadingMore, loadMoreData]);
|
||||
|
||||
// 更新图表数据
|
||||
useEffect(() => {
|
||||
if (data.length === 0) {
|
||||
@@ -170,16 +301,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 图表配置
|
||||
// 图表配置(H5 响应式)
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: {
|
||||
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
|
||||
left: 'center',
|
||||
top: 10,
|
||||
top: isMobile ? 5 : 10,
|
||||
textStyle: {
|
||||
color: '#e0e0e0',
|
||||
fontSize: 18,
|
||||
fontSize: isMobile ? 14 : 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
@@ -244,16 +375,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '12%',
|
||||
height: '60%',
|
||||
left: isMobile ? '12%' : '5%',
|
||||
right: isMobile ? '5%' : '5%',
|
||||
top: isMobile ? '12%' : '12%',
|
||||
height: isMobile ? '55%' : '60%',
|
||||
},
|
||||
{
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '77%',
|
||||
height: '18%',
|
||||
left: isMobile ? '12%' : '5%',
|
||||
right: isMobile ? '5%' : '5%',
|
||||
top: isMobile ? '72%' : '77%',
|
||||
height: isMobile ? '20%' : '18%',
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
@@ -268,7 +399,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
interval: Math.floor(dates.length / 8),
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
|
||||
},
|
||||
splitLine: {
|
||||
show: false,
|
||||
@@ -285,7 +417,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
interval: Math.floor(dates.length / 8),
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -293,6 +426,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 0,
|
||||
splitNumber: isMobile ? 4 : 5,
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
@@ -306,12 +440,14 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
formatter: (value: number) => value.toFixed(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 1,
|
||||
splitNumber: isMobile ? 2 : 3,
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
@@ -322,6 +458,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
formatter: (value: number) => {
|
||||
if (value >= 100000000) {
|
||||
return (value / 100000000).toFixed(1) + '亿';
|
||||
@@ -419,7 +556,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
|
||||
return () => clearTimeout(retryTimer);
|
||||
}
|
||||
}, [data, stock]);
|
||||
}, [data, stock, isMobile]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
@@ -474,13 +611,13 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '90vw',
|
||||
maxWidth: '1400px',
|
||||
maxHeight: '85vh',
|
||||
width: isMobile ? '96vw' : '90vw',
|
||||
maxWidth: isMobile ? 'none' : '1400px',
|
||||
maxHeight: isMobile ? '85vh' : '85vh',
|
||||
backgroundColor: '#1a1a1a',
|
||||
border: '2px solid #ffd700',
|
||||
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
|
||||
borderRadius: '8px',
|
||||
borderRadius: isMobile ? '12px' : '8px',
|
||||
zIndex: 10002,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -490,7 +627,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
padding: isMobile ? '12px 16px' : '16px 24px',
|
||||
borderBottom: '1px solid #404040',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
@@ -498,20 +635,35 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '12px', flexWrap: isMobile ? 'wrap' : 'nowrap' }}>
|
||||
<span style={{ fontSize: isMobile ? '14px' : '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
|
||||
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
||||
</span>
|
||||
{data.length > 0 && (
|
||||
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
|
||||
共{data.length}个交易日(最多1年)
|
||||
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666', fontStyle: 'italic' }}>
|
||||
共{data.length}个交易日
|
||||
{hasMore ? '(向左滑动加载更多)' : '(已加载全部)'}
|
||||
</span>
|
||||
)}
|
||||
{loadingMore && (
|
||||
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#3182ce', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
border: '2px solid #404040',
|
||||
borderTop: '2px solid #3182ce',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
display: 'inline-block'
|
||||
}} />
|
||||
加载更多...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
|
||||
<span style={{ fontSize: '14px', color: '#999' }}>日K线图</span>
|
||||
<span style={{ fontSize: '12px', color: '#666' }}>
|
||||
💡 鼠标滚轮缩放 | 拖动查看不同时间段
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '16px', marginTop: '4px' }}>
|
||||
<span style={{ fontSize: isMobile ? '12px' : '14px', color: '#999' }}>日K线图</span>
|
||||
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666' }}>
|
||||
💡 {isMobile ? '滚轮缩放 | 拖动查看' : '鼠标滚轮缩放 | 拖动查看不同时间段'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -534,26 +686,33 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
|
||||
<div style={{
|
||||
padding: isMobile ? '8px' : '16px',
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#2a1a1a',
|
||||
border: '1px solid #ef5350',
|
||||
borderRadius: '4px',
|
||||
padding: '12px 16px',
|
||||
marginBottom: '16px',
|
||||
padding: isMobile ? '8px 12px' : '12px 16px',
|
||||
marginBottom: isMobile ? '8px' : '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: '#ef5350' }}>⚠</span>
|
||||
<span style={{ color: '#e0e0e0' }}>{error}</span>
|
||||
<span style={{ color: '#e0e0e0', fontSize: isMobile ? '12px' : '14px' }}>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ position: 'relative', height: '680px', width: '100%' }}>
|
||||
<div style={{ position: 'relative', height: isMobile ? '450px' : '680px', width: '100%' }}>
|
||||
{loading && (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -17,7 +18,9 @@ import {
|
||||
AlertIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { stockService } from '@services/eventService';
|
||||
import dayjs from 'dayjs';
|
||||
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
|
||||
/**
|
||||
* 股票信息
|
||||
@@ -67,7 +70,10 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [data, setData] = useState<TimelineDataPoint[]>([]);
|
||||
|
||||
// 加载分时图数据
|
||||
// H5 响应式适配
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
// 加载分时图数据(优先使用缓存)
|
||||
const loadData = async () => {
|
||||
if (!stock?.stock_code) return;
|
||||
|
||||
@@ -75,20 +81,30 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await stockService.getKlineData(
|
||||
stock.stock_code,
|
||||
'timeline',
|
||||
eventTime || undefined
|
||||
);
|
||||
// 标准化事件时间
|
||||
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
|
||||
console.log('[TimelineChartModal] API响应:', response);
|
||||
// 先检查缓存
|
||||
const cacheKey = getCacheKey(stock.stock_code, stableEventTime, 'timeline');
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
|
||||
if (!response || !response.data || response.data.length === 0) {
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
console.log('[TimelineChartModal] 使用缓存数据, 数据条数:', cachedData.length);
|
||||
setData(cachedData);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 缓存没有则请求(会自动存入缓存)
|
||||
console.log('[TimelineChartModal] 缓存未命中,发起请求');
|
||||
const result = await fetchKlineData(stock.stock_code, stableEventTime, 'timeline');
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
throw new Error('暂无分时数据');
|
||||
}
|
||||
|
||||
console.log('[TimelineChartModal] 数据条数:', response.data.length);
|
||||
setData(response.data);
|
||||
console.log('[TimelineChartModal] 数据条数:', result.length);
|
||||
setData(result);
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
||||
setError(errorMsg);
|
||||
@@ -176,16 +192,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 图表配置
|
||||
// 图表配置(H5 响应式)
|
||||
const option: echarts.EChartsOption = {
|
||||
backgroundColor: '#1a1a1a',
|
||||
title: {
|
||||
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
|
||||
left: 'center',
|
||||
top: 10,
|
||||
top: isMobile ? 5 : 10,
|
||||
textStyle: {
|
||||
color: '#e0e0e0',
|
||||
fontSize: 18,
|
||||
fontSize: isMobile ? 14 : 18,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
@@ -236,16 +252,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
},
|
||||
grid: [
|
||||
{
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '15%',
|
||||
height: '55%',
|
||||
left: isMobile ? '12%' : '5%',
|
||||
right: isMobile ? '5%' : '5%',
|
||||
top: isMobile ? '12%' : '15%',
|
||||
height: isMobile ? '58%' : '55%',
|
||||
},
|
||||
{
|
||||
left: '5%',
|
||||
right: '5%',
|
||||
top: '75%',
|
||||
height: '15%',
|
||||
left: isMobile ? '12%' : '5%',
|
||||
right: isMobile ? '5%' : '5%',
|
||||
top: isMobile ? '75%' : '75%',
|
||||
height: isMobile ? '18%' : '15%',
|
||||
},
|
||||
],
|
||||
xAxis: [
|
||||
@@ -260,7 +276,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
interval: Math.floor(times.length / 6),
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
|
||||
},
|
||||
splitLine: {
|
||||
show: true,
|
||||
@@ -280,7 +297,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
interval: Math.floor(times.length / 6),
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -288,6 +306,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 0,
|
||||
splitNumber: isMobile ? 4 : 5,
|
||||
splitLine: {
|
||||
show: true,
|
||||
lineStyle: {
|
||||
@@ -301,12 +320,14 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
formatter: (value: number) => value.toFixed(2),
|
||||
},
|
||||
},
|
||||
{
|
||||
scale: true,
|
||||
gridIndex: 1,
|
||||
splitNumber: isMobile ? 2 : 3,
|
||||
splitLine: {
|
||||
show: false,
|
||||
},
|
||||
@@ -317,6 +338,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
},
|
||||
axisLabel: {
|
||||
color: '#999',
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
formatter: (value: number) => {
|
||||
if (value >= 10000) {
|
||||
return (value / 10000).toFixed(1) + '万';
|
||||
@@ -432,7 +454,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
|
||||
return () => clearTimeout(retryTimer);
|
||||
}
|
||||
}, [data, stock]);
|
||||
}, [data, stock, isMobile]);
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
@@ -444,29 +466,30 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
if (!stock) return null;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size={size}>
|
||||
<Modal isOpen={isOpen} onClose={onClose} size={size} isCentered>
|
||||
<ModalOverlay bg="blackAlpha.700" />
|
||||
<ModalContent
|
||||
maxW="90vw"
|
||||
maxW={isMobile ? '96vw' : '90vw'}
|
||||
maxH="85vh"
|
||||
borderRadius={isMobile ? '12px' : '8px'}
|
||||
bg="#1a1a1a"
|
||||
borderColor="#404040"
|
||||
borderWidth="1px"
|
||||
border="2px solid #ffd700"
|
||||
boxShadow="0 0 30px rgba(255, 215, 0, 0.5)"
|
||||
>
|
||||
<ModalHeader pb={3} borderBottomWidth="1px" borderColor="#404040">
|
||||
<VStack align="flex-start" spacing={1}>
|
||||
<ModalHeader pb={isMobile ? 2 : 3} borderBottomWidth="1px" borderColor="#404040">
|
||||
<VStack align="flex-start" spacing={0}>
|
||||
<HStack>
|
||||
<Text fontSize="lg" fontWeight="bold" color="#e0e0e0">
|
||||
<Text fontSize={isMobile ? 'md' : 'lg'} fontWeight="bold" color="#e0e0e0">
|
||||
{stock.stock_name || stock.stock_code} ({stock.stock_code})
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm" color="#999">
|
||||
<Text fontSize={isMobile ? 'xs' : 'sm'} color="#999">
|
||||
分时走势图
|
||||
</Text>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
|
||||
<ModalBody p={4}>
|
||||
<ModalBody p={isMobile ? 2 : 4}>
|
||||
{error && (
|
||||
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
|
||||
<AlertIcon color="#ef5350" />
|
||||
@@ -474,7 +497,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box position="relative" h="600px" w="100%">
|
||||
<Box position="relative" h={isMobile ? '400px' : '600px'} w="100%">
|
||||
{loading && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
|
||||
@@ -1049,10 +1049,26 @@ export default function SubscriptionContent() {
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
|
||||
<Text fontSize="xs" color={secondaryText} pl={11} flex={1}>
|
||||
{plan.description}
|
||||
</Text>
|
||||
<Flex justify="space-between" align="flex-start" flexWrap="wrap" gap={2}>
|
||||
<VStack align="start" spacing={0.5} pl={11} flex={1}>
|
||||
{plan.description && plan.description.includes('|') ? (
|
||||
plan.description.split('|').map((item, idx) => (
|
||||
<Text
|
||||
key={idx}
|
||||
fontSize="sm"
|
||||
color={plan.name === 'max' ? 'purple.600' : 'blue.600'}
|
||||
lineHeight="1.5"
|
||||
fontWeight="medium"
|
||||
>
|
||||
✓ {item.trim()}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="xs" color={secondaryText}>
|
||||
{plan.description}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
{(() => {
|
||||
// 获取当前选中的周期信息
|
||||
if (plan.pricing_options) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Input,
|
||||
Icon,
|
||||
Container,
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaWeixin,
|
||||
@@ -42,6 +43,87 @@ import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents';
|
||||
import { subscriptionConfig, themeColors } from '../../views/Pages/Account/subscription-content';
|
||||
|
||||
// 计费周期选择器组件 - 移动端垂直布局(年付在上),桌面端水平布局
|
||||
interface CycleSelectorProps {
|
||||
options: any[];
|
||||
selectedCycle: string;
|
||||
onSelectCycle: (cycle: string) => void;
|
||||
}
|
||||
|
||||
function CycleSelector({ options, selectedCycle, onSelectCycle }: CycleSelectorProps) {
|
||||
// 使用 useBreakpointValue 动态获取是否是移动端
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 移动端倒序显示(年付在上),桌面端正常顺序
|
||||
const displayOptions = isMobile ? [...options].reverse() : options;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
gap={3}
|
||||
p={2}
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
backdropFilter="blur(10px)"
|
||||
justify="center"
|
||||
align="center"
|
||||
w={{ base: 'full', md: 'auto' }}
|
||||
maxW={{ base: '320px', md: 'none' }}
|
||||
mx="auto"
|
||||
>
|
||||
{displayOptions.map((option: any) => (
|
||||
<Box key={option.cycleKey} position="relative" w={{ base: 'full', md: 'auto' }}>
|
||||
{option.discountPercent > 0 && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={{ base: '50%', md: '-10px' }}
|
||||
right={{ base: '10px', md: '-10px' }}
|
||||
transform={{ base: 'translateY(-50%)', md: 'none' }}
|
||||
colorScheme="red"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
zIndex={1}
|
||||
>
|
||||
省{option.discountPercent}%
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
w={{ base: 'full', md: 'auto' }}
|
||||
px={6}
|
||||
py={6}
|
||||
borderRadius="lg"
|
||||
bg={selectedCycle === option.cycleKey ? 'linear-gradient(135deg, #D4AF37, #B8941F)' : 'transparent'}
|
||||
color={selectedCycle === option.cycleKey ? '#000' : '#fff'}
|
||||
border="1px solid"
|
||||
borderColor={selectedCycle === option.cycleKey ? 'rgba(212, 175, 55, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
|
||||
onClick={() => onSelectCycle(option.cycleKey)}
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.5)',
|
||||
shadow: selectedCycle === option.cycleKey
|
||||
? '0 0 20px rgba(212, 175, 55, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
fontWeight="bold"
|
||||
justifyContent={{ base: 'flex-start', md: 'center' }}
|
||||
pl={{ base: 6, md: 6 }}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubscriptionContentNew() {
|
||||
const { user } = useAuth();
|
||||
const subscriptionEvents = useSubscriptionEvents({
|
||||
@@ -751,61 +833,11 @@ export default function SubscriptionContentNew() {
|
||||
选择计费周期 · 时长越长优惠越大
|
||||
</Text>
|
||||
|
||||
<HStack
|
||||
spacing={3}
|
||||
p={2}
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
backdropFilter="blur(10px)"
|
||||
flexWrap="wrap"
|
||||
justify="center"
|
||||
>
|
||||
{getMergedPlans()[1]?.pricingOptions?.map((option: any, index: number) => (
|
||||
<Box key={index} position="relative">
|
||||
{option.discountPercent > 0 && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="-10px"
|
||||
right="-10px"
|
||||
colorScheme="red"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
zIndex={1}
|
||||
>
|
||||
省{option.discountPercent}%
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
px={6}
|
||||
py={6}
|
||||
borderRadius="lg"
|
||||
bg={selectedCycle === option.cycleKey ? 'linear-gradient(135deg, #D4AF37, #B8941F)' : 'transparent'}
|
||||
color={selectedCycle === option.cycleKey ? '#000' : '#fff'}
|
||||
border="1px solid"
|
||||
borderColor={selectedCycle === option.cycleKey ? 'rgba(212, 175, 55, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
|
||||
onClick={() => setSelectedCycle(option.cycleKey)}
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.5)',
|
||||
shadow: selectedCycle === option.cycleKey
|
||||
? '0 0 20px rgba(212, 175, 55, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
<CycleSelector
|
||||
options={getMergedPlans()[1]?.pricingOptions || []}
|
||||
selectedCycle={selectedCycle}
|
||||
onSelectCycle={setSelectedCycle}
|
||||
/>
|
||||
|
||||
{(() => {
|
||||
const currentOption = getMergedPlans()[1]?.pricingOptions?.find(
|
||||
|
||||
135
src/components/TradeDatePicker/index.tsx
Normal file
135
src/components/TradeDatePicker/index.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Input,
|
||||
Text,
|
||||
Icon,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { InfoIcon } from '@chakra-ui/icons';
|
||||
import { FaCalendarAlt } from 'react-icons/fa';
|
||||
|
||||
export interface TradeDatePickerProps {
|
||||
/** 当前选中的日期 */
|
||||
value: Date | null;
|
||||
/** 日期变化回调 */
|
||||
onChange: (date: Date) => void;
|
||||
/** 默认日期(组件初始化时使用) */
|
||||
defaultDate?: Date;
|
||||
/** 最新交易日期(用于显示提示) */
|
||||
latestTradeDate?: Date | null;
|
||||
/** 最小可选日期 */
|
||||
minDate?: Date;
|
||||
/** 最大可选日期,默认今天 */
|
||||
maxDate?: Date;
|
||||
/** 标签文字,默认"交易日期" */
|
||||
label?: string;
|
||||
/** 输入框宽度 */
|
||||
inputWidth?: string | object;
|
||||
/** 是否显示标签图标 */
|
||||
showIcon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 交易日期选择器组件
|
||||
*
|
||||
* 提供日期输入框和最新交易日期提示,供概念中心、个股中心等页面复用。
|
||||
* 快捷按钮(今天、昨天等)由各页面自行实现。
|
||||
*/
|
||||
const TradeDatePicker: React.FC<TradeDatePickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
defaultDate,
|
||||
latestTradeDate,
|
||||
minDate,
|
||||
maxDate,
|
||||
label = '交易日期',
|
||||
inputWidth = { base: '100%', lg: '200px' },
|
||||
showIcon = true,
|
||||
}) => {
|
||||
// 颜色主题
|
||||
const labelColor = useColorModeValue('purple.700', 'purple.300');
|
||||
const iconColor = useColorModeValue('purple.500', 'purple.400');
|
||||
const inputBorderColor = useColorModeValue('purple.200', 'purple.600');
|
||||
const tipBg = useColorModeValue('blue.50', 'blue.900');
|
||||
const tipBorderColor = useColorModeValue('blue.200', 'blue.600');
|
||||
const tipTextColor = useColorModeValue('blue.600', 'blue.200');
|
||||
const tipIconColor = useColorModeValue('blue.500', 'blue.300');
|
||||
|
||||
// 使用默认日期初始化(仅在 value 为 null 且有 defaultDate 时)
|
||||
React.useEffect(() => {
|
||||
if (value === null && defaultDate) {
|
||||
onChange(defaultDate);
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 处理日期变化
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const dateStr = e.target.value;
|
||||
if (dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
onChange(date);
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化日期为 YYYY-MM-DD
|
||||
const formatDateValue = (date: Date | null): string => {
|
||||
if (!date) return '';
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// 计算日期范围
|
||||
const minDateStr = minDate ? formatDateValue(minDate) : undefined;
|
||||
const maxDateStr = maxDate
|
||||
? formatDateValue(maxDate)
|
||||
: new Date().toISOString().split('T')[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 标签 */}
|
||||
<HStack spacing={3}>
|
||||
{showIcon && <Icon as={FaCalendarAlt} color={iconColor} boxSize={5} />}
|
||||
<Text fontWeight="bold" color={labelColor}>
|
||||
{label}:
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 日期输入框 */}
|
||||
<Input
|
||||
type="date"
|
||||
value={formatDateValue(value)}
|
||||
onChange={handleDateChange}
|
||||
min={minDateStr}
|
||||
max={maxDateStr}
|
||||
width={inputWidth}
|
||||
focusBorderColor="purple.500"
|
||||
borderColor={inputBorderColor}
|
||||
borderRadius="lg"
|
||||
fontWeight="medium"
|
||||
/>
|
||||
|
||||
{/* 最新交易日期提示 */}
|
||||
{latestTradeDate && (
|
||||
<Tooltip label="数据库中最新的交易日期">
|
||||
<HStack
|
||||
spacing={2}
|
||||
bg={tipBg}
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
border="1px solid"
|
||||
borderColor={tipBorderColor}
|
||||
>
|
||||
<Icon as={InfoIcon} color={tipIconColor} boxSize={3} />
|
||||
<Text fontSize="sm" color={tipTextColor} fontWeight="medium">
|
||||
最新: {latestTradeDate.toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeDatePicker;
|
||||
@@ -19,6 +19,7 @@ import { notificationMetricsService } from '../services/notificationMetricsServi
|
||||
import { notificationHistoryService } from '../services/notificationHistoryService';
|
||||
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes';
|
||||
import { usePermissionGuide, GUIDE_TYPES } from '../hooks/usePermissionGuide';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
|
||||
// 连接状态枚举
|
||||
const CONNECTION_STATUS = {
|
||||
@@ -460,7 +461,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
timestamp: Date.now(),
|
||||
isAIGenerated: event.is_ai_generated || false,
|
||||
clickable: true,
|
||||
link: `/event-detail/${event.id}`,
|
||||
link: getEventDetailUrl(event.id),
|
||||
autoClose: autoClose,
|
||||
extra: {
|
||||
eventId: event.id,
|
||||
|
||||
1
src/data/tradingDays.json
Normal file
1
src/data/tradingDays.json
Normal file
File diff suppressed because one or more lines are too long
@@ -18,21 +18,21 @@ export const useHomeResponsive = (): ResponsiveConfig => {
|
||||
});
|
||||
|
||||
const headingSize = useBreakpointValue({
|
||||
base: 'xl',
|
||||
md: '3xl',
|
||||
lg: '4xl'
|
||||
base: 'lg',
|
||||
md: 'xl',
|
||||
lg: '2xl'
|
||||
});
|
||||
|
||||
const headingLetterSpacing = useBreakpointValue({
|
||||
base: '-1px',
|
||||
md: '-1.5px',
|
||||
lg: '-2px'
|
||||
base: '-0.5px',
|
||||
md: '-1px',
|
||||
lg: '-1.5px'
|
||||
});
|
||||
|
||||
const heroTextSize = useBreakpointValue({
|
||||
base: 'md',
|
||||
md: 'lg',
|
||||
lg: 'xl'
|
||||
base: 'xs',
|
||||
md: 'sm',
|
||||
lg: 'md'
|
||||
});
|
||||
|
||||
const containerPx = useBreakpointValue({
|
||||
|
||||
261
src/hooks/useIndexQuote.js
Normal file
261
src/hooks/useIndexQuote.js
Normal file
@@ -0,0 +1,261 @@
|
||||
// src/hooks/useIndexQuote.js
|
||||
// 指数实时行情 Hook - 交易时间内每分钟自动更新
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// 交易日数据会从后端获取,这里只做时间判断
|
||||
const TRADING_SESSIONS = [
|
||||
{ start: { hour: 9, minute: 30 }, end: { hour: 11, minute: 30 } },
|
||||
{ start: { hour: 13, minute: 0 }, end: { hour: 15, minute: 0 } },
|
||||
];
|
||||
|
||||
/**
|
||||
* 判断当前时间是否在交易时段内
|
||||
*/
|
||||
const isInTradingSession = () => {
|
||||
const now = new Date();
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
return TRADING_SESSIONS.some(session => {
|
||||
const startMinutes = session.start.hour * 60 + session.start.minute;
|
||||
const endMinutes = session.end.hour * 60 + session.end.minute;
|
||||
return currentMinutes >= startMinutes && currentMinutes <= endMinutes;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指数实时行情
|
||||
*/
|
||||
const fetchIndexRealtime = async (indexCode) => {
|
||||
try {
|
||||
const response = await fetch(`/api/index/${indexCode}/realtime`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('useIndexQuote', 'fetchIndexRealtime error', { indexCode, error: error.message });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 指数实时行情 Hook
|
||||
*
|
||||
* @param {string} indexCode - 指数代码,如 '000001' (上证指数) 或 '399001' (深证成指)
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {number} options.refreshInterval - 刷新间隔(毫秒),默认 60000(1分钟)
|
||||
* @param {boolean} options.autoRefresh - 是否自动刷新,默认 true
|
||||
*
|
||||
* @returns {Object} { quote, loading, error, isTrading, refresh }
|
||||
*/
|
||||
export const useIndexQuote = (indexCode, options = {}) => {
|
||||
const {
|
||||
refreshInterval = 60000, // 默认1分钟
|
||||
autoRefresh = true,
|
||||
} = options;
|
||||
|
||||
const [quote, setQuote] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isTrading, setIsTrading] = useState(false);
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 加载数据
|
||||
const loadQuote = useCallback(async () => {
|
||||
if (!indexCode) return;
|
||||
|
||||
try {
|
||||
const data = await fetchIndexRealtime(indexCode);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (data) {
|
||||
setQuote(data);
|
||||
setIsTrading(data.is_trading);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('无法获取行情数据');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
setError(err.message);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [indexCode]);
|
||||
|
||||
// 手动刷新
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadQuote();
|
||||
}, [loadQuote]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
loadQuote();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [loadQuote]);
|
||||
|
||||
// 自动刷新逻辑
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || !indexCode) return;
|
||||
|
||||
// 清除旧的定时器
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// 设置定时器,检查是否在交易时间内
|
||||
const checkAndRefresh = () => {
|
||||
const inSession = isInTradingSession();
|
||||
setIsTrading(inSession);
|
||||
|
||||
if (inSession) {
|
||||
loadQuote();
|
||||
}
|
||||
};
|
||||
|
||||
// 立即检查一次
|
||||
checkAndRefresh();
|
||||
|
||||
// 设置定时刷新
|
||||
intervalRef.current = setInterval(checkAndRefresh, refreshInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, indexCode, refreshInterval, loadQuote]);
|
||||
|
||||
return {
|
||||
quote,
|
||||
loading,
|
||||
error,
|
||||
isTrading,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量获取多个指数的实时行情
|
||||
*
|
||||
* @param {string[]} indexCodes - 指数代码数组
|
||||
* @param {Object} options - 配置选项
|
||||
*/
|
||||
export const useMultiIndexQuotes = (indexCodes = [], options = {}) => {
|
||||
const {
|
||||
refreshInterval = 60000,
|
||||
autoRefresh = true,
|
||||
} = options;
|
||||
|
||||
const [quotes, setQuotes] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isTrading, setIsTrading] = useState(false);
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 批量加载数据
|
||||
const loadQuotes = useCallback(async () => {
|
||||
if (!indexCodes || indexCodes.length === 0) return;
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
indexCodes.map(code => fetchIndexRealtime(code))
|
||||
);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const newQuotes = {};
|
||||
let hasTrading = false;
|
||||
|
||||
results.forEach((data, idx) => {
|
||||
if (data) {
|
||||
newQuotes[indexCodes[idx]] = data;
|
||||
if (data.is_trading) hasTrading = true;
|
||||
}
|
||||
});
|
||||
|
||||
setQuotes(newQuotes);
|
||||
setIsTrading(hasTrading);
|
||||
} catch (err) {
|
||||
logger.error('useMultiIndexQuotes', 'loadQuotes error', err);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [indexCodes]);
|
||||
|
||||
// 手动刷新
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadQuotes();
|
||||
}, [loadQuotes]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
loadQuotes();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [loadQuotes]);
|
||||
|
||||
// 自动刷新逻辑
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || indexCodes.length === 0) return;
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
const checkAndRefresh = () => {
|
||||
const inSession = isInTradingSession();
|
||||
setIsTrading(inSession);
|
||||
|
||||
if (inSession) {
|
||||
loadQuotes();
|
||||
}
|
||||
};
|
||||
|
||||
checkAndRefresh();
|
||||
intervalRef.current = setInterval(checkAndRefresh, refreshInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, indexCodes, refreshInterval, loadQuotes]);
|
||||
|
||||
return {
|
||||
quotes,
|
||||
loading,
|
||||
isTrading,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
export default useIndexQuote;
|
||||
@@ -10,7 +10,7 @@ const WATCHLIST_PAGE_SIZE = 10;
|
||||
|
||||
/**
|
||||
* 自选股管理 Hook
|
||||
* 提供自选股加载、分页、移除等功能
|
||||
* 提供自选股加载、分页、添加、移除等功能
|
||||
*
|
||||
* @returns {{
|
||||
* watchlistQuotes: Array,
|
||||
@@ -19,7 +19,9 @@ const WATCHLIST_PAGE_SIZE = 10;
|
||||
* setWatchlistPage: Function,
|
||||
* WATCHLIST_PAGE_SIZE: number,
|
||||
* loadWatchlistQuotes: Function,
|
||||
* handleRemoveFromWatchlist: Function
|
||||
* handleAddToWatchlist: Function,
|
||||
* handleRemoveFromWatchlist: Function,
|
||||
* isInWatchlist: Function
|
||||
* }}
|
||||
*/
|
||||
export const useWatchlist = () => {
|
||||
@@ -58,6 +60,32 @@ export const useWatchlist = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 添加到自选股
|
||||
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const resp = await fetch(base + '/api/account/watchlist', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok && data.success) {
|
||||
// 刷新自选股列表
|
||||
loadWatchlistQuotes();
|
||||
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
|
||||
return true;
|
||||
} else {
|
||||
toast({ title: '添加失败', status: 'error', duration: 2000 });
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 });
|
||||
return false;
|
||||
}
|
||||
}, [toast, loadWatchlistQuotes]);
|
||||
|
||||
// 从自选股移除
|
||||
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
||||
try {
|
||||
@@ -85,9 +113,20 @@ export const useWatchlist = () => {
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
|
||||
return false;
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// 判断股票是否在自选股中
|
||||
const isInWatchlist = useCallback((stockCode) => {
|
||||
const normalize6 = (code) => {
|
||||
const m = String(code || '').match(/(\d{6})/);
|
||||
return m ? m[1] : String(code || '');
|
||||
};
|
||||
const target = normalize6(stockCode);
|
||||
return watchlistQuotes.some(item => normalize6(item.stock_code) === target);
|
||||
}, [watchlistQuotes]);
|
||||
|
||||
return {
|
||||
watchlistQuotes,
|
||||
watchlistLoading,
|
||||
@@ -95,6 +134,8 @@ export const useWatchlist = () => {
|
||||
setWatchlistPage,
|
||||
WATCHLIST_PAGE_SIZE,
|
||||
loadWatchlistQuotes,
|
||||
handleRemoveFromWatchlist
|
||||
handleAddToWatchlist,
|
||||
handleRemoveFromWatchlist,
|
||||
isInWatchlist
|
||||
};
|
||||
};
|
||||
|
||||
@@ -23,7 +23,13 @@ const AppFooter = () => {
|
||||
>
|
||||
京公网安备11010802046286号
|
||||
</Link>
|
||||
<Text>京ICP备2025107343号-1</Text>
|
||||
<Link
|
||||
href="https://beian.miit.gov.cn/"
|
||||
isExternal
|
||||
_hover={{ color: 'gray.600' }}
|
||||
>
|
||||
京ICP备2025107343号-1
|
||||
</Link>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
@@ -30,12 +30,12 @@ const MemoizedAppFooter = memo(AppFooter);
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column">
|
||||
<Box flex="1" h="100vh" w="100%" position="relative" display="flex" flexDirection="column" bg="#1A202C">
|
||||
{/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedHomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
||||
<Box flex="1" pt="60px">
|
||||
<Box flex="1" pt="60px" bg="#1A202C">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
|
||||
@@ -61,6 +61,20 @@ export const generateDailyData = (indexCode, days = 30) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算简单移动均价(用于分时图均价线)
|
||||
* @param {Array} data - 已有数据
|
||||
* @param {number} currentPrice - 当前价格
|
||||
* @param {number} period - 均线周期(默认5)
|
||||
* @returns {number} 均价
|
||||
*/
|
||||
function calculateAvgPrice(data, currentPrice, period = 5) {
|
||||
const recentPrices = data.slice(-period).map(d => d.price || d.close);
|
||||
recentPrices.push(currentPrice);
|
||||
const sum = recentPrices.reduce((acc, p) => acc + p, 0);
|
||||
return parseFloat((sum / recentPrices.length).toFixed(2));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成时间范围内的数据
|
||||
*/
|
||||
@@ -80,6 +94,11 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
|
||||
|
||||
// ✅ 修复:为分时图添加完整的 OHLC 字段
|
||||
const closePrice = parseFloat(price.toFixed(2));
|
||||
|
||||
// 计算均价和涨跌幅
|
||||
const avgPrice = calculateAvgPrice(data, closePrice);
|
||||
const changePercent = parseFloat(((closePrice - basePrice) / basePrice * 100).toFixed(2));
|
||||
|
||||
data.push({
|
||||
time: formatTime(current),
|
||||
timestamp: current.getTime(), // ✅ 新增:毫秒时间戳
|
||||
@@ -88,6 +107,8 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
|
||||
low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘)
|
||||
close: closePrice, // ✅ 保留:收盘价
|
||||
price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用)
|
||||
avg_price: avgPrice, // ✅ 新增:均价(供 TimelineChartModal 使用)
|
||||
change_percent: changePercent, // ✅ 新增:涨跌幅(供 TimelineChartModal 使用)
|
||||
volume: volume,
|
||||
prev_close: basePrice
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ export const accountHandlers = [
|
||||
}),
|
||||
|
||||
// 6. 添加自选股
|
||||
http.post('/api/account/watchlist/add', async ({ request }) => {
|
||||
http.post('/api/account/watchlist', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
@@ -188,6 +188,22 @@ export const accountHandlers = [
|
||||
|
||||
mockWatchlist.push(newItem);
|
||||
|
||||
// 同步添加到 mockRealtimeQuotes(导航栏自选股菜单使用此数组)
|
||||
mockRealtimeQuotes.push({
|
||||
stock_code: stock_code,
|
||||
stock_name: stock_name,
|
||||
current_price: null,
|
||||
change_percent: 0,
|
||||
change: 0,
|
||||
volume: 0,
|
||||
turnover: 0,
|
||||
high: 0,
|
||||
low: 0,
|
||||
open: 0,
|
||||
prev_close: 0,
|
||||
update_time: new Date().toTimeString().slice(0, 8)
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: '添加成功',
|
||||
@@ -210,9 +226,20 @@ export const accountHandlers = [
|
||||
const { id } = params;
|
||||
console.log('[Mock] 删除自选股:', id);
|
||||
|
||||
const index = mockWatchlist.findIndex(item => item.id === parseInt(id));
|
||||
// 支持按 stock_code 或 id 匹配删除
|
||||
const index = mockWatchlist.findIndex(item =>
|
||||
item.stock_code === id || item.id === parseInt(id)
|
||||
);
|
||||
|
||||
if (index !== -1) {
|
||||
const stockCode = mockWatchlist[index].stock_code;
|
||||
mockWatchlist.splice(index, 1);
|
||||
|
||||
// 同步从 mockRealtimeQuotes 移除
|
||||
const quotesIndex = mockRealtimeQuotes.findIndex(item => item.stock_code === stockCode);
|
||||
if (quotesIndex !== -1) {
|
||||
mockRealtimeQuotes.splice(quotesIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
@@ -263,6 +290,26 @@ export const accountHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// 10. 获取事件帖子(用户发布的评论/帖子)
|
||||
http.get('/api/account/events/posts', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json(
|
||||
{ success: false, error: '未登录' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Mock] 获取事件帖子');
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: mockEventComments // 复用 mockEventComments 数据
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 投资计划与复盘 ====================
|
||||
|
||||
// 10. 获取投资计划列表
|
||||
@@ -696,4 +743,81 @@ export const accountHandlers = [
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 21. 获取订阅套餐列表
|
||||
http.get('/api/subscription/plans', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'pro',
|
||||
display_name: 'Pro 专业版',
|
||||
description: '事件关联股票深度分析 | 历史事件智能对比复盘 | 事件概念关联与挖掘 | 概念板块个股追踪 | 概念深度研报与解读 | 个股异动实时预警',
|
||||
monthly_price: 299,
|
||||
yearly_price: 2699,
|
||||
pricing_options: [
|
||||
{ cycle_key: 'monthly', label: '月付', months: 1, price: 299, original_price: null, discount_percent: 0 },
|
||||
{ cycle_key: 'quarterly', label: '季付', months: 3, price: 799, original_price: 897, discount_percent: 11 },
|
||||
{ cycle_key: 'semiannual', label: '半年付', months: 6, price: 1499, original_price: 1794, discount_percent: 16 },
|
||||
{ cycle_key: 'yearly', label: '年付', months: 12, price: 2699, original_price: 3588, discount_percent: 25 }
|
||||
],
|
||||
features: [
|
||||
'新闻信息流',
|
||||
'历史事件对比',
|
||||
'事件传导链分析(AI)',
|
||||
'事件-相关标的分析',
|
||||
'相关概念展示',
|
||||
'AI复盘功能',
|
||||
'企业概览',
|
||||
'个股深度分析(AI) - 50家/月',
|
||||
'高效数据筛选工具',
|
||||
'概念中心(548大概念)',
|
||||
'历史时间轴查询 - 100天',
|
||||
'涨停板块数据分析',
|
||||
'个股涨停分析'
|
||||
],
|
||||
sort_order: 1
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'max',
|
||||
display_name: 'Max 旗舰版',
|
||||
description: '包含Pro版全部功能 | 事件传导链路智能分析 | 概念演变时间轴追溯 | 个股全方位深度研究 | 价小前投研助手无限使用 | 新功能优先体验权 | 专属客服一对一服务',
|
||||
monthly_price: 599,
|
||||
yearly_price: 5399,
|
||||
pricing_options: [
|
||||
{ cycle_key: 'monthly', label: '月付', months: 1, price: 599, original_price: null, discount_percent: 0 },
|
||||
{ cycle_key: 'quarterly', label: '季付', months: 3, price: 1599, original_price: 1797, discount_percent: 11 },
|
||||
{ cycle_key: 'semiannual', label: '半年付', months: 6, price: 2999, original_price: 3594, discount_percent: 17 },
|
||||
{ cycle_key: 'yearly', label: '年付', months: 12, price: 5399, original_price: 7188, discount_percent: 25 }
|
||||
],
|
||||
features: [
|
||||
'新闻信息流',
|
||||
'历史事件对比',
|
||||
'事件传导链分析(AI)',
|
||||
'事件-相关标的分析',
|
||||
'相关概念展示',
|
||||
'板块深度分析(AI)',
|
||||
'AI复盘功能',
|
||||
'企业概览',
|
||||
'个股深度分析(AI) - 无限制',
|
||||
'高效数据筛选工具',
|
||||
'概念中心(548大概念)',
|
||||
'历史时间轴查询 - 无限制',
|
||||
'概念高频更新',
|
||||
'涨停板块数据分析',
|
||||
'个股涨停分析'
|
||||
],
|
||||
sort_order: 2
|
||||
}
|
||||
];
|
||||
|
||||
console.log('[Mock] 获取订阅套餐列表:', plans.length, '个套餐');
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: plans
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -255,6 +255,48 @@ export const eventHandlers = [
|
||||
|
||||
// ==================== 事件详情相关 ====================
|
||||
|
||||
// 获取事件详情
|
||||
http.get('/api/events/:eventId', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 获取事件详情, eventId:', eventId);
|
||||
|
||||
try {
|
||||
// 返回模拟的事件详情数据
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: parseInt(eventId),
|
||||
title: `测试事件 ${eventId} - 重大政策发布`,
|
||||
description: '这是一个模拟的事件描述,用于开发测试。该事件涉及重要政策变化,可能对相关板块产生显著影响。建议关注后续发展动态。',
|
||||
importance: ['S', 'A', 'B', 'C'][Math.floor(Math.random() * 4)],
|
||||
created_at: new Date().toISOString(),
|
||||
trading_date: new Date().toISOString().split('T')[0],
|
||||
event_type: ['政策', '财报', '行业', '宏观'][Math.floor(Math.random() * 4)],
|
||||
related_avg_chg: parseFloat((Math.random() * 10 - 5).toFixed(2)),
|
||||
follower_count: Math.floor(Math.random() * 500) + 50,
|
||||
view_count: Math.floor(Math.random() * 5000) + 100,
|
||||
is_following: false,
|
||||
post_count: Math.floor(Math.random() * 50),
|
||||
expectation_surprise_score: parseFloat((Math.random() * 100).toFixed(1)),
|
||||
},
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取事件详情失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件详情失败',
|
||||
data: null
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取事件相关股票
|
||||
http.get('/api/events/:eventId/stocks', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
@@ -71,4 +71,269 @@ export const marketHandlers = [
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.latestMinuteData);
|
||||
}),
|
||||
|
||||
// 9. 热门概念数据(个股中心页面使用)
|
||||
http.get('/api/concepts/daily-top', async ({ request }) => {
|
||||
await delay(300);
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '6');
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
// 获取当前日期或指定日期
|
||||
const tradeDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
// 热门概念列表
|
||||
const conceptPool = [
|
||||
{ name: '人工智能', desc: '人工智能是"技术突破+政策扶持"双轮驱动的硬科技主题。随着大模型技术的突破,AI应用场景不断拓展。' },
|
||||
{ name: '新能源汽车', desc: '新能源汽车行业景气度持续向好,渗透率不断提升。政策支持力度大,产业链上下游企业均受益。' },
|
||||
{ name: '半导体', desc: '国产半导体替代加速,自主可控需求强烈。政策和资金支持力度大,行业迎来黄金发展期。' },
|
||||
{ name: '光伏', desc: '光伏装机量快速增长,成本持续下降,行业景气度维持高位。双碳目标下前景广阔。' },
|
||||
{ name: '锂电池', desc: '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。新能源汽车和储能需求旺盛。' },
|
||||
{ name: '储能', desc: '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。未来市场空间巨大。' },
|
||||
{ name: '算力', desc: 'AI大模型推动算力需求爆发,数据中心、服务器、芯片等产业链受益明显。' },
|
||||
{ name: '机器人', desc: '人形机器人产业化加速,特斯拉、小米等巨头入局,产业链迎来发展机遇。' },
|
||||
];
|
||||
|
||||
// 股票池(扩展到足够多的股票)
|
||||
const stockPool = [
|
||||
{ stock_code: '600519', stock_name: '贵州茅台' },
|
||||
{ stock_code: '300750', stock_name: '宁德时代' },
|
||||
{ stock_code: '601318', stock_name: '中国平安' },
|
||||
{ stock_code: '002594', stock_name: '比亚迪' },
|
||||
{ stock_code: '601012', stock_name: '隆基绿能' },
|
||||
{ stock_code: '300274', stock_name: '阳光电源' },
|
||||
{ stock_code: '688981', stock_name: '中芯国际' },
|
||||
{ stock_code: '000725', stock_name: '京东方A' },
|
||||
{ stock_code: '600036', stock_name: '招商银行' },
|
||||
{ stock_code: '000858', stock_name: '五粮液' },
|
||||
{ stock_code: '601166', stock_name: '兴业银行' },
|
||||
{ stock_code: '600276', stock_name: '恒瑞医药' },
|
||||
{ stock_code: '000333', stock_name: '美的集团' },
|
||||
{ stock_code: '600887', stock_name: '伊利股份' },
|
||||
{ stock_code: '002415', stock_name: '海康威视' },
|
||||
{ stock_code: '601888', stock_name: '中国中免' },
|
||||
{ stock_code: '300059', stock_name: '东方财富' },
|
||||
{ stock_code: '002475', stock_name: '立讯精密' },
|
||||
{ stock_code: '600900', stock_name: '长江电力' },
|
||||
{ stock_code: '601398', stock_name: '工商银行' },
|
||||
{ stock_code: '600030', stock_name: '中信证券' },
|
||||
{ stock_code: '000568', stock_name: '泸州老窖' },
|
||||
{ stock_code: '002352', stock_name: '顺丰控股' },
|
||||
{ stock_code: '600809', stock_name: '山西汾酒' },
|
||||
{ stock_code: '300015', stock_name: '爱尔眼科' },
|
||||
{ stock_code: '002142', stock_name: '宁波银行' },
|
||||
{ stock_code: '601899', stock_name: '紫金矿业' },
|
||||
{ stock_code: '600309', stock_name: '万华化学' },
|
||||
{ stock_code: '002304', stock_name: '洋河股份' },
|
||||
{ stock_code: '600585', stock_name: '海螺水泥' },
|
||||
{ stock_code: '601288', stock_name: '农业银行' },
|
||||
{ stock_code: '600050', stock_name: '中国联通' },
|
||||
{ stock_code: '000001', stock_name: '平安银行' },
|
||||
{ stock_code: '601668', stock_name: '中国建筑' },
|
||||
{ stock_code: '600028', stock_name: '中国石化' },
|
||||
{ stock_code: '601857', stock_name: '中国石油' },
|
||||
{ stock_code: '600000', stock_name: '浦发银行' },
|
||||
{ stock_code: '601328', stock_name: '交通银行' },
|
||||
{ stock_code: '000002', stock_name: '万科A' },
|
||||
{ stock_code: '600104', stock_name: '上汽集团' },
|
||||
{ stock_code: '601601', stock_name: '中国太保' },
|
||||
{ stock_code: '600016', stock_name: '民生银行' },
|
||||
{ stock_code: '601628', stock_name: '中国人寿' },
|
||||
{ stock_code: '600031', stock_name: '三一重工' },
|
||||
{ stock_code: '002230', stock_name: '科大讯飞' },
|
||||
{ stock_code: '300124', stock_name: '汇川技术' },
|
||||
{ stock_code: '002049', stock_name: '紫光国微' },
|
||||
{ stock_code: '688012', stock_name: '中微公司' },
|
||||
{ stock_code: '688008', stock_name: '澜起科技' },
|
||||
{ stock_code: '603501', stock_name: '韦尔股份' },
|
||||
];
|
||||
|
||||
// 生成历史触发时间
|
||||
const generateHappenedTimes = (seed) => {
|
||||
const times = [];
|
||||
const count = 3 + (seed % 3); // 3-5个时间点
|
||||
for (let k = 0; k < count; k++) {
|
||||
const daysAgo = 30 + (seed * 7 + k * 11) % 330;
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - daysAgo);
|
||||
times.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
return times.sort().reverse();
|
||||
};
|
||||
|
||||
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
|
||||
|
||||
// 生成概念数据
|
||||
const concepts = [];
|
||||
for (let i = 0; i < Math.min(limit, conceptPool.length); i++) {
|
||||
const concept = conceptPool[i];
|
||||
const changePercent = parseFloat((Math.random() * 8 - 1).toFixed(2)); // -1% ~ 7%
|
||||
const stockCount = Math.floor(Math.random() * 20) + 15; // 15-35只股票
|
||||
|
||||
// 生成与 stockCount 一致的股票列表(包含完整字段)
|
||||
const relatedStocks = [];
|
||||
for (let j = 0; j < stockCount; j++) {
|
||||
const idx = (i * 7 + j) % stockPool.length;
|
||||
const stock = stockPool[idx];
|
||||
relatedStocks.push({
|
||||
stock_code: stock.stock_code,
|
||||
stock_name: stock.stock_name,
|
||||
reason: `作为行业龙头企业,${stock.stock_name}在该领域具有核心竞争优势,市场份额领先。`,
|
||||
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
|
||||
});
|
||||
}
|
||||
|
||||
concepts.push({
|
||||
concept_id: `CONCEPT_${1001 + i}`,
|
||||
concept: concept.name, // 原始字段名
|
||||
concept_name: concept.name, // 兼容字段名
|
||||
description: concept.desc,
|
||||
stock_count: stockCount,
|
||||
score: parseFloat((Math.random() * 5 + 3).toFixed(2)), // 3-8 分数
|
||||
match_type: matchTypes[i % 3],
|
||||
price_info: {
|
||||
avg_change_pct: changePercent,
|
||||
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
|
||||
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
|
||||
},
|
||||
change_percent: changePercent, // 兼容字段
|
||||
happened_times: generateHappenedTimes(i),
|
||||
stocks: relatedStocks,
|
||||
hot_score: Math.floor(Math.random() * 100)
|
||||
});
|
||||
}
|
||||
|
||||
// 按涨跌幅降序排序
|
||||
concepts.sort((a, b) => b.change_percent - a.change_percent);
|
||||
|
||||
console.log('[Mock Market] 获取热门概念:', { limit, date: tradeDate, count: concepts.length });
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: concepts,
|
||||
trade_date: tradeDate
|
||||
});
|
||||
}),
|
||||
|
||||
// 10. 市值热力图数据(个股中心页面使用)
|
||||
http.get('/api/market/heatmap', async ({ request }) => {
|
||||
await delay(400);
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '500');
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
const tradeDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
// 行业列表
|
||||
const industries = ['食品饮料', '银行', '医药生物', '电子', '计算机', '汽车', '电力设备', '机械设备', '化工', '房地产', '有色金属', '钢铁'];
|
||||
const provinces = ['北京', '上海', '广东', '浙江', '江苏', '山东', '四川', '湖北', '福建', '安徽'];
|
||||
|
||||
// 常见股票数据
|
||||
const majorStocks = [
|
||||
{ code: '600519', name: '贵州茅台', cap: 1850, industry: '食品饮料', province: '贵州' },
|
||||
{ code: '601318', name: '中国平安', cap: 920, industry: '保险', province: '广东' },
|
||||
{ code: '600036', name: '招商银行', cap: 850, industry: '银行', province: '广东' },
|
||||
{ code: '300750', name: '宁德时代', cap: 780, industry: '电力设备', province: '福建' },
|
||||
{ code: '601166', name: '兴业银行', cap: 420, industry: '银行', province: '福建' },
|
||||
{ code: '000858', name: '五粮液', cap: 580, industry: '食品饮料', province: '四川' },
|
||||
{ code: '002594', name: '比亚迪', cap: 650, industry: '汽车', province: '广东' },
|
||||
{ code: '601012', name: '隆基绿能', cap: 320, industry: '电力设备', province: '陕西' },
|
||||
{ code: '688981', name: '中芯国际', cap: 280, industry: '电子', province: '上海' },
|
||||
{ code: '600900', name: '长江电力', cap: 520, industry: '公用事业', province: '湖北' },
|
||||
];
|
||||
|
||||
// 生成热力图数据
|
||||
const heatmapData = [];
|
||||
let risingCount = 0;
|
||||
let fallingCount = 0;
|
||||
|
||||
// 先添加主要股票
|
||||
majorStocks.forEach(stock => {
|
||||
const changePercent = parseFloat((Math.random() * 12 - 4).toFixed(2)); // -4% ~ 8%
|
||||
const amount = parseFloat((Math.random() * 100 + 10).toFixed(2)); // 10-110亿
|
||||
|
||||
if (changePercent > 0) risingCount++;
|
||||
else if (changePercent < 0) fallingCount++;
|
||||
|
||||
heatmapData.push({
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name,
|
||||
market_cap: stock.cap,
|
||||
change_percent: changePercent,
|
||||
amount: amount,
|
||||
industry: stock.industry,
|
||||
province: stock.province
|
||||
});
|
||||
});
|
||||
|
||||
// 生成更多随机股票数据
|
||||
for (let i = majorStocks.length; i < Math.min(limit, 200); i++) {
|
||||
const changePercent = parseFloat((Math.random() * 14 - 5).toFixed(2)); // -5% ~ 9%
|
||||
const marketCap = parseFloat((Math.random() * 500 + 20).toFixed(2)); // 20-520亿
|
||||
const amount = parseFloat((Math.random() * 50 + 1).toFixed(2)); // 1-51亿
|
||||
|
||||
if (changePercent > 0) risingCount++;
|
||||
else if (changePercent < 0) fallingCount++;
|
||||
|
||||
heatmapData.push({
|
||||
stock_code: `${600000 + i}`,
|
||||
stock_name: `股票${i}`,
|
||||
market_cap: marketCap,
|
||||
change_percent: changePercent,
|
||||
amount: amount,
|
||||
industry: industries[Math.floor(Math.random() * industries.length)],
|
||||
province: provinces[Math.floor(Math.random() * provinces.length)]
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Mock Market] 获取热力图数据:', { limit, date: tradeDate, count: heatmapData.length });
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: heatmapData,
|
||||
trade_date: tradeDate,
|
||||
statistics: {
|
||||
rising_count: risingCount,
|
||||
falling_count: fallingCount
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 11. 市场统计数据(个股中心页面使用)
|
||||
http.get('/api/market/statistics', async ({ request }) => {
|
||||
await delay(200);
|
||||
const url = new URL(request.url);
|
||||
const date = url.searchParams.get('date');
|
||||
|
||||
const tradeDate = date || new Date().toISOString().split('T')[0];
|
||||
|
||||
// 生成最近30个交易日
|
||||
const availableDates = [];
|
||||
const currentDate = new Date(tradeDate);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date(currentDate);
|
||||
d.setDate(d.getDate() - i);
|
||||
// 跳过周末
|
||||
if (d.getDay() !== 0 && d.getDay() !== 6) {
|
||||
availableDates.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[Mock Market] 获取市场统计数据:', { date: tradeDate });
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
summary: {
|
||||
total_market_cap: parseFloat((Math.random() * 5000 + 80000).toFixed(2)), // 80000-85000亿
|
||||
total_amount: parseFloat((Math.random() * 3000 + 8000).toFixed(2)), // 8000-11000亿
|
||||
avg_pe: parseFloat((Math.random() * 5 + 12).toFixed(2)), // 12-17
|
||||
avg_pb: parseFloat((Math.random() * 0.5 + 1.3).toFixed(2)), // 1.3-1.8
|
||||
rising_stocks: Math.floor(Math.random() * 1500 + 1500), // 1500-3000
|
||||
falling_stocks: Math.floor(Math.random() * 1500 + 1000), // 1000-2500
|
||||
unchanged_stocks: Math.floor(Math.random() * 200 + 100) // 100-300
|
||||
},
|
||||
trade_date: tradeDate,
|
||||
available_dates: availableDates.slice(0, 20) // 返回最近20个交易日
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -123,6 +123,45 @@ const generateStockList = () => {
|
||||
|
||||
// 股票相关的 Handlers
|
||||
export const stockHandlers = [
|
||||
// 搜索股票(个股中心页面使用)
|
||||
http.get('/api/stocks/search', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get('q') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
||||
|
||||
console.log('[Mock Stock] 搜索股票:', { query, limit });
|
||||
|
||||
const stocks = generateStockList();
|
||||
|
||||
// 如果没有搜索词,返回空结果
|
||||
if (!query.trim()) {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
// 过滤匹配的股票
|
||||
const results = stocks.filter(s =>
|
||||
s.code.includes(query) || s.name.includes(query)
|
||||
).slice(0, limit);
|
||||
|
||||
// 返回格式化数据
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: results.map(s => ({
|
||||
stock_code: s.code,
|
||||
stock_name: s.name,
|
||||
market: s.code.startsWith('6') ? 'SH' : 'SZ',
|
||||
industry: ['银行', '证券', '保险', '白酒', '医药', '科技', '新能源', '汽车', '地产', '家电'][Math.floor(Math.random() * 10)],
|
||||
change_pct: parseFloat((Math.random() * 10 - 3).toFixed(2)),
|
||||
price: parseFloat((Math.random() * 100 + 5).toFixed(2))
|
||||
}))
|
||||
});
|
||||
}),
|
||||
|
||||
// 获取所有股票列表
|
||||
http.get('/api/stocklist', async () => {
|
||||
await delay(200);
|
||||
@@ -224,4 +263,129 @@ export const stockHandlers = [
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 批量获取股票K线数据
|
||||
http.post('/api/stock/batch-kline', async ({ request }) => {
|
||||
await delay(400);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { codes, type = 'timeline', event_time } = body;
|
||||
|
||||
console.log('[Mock Stock] 批量获取K线数据:', {
|
||||
stockCount: codes?.length,
|
||||
type,
|
||||
eventTime: event_time
|
||||
});
|
||||
|
||||
if (!codes || !Array.isArray(codes) || codes.length === 0) {
|
||||
return HttpResponse.json(
|
||||
{ error: '股票代码列表不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 为每只股票生成数据
|
||||
const batchData = {};
|
||||
codes.forEach(stockCode => {
|
||||
let data;
|
||||
if (type === 'timeline') {
|
||||
data = generateTimelineData('000001.SH');
|
||||
} else if (type === 'daily') {
|
||||
data = generateDailyData('000001.SH', 60);
|
||||
} else {
|
||||
data = [];
|
||||
}
|
||||
|
||||
batchData[stockCode] = {
|
||||
success: true,
|
||||
data: data,
|
||||
stock_code: stockCode
|
||||
};
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: batchData,
|
||||
type: type,
|
||||
message: '批量获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 批量获取K线数据失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ error: '批量获取K线数据失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票报价(批量)
|
||||
http.post('/api/stock/quotes', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { codes, event_time } = body;
|
||||
|
||||
console.log('[Mock Stock] 获取股票报价:', {
|
||||
stockCount: codes?.length,
|
||||
eventTime: event_time
|
||||
});
|
||||
|
||||
if (!codes || !Array.isArray(codes) || codes.length === 0) {
|
||||
return HttpResponse.json(
|
||||
{ success: false, error: '股票代码列表不能为空' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 生成股票列表用于查找名称
|
||||
const stockList = generateStockList();
|
||||
const stockMap = {};
|
||||
stockList.forEach(s => {
|
||||
stockMap[s.code] = s.name;
|
||||
});
|
||||
|
||||
// 为每只股票生成报价数据
|
||||
const quotesData = {};
|
||||
codes.forEach(stockCode => {
|
||||
// 生成基础价格(10-200之间)
|
||||
const basePrice = parseFloat((Math.random() * 190 + 10).toFixed(2));
|
||||
// 涨跌幅(-10% 到 +10%)
|
||||
const changePercent = parseFloat((Math.random() * 20 - 10).toFixed(2));
|
||||
// 涨跌额
|
||||
const change = parseFloat((basePrice * changePercent / 100).toFixed(2));
|
||||
// 昨收
|
||||
const prevClose = parseFloat((basePrice - change).toFixed(2));
|
||||
|
||||
quotesData[stockCode] = {
|
||||
code: stockCode,
|
||||
name: stockMap[stockCode] || `股票${stockCode}`,
|
||||
price: basePrice,
|
||||
change: change,
|
||||
change_percent: changePercent,
|
||||
prev_close: prevClose,
|
||||
open: parseFloat((prevClose * (1 + (Math.random() * 0.02 - 0.01))).toFixed(2)),
|
||||
high: parseFloat((basePrice * (1 + Math.random() * 0.05)).toFixed(2)),
|
||||
low: parseFloat((basePrice * (1 - Math.random() * 0.05)).toFixed(2)),
|
||||
volume: Math.floor(Math.random() * 100000000),
|
||||
amount: parseFloat((Math.random() * 10000000000).toFixed(2)),
|
||||
market: stockCode.startsWith('6') ? 'SH' : 'SZ',
|
||||
update_time: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: quotesData,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 获取股票报价失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ success: false, error: '获取股票报价失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -98,7 +98,7 @@ export const routeConfig = [
|
||||
|
||||
// ==================== 事件模块 ====================
|
||||
{
|
||||
path: 'event-detail/:eventId',
|
||||
path: 'event-detail',
|
||||
component: lazyComponents.EventDetail,
|
||||
protection: PROTECTION_MODES.REDIRECT,
|
||||
layout: 'main',
|
||||
@@ -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`);
|
||||
},
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
iframe[src*="bytedesk"],
|
||||
iframe[src*="/chat/"],
|
||||
iframe[src*="/visitor/"] {
|
||||
position: fixed !important;
|
||||
z-index: 999999 !important;
|
||||
width: 100% !important; /* 填满外层容器 */
|
||||
height: 100% !important; /* 填满外层容器 */
|
||||
}
|
||||
|
||||
/* Bytedesk 覆盖层(如果存在) */
|
||||
@@ -37,16 +38,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 秒后自动消失 ========== */
|
||||
/* 提示框("在线客服 点击咨询"气泡)- 扩展选择器 */
|
||||
|
||||
@@ -27,6 +27,18 @@ import { MainPanelComponent } from "./additions/layout/MainPanel";
|
||||
import { PanelContentComponent } from "./additions/layout/PanelContent";
|
||||
import { PanelContainerComponent } from "./additions/layout/PanelContainer";
|
||||
// import { mode } from "@chakra-ui/theme-tools";
|
||||
|
||||
// Container 组件样式覆盖 - 移除默认背景色
|
||||
const ContainerComponent = {
|
||||
components: {
|
||||
Container: {
|
||||
baseStyle: {
|
||||
bg: "1A202C",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default extendTheme(
|
||||
{ breakpoints }, // Breakpoints
|
||||
globalStyles,
|
||||
@@ -37,5 +49,6 @@ export default extendTheme(
|
||||
CardComponent, // Card component
|
||||
MainPanelComponent, // Main Panel component
|
||||
PanelContentComponent, // Panel Content component
|
||||
PanelContainerComponent // Panel Container component
|
||||
PanelContainerComponent, // Panel Container component
|
||||
ContainerComponent // Container 背景透明
|
||||
);
|
||||
|
||||
55
src/utils/idEncoder.ts
Normal file
55
src/utils/idEncoder.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* ID 加密/解密工具
|
||||
* 用于隐藏 URL 中的真实 ID,防止用户猜测遍历
|
||||
*
|
||||
* 使用 Base64 编码 + 前缀混淆
|
||||
* 例如: 15901 -> "ZXYtMTU5MDE"
|
||||
*/
|
||||
|
||||
const SECRET_PREFIX = 'ev-';
|
||||
|
||||
/**
|
||||
* 加密事件 ID
|
||||
* @param id - 原始 ID
|
||||
* @returns 加密后的字符串
|
||||
*/
|
||||
export const encodeEventId = (id: number | string): string => {
|
||||
if (id === null || id === undefined) return '';
|
||||
return btoa(SECRET_PREFIX + String(id));
|
||||
};
|
||||
|
||||
/**
|
||||
* 解密事件 ID
|
||||
* @param encoded - 加密后的字符串
|
||||
* @returns 原始 ID,解密失败返回 null
|
||||
*/
|
||||
export const decodeEventId = (encoded: string): string | null => {
|
||||
if (!encoded) return null;
|
||||
|
||||
try {
|
||||
const decoded = atob(encoded);
|
||||
if (decoded.startsWith(SECRET_PREFIX)) {
|
||||
return decoded.slice(SECRET_PREFIX.length);
|
||||
}
|
||||
// 兼容:如果是纯数字(旧链接),直接返回
|
||||
if (/^\d+$/.test(encoded)) {
|
||||
return encoded;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
// Base64 解码失败,可能是旧的纯数字链接
|
||||
if (/^\d+$/.test(encoded)) {
|
||||
return encoded;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成事件详情页 URL
|
||||
* @param eventId - 事件 ID
|
||||
* @returns 完整路径
|
||||
*/
|
||||
export const getEventDetailUrl = (eventId: number | string): string => {
|
||||
return `/event-detail?id=${encodeEventId(eventId)}`;
|
||||
};
|
||||
@@ -93,6 +93,13 @@ const CompactSearchBox = ({
|
||||
loadStocks();
|
||||
}, []);
|
||||
|
||||
// 预加载行业数据(解决第一次点击无数据问题)
|
||||
useEffect(() => {
|
||||
if (!industryData || industryData.length === 0) {
|
||||
dispatch(fetchIndustryData());
|
||||
}
|
||||
}, [dispatch, industryData]);
|
||||
|
||||
// 初始化筛选条件
|
||||
const findIndustryPath = useCallback((targetCode, data, currentPath = []) => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
|
||||
// 动态新闻详情面板主组件(组装所有子组件)
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useReducer } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
@@ -13,14 +12,13 @@ import {
|
||||
Center,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Box,
|
||||
} from '@chakra-ui/react';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { eventService } from '../../../../services/eventService';
|
||||
import { getImportanceConfig } from '@constants/importanceLevels';
|
||||
import { eventService } from '@services/eventService';
|
||||
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
|
||||
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
|
||||
import { useAuth } from '../../../../contexts/AuthContext';
|
||||
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
import EventHeaderInfo from './EventHeaderInfo';
|
||||
import CompactMetaBar from './CompactMetaBar';
|
||||
import EventDescriptionSection from './EventDescriptionSection';
|
||||
@@ -28,12 +26,56 @@ import RelatedConceptsSection from './RelatedConceptsSection';
|
||||
import RelatedStocksSection from './RelatedStocksSection';
|
||||
import CompactStockItem from './CompactStockItem';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
|
||||
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
|
||||
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
|
||||
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
|
||||
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
|
||||
import EventCommentSection from '../../../../components/EventCommentSection';
|
||||
import HistoricalEvents from '@views/EventDetail/components/HistoricalEvents';
|
||||
import TransmissionChainAnalysis from '@views/EventDetail/components/TransmissionChainAnalysis';
|
||||
import SubscriptionBadge from '@components/SubscriptionBadge';
|
||||
import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal';
|
||||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||
import { useWatchlist } from '@hooks/useWatchlist';
|
||||
import EventCommentSection from '@components/EventCommentSection';
|
||||
|
||||
// 折叠区块状态管理 - 使用 useReducer 整合
|
||||
const initialSectionState = {
|
||||
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
|
||||
concepts: { isOpen: false },
|
||||
historical: { isOpen: false, hasLoaded: false },
|
||||
transmission: { isOpen: false, hasLoaded: false }
|
||||
};
|
||||
|
||||
const sectionReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE':
|
||||
return {
|
||||
...state,
|
||||
[action.section]: {
|
||||
...state[action.section],
|
||||
isOpen: !state[action.section].isOpen
|
||||
}
|
||||
};
|
||||
case 'SET_LOADED':
|
||||
return {
|
||||
...state,
|
||||
[action.section]: {
|
||||
...state[action.section],
|
||||
hasLoaded: true
|
||||
}
|
||||
};
|
||||
case 'SET_QUOTES_LOADED':
|
||||
return {
|
||||
...state,
|
||||
stocks: { ...state.stocks, hasLoadedQuotes: true }
|
||||
};
|
||||
case 'RESET_ALL':
|
||||
return {
|
||||
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
|
||||
concepts: { isOpen: false },
|
||||
historical: { isOpen: false, hasLoaded: false },
|
||||
transmission: { isOpen: false, hasLoaded: false }
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 动态新闻详情面板主组件
|
||||
@@ -47,7 +89,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
const cardBg = PROFESSIONAL_COLORS.background.card;
|
||||
const borderColor = PROFESSIONAL_COLORS.border.default;
|
||||
const textColor = PROFESSIONAL_COLORS.text.secondary;
|
||||
const toast = useToast();
|
||||
|
||||
// 使用 useWatchlist Hook 管理自选股
|
||||
const {
|
||||
handleAddToWatchlist,
|
||||
handleRemoveFromWatchlist,
|
||||
isInWatchlist,
|
||||
loadWatchlistQuotes
|
||||
} = useWatchlist();
|
||||
|
||||
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type)
|
||||
const userTier = user?.subscription_type || 'free';
|
||||
@@ -77,7 +126,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
|
||||
// 使用 Hook 获取实时数据
|
||||
// - autoLoad: false - 禁用自动加载所有数据,改为手动触发
|
||||
// - autoLoadQuotes: false - 禁用自动加载行情,延迟到展开时加载(减少请求)
|
||||
// - autoLoadQuotes: true - 股票数据加载后自动加载行情(相关股票默认展开)
|
||||
const {
|
||||
stocks,
|
||||
quotes,
|
||||
@@ -89,7 +138,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 () => {
|
||||
@@ -100,11 +149,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
const response = await eventService.getEventDetail(event.id);
|
||||
if (response.success) {
|
||||
setFullEventDetail(response.data);
|
||||
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
|
||||
eventId: event.id,
|
||||
viewCount: response.data.view_count,
|
||||
title: response.data.title
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
|
||||
@@ -121,30 +165,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
const canAccessHistorical = hasAccess('pro');
|
||||
const canAccessTransmission = hasAccess('max');
|
||||
|
||||
// 子区块折叠状态管理 + 加载追踪
|
||||
// 相关股票默认折叠,只显示数量吸引点击
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(false);
|
||||
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
|
||||
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
|
||||
|
||||
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
|
||||
|
||||
// 历史事件默认折叠,但预加载数量
|
||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
|
||||
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
|
||||
|
||||
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
|
||||
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
|
||||
|
||||
// 自选股管理(使用 localStorage)
|
||||
const [watchlistSet, setWatchlistSet] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('stock_watchlist');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
// 子区块折叠状态管理 - 使用 useReducer 整合
|
||||
const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
|
||||
|
||||
// 锁定点击处理 - 弹出升级弹窗
|
||||
const handleLockedClick = useCallback((featureName, requiredLevel) => {
|
||||
@@ -165,85 +187,62 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
}, []);
|
||||
|
||||
// 相关股票 - 展开时加载行情(需要 PRO 权限)
|
||||
// 股票列表在事件切换时预加载(显示数量),行情在展开时才加载
|
||||
const handleStocksToggle = useCallback(() => {
|
||||
const newState = !isStocksOpen;
|
||||
setIsStocksOpen(newState);
|
||||
const willOpen = !sectionState.stocks.isOpen;
|
||||
dispatchSection({ type: 'TOGGLE', section: 'stocks' });
|
||||
|
||||
// 展开时加载行情数据(如果还没加载过)
|
||||
if (newState && !hasLoadedQuotes && stocks.length > 0) {
|
||||
console.log('%c📈 [相关股票] 首次展开,加载行情数据', 'color: #10B981; font-weight: bold;', {
|
||||
eventId: event?.id,
|
||||
stockCount: stocks.length
|
||||
});
|
||||
if (willOpen && !sectionState.stocks.hasLoadedQuotes && stocks.length > 0) {
|
||||
refreshQuotes();
|
||||
setHasLoadedQuotes(true);
|
||||
dispatchSection({ type: 'SET_QUOTES_LOADED' });
|
||||
}
|
||||
}, [isStocksOpen, hasLoadedQuotes, stocks.length, refreshQuotes, event?.id]);
|
||||
}, [sectionState.stocks, stocks.length, refreshQuotes]);
|
||||
|
||||
// 相关概念 - 展开/收起(无需加载)
|
||||
const handleConceptsToggle = useCallback(() => {
|
||||
setIsConceptsOpen(!isConceptsOpen);
|
||||
}, [isConceptsOpen]);
|
||||
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
|
||||
}, []);
|
||||
|
||||
// 历史事件对比 - 数据已预加载,只需切换展开状态
|
||||
const handleHistoricalToggle = useCallback(() => {
|
||||
const newState = !isHistoricalOpen;
|
||||
setIsHistoricalOpen(newState);
|
||||
|
||||
// 数据已在事件切换时预加载,这里只需展开
|
||||
if (newState) {
|
||||
console.log('%c📜 [历史事件] 展开(数据已预加载)', 'color: #3B82F6; font-weight: bold;', {
|
||||
eventId: event?.id,
|
||||
count: historicalEvents?.length || 0
|
||||
});
|
||||
}
|
||||
}, [isHistoricalOpen, event?.id, historicalEvents?.length]);
|
||||
dispatchSection({ type: 'TOGGLE', section: 'historical' });
|
||||
}, []);
|
||||
|
||||
// 传导链分析 - 展开时加载
|
||||
const handleTransmissionToggle = useCallback(() => {
|
||||
const newState = !isTransmissionOpen;
|
||||
setIsTransmissionOpen(newState);
|
||||
const willOpen = !sectionState.transmission.isOpen;
|
||||
dispatchSection({ type: 'TOGGLE', section: 'transmission' });
|
||||
|
||||
if (newState && !hasLoadedTransmission) {
|
||||
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
|
||||
if (willOpen && !sectionState.transmission.hasLoaded) {
|
||||
loadChainAnalysis();
|
||||
setHasLoadedTransmission(true);
|
||||
dispatchSection({ type: 'SET_LOADED', section: 'transmission' });
|
||||
}
|
||||
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]);
|
||||
}, [sectionState.transmission, loadChainAnalysis]);
|
||||
|
||||
// 事件切换时重置所有子模块状态
|
||||
useEffect(() => {
|
||||
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
|
||||
|
||||
// 🎯 加载事件详情(增加浏览量)
|
||||
// 加载事件详情(增加浏览量)
|
||||
loadEventDetail();
|
||||
|
||||
// 重置所有加载状态
|
||||
setHasLoadedStocks(false);
|
||||
setHasLoadedQuotes(false); // 重置行情加载状态
|
||||
setHasLoadedHistorical(false);
|
||||
setHasLoadedTransmission(false);
|
||||
// 加载自选股数据(用于判断股票是否已关注)
|
||||
loadWatchlistQuotes();
|
||||
|
||||
// 相关股票默认折叠,但预加载股票列表(显示数量吸引点击)
|
||||
setIsStocksOpen(false);
|
||||
// 重置所有折叠区块状态
|
||||
dispatchSection({ type: 'RESET_ALL' });
|
||||
|
||||
// 相关股票默认展开,预加载股票列表和行情数据
|
||||
if (canAccessStocks) {
|
||||
console.log('%c📊 [相关股票] 事件切换,预加载股票列表(获取数量)', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
||||
loadStocksData();
|
||||
setHasLoadedStocks(true);
|
||||
dispatchSection({ type: 'SET_LOADED', section: 'stocks' });
|
||||
dispatchSection({ type: 'SET_QUOTES_LOADED' });
|
||||
}
|
||||
|
||||
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
|
||||
setIsHistoricalOpen(false);
|
||||
if (canAccessHistorical) {
|
||||
console.log('%c📜 [历史事件] 事件切换,预加载历史事件(获取数量)', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
|
||||
loadHistoricalData();
|
||||
setHasLoadedHistorical(true);
|
||||
dispatchSection({ type: 'SET_LOADED', section: 'historical' });
|
||||
}
|
||||
|
||||
setIsConceptsOpen(false);
|
||||
setIsTransmissionOpen(false);
|
||||
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail]);
|
||||
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail, loadWatchlistQuotes]);
|
||||
|
||||
// 切换关注状态
|
||||
const handleToggleFollow = useCallback(async () => {
|
||||
@@ -251,42 +250,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
dispatch(toggleEventFollow(event.id));
|
||||
}, [dispatch, event?.id]);
|
||||
|
||||
// 切换自选股
|
||||
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
|
||||
try {
|
||||
const newWatchlist = new Set(watchlistSet);
|
||||
|
||||
if (isInWatchlist) {
|
||||
newWatchlist.delete(stockCode);
|
||||
toast({
|
||||
title: '已移除自选股',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
newWatchlist.add(stockCode);
|
||||
toast({
|
||||
title: '已添加至自选股',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
|
||||
setWatchlistSet(newWatchlist);
|
||||
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
|
||||
} catch (error) {
|
||||
console.error('切换自选股失败:', error);
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
// 切换自选股(使用 useWatchlist Hook)
|
||||
const handleWatchlistToggle = useCallback(async (stockCode, stockName, currentlyInWatchlist) => {
|
||||
if (currentlyInWatchlist) {
|
||||
await handleRemoveFromWatchlist(stockCode);
|
||||
} else {
|
||||
await handleAddToWatchlist(stockCode, stockName);
|
||||
}
|
||||
}, [watchlistSet, toast]);
|
||||
}, [handleAddToWatchlist, handleRemoveFromWatchlist]);
|
||||
|
||||
// 空状态
|
||||
if (!event) {
|
||||
@@ -335,15 +306,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
|
||||
<CollapsibleSection
|
||||
title="相关股票"
|
||||
isOpen={isStocksOpen}
|
||||
isOpen={sectionState.stocks.isOpen}
|
||||
onToggle={handleStocksToggle}
|
||||
count={stocks?.length || 0}
|
||||
subscriptionBadge={(() => {
|
||||
if (!canAccessStocks) {
|
||||
return <SubscriptionBadge tier="pro" size="sm" />;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
subscriptionBadge={!canAccessStocks ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessStocks}
|
||||
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
|
||||
showModeToggle={canAccessStocks}
|
||||
@@ -378,7 +344,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
stocks={stocks}
|
||||
quotes={quotes}
|
||||
eventTime={event.created_at}
|
||||
watchlistSet={watchlistSet}
|
||||
isInWatchlist={isInWatchlist}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
)}
|
||||
@@ -389,7 +355,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
eventTitle={event.title}
|
||||
effectiveTradingDate={event.trading_date || event.created_at}
|
||||
eventTime={event.created_at}
|
||||
isOpen={isConceptsOpen}
|
||||
isOpen={sectionState.concepts.isOpen}
|
||||
onToggle={handleConceptsToggle}
|
||||
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessConcepts}
|
||||
@@ -399,7 +365,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
||||
<CollapsibleSection
|
||||
title="历史事件对比"
|
||||
isOpen={isHistoricalOpen}
|
||||
isOpen={sectionState.historical.isOpen}
|
||||
onToggle={handleHistoricalToggle}
|
||||
count={historicalEvents?.length || 0}
|
||||
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
@@ -422,7 +388,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
|
||||
<CollapsibleSection
|
||||
title="传导链分析"
|
||||
isOpen={isTransmissionOpen}
|
||||
isOpen={sectionState.transmission.isOpen}
|
||||
onToggle={handleTransmissionToggle}
|
||||
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
|
||||
isLocked={!canAccessTransmission}
|
||||
@@ -450,7 +416,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
||||
featureName={upgradeModal.featureName}
|
||||
currentLevel={userTier}
|
||||
/>
|
||||
): null }
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 相关股票列表区组件(纯内容部分)
|
||||
@@ -12,16 +15,145 @@ import StockListItem from './StockListItem';
|
||||
* @param {Array<Object>} props.stocks - 股票数组
|
||||
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
|
||||
* @param {string} props.eventTime - 事件时间
|
||||
* @param {Set} props.watchlistSet - 自选股代码集合
|
||||
* @param {Function} props.isInWatchlist - 检查股票是否在自选股中的函数
|
||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||
*/
|
||||
const RelatedStocksSection = ({
|
||||
stocks,
|
||||
quotes = {},
|
||||
eventTime = null,
|
||||
watchlistSet = new Set(),
|
||||
isInWatchlist = () => false,
|
||||
onWatchlistToggle
|
||||
}) => {
|
||||
// 分时图数据状态:{ [stockCode]: data[] }
|
||||
const [timelineDataMap, setTimelineDataMap] = useState({});
|
||||
const [timelineLoading, setTimelineLoading] = useState(false);
|
||||
|
||||
// 日K线数据状态:{ [stockCode]: data[] }
|
||||
const [dailyDataMap, setDailyDataMap] = useState({});
|
||||
const [dailyLoading, setDailyLoading] = useState(false);
|
||||
|
||||
// 稳定的事件时间
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
// 稳定的股票列表 key
|
||||
const stocksKey = useMemo(() => {
|
||||
if (!stocks || stocks.length === 0) return '';
|
||||
return stocks.map(s => s.stock_code).sort().join(',');
|
||||
}, [stocks]);
|
||||
|
||||
// 计算分时图是否应该显示 loading
|
||||
const shouldShowTimelineLoading = useMemo(() => {
|
||||
if (!stocks || stocks.length === 0) return false;
|
||||
const currentDataKeys = Object.keys(timelineDataMap).sort().join(',');
|
||||
if (stocksKey !== currentDataKeys) {
|
||||
return true;
|
||||
}
|
||||
return timelineLoading;
|
||||
}, [stocks, stocksKey, timelineDataMap, timelineLoading]);
|
||||
|
||||
// 计算日K线是否应该显示 loading
|
||||
const shouldShowDailyLoading = useMemo(() => {
|
||||
if (!stocks || stocks.length === 0) return false;
|
||||
const currentDataKeys = Object.keys(dailyDataMap).sort().join(',');
|
||||
if (stocksKey !== currentDataKeys) {
|
||||
return true;
|
||||
}
|
||||
return dailyLoading;
|
||||
}, [stocks, stocksKey, dailyDataMap, dailyLoading]);
|
||||
|
||||
// 批量加载分时图数据
|
||||
useEffect(() => {
|
||||
if (!stocks || stocks.length === 0) {
|
||||
setTimelineDataMap({});
|
||||
setTimelineLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTimelineLoading(true);
|
||||
const stockCodes = stocks.map(s => s.stock_code);
|
||||
|
||||
// 检查缓存
|
||||
const cachedData = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, stableEventTime, 'timeline');
|
||||
const cached = klineDataCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
cachedData[code] = cached;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(cachedData).length === stockCodes.length) {
|
||||
setTimelineDataMap(cachedData);
|
||||
setTimelineLoading(false);
|
||||
logger.debug('RelatedStocksSection', '分时图数据全部来自缓存', { stockCount: stockCodes.length });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('RelatedStocksSection', '批量加载分时图数据', {
|
||||
totalCount: stockCodes.length,
|
||||
eventTime: stableEventTime
|
||||
});
|
||||
|
||||
fetchBatchKlineData(stockCodes, stableEventTime, 'timeline')
|
||||
.then((batchData) => {
|
||||
setTimelineDataMap({ ...cachedData, ...batchData });
|
||||
setTimelineLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('RelatedStocksSection', '批量加载分时图数据失败', error);
|
||||
setTimelineDataMap(cachedData);
|
||||
setTimelineLoading(false);
|
||||
});
|
||||
}, [stocksKey, stableEventTime]);
|
||||
|
||||
// 批量加载日K线数据
|
||||
useEffect(() => {
|
||||
if (!stocks || stocks.length === 0) {
|
||||
setDailyDataMap({});
|
||||
setDailyLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setDailyLoading(true);
|
||||
const stockCodes = stocks.map(s => s.stock_code);
|
||||
|
||||
// 检查缓存
|
||||
const cachedData = {};
|
||||
stockCodes.forEach(code => {
|
||||
const cacheKey = getCacheKey(code, stableEventTime, 'daily');
|
||||
const cached = klineDataCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
cachedData[code] = cached;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(cachedData).length === stockCodes.length) {
|
||||
setDailyDataMap(cachedData);
|
||||
setDailyLoading(false);
|
||||
logger.debug('RelatedStocksSection', '日K线数据全部来自缓存', { stockCount: stockCodes.length });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('RelatedStocksSection', '批量加载日K线数据', {
|
||||
totalCount: stockCodes.length,
|
||||
eventTime: stableEventTime
|
||||
});
|
||||
|
||||
fetchBatchKlineData(stockCodes, stableEventTime, 'daily')
|
||||
.then((batchData) => {
|
||||
setDailyDataMap({ ...cachedData, ...batchData });
|
||||
setDailyLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('RelatedStocksSection', '批量加载日K线数据失败', error);
|
||||
setDailyDataMap(cachedData);
|
||||
setDailyLoading(false);
|
||||
});
|
||||
}, [stocksKey, stableEventTime]);
|
||||
|
||||
// 如果没有股票数据,不渲染
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return null;
|
||||
@@ -35,8 +167,12 @@ const RelatedStocksSection = ({
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
eventTime={eventTime}
|
||||
isInWatchlist={watchlistSet.has(stock.stock_code)}
|
||||
isInWatchlist={isInWatchlist(stock.stock_code)}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
timelineData={timelineDataMap[stock.stock_code]}
|
||||
timelineLoading={shouldShowTimelineLoading && !timelineDataMap[stock.stock_code]}
|
||||
dailyData={dailyDataMap[stock.stock_code]}
|
||||
dailyLoading={shouldShowDailyLoading && !dailyDataMap[stock.stock_code]}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
|
||||
@@ -39,13 +39,21 @@ import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||
* @param {string} props.eventTime - 事件时间(可选)
|
||||
* @param {boolean} props.isInWatchlist - 是否在自选股中
|
||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||
* @param {Array} props.timelineData - 预加载的分时图数据(可选,由父组件批量加载后传入)
|
||||
* @param {boolean} props.timelineLoading - 分时图数据加载状态
|
||||
* @param {Array} props.dailyData - 预加载的日K线数据(可选,由父组件批量加载后传入)
|
||||
* @param {boolean} props.dailyLoading - 日K线数据加载状态
|
||||
*/
|
||||
const StockListItem = ({
|
||||
stock,
|
||||
quote = null,
|
||||
eventTime = null,
|
||||
isInWatchlist = false,
|
||||
onWatchlistToggle
|
||||
onWatchlistToggle,
|
||||
timelineData,
|
||||
timelineLoading = false,
|
||||
dailyData,
|
||||
dailyLoading = false
|
||||
}) => {
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
const cardBg = PROFESSIONAL_COLORS.background.card;
|
||||
@@ -66,7 +74,7 @@ const StockListItem = ({
|
||||
|
||||
const handleWatchlistClick = (e) => {
|
||||
e.stopPropagation();
|
||||
onWatchlistToggle?.(stock.stock_code, isInWatchlist);
|
||||
onWatchlistToggle?.(stock.stock_code, stock.stock_name, isInWatchlist);
|
||||
};
|
||||
|
||||
// 格式化涨跌幅显示
|
||||
@@ -187,12 +195,13 @@ const StockListItem = ({
|
||||
{onWatchlistToggle && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant={isInWatchlist ? 'solid' : 'ghost'}
|
||||
variant={isInWatchlist ? 'solid' : 'outline'}
|
||||
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
|
||||
icon={<StarIcon />}
|
||||
icon={<StarIcon color={isInWatchlist ? undefined : 'gray.400'} />}
|
||||
onClick={handleWatchlistClick}
|
||||
aria-label={isInWatchlist ? '已关注' : '加自选'}
|
||||
borderRadius="full"
|
||||
borderColor={isInWatchlist ? undefined : 'gray.300'}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
@@ -236,6 +245,8 @@ const StockListItem = ({
|
||||
<MiniTimelineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
preloadedData={timelineData}
|
||||
loading={timelineLoading}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
@@ -278,6 +289,8 @@ const StockListItem = ({
|
||||
<MiniKLineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
preloadedData={dailyData}
|
||||
loading={dailyLoading}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
|
||||
@@ -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,36 +1,8 @@
|
||||
.event-detail-modal {
|
||||
top: 20% !important;
|
||||
margin: 0 auto !important;
|
||||
padding-bottom: 0 !important;
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: 24px !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
// 标题样式 - 深色文字(白色背景)
|
||||
.ant-modal-title {
|
||||
// 事件详情抽屉样式(从底部弹出)
|
||||
// 注意:大部分样式已在 TSX 的 styles 属性中配置,这里只保留必要的覆盖
|
||||
.event-detail-drawer {
|
||||
// 标题样式
|
||||
.ant-drawer-title {
|
||||
color: #1A202C;
|
||||
}
|
||||
|
||||
// 关闭按钮样式 - 深色(白色背景)
|
||||
.ant-modal-close {
|
||||
color: #4A5568;
|
||||
|
||||
&:hover {
|
||||
color: #1A202C;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自底向上滑入动画
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Modal } from 'antd';
|
||||
import { Drawer } from 'antd';
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { selectIsMobile } from '@store/slices/deviceSlice';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
|
||||
import './EventDetailModal.less';
|
||||
@@ -15,7 +16,7 @@ interface EventDetailModalProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件详情弹窗组件
|
||||
* 事件详情抽屉组件(从底部弹出)
|
||||
*/
|
||||
const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
open,
|
||||
@@ -25,23 +26,35 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
|
||||
const isMobile = useSelector(selectIsMobile);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Drawer
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
onClose={onClose}
|
||||
placement="bottom"
|
||||
height={isMobile ? 'calc(100vh - 60px)' : 'calc(100vh - 100px)'}
|
||||
width={isMobile ? '100%' : '70vw'}
|
||||
title={event?.title || '事件详情'}
|
||||
width='100vw'
|
||||
destroyOnClose
|
||||
className="event-detail-modal"
|
||||
destroyOnHidden
|
||||
rootClassName="event-detail-drawer"
|
||||
closeIcon={null}
|
||||
extra={
|
||||
<CloseOutlined
|
||||
onClick={onClose}
|
||||
style={{ cursor: 'pointer', fontSize: 16, color: '#4A5568' }}
|
||||
/>
|
||||
}
|
||||
styles={{
|
||||
mask: { background: 'transparent' },
|
||||
content: { borderRadius: 24, padding: 0, maxWidth: 1400, background: 'transparent', margin: '0 auto' },
|
||||
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px', borderRadius: '24px 24px 0 0', margin: 0 },
|
||||
body: { padding: 0 },
|
||||
wrapper: isMobile ? {} : {
|
||||
maxWidth: 1400,
|
||||
margin: '0 auto',
|
||||
borderRadius: '16px 16px 0 0',
|
||||
},
|
||||
content: { borderRadius: '16px 16px 0 0' },
|
||||
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px' },
|
||||
body: { padding: 0, background: '#FFFFFF' },
|
||||
}}
|
||||
>
|
||||
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
|
||||
</Modal>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/views/Community/components/HeroPanel.js
|
||||
// 顶部说明面板组件:事件中心 + 沪深指数K线图 + 热门概念3D动画
|
||||
// 交易时间内自动更新指数行情(每分钟一次)
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
@@ -22,10 +23,12 @@ import {
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { AlertCircle, Clock, TrendingUp, Info } from 'lucide-react';
|
||||
import { AlertCircle, Clock, TrendingUp, Info, RefreshCw } from 'lucide-react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { useIndexQuote } from '../../../hooks/useIndexQuote';
|
||||
|
||||
// 定义动画
|
||||
const animations = `
|
||||
@@ -104,6 +107,7 @@ const isInTradingTime = () => {
|
||||
|
||||
/**
|
||||
* 精美K线指数卡片 - 类似 KLineChartModal 风格
|
||||
* 交易时间内自动更新实时行情(每分钟一次)
|
||||
*/
|
||||
const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
const [chartData, setChartData] = useState(null);
|
||||
@@ -113,38 +117,66 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
const upColor = '#ef5350'; // 涨 - 红色
|
||||
const downColor = '#26a69a'; // 跌 - 绿色
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
// 使用实时行情 Hook - 交易时间内每分钟自动更新
|
||||
const { quote, isTrading, refresh: refreshQuote } = useIndexQuote(indexCode, {
|
||||
refreshInterval: 60000, // 1分钟
|
||||
autoRefresh: true,
|
||||
});
|
||||
|
||||
// 加载日K线图数据
|
||||
const loadChartData = useCallback(async () => {
|
||||
const data = await fetchIndexKline(indexCode);
|
||||
if (data?.data?.length > 0) {
|
||||
const latest = data.data[data.data.length - 1];
|
||||
const prevClose = latest.prev_close || data.data[data.data.length - 2]?.close || latest.open;
|
||||
const changeAmount = latest.close - prevClose;
|
||||
const changePct = prevClose ? ((changeAmount / prevClose) * 100) : 0;
|
||||
|
||||
setLatestData({
|
||||
close: latest.close,
|
||||
open: latest.open,
|
||||
high: latest.high,
|
||||
low: latest.low,
|
||||
changeAmount: changeAmount,
|
||||
changePct: changePct,
|
||||
isPositive: changeAmount >= 0
|
||||
});
|
||||
|
||||
const recentData = data.data.slice(-60); // 增加到60天
|
||||
const recentData = data.data.slice(-60); // 最近60天
|
||||
setChartData({
|
||||
dates: recentData.map(item => item.time),
|
||||
klineData: recentData.map(item => [item.open, item.close, item.low, item.high]),
|
||||
volumes: recentData.map(item => item.volume || 0),
|
||||
rawData: recentData
|
||||
});
|
||||
|
||||
// 如果没有实时行情,使用日线数据的最新值
|
||||
if (!quote) {
|
||||
const latest = data.data[data.data.length - 1];
|
||||
const prevClose = latest.prev_close || data.data[data.data.length - 2]?.close || latest.open;
|
||||
const changeAmount = latest.close - prevClose;
|
||||
const changePct = prevClose ? ((changeAmount / prevClose) * 100) : 0;
|
||||
|
||||
setLatestData({
|
||||
close: latest.close,
|
||||
open: latest.open,
|
||||
high: latest.high,
|
||||
low: latest.low,
|
||||
changeAmount: changeAmount,
|
||||
changePct: changePct,
|
||||
isPositive: changeAmount >= 0
|
||||
});
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, [indexCode]);
|
||||
}, [indexCode, quote]);
|
||||
|
||||
// 初始加载日K数据
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
loadChartData();
|
||||
}, [loadChartData]);
|
||||
|
||||
// 当实时行情更新时,更新 latestData
|
||||
useEffect(() => {
|
||||
if (quote) {
|
||||
setLatestData({
|
||||
close: quote.price,
|
||||
open: quote.open,
|
||||
high: quote.high,
|
||||
low: quote.low,
|
||||
changeAmount: quote.change,
|
||||
changePct: quote.change_pct,
|
||||
isPositive: quote.change >= 0,
|
||||
updateTime: quote.update_time,
|
||||
isRealtime: true,
|
||||
});
|
||||
}
|
||||
}, [quote]);
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
if (!chartData) return {};
|
||||
@@ -306,6 +338,30 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
<Text fontSize="sm" color="whiteAlpha.800" fontWeight="semibold">
|
||||
{indexName}
|
||||
</Text>
|
||||
{/* 实时状态指示 */}
|
||||
{isTrading && latestData?.isRealtime && (
|
||||
<Tooltip label="实时行情,每分钟更新" placement="top">
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
bg="rgba(0,218,60,0.1)"
|
||||
borderRadius="full"
|
||||
border="1px solid rgba(0,218,60,0.3)"
|
||||
>
|
||||
<Box
|
||||
w="5px"
|
||||
h="5px"
|
||||
borderRadius="full"
|
||||
bg="#00da3c"
|
||||
animation="pulse 1.5s infinite"
|
||||
/>
|
||||
<Text fontSize="9px" color="#00da3c" fontWeight="bold">
|
||||
实时
|
||||
</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<Text fontSize="lg" fontWeight="bold" color="white" fontFamily="monospace">
|
||||
@@ -338,16 +394,22 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
/>
|
||||
{/* 底部提示 */}
|
||||
<Text
|
||||
{/* 底部提示 - 显示更新时间 */}
|
||||
<HStack
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
right={1}
|
||||
fontSize="9px"
|
||||
color="whiteAlpha.300"
|
||||
spacing={2}
|
||||
>
|
||||
滚轮缩放 · 拖动查看
|
||||
</Text>
|
||||
{latestData?.updateTime && (
|
||||
<Text fontSize="9px" color="whiteAlpha.400">
|
||||
{latestData.updateTime}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="9px" color="whiteAlpha.300">
|
||||
滚轮缩放 · 拖动查看
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
import { useDisclosure, useBreakpointValue } from '@chakra-ui/react';
|
||||
import EventDetailModal from './EventDetailModal';
|
||||
import dayjs from 'dayjs';
|
||||
import './HotEvents.css';
|
||||
@@ -31,6 +31,8 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
||||
const [modalEvent, setModalEvent] = useState(null);
|
||||
// H5 端不显示 Tooltip(避免触摸触发后无法消除的黑色悬浮框)
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
const renderPriceChange = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -154,21 +156,33 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
>
|
||||
{/* Custom layout without Card.Meta */}
|
||||
<div className="event-header">
|
||||
<Tooltip title={event.title}>
|
||||
{isMobile ? (
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={event.title}>
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="event-tag">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tooltip title={event.description}>
|
||||
{isMobile ? (
|
||||
<div className="event-description">
|
||||
{event.description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={event.description}>
|
||||
<div className="event-description">
|
||||
{event.description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
|
||||
@@ -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(() => {
|
||||
// 静默处理错误,预加载失败不影响用户体验
|
||||
});
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
|
||||
/**
|
||||
* 事件筛选逻辑 Hook
|
||||
@@ -145,7 +146,7 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
|
||||
});
|
||||
|
||||
if (navigate) {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
navigate(getEventDetailUrl(eventId));
|
||||
}
|
||||
}, [navigate, track]);
|
||||
|
||||
|
||||
@@ -107,28 +107,6 @@ const Community = () => {
|
||||
}
|
||||
}, [events, loading, pagination, filters]);
|
||||
|
||||
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
|
||||
const hasScrolled = useRef(false);
|
||||
useEffect(() => {
|
||||
// 只在第一次挂载时执行滚动
|
||||
if (hasScrolled.current) return;
|
||||
|
||||
// 延迟执行,确保DOM已完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
hasScrolled.current = true;
|
||||
// 滚动到容器顶部,自动考虑导航栏的高度
|
||||
containerRef.current.scrollIntoView({
|
||||
behavior: 'auto',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []); // 空依赖数组,只在组件挂载时执行一次
|
||||
|
||||
/**
|
||||
* ⚡ 【核心逻辑】注册 Socket 新事件回调 - 当收到新事件时智能刷新列表
|
||||
*
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
useDisclosure,
|
||||
SimpleGrid,
|
||||
Tooltip,
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
@@ -111,6 +112,9 @@ const ConceptTimelineModal = ({
|
||||
const [selectedNews, setSelectedNews] = useState(null);
|
||||
const [isNewsModalOpen, setIsNewsModalOpen] = useState(false);
|
||||
|
||||
// 响应式配置
|
||||
const isMobile = useBreakpointValue({ base: true, md: false }, { fallback: 'md' });
|
||||
|
||||
// 辅助函数:格式化日期显示(包含年份)
|
||||
const formatDateDisplay = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
@@ -602,37 +606,41 @@ const ConceptTimelineModal = ({
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="1400px" m={4}>
|
||||
<ModalContent maxW="1400px" m={{ base: 0, md: 'auto' }} mx="auto">
|
||||
<ModalHeader
|
||||
bgGradient="linear(135deg, purple.600 0%, purple.500 50%, pink.500 100%)"
|
||||
color="white"
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
py={6}
|
||||
py={{ base: 3, md: 6 }}
|
||||
px={{ base: 3, md: 6 }}
|
||||
boxShadow="lg"
|
||||
>
|
||||
<HStack spacing={4} flexWrap="wrap">
|
||||
<HStack spacing={{ base: 2, md: 4 }} flexWrap="wrap">
|
||||
<Icon
|
||||
as={FaChartLine}
|
||||
boxSize={6}
|
||||
boxSize={{ base: 4, md: 6 }}
|
||||
filter="drop-shadow(0 2px 4px rgba(0,0,0,0.2))"
|
||||
/>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
fontSize={{ base: 'md', md: 'xl' }}
|
||||
fontWeight="bold"
|
||||
textShadow="0 2px 4px rgba(0,0,0,0.2)"
|
||||
noOfLines={1}
|
||||
maxW={{ base: '120px', md: 'none' }}
|
||||
>
|
||||
{conceptName} - 历史时间轴
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme="yellow"
|
||||
px={3}
|
||||
px={{ base: 2, md: 3 }}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
boxShadow="md"
|
||||
>
|
||||
最近100天
|
||||
@@ -640,20 +648,29 @@ const ConceptTimelineModal = ({
|
||||
<Badge
|
||||
bg="whiteAlpha.300"
|
||||
color="white"
|
||||
px={3}
|
||||
px={{ base: 2, md: 3 }}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="xs"
|
||||
backdropFilter="blur(10px)"
|
||||
display={{ base: 'none', sm: 'flex' }}
|
||||
>
|
||||
🔥 Max版功能
|
||||
</Badge>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="white" />
|
||||
<ModalCloseButton
|
||||
color="white"
|
||||
size="lg"
|
||||
top={{ base: 2, md: 4 }}
|
||||
right={{ base: 2, md: 4 }}
|
||||
_hover={{ bg: 'whiteAlpha.300' }}
|
||||
zIndex={20}
|
||||
/>
|
||||
|
||||
<ModalBody
|
||||
py={6}
|
||||
py={{ base: 2, md: 6 }}
|
||||
px={{ base: 0, md: 6 }}
|
||||
bg="gray.50"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
@@ -680,103 +697,116 @@ const ConceptTimelineModal = ({
|
||||
</VStack>
|
||||
</Center>
|
||||
) : timelineData.length > 0 ? (
|
||||
<Box position="relative" maxW="1200px" mx="auto" px={4}>
|
||||
{/* 图例说明 */}
|
||||
<Flex justify="center" mb={6} flexWrap="wrap" gap={4}>
|
||||
<Box position="relative" maxW="1200px" mx="auto" px={{ base: 2, md: 4 }}>
|
||||
{/* 图例说明 - H5端保持一行 */}
|
||||
<Flex
|
||||
justify="center"
|
||||
mb={{ base: 3, md: 6 }}
|
||||
flexWrap={{ base: 'nowrap', md: 'wrap' }}
|
||||
gap={{ base: 1, md: 4 }}
|
||||
overflowX={{ base: 'auto', md: 'visible' }}
|
||||
pb={{ base: 2, md: 0 }}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': { display: 'none' },
|
||||
scrollbarWidth: 'none',
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="purple.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="purple.200"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box w={3} h={3} bg="#9F7AEA" borderRadius="full" boxShadow="sm" />
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">📰 新闻</Text>
|
||||
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#9F7AEA" borderRadius="full" />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">📰 新闻</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="purple.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="purple.300"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Box w={3} h={3} bg="#805AD5" borderRadius="full" boxShadow="sm" />
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">📊 研报</Text>
|
||||
<Box w={{ base: 2, md: 3 }} h={{ base: 2, md: 3 }} bg="#805AD5" borderRadius="full" />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">📊 研报</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="red.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="red.200"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Icon as={FaArrowUp} color="red.500" boxSize={3} />
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">上涨</Text>
|
||||
<Icon as={FaArrowUp} color="red.500" boxSize={{ base: 2, md: 3 }} />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">上涨</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="green.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="green.200"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Icon as={FaArrowDown} color="green.500" boxSize={3} />
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">下跌</Text>
|
||||
<Icon as={FaArrowDown} color="green.500" boxSize={{ base: 2, md: 3 }} />
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">下跌</Text>
|
||||
</HStack>
|
||||
<HStack
|
||||
spacing={2}
|
||||
px={4}
|
||||
py={2}
|
||||
spacing={{ base: 1, md: 2 }}
|
||||
px={{ base: 2, md: 4 }}
|
||||
py={{ base: 1, md: 2 }}
|
||||
bg="orange.50"
|
||||
borderRadius="lg"
|
||||
border="1px solid"
|
||||
borderColor="orange.200"
|
||||
boxShadow="sm"
|
||||
transition="all 0.2s"
|
||||
_hover={{ transform: 'translateY(-2px)', boxShadow: 'md' }}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="sm" fontWeight="bold">🔥</Text>
|
||||
<Text fontSize="sm" fontWeight="medium" color="gray.700">涨3%+</Text>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="bold">🔥</Text>
|
||||
<Text fontSize={{ base: 'xs', md: 'sm' }} fontWeight="medium" color="gray.700" whiteSpace="nowrap">涨3%+</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* FullCalendar 日历组件 */}
|
||||
<Box
|
||||
height={{ base: '600px', md: '700px' }}
|
||||
height={{ base: '500px', md: '700px' }}
|
||||
bg="white"
|
||||
borderRadius="xl"
|
||||
boxShadow="lg"
|
||||
p={4}
|
||||
borderRadius={{ base: 'none', md: 'xl' }}
|
||||
boxShadow={{ base: 'none', md: 'lg' }}
|
||||
p={{ base: 1, md: 4 }}
|
||||
sx={{
|
||||
// FullCalendar 样式定制
|
||||
'.fc': {
|
||||
height: '100%',
|
||||
},
|
||||
'.fc-header-toolbar': {
|
||||
marginBottom: '1.5rem',
|
||||
marginBottom: { base: '0.5rem', md: '1.5rem' },
|
||||
padding: { base: '0 4px', md: '0' },
|
||||
flexWrap: 'nowrap',
|
||||
gap: { base: '4px', md: '8px' },
|
||||
},
|
||||
'.fc-toolbar-chunk': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
'.fc-toolbar-title': {
|
||||
fontSize: '1.5rem',
|
||||
fontSize: { base: '1rem', md: '1.5rem' },
|
||||
fontWeight: 'bold',
|
||||
color: 'purple.600',
|
||||
},
|
||||
@@ -784,6 +814,8 @@ const ConceptTimelineModal = ({
|
||||
backgroundColor: '#9F7AEA',
|
||||
borderColor: '#9F7AEA',
|
||||
color: 'white',
|
||||
padding: { base: '4px 8px', md: '6px 12px' },
|
||||
fontSize: { base: '12px', md: '14px' },
|
||||
'&:hover': {
|
||||
backgroundColor: '#805AD5',
|
||||
borderColor: '#805AD5',
|
||||
@@ -806,14 +838,18 @@ const ConceptTimelineModal = ({
|
||||
},
|
||||
},
|
||||
'.fc-daygrid-day-number': {
|
||||
padding: '4px',
|
||||
fontSize: '0.875rem',
|
||||
padding: { base: '2px', md: '4px' },
|
||||
fontSize: { base: '0.75rem', md: '0.875rem' },
|
||||
},
|
||||
'.fc-col-header-cell-cushion': {
|
||||
fontSize: { base: '0.75rem', md: '0.875rem' },
|
||||
padding: { base: '4px 2px', md: '8px' },
|
||||
},
|
||||
'.fc-event': {
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
padding: '2px 4px',
|
||||
fontSize: '0.75rem',
|
||||
padding: { base: '1px 2px', md: '2px 4px' },
|
||||
fontSize: { base: '0.65rem', md: '0.75rem' },
|
||||
fontWeight: 'bold',
|
||||
borderRadius: '4px',
|
||||
transition: 'all 0.2s',
|
||||
@@ -823,7 +859,13 @@ const ConceptTimelineModal = ({
|
||||
},
|
||||
},
|
||||
'.fc-daygrid-event-harness': {
|
||||
marginBottom: '2px',
|
||||
marginBottom: { base: '1px', md: '2px' },
|
||||
},
|
||||
// H5 端隐藏事件文字,只显示色块
|
||||
'@media (max-width: 768px)': {
|
||||
'.fc-event-title': {
|
||||
fontSize: '0.6rem',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -882,32 +924,11 @@ const ConceptTimelineModal = ({
|
||||
)}
|
||||
|
||||
{/* 风险提示 */}
|
||||
<Box px={6}>
|
||||
<Box px={{ base: 2, md: 6 }}>
|
||||
<RiskDisclaimer variant="default" />
|
||||
</Box>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter
|
||||
borderTop="2px solid"
|
||||
borderColor="purple.100"
|
||||
bg="gray.50"
|
||||
py={4}
|
||||
>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
size="lg"
|
||||
px={8}
|
||||
onClick={onClose}
|
||||
boxShadow="md"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'lg',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Collapse,
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, ViewIcon, CalendarIcon, ExternalLinkIcon, StarIcon, ChevronDownIcon, InfoIcon, CloseIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import { FaThLarge, FaList, FaTags, FaChartLine, FaRobot, FaTable, FaHistory, FaBrain, FaLightbulb, FaRocket, FaShieldAlt, FaCalendarAlt, FaArrowUp, FaArrowDown, FaNewspaper, FaFileAlt, FaExpand, FaCompress, FaClock, FaLock } from 'react-icons/fa';
|
||||
@@ -85,6 +86,8 @@ import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import ConceptTimelineModal from './ConceptTimelineModal';
|
||||
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
||||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
// 导入订阅权限管理
|
||||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
@@ -527,109 +530,6 @@ const ConceptCenter = () => {
|
||||
return `https://valuefrontier.cn/company?scode=${seccode}`;
|
||||
};
|
||||
|
||||
// 渲染动态表格列
|
||||
const renderStockTable = () => {
|
||||
if (!selectedConceptStocks || selectedConceptStocks.length === 0) {
|
||||
return <Text>暂无相关股票数据</Text>;
|
||||
}
|
||||
|
||||
const allFields = new Set();
|
||||
selectedConceptStocks.forEach(stock => {
|
||||
Object.keys(stock).forEach(key => allFields.add(key));
|
||||
});
|
||||
|
||||
// 定义固定的列顺序,包含新增的现价和涨跌幅列
|
||||
const orderedFields = ['stock_name', 'stock_code', 'current_price', 'change_percent'];
|
||||
allFields.forEach(field => {
|
||||
if (!orderedFields.includes(field)) {
|
||||
orderedFields.push(field);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{loadingStockData && (
|
||||
<Box mb={4} textAlign="center">
|
||||
<HStack justify="center" spacing={2}>
|
||||
<Spinner size="sm" color="purple.500" />
|
||||
<Text fontSize="sm" color="gray.600">正在获取行情数据...</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TableContainer maxH="60vh" overflowY="auto">
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead position="sticky" top={0} bg="white" zIndex={1}>
|
||||
<Tr>
|
||||
{orderedFields.map(field => (
|
||||
<Th key={field}>
|
||||
{field === 'stock_name' ? '股票名称' :
|
||||
field === 'stock_code' ? '股票代码' :
|
||||
field === 'current_price' ? '现价' :
|
||||
field === 'change_percent' ? '当日涨跌幅' : field}
|
||||
</Th>
|
||||
))}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{selectedConceptStocks.map((stock, idx) => {
|
||||
const marketData = stockMarketData[stock.stock_code];
|
||||
const companyLink = generateCompanyLink(stock.stock_code);
|
||||
|
||||
return (
|
||||
<Tr key={idx} _hover={{ bg: 'gray.50' }}>
|
||||
{orderedFields.map(field => {
|
||||
let cellContent = stock[field] || '-';
|
||||
let cellProps = {};
|
||||
|
||||
// 处理特殊字段
|
||||
if (field === 'current_price') {
|
||||
cellContent = marketData ? formatPrice(marketData.close) : (loadingStockData ? <Spinner size="xs" /> : '-');
|
||||
} else if (field === 'change_percent') {
|
||||
if (marketData) {
|
||||
cellContent = formatStockChangePercent(marketData.change_percent);
|
||||
cellProps.color = `${getStockChangeColor(marketData.change_percent)}.500`;
|
||||
cellProps.fontWeight = 'bold';
|
||||
} else {
|
||||
cellContent = loadingStockData ? <Spinner size="xs" /> : '-';
|
||||
}
|
||||
} else if (field === 'stock_name' || field === 'stock_code') {
|
||||
// 添加超链接
|
||||
cellContent = (
|
||||
<Text
|
||||
as="a"
|
||||
href={companyLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
color="blue.600"
|
||||
textDecoration="underline"
|
||||
_hover={{
|
||||
color: 'blue.800',
|
||||
textDecoration: 'underline'
|
||||
}}
|
||||
cursor="pointer"
|
||||
>
|
||||
{stock[field] || '-'}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Td key={field} {...cellProps}>
|
||||
{cellContent}
|
||||
</Td>
|
||||
);
|
||||
})}
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 格式化添加日期显示
|
||||
const formatAddedDate = (concept) => {
|
||||
// 优先使用 created_at 或 added_date 字段
|
||||
@@ -672,6 +572,10 @@ const ConceptCenter = () => {
|
||||
const changePercent = concept.price_info?.avg_change_pct;
|
||||
const changeColor = getChangeColor(changePercent);
|
||||
const hasChange = changePercent !== null && changePercent !== undefined;
|
||||
// H5 端使用更紧凑的尺寸
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const coverHeight = useBreakpointValue({ base: '100px', md: '180px' });
|
||||
const logoSize = useBreakpointValue({ base: '60px', md: '120px' });
|
||||
|
||||
// 生成随机涨幅数字背景
|
||||
const generateNumbersBackground = () => {
|
||||
@@ -705,7 +609,7 @@ const ConceptCenter = () => {
|
||||
boxShadow="0 4px 12px rgba(0, 0, 0, 0.1)"
|
||||
>
|
||||
{/* 毛玻璃涨幅数字背景 */}
|
||||
<Box position="relative" height="180px" overflow="hidden">
|
||||
<Box position="relative" height={coverHeight} overflow="hidden">
|
||||
{/* 渐变背景层 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
@@ -757,8 +661,8 @@ const ConceptCenter = () => {
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
width="120px"
|
||||
height="120px"
|
||||
width={logoSize}
|
||||
height={logoSize}
|
||||
opacity={0.15}
|
||||
>
|
||||
<Image
|
||||
@@ -849,11 +753,11 @@ const ConceptCenter = () => {
|
||||
</Badge>
|
||||
</Box>
|
||||
|
||||
<CardBody p={4}>
|
||||
<VStack align="start" spacing={2}>
|
||||
<CardBody p={{ base: 3, md: 4 }}>
|
||||
<VStack align="start" spacing={{ base: 1, md: 2 }}>
|
||||
{/* 概念名称 */}
|
||||
<Heading
|
||||
size="sm"
|
||||
size={{ base: 'xs', md: 'sm' }}
|
||||
color="gray.800"
|
||||
noOfLines={1}
|
||||
bgGradient="linear(to-r, purple.600, pink.600)"
|
||||
@@ -863,15 +767,15 @@ const ConceptCenter = () => {
|
||||
{concept.concept}
|
||||
</Heading>
|
||||
|
||||
{/* 描述信息 */}
|
||||
<Text color="gray.600" fontSize="xs" noOfLines={2} minH="32px">
|
||||
{/* 描述信息 - H5端显示1行 */}
|
||||
<Text color="gray.600" fontSize="xs" noOfLines={isMobile ? 1 : 2} minH={{ base: '16px', md: '32px' }}>
|
||||
{concept.description || '暂无描述信息'}
|
||||
</Text>
|
||||
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Box
|
||||
width="100%"
|
||||
p={3}
|
||||
p={{ base: 2, md: 3 }}
|
||||
bg="linear-gradient(135deg, rgba(99, 102, 241, 0.05) 0%, rgba(168, 85, 247, 0.05) 100%)"
|
||||
borderRadius="lg"
|
||||
cursor="pointer"
|
||||
@@ -886,7 +790,7 @@ const ConceptCenter = () => {
|
||||
>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Box flex={1}>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<HStack spacing={2} mb={{ base: 1, md: 2 }}>
|
||||
<Icon as={FaChartLine} boxSize={3} color="purple.500" />
|
||||
<Text fontSize="xs" color="purple.700" fontWeight="bold">
|
||||
热门个股
|
||||
@@ -942,20 +846,20 @@ const ConceptCenter = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider borderColor="purple.100" />
|
||||
<Divider borderColor="purple.100" my={{ base: 1, md: 0 }} />
|
||||
|
||||
<Flex width="100%" justify="space-between" align="center">
|
||||
{formatAddedDate(concept)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
size={{ base: 'xs', md: 'sm' }}
|
||||
leftIcon={<FaHistory />}
|
||||
bgGradient="linear(to-r, purple.500, pink.500)"
|
||||
color="white"
|
||||
variant="solid"
|
||||
onClick={(e) => handleViewContent(e, concept.concept, concept.concept_id)}
|
||||
borderRadius="full"
|
||||
px={4}
|
||||
px={{ base: 2, md: 4 }}
|
||||
fontWeight="medium"
|
||||
boxShadow="0 4px 12px rgba(139, 92, 246, 0.3)"
|
||||
_hover={{
|
||||
@@ -1179,23 +1083,23 @@ const ConceptCenter = () => {
|
||||
align={{ base: 'stretch', lg: 'center' }}
|
||||
gap={4}
|
||||
>
|
||||
<HStack spacing={3}>
|
||||
<Icon as={FaCalendarAlt} color="purple.500" boxSize={5} />
|
||||
<Text fontWeight="bold" color="purple.700">交易日期:</Text>
|
||||
</HStack>
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
value={selectedDate ? selectedDate.toISOString().split('T')[0] : ''}
|
||||
onChange={handleDateChange}
|
||||
max={new Date().toISOString().split('T')[0]}
|
||||
width={{ base: '100%', lg: '200px' }}
|
||||
focusBorderColor="purple.500"
|
||||
borderColor="purple.200"
|
||||
borderRadius="lg"
|
||||
fontWeight="medium"
|
||||
{/* 使用通用日期选择器组件 */}
|
||||
<TradeDatePicker
|
||||
value={selectedDate}
|
||||
onChange={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const previousDate = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||||
trackFilterApplied('date', dateStr, previousDate);
|
||||
setSelectedDate(date);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ date: dateStr, page: 1 });
|
||||
fetchConcepts(searchQuery, 1, date, sortBy);
|
||||
}}
|
||||
latestTradeDate={latestTradeDate}
|
||||
label="交易日期"
|
||||
/>
|
||||
|
||||
{/* 快捷按钮保留在页面内 */}
|
||||
<ButtonGroup size="sm" variant="outline" flexWrap="wrap">
|
||||
<Button
|
||||
onClick={() => handleQuickDateSelect(0)}
|
||||
@@ -1246,25 +1150,6 @@ const ConceptCenter = () => {
|
||||
一月前
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
{latestTradeDate && (
|
||||
<Tooltip label="数据库中最新的交易日期">
|
||||
<HStack
|
||||
spacing={2}
|
||||
bg="blue.50"
|
||||
px={3}
|
||||
py={1.5}
|
||||
borderRadius="full"
|
||||
border="1px solid"
|
||||
borderColor="blue.200"
|
||||
>
|
||||
<Icon as={InfoIcon} color="blue.500" boxSize={3} />
|
||||
<Text fontSize="sm" color="blue.600" fontWeight="medium">
|
||||
最新: {latestTradeDate.toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
@@ -1463,7 +1348,7 @@ const ConceptCenter = () => {
|
||||
fontSize="md"
|
||||
transition="all 0.2s"
|
||||
border="none"
|
||||
height="100%"
|
||||
alignSelf="stretch"
|
||||
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
|
||||
>
|
||||
搜索
|
||||
@@ -1598,7 +1483,7 @@ const ConceptCenter = () => {
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }}>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
@@ -1606,7 +1491,7 @@ const ConceptCenter = () => {
|
||||
) : concepts.length > 0 ? (
|
||||
<>
|
||||
{viewMode === 'grid' ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6} className="concept-grid">
|
||||
<SimpleGrid columns={{ base: 2, md: 2, lg: 3 }} spacing={{ base: 3, md: 6 }} className="concept-grid">
|
||||
{concepts.map((concept, index) => (
|
||||
<Box key={concept.concept_id} className="concept-item" role="group">
|
||||
<ConceptCard concept={concept} position={index} />
|
||||
@@ -1758,32 +1643,15 @@ const ConceptCenter = () => {
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
{/* 股票详情Modal */}
|
||||
<Modal
|
||||
{/* 股票详情Modal - 复用通用组件 */}
|
||||
<ConceptStocksModal
|
||||
isOpen={isStockModalOpen}
|
||||
onClose={() => setIsStockModalOpen(false)}
|
||||
size="6xl"
|
||||
scrollBehavior="inside"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader bg="purple.500" color="white">
|
||||
<HStack>
|
||||
<Icon as={FaTable} />
|
||||
<Text>{selectedConceptName} - 相关个股</Text>
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color="white" />
|
||||
<ModalBody py={6}>
|
||||
{renderStockTable()}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button colorScheme="purple" onClick={() => setIsStockModalOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
concept={{
|
||||
concept_name: selectedConceptName,
|
||||
stocks: selectedConceptStocks
|
||||
}}
|
||||
/>
|
||||
{/* 时间轴Modal */}
|
||||
<ConceptTimelineModal
|
||||
isOpen={isTimelineModalOpen}
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
} from 'react-icons/fi';
|
||||
import MyFutureEvents from './components/MyFutureEvents';
|
||||
import InvestmentPlanningCenter from './components/InvestmentPlanningCenter';
|
||||
import { getEventDetailUrl } from '@/utils/idEncoder';
|
||||
|
||||
export default function CenterDashboard() {
|
||||
const { user } = useAuth();
|
||||
@@ -69,9 +70,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 +99,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 +219,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(() => {
|
||||
@@ -431,7 +442,7 @@ export default function CenterDashboard() {
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<LinkOverlay
|
||||
as={Link}
|
||||
to={`/event-detail/${event.id}`}
|
||||
to={getEventDetailUrl(event.id)}
|
||||
>
|
||||
<Text fontWeight="medium" fontSize="md" noOfLines={2}>
|
||||
{event.title}
|
||||
|
||||
@@ -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,909 +1,88 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
/**
|
||||
* EventDetail - 事件详情页面
|
||||
* 使用 DynamicNewsDetailPanel 组件展示事件详情
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
VStack,
|
||||
HStack,
|
||||
Spinner,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Flex,
|
||||
useColorModeValue,
|
||||
Grid,
|
||||
GridItem,
|
||||
Icon,
|
||||
Text,
|
||||
Badge,
|
||||
Divider,
|
||||
useDisclosure,
|
||||
Button,
|
||||
Heading,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
SimpleGrid,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Textarea,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Input,
|
||||
Collapse,
|
||||
Center,
|
||||
useToast,
|
||||
Skeleton,
|
||||
Box,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiLock } from 'react-icons/fi';
|
||||
import {
|
||||
FiTrendingUp,
|
||||
FiActivity,
|
||||
FiMessageSquare,
|
||||
FiClock,
|
||||
FiBarChart2,
|
||||
FiLink,
|
||||
FiZap,
|
||||
FiGlobe,
|
||||
FiHeart,
|
||||
FiTrash2,
|
||||
FiChevronDown,
|
||||
FiChevronUp,
|
||||
} from 'react-icons/fi';
|
||||
import { FaHeart, FaRegHeart, FaComment } from 'react-icons/fa';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
|
||||
// 导入新建的业务组件
|
||||
import EventHeader from './components/EventHeader';
|
||||
import RelatedConcepts from './components/RelatedConcepts';
|
||||
import HistoricalEvents from './components/HistoricalEvents';
|
||||
import RelatedStocks from './components/RelatedStocks';
|
||||
// Navigation bar now provided by MainLayout
|
||||
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
import TransmissionChainAnalysis from './components/TransmissionChainAnalysis';
|
||||
|
||||
// 导入你的 Flask API 服务
|
||||
import { eventService } from '../../services/eventService';
|
||||
import { debugEventService } from '../../utils/debugEventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useEventDetailEvents } from './hooks/useEventDetailEvents';
|
||||
|
||||
// 临时调试代码 - 生产环境测试后请删除
|
||||
if (typeof window !== 'undefined') {
|
||||
logger.debug('EventDetail', '调试 eventService');
|
||||
debugEventService();
|
||||
}
|
||||
|
||||
// 统计卡片组件 - 更简洁的设计
|
||||
const StatCard = ({ icon, label, value, color }) => {
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const iconColor = useColorModeValue(`${color}.500`, `${color}.300`);
|
||||
|
||||
return (
|
||||
<Stat
|
||||
p={6}
|
||||
bg={bg}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack spacing={3} align="flex-start">
|
||||
<Icon as={icon} boxSize={5} color={iconColor} mt={1} />
|
||||
<Box flex={1}>
|
||||
<StatLabel color="gray.500" fontSize="sm">{label}</StatLabel>
|
||||
<StatNumber fontSize="2xl" color={iconColor}>{value}</StatNumber>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Stat>
|
||||
);
|
||||
};
|
||||
|
||||
// 帖子组件
|
||||
const PostItem = ({ post, onRefresh, eventEvents }) => {
|
||||
const [showComments, setShowComments] = useState(false);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [liked, setLiked] = useState(post.liked || false);
|
||||
const [likesCount, setLikesCount] = useState(post.likes_count || 0);
|
||||
const toast = useToast();
|
||||
const { user } = useAuth();
|
||||
const bg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!showComments) {
|
||||
setShowComments(true);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await eventService.getPostComments(post.id);
|
||||
if (result.success) {
|
||||
setComments(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PostItem', 'loadComments', error, { postId: post.id });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
setShowComments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLike = async () => {
|
||||
try {
|
||||
const result = await eventService.likePost(post.id);
|
||||
if (result.success) {
|
||||
const newLikedState = result.liked;
|
||||
setLiked(newLikedState);
|
||||
setLikesCount(result.likes_count);
|
||||
|
||||
// 🎯 追踪评论点赞
|
||||
if (eventEvents && eventEvents.trackCommentLiked) {
|
||||
eventEvents.trackCommentLiked(post.id, newLikedState);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '操作失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!newComment.trim()) return;
|
||||
|
||||
try {
|
||||
const result = await eventService.addPostComment(post.id, {
|
||||
content: newComment,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 🎯 追踪添加评论
|
||||
if (eventEvents && eventEvents.trackCommentAdded) {
|
||||
eventEvents.trackCommentAdded(
|
||||
result.data?.id || post.id,
|
||||
newComment.length
|
||||
);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '评论发表成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
setNewComment('');
|
||||
// 重新加载评论
|
||||
const commentsResult = await eventService.getPostComments(post.id);
|
||||
if (commentsResult.success) {
|
||||
setComments(commentsResult.data);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '评论失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (window.confirm('确定要删除这个帖子吗?')) {
|
||||
try {
|
||||
const result = await eventService.deletePost(post.id);
|
||||
if (result.success) {
|
||||
// 🎯 追踪删除评论
|
||||
if (eventEvents && eventEvents.trackCommentDeleted) {
|
||||
eventEvents.trackCommentDeleted(post.id);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
p={6}
|
||||
mb={4}
|
||||
>
|
||||
{/* 帖子头部 */}
|
||||
<HStack justify="space-between" mb={4}>
|
||||
<HStack spacing={3}>
|
||||
<Avatar
|
||||
size="sm"
|
||||
name={post.user?.username}
|
||||
src={post.user?.avatar_url}
|
||||
/>
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text fontWeight="medium">{post.user?.username || '匿名用户'}</Text>
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{format(new Date(post.created_at), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
{/* 帖子内容 */}
|
||||
{post.title && (
|
||||
<Heading size="md" mb={2}>
|
||||
{post.title}
|
||||
</Heading>
|
||||
)}
|
||||
<Text mb={4} whiteSpace="pre-wrap">
|
||||
{post.content}
|
||||
</Text>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={liked ? <FaHeart /> : <FaRegHeart />}
|
||||
color={liked ? 'red.500' : 'gray.500'}
|
||||
onClick={handleLike}
|
||||
>
|
||||
{likesCount}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
leftIcon={<FaComment />}
|
||||
rightIcon={showComments ? <FiChevronUp /> : <FiChevronDown />}
|
||||
onClick={loadComments}
|
||||
>
|
||||
{post.comments_count || 0} 评论
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 评论区 */}
|
||||
<Collapse in={showComments} animateOpacity>
|
||||
<Box mt={4} pt={4} borderTopWidth="1px" borderColor={borderColor}>
|
||||
{/* 评论输入 */}
|
||||
<HStack mb={4}>
|
||||
<Textarea
|
||||
placeholder="写下你的评论..."
|
||||
size="sm"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
size="sm"
|
||||
onClick={handleAddComment}
|
||||
isDisabled={!newComment.trim()}
|
||||
>
|
||||
评论
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 评论列表 */}
|
||||
{isLoading ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" />
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{comments.map((comment) => (
|
||||
<Box key={comment.id} pl={4} borderLeftWidth="2px" borderColor="gray.200">
|
||||
<HStack mb={1}>
|
||||
<Text fontWeight="medium" fontSize="sm">
|
||||
{comment.user?.username || '匿名用户'}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
{format(new Date(comment.created_at), 'MM-dd HH:mm')}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="sm">{comment.content}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{comments.length === 0 && (
|
||||
<Text color="gray.500" textAlign="center" py={2}>
|
||||
暂无评论
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
import { decodeEventId } from '@/utils/idEncoder';
|
||||
import { eventService } from '@/services/eventService';
|
||||
import { DynamicNewsDetailPanel } from '@/views/Community/components/DynamicNewsDetail';
|
||||
import { logger } from '@/utils/logger';
|
||||
import ErrorPage from '@/components/ErrorPage';
|
||||
|
||||
const EventDetail = () => {
|
||||
const { eventId } = useParams();
|
||||
const location = useLocation();
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const toast = useToast();
|
||||
const { eventId: pathEventId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// 用户认证和权限控制
|
||||
const { user } = useAuth();
|
||||
const { hasFeatureAccess, getUpgradeRecommendation } = useSubscription();
|
||||
// 优先从查询参数获取加密 ID,兼容旧的路径参数
|
||||
const encodedId = searchParams.get('id');
|
||||
const eventId = encodedId ? decodeEventId(encodedId) : pathEventId;
|
||||
|
||||
// 滚动位置管理
|
||||
const scrollPositionRef = useRef(0);
|
||||
|
||||
// State hooks
|
||||
const [eventData, setEventData] = useState(null);
|
||||
const [relatedStocks, setRelatedStocks] = useState([]);
|
||||
const [relatedConcepts, setRelatedConcepts] = useState([]);
|
||||
const [historicalEvents, setHistoricalEvents] = useState([]);
|
||||
const [posts, setPosts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [postsLoading, setPostsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
// 🎯 初始化事件详情埋点Hook(传入event对象)
|
||||
const eventEvents = useEventDetailEvents({
|
||||
event: eventData ? {
|
||||
id: eventData.id,
|
||||
title: eventData.title,
|
||||
importance: eventData.importance
|
||||
} : null
|
||||
});
|
||||
const [newPostContent, setNewPostContent] = useState('');
|
||||
const [newPostTitle, setNewPostTitle] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [upgradeModal, setUpgradeModal] = useState({ isOpen: false, feature: '功能', required: 'pro' });
|
||||
|
||||
// 从URL路径中提取eventId(处理多种URL格式)
|
||||
const getEventIdFromPath = () => {
|
||||
const pathParts = location.pathname.split('/');
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
const secondLastPart = pathParts[pathParts.length - 2];
|
||||
|
||||
if (!isNaN(lastPart) && lastPart) {
|
||||
return lastPart;
|
||||
}
|
||||
if (!isNaN(secondLastPart) && secondLastPart) {
|
||||
return secondLastPart;
|
||||
}
|
||||
return eventId;
|
||||
};
|
||||
|
||||
const actualEventId = getEventIdFromPath();
|
||||
|
||||
// 保存当前滚动位置
|
||||
const saveScrollPosition = () => {
|
||||
scrollPositionRef.current = window.scrollY || window.pageYOffset;
|
||||
};
|
||||
|
||||
// 恢复滚动位置
|
||||
const restoreScrollPosition = () => {
|
||||
window.scrollTo(0, scrollPositionRef.current);
|
||||
};
|
||||
// 状态
|
||||
const [eventData, setEventData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// 加载事件基础数据
|
||||
useEffect(() => {
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
if (!eventId) {
|
||||
setError('无效的事件ID');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载基本事件信息(免费用户也可以访问)
|
||||
const eventResponse = await eventService.getEventDetail(actualEventId);
|
||||
setEventData(eventResponse.data);
|
||||
|
||||
// 总是尝试加载相关股票(权限在组件内部检查)
|
||||
let stocksCount = 0;
|
||||
try {
|
||||
const stocksResponse = await eventService.getRelatedStocks(actualEventId);
|
||||
setRelatedStocks(stocksResponse.data || []);
|
||||
stocksCount = stocksResponse.data?.length || 0;
|
||||
} catch (e) {
|
||||
logger.warn('EventDetail', '加载相关股票失败', { eventId: actualEventId, error: e.message });
|
||||
setRelatedStocks([]);
|
||||
}
|
||||
|
||||
// 根据权限决定是否加载相关概念
|
||||
if (hasFeatureAccess('related_concepts')) {
|
||||
try {
|
||||
const conceptsResponse = await eventService.getRelatedConcepts(actualEventId);
|
||||
setRelatedConcepts(conceptsResponse.data || []);
|
||||
} catch (e) {
|
||||
logger.warn('EventDetail', '加载相关概念失败', { eventId: actualEventId, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 历史事件所有用户都可以访问,但免费用户只看到前2条
|
||||
let timelineCount = 0;
|
||||
try {
|
||||
const eventsResponse = await eventService.getHistoricalEvents(actualEventId);
|
||||
setHistoricalEvents(eventsResponse.data || []);
|
||||
timelineCount = eventsResponse.data?.length || 0;
|
||||
} catch (e) {
|
||||
logger.warn('EventDetail', '历史事件加载失败', { eventId: actualEventId, error: e.message });
|
||||
}
|
||||
|
||||
// 🎯 追踪事件分析内容查看(数据加载完成后)
|
||||
if (eventResponse.data && eventEvents) {
|
||||
eventEvents.trackEventAnalysisViewed({
|
||||
type: 'overview',
|
||||
relatedStockCount: stocksCount,
|
||||
timelineEventCount: timelineCount,
|
||||
marketImpact: eventResponse.data.market_impact
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'loadEventData', err, { eventId: actualEventId });
|
||||
setError(err.message || '加载事件数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await eventService.getEventDetail(eventId);
|
||||
setEventData(response.data);
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'loadEventData', err, { eventId });
|
||||
setError(err.message || '加载事件数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refetchStocks = async () => {
|
||||
if (!hasFeatureAccess('related_stocks')) return;
|
||||
try {
|
||||
const stocksResponse = await eventService.getRelatedStocks(actualEventId);
|
||||
setRelatedStocks(stocksResponse.data);
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'refetchStocks', err, { eventId: actualEventId });
|
||||
}
|
||||
};
|
||||
loadEventData();
|
||||
}, [eventId]);
|
||||
|
||||
const handleFollowToggle = async () => {
|
||||
try {
|
||||
await eventService.toggleFollow(actualEventId, eventData.is_following);
|
||||
|
||||
setEventData(prev => ({
|
||||
...prev,
|
||||
is_following: !prev.is_following,
|
||||
follower_count: prev.is_following
|
||||
? prev.follower_count - 1
|
||||
: prev.follower_count + 1
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'handleFollowToggle', err, { eventId: actualEventId });
|
||||
}
|
||||
};
|
||||
|
||||
// 加载帖子列表
|
||||
const loadPosts = async () => {
|
||||
setPostsLoading(true);
|
||||
try {
|
||||
const result = await eventService.getPosts(actualEventId);
|
||||
if (result.success) {
|
||||
setPosts(result.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('EventDetail', 'loadPosts', err, { eventId: actualEventId });
|
||||
} finally {
|
||||
setPostsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建新帖子
|
||||
const handleCreatePost = async () => {
|
||||
if (!newPostContent.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await eventService.createPost(actualEventId, {
|
||||
title: newPostTitle.trim(),
|
||||
content: newPostContent.trim(),
|
||||
content_type: 'text',
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '帖子发布成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
setNewPostContent('');
|
||||
setNewPostTitle('');
|
||||
loadPosts();
|
||||
// 更新帖子数
|
||||
setEventData(prev => ({
|
||||
...prev,
|
||||
post_count: (prev.post_count || 0) + 1
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: '发布失败',
|
||||
description: err.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect hook - must be called after all state hooks
|
||||
useEffect(() => {
|
||||
if (actualEventId) {
|
||||
// 保存当前滚动位置
|
||||
saveScrollPosition();
|
||||
|
||||
loadEventData();
|
||||
loadPosts();
|
||||
|
||||
// 数据加载完成后恢复滚动位置
|
||||
// 使用 setTimeout 确保 DOM 已更新
|
||||
const timer = setTimeout(() => {
|
||||
restoreScrollPosition();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setError('无效的事件ID');
|
||||
setLoading(false);
|
||||
}
|
||||
}, [actualEventId, location.pathname]);
|
||||
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={6}>
|
||||
<Skeleton height="150px" borderRadius="lg" />
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4} w="100%">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} height="80px" borderRadius="md" />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Grid templateColumns={{ base: "1fr", lg: "1fr 1fr" }} gap={6} w="100%">
|
||||
<Skeleton height="300px" borderRadius="lg" />
|
||||
<Skeleton height="300px" borderRadius="lg" />
|
||||
</Grid>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%" p={4}>
|
||||
<Container maxW="7xl" py={8}>
|
||||
<Center minH="60vh">
|
||||
<Alert
|
||||
status="error"
|
||||
borderRadius="lg"
|
||||
maxW="md"
|
||||
flexDirection="column"
|
||||
textAlign="center"
|
||||
p={6}
|
||||
>
|
||||
<AlertIcon boxSize="40px" mr={0} />
|
||||
<AlertTitle mt={4} mb={2} fontSize="lg">
|
||||
加载失败
|
||||
</AlertTitle>
|
||||
<AlertDescription maxWidth="sm">
|
||||
{error}
|
||||
{actualEventId && (
|
||||
<Text mt={2} fontSize="sm" color="gray.500">
|
||||
事件ID: {actualEventId}
|
||||
</Text>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</Center>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 主要内容
|
||||
// 加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" w="100%">
|
||||
{/* Navigation bar provided by MainLayout */}
|
||||
<Container maxW="7xl" py={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
{/* 事件基本信息 */}
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
<EventHeader
|
||||
event={eventData}
|
||||
onFollowToggle={handleFollowToggle}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<SimpleGrid columns={{ base: 2, md: 4 }} spacing={4}>
|
||||
<StatCard
|
||||
icon={FiTrendingUp}
|
||||
label="关注度"
|
||||
value={eventData?.follower_count || 0}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={hasFeatureAccess('related_stocks') ? FiActivity : FiLock}
|
||||
label="相关标的"
|
||||
value={hasFeatureAccess('related_stocks') ? relatedStocks.length : '🔒需Pro'}
|
||||
color={hasFeatureAccess('related_stocks') ? "green" : "orange"}
|
||||
/>
|
||||
<StatCard
|
||||
icon={FiZap}
|
||||
label="预期偏离度"
|
||||
value={`${(eventData?.expectation_surprise_score || 0).toFixed(1)}%`}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard
|
||||
icon={FiMessageSquare}
|
||||
label="讨论数"
|
||||
value={eventData?.post_count || 0}
|
||||
color="orange"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* 主要内容标签页 */}
|
||||
<Tabs colorScheme="blue" size="md">
|
||||
<TabList>
|
||||
<Tab>
|
||||
相关标的
|
||||
{!hasFeatureAccess('related_stocks') && (
|
||||
<Icon as={FiLock} ml={1} boxSize={3} color="orange.400" />
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>
|
||||
相关概念
|
||||
{!hasFeatureAccess('related_concepts') && (
|
||||
<Icon as={FiLock} ml={1} boxSize={3} color="orange.400" />
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>历史事件</Tab>
|
||||
<Tab>
|
||||
传导链分析
|
||||
{!hasFeatureAccess('transmission_chain') && (
|
||||
<Icon as={FiLock} ml={1} boxSize={3} color="purple.400" />
|
||||
)}
|
||||
</Tab>
|
||||
<Tab>讨论区</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 相关标的标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
{!hasFeatureAccess('related_stocks') ? (
|
||||
<VStack spacing={3} align="center" py={8}>
|
||||
<Icon as={FiLock} boxSize={8} color="orange.400" />
|
||||
<Text>该功能为Pro专享,请升级订阅后查看相关标的。</Text>
|
||||
<Button colorScheme="blue" onClick={() => setUpgradeModal({ isOpen: true, feature: '相关标的', required: 'pro' })}>升级到Pro版</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<RelatedStocks
|
||||
eventId={actualEventId}
|
||||
eventTime={eventData?.created_at}
|
||||
stocks={relatedStocks}
|
||||
loading={false}
|
||||
error={null}
|
||||
onStockAdded={refetchStocks}
|
||||
onStockDeleted={refetchStocks}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 相关概念标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
{!hasFeatureAccess('related_concepts') ? (
|
||||
<VStack spacing={3} align="center" py={8}>
|
||||
<Icon as={FiLock} boxSize={8} color="orange.400" />
|
||||
<Text>该功能为Pro专享,请升级订阅后查看相关概念。</Text>
|
||||
<Button colorScheme="blue" onClick={() => setUpgradeModal({ isOpen: true, feature: '相关概念', required: 'pro' })}>升级到Pro版</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<RelatedConcepts
|
||||
eventTitle={eventData?.title}
|
||||
eventTime={eventData?.created_at}
|
||||
eventId={actualEventId}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 历史事件标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
<HistoricalEvents
|
||||
events={historicalEvents}
|
||||
expectationScore={eventData?.expectation_surprise_score}
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
{!hasFeatureAccess('historical_events_full') && historicalEvents.length > 0 && (
|
||||
<Box mt={4} p={3} bg="orange.50" borderRadius="md" border="1px solid" borderColor="orange.200">
|
||||
<HStack>
|
||||
<Icon as={FiLock} color="orange.400" />
|
||||
<Text color="orange.700" fontSize="sm">
|
||||
免费版仅展示前2条历史事件,
|
||||
<Button
|
||||
variant="link"
|
||||
colorScheme="orange"
|
||||
size="sm"
|
||||
onClick={() => setUpgradeModal({ isOpen: true, feature: '完整历史事件', required: 'pro' })}
|
||||
>
|
||||
升级Pro版
|
||||
</Button>
|
||||
可查看全部。
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 传导链分析标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
>
|
||||
{!hasFeatureAccess('transmission_chain') ? (
|
||||
<VStack spacing={3} align="center" py={8}>
|
||||
<Icon as={FiLock} boxSize={8} color="purple.400" />
|
||||
<Text>传导链分析为Max专享,请升级订阅后查看。</Text>
|
||||
<Button colorScheme="purple" onClick={() => setUpgradeModal({ isOpen: true, feature: '传导链分析', required: 'max' })}>升级到Max版</Button>
|
||||
</VStack>
|
||||
) : (
|
||||
<TransmissionChainAnalysis
|
||||
eventId={actualEventId}
|
||||
eventService={eventService}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* 讨论区标签页 */}
|
||||
<TabPanel px={0}>
|
||||
<VStack spacing={6}>
|
||||
{/* 发布新帖子 */}
|
||||
{user && (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={6}
|
||||
w="100%"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Input
|
||||
placeholder="帖子标题(可选)"
|
||||
value={newPostTitle}
|
||||
onChange={(e) => setNewPostTitle(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
placeholder="分享你的想法..."
|
||||
value={newPostContent}
|
||||
onChange={(e) => setNewPostContent(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
<HStack w="100%" justify="flex-end">
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={handleCreatePost}
|
||||
isLoading={submitting}
|
||||
isDisabled={!newPostContent.trim()}
|
||||
>
|
||||
发布
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 帖子列表 */}
|
||||
<Box w="100%">
|
||||
{postsLoading ? (
|
||||
<VStack spacing={4}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} height="120px" w="100%" borderRadius="lg" />
|
||||
))}
|
||||
</VStack>
|
||||
) : posts.length > 0 ? (
|
||||
posts.map((post) => (
|
||||
<PostItem
|
||||
key={post.id}
|
||||
post={post}
|
||||
onRefresh={loadPosts}
|
||||
eventEvents={eventEvents}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.800')}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.700')}
|
||||
p={8}
|
||||
textAlign="center"
|
||||
>
|
||||
<Text color="gray.500">还没有讨论,来发布第一个帖子吧!</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</VStack>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
{/* 升级弹窗 */}
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModal.isOpen}
|
||||
onClose={() => setUpgradeModal({ isOpen: false, feature: '功能', required: 'pro' })}
|
||||
requiredLevel={upgradeModal.required}
|
||||
featureName={upgradeModal.feature}
|
||||
currentLevel={user?.subscription_type || 'free'}
|
||||
/>
|
||||
</Box>
|
||||
<Box minH="100vh" w="100%">
|
||||
<Center py={20}>
|
||||
<Spinner size="xl" color="blue.500" />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (!error) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title="页面找不到了"
|
||||
description={error}
|
||||
detail={eventId}
|
||||
detailLabel="事件ID"
|
||||
showRetry
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 主内容
|
||||
return (
|
||||
<Box maxW="7xl" mx="auto"><DynamicNewsDetailPanel event={eventData} showHeader={true} /></Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetail;
|
||||
export default EventDetail;
|
||||
|
||||
@@ -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,16 +88,10 @@ 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
|
||||
spacing={{ base: 8, md: 12, lg: 16 }}
|
||||
spacing={{ base: 5, md: 8, lg: 10 }}
|
||||
align="stretch"
|
||||
minH={heroHeight}
|
||||
justify="center"
|
||||
@@ -113,8 +104,8 @@ const HomePage: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* 核心功能面板 */}
|
||||
<Box pb={{ base: 8, md: 12 }}>
|
||||
<VStack spacing={{ base: 6, md: 8 }}>
|
||||
<Box pb={{ base: 5, md: 8 }}>
|
||||
<VStack spacing={{ base: 4, md: 5 }}>
|
||||
{/* 特色功能卡片 - 新闻中心 */}
|
||||
<FeaturedFeatureCard
|
||||
feature={featuredFeature}
|
||||
@@ -124,7 +115,7 @@ const HomePage: React.FC = () => {
|
||||
{/* 其他功能卡片 */}
|
||||
<SimpleGrid
|
||||
columns={{ base: 1, md: 2, lg: 3 }}
|
||||
spacing={{ base: 4, md: 5, lg: 6 }}
|
||||
spacing={{ base: 2, md: 3, lg: 4 }}
|
||||
w="100%"
|
||||
>
|
||||
{regularFeatures.map((feature) => (
|
||||
|
||||
@@ -34,51 +34,51 @@ export const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
backdropFilter="blur(10px)"
|
||||
border="1px solid"
|
||||
borderColor="whiteAlpha.200"
|
||||
borderRadius={{ base: 'xl', md: '2xl' }}
|
||||
borderRadius={{ base: 'lg', md: 'xl' }}
|
||||
transition="all 0.3s ease"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: '2xl'
|
||||
transform: 'translateY(-3px)',
|
||||
shadow: 'xl'
|
||||
}}
|
||||
_active={{
|
||||
bg: 'whiteAlpha.200',
|
||||
borderColor: `${feature.color}.400`,
|
||||
transform: 'translateY(-2px)'
|
||||
transform: 'translateY(-1px)'
|
||||
}}
|
||||
onClick={() => onClick(feature)}
|
||||
minH={{ base: 'auto', md: '180px' }}
|
||||
minH={{ base: 'auto', md: '120px' }}
|
||||
cursor="pointer"
|
||||
>
|
||||
<CardBody p={{ base: 5, md: 6 }}>
|
||||
<VStack spacing={{ base: 3, md: 4 }} align="start" h="100%">
|
||||
<CardBody p={{ base: 3, md: 4 }}>
|
||||
<VStack spacing={{ base: 2, md: 2.5 }} align="start" h="100%">
|
||||
<HStack>
|
||||
<Box
|
||||
p={{ base: 2, md: 3 }}
|
||||
borderRadius="lg"
|
||||
p={{ base: 1.5, md: 2 }}
|
||||
borderRadius="md"
|
||||
bg={`${feature.color}.50`}
|
||||
border="1px solid"
|
||||
borderColor={`${feature.color}.200`}
|
||||
>
|
||||
<Text fontSize={{ base: 'xl', md: '2xl' }}>{feature.icon}</Text>
|
||||
<Text fontSize={{ base: 'md', md: 'lg' }}>{feature.icon}</Text>
|
||||
</Box>
|
||||
<Badge
|
||||
colorScheme={feature.color}
|
||||
variant="solid"
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
fontSize={{ base: '2xs', md: 'xs' }}
|
||||
>
|
||||
{feature.badge}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<VStack align="start" spacing={{ base: 1, md: 2 }} flex={1}>
|
||||
<Heading size={{ base: 'md', md: 'lg' }} color="white">
|
||||
<VStack align="start" spacing={{ base: 0.5, md: 1 }} flex={1}>
|
||||
<Heading size={{ base: 'sm', md: 'md' }} color="white">
|
||||
{feature.title}
|
||||
</Heading>
|
||||
<Text
|
||||
color="whiteAlpha.800"
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
fontSize={{ base: '2xs', md: 'xs' }}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{feature.description}
|
||||
@@ -87,11 +87,11 @@ export const FeatureCard: React.FC<FeatureCardProps> = ({
|
||||
|
||||
<Button
|
||||
colorScheme={feature.color}
|
||||
size={{ base: 'md', md: 'sm' }}
|
||||
size={{ base: 'sm', md: 'xs' }}
|
||||
variant="outline"
|
||||
alignSelf="flex-end"
|
||||
w={{ base: '100%', md: 'auto' }}
|
||||
minH="44px"
|
||||
minH="32px"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(feature);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const FeaturedFeatureCard: React.FC<FeaturedFeatureCardProps> = ({
|
||||
bg="transparent"
|
||||
border="2px solid"
|
||||
borderColor="yellow.400"
|
||||
borderRadius={{ base: '2xl', md: '3xl' }}
|
||||
borderRadius={{ base: 'xl', md: '2xl' }}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
shadow="2xl"
|
||||
@@ -50,35 +50,35 @@ export const FeaturedFeatureCard: React.FC<FeaturedFeatureCardProps> = ({
|
||||
zIndex: 0
|
||||
}}
|
||||
>
|
||||
<CardBody p={{ base: 6, md: 8 }} position="relative" zIndex={1}>
|
||||
<CardBody p={{ base: 4, md: 5 }} position="relative" zIndex={1}>
|
||||
<Flex
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
align={{ base: 'stretch', md: 'center' }}
|
||||
justify={{ base: 'flex-start', md: 'space-between' }}
|
||||
gap={{ base: 4, md: 6 }}
|
||||
gap={{ base: 3, md: 4 }}
|
||||
>
|
||||
<Flex align="center" gap={{ base: 4, md: 6 }} flex={1}>
|
||||
<Flex align="center" gap={{ base: 3, md: 4 }} flex={1}>
|
||||
<Box
|
||||
p={{ base: 3, md: 4 }}
|
||||
borderRadius={{ base: 'lg', md: 'xl' }}
|
||||
p={{ base: 2, md: 2.5 }}
|
||||
borderRadius={{ base: 'md', md: 'lg' }}
|
||||
bg="yellow.400"
|
||||
color="black"
|
||||
>
|
||||
<Text fontSize={{ base: '2xl', md: '3xl' }}>{feature.icon}</Text>
|
||||
<Text fontSize={{ base: 'xl', md: '2xl' }}>{feature.icon}</Text>
|
||||
</Box>
|
||||
<VStack align="start" spacing={{ base: 1, md: 2 }} flex={1}>
|
||||
<VStack align="start" spacing={{ base: 0.5, md: 1 }} flex={1}>
|
||||
<HStack>
|
||||
<Heading size={{ base: 'lg', md: 'xl' }} color="white">
|
||||
<Heading size={{ base: 'md', md: 'lg' }} color="white">
|
||||
{feature.title}
|
||||
</Heading>
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize={{ base: 'xs', md: 'sm' }}>
|
||||
<Badge colorScheme="yellow" variant="solid" fontSize={{ base: '2xs', md: 'xs' }}>
|
||||
{feature.badge}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<Text
|
||||
color="whiteAlpha.800"
|
||||
fontSize={{ base: 'md', md: 'lg' }}
|
||||
maxW={{ md: 'md' }}
|
||||
fontSize={{ base: 'xs', md: 'sm' }}
|
||||
maxW={{ md: 'sm' }}
|
||||
lineHeight="tall"
|
||||
>
|
||||
{feature.description}
|
||||
@@ -87,12 +87,12 @@ export const FeaturedFeatureCard: React.FC<FeaturedFeatureCardProps> = ({
|
||||
</Flex>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
size={{ base: 'md', md: 'lg' }}
|
||||
size={{ base: 'sm', md: 'md' }}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
w={{ base: '100%', md: 'auto' }}
|
||||
onClick={() => onClick(feature)}
|
||||
minH="44px"
|
||||
minH="36px"
|
||||
flexShrink={0}
|
||||
>
|
||||
进入功能 →
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -21,9 +21,9 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<VStack
|
||||
spacing={{ base: 4, md: 5, lg: 6 }}
|
||||
spacing={{ base: 2, md: 3, lg: 4 }}
|
||||
textAlign="center"
|
||||
pt={{ base: 4, md: 6, lg: 8 }}
|
||||
pt={{ base: 2, md: 4, lg: 5 }}
|
||||
>
|
||||
<Heading
|
||||
size={headingSize}
|
||||
@@ -37,9 +37,9 @@ export const HeroHeader: React.FC<HeroHeaderProps> = ({
|
||||
<Text
|
||||
fontSize={heroTextSize}
|
||||
color="whiteAlpha.800"
|
||||
maxW={{ base: '100%', md: '2xl', lg: '3xl' }}
|
||||
maxW={{ base: '100%', md: 'xl', lg: '2xl' }}
|
||||
lineHeight="tall"
|
||||
px={{ base: 4, md: 0 }}
|
||||
px={{ base: 2, md: 0 }}
|
||||
>
|
||||
专业投资研究工具,助您把握市场机遇
|
||||
</Text>
|
||||
|
||||
@@ -27,7 +27,7 @@ export const subscriptionConfig = {
|
||||
{
|
||||
name: 'pro',
|
||||
displayName: 'Pro 专业版',
|
||||
description: '为专业投资者打造,解锁高级分析功能',
|
||||
description: '事件关联股票深度分析\n历史事件智能对比复盘\n事件概念关联与挖掘\n概念板块个股追踪\n概念深度研报与解读\n个股异动实时预警',
|
||||
icon: 'gem',
|
||||
badge: '推荐',
|
||||
badgeColor: 'gold',
|
||||
@@ -68,27 +68,18 @@ export const subscriptionConfig = {
|
||||
},
|
||||
],
|
||||
features: [
|
||||
{ name: '新闻信息流', enabled: true },
|
||||
{ name: '历史事件对比', enabled: true },
|
||||
{ name: '事件传导链分析(AI)', enabled: true },
|
||||
{ name: '事件-相关标的分析', enabled: true },
|
||||
{ name: '相关概念展示', enabled: true },
|
||||
{ name: 'AI复盘功能', enabled: true },
|
||||
{ name: '企业概览', enabled: true },
|
||||
{ name: '个股深度分析(AI)', enabled: true, limit: '50家/月' },
|
||||
{ name: '高效数据筛选工具', enabled: true },
|
||||
{ name: '概念中心(548大概念)', enabled: true },
|
||||
{ name: '历史时间轴查询', enabled: true, limit: '100天' },
|
||||
{ name: '涨停板块数据分析', enabled: true },
|
||||
{ name: '个股涨停分析', enabled: true },
|
||||
{ name: '板块深度分析(AI)', enabled: false },
|
||||
{ name: '概念高频更新', enabled: false },
|
||||
{ name: '事件关联股票深度分析', enabled: true },
|
||||
{ name: '历史事件智能对比复盘', enabled: true },
|
||||
{ name: '事件概念关联与挖掘', enabled: true },
|
||||
{ name: '概念板块个股追踪', enabled: true },
|
||||
{ name: '概念深度研报与解读', enabled: true },
|
||||
{ name: '个股异动实时预警', enabled: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'max',
|
||||
displayName: 'Max 旗舰版',
|
||||
description: '旗舰级体验,无限制使用所有功能',
|
||||
description: '包含Pro版全部功能\n事件传导链路智能分析\n概念演变时间轴追溯\n个股全方位深度研究\n价小前投研助手无限使用\n新功能优先体验权\n专属客服一对一服务',
|
||||
icon: 'crown',
|
||||
badge: '最受欢迎',
|
||||
badgeColor: 'gold',
|
||||
@@ -129,21 +120,13 @@ export const subscriptionConfig = {
|
||||
},
|
||||
],
|
||||
features: [
|
||||
{ name: '新闻信息流', enabled: true },
|
||||
{ name: '历史事件对比', enabled: true },
|
||||
{ name: '事件传导链分析(AI)', enabled: true },
|
||||
{ name: '事件-相关标的分析', enabled: true },
|
||||
{ name: '相关概念展示', enabled: true },
|
||||
{ name: '板块深度分析(AI)', enabled: true },
|
||||
{ name: 'AI复盘功能', enabled: true },
|
||||
{ name: '企业概览', enabled: true },
|
||||
{ name: '个股深度分析(AI)', enabled: true, limit: '无限制' },
|
||||
{ name: '高效数据筛选工具', enabled: true },
|
||||
{ name: '概念中心(548大概念)', enabled: true },
|
||||
{ name: '历史时间轴查询', enabled: true, limit: '无限制' },
|
||||
{ name: '概念高频更新', enabled: true },
|
||||
{ name: '涨停板块数据分析', enabled: true },
|
||||
{ name: '个股涨停分析', enabled: true },
|
||||
{ name: '包含Pro版全部功能', enabled: true },
|
||||
{ name: '事件传导链路智能分析', enabled: true },
|
||||
{ name: '概念演变时间轴追溯', enabled: true },
|
||||
{ name: '个股全方位深度研究', enabled: true },
|
||||
{ name: '价小前投研助手无限使用', enabled: true },
|
||||
{ name: '新功能优先体验权', enabled: true },
|
||||
{ name: '专属客服一对一服务', enabled: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
Spacer,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useColorMode,
|
||||
useToast,
|
||||
Spinner,
|
||||
Center,
|
||||
@@ -42,7 +41,6 @@ import {
|
||||
useDisclosure,
|
||||
Image,
|
||||
Fade,
|
||||
ScaleFade,
|
||||
Collapse,
|
||||
Stack,
|
||||
Progress,
|
||||
@@ -50,37 +48,26 @@ import {
|
||||
TagLabel,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverBody,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||
import { SearchIcon, CloseIcon, ArrowForwardIcon, TrendingUpIcon, InfoIcon, ChevronRightIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
|
||||
import ConceptStocksModal from '@components/ConceptStocksModal';
|
||||
import TradeDatePicker from '@components/TradeDatePicker';
|
||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import * as echarts from 'echarts';
|
||||
import { logger } from '../../utils/logger';
|
||||
import tradingDays from '../../data/tradingDays.json';
|
||||
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
||||
|
||||
// 交易日 Set,用于快速查找
|
||||
const tradingDaysSet = new Set(tradingDays);
|
||||
// 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();
|
||||
const { colorMode, toggleColorMode } = useColorMode();
|
||||
const colorMode = 'light'; // 固定为 light 模式
|
||||
const heatmapRef = useRef(null);
|
||||
const heatmapChart = useRef(null);
|
||||
|
||||
@@ -110,7 +97,10 @@ const StockOverview = () => {
|
||||
const [selectedDate, setSelectedDate] = useState(null);
|
||||
const [marketStats, setMarketStats] = useState(null);
|
||||
const [availableDates, setAvailableDates] = useState([]);
|
||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||
|
||||
// 个股列表弹窗状态
|
||||
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
|
||||
const [selectedConcept, setSelectedConcept] = useState(null);
|
||||
|
||||
// 专业的颜色主题
|
||||
const bgColor = useColorModeValue('white', '#0a0a0a');
|
||||
@@ -124,6 +114,13 @@ const StockOverview = () => {
|
||||
const accentColor = useColorModeValue('purple.600', goldColor);
|
||||
const heroBg = useColorModeValue('linear(to-br, purple.600, pink.500)', 'linear(to-br, #0a0a0a, #1a1a1a)');
|
||||
|
||||
// 打开个股列表弹窗
|
||||
const handleViewStocks = useCallback((e, concept) => {
|
||||
e.stopPropagation();
|
||||
setSelectedConcept(concept);
|
||||
setIsStockModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// 防抖搜索
|
||||
const debounceSearch = useCallback(
|
||||
(() => {
|
||||
@@ -187,7 +184,27 @@ const StockOverview = () => {
|
||||
|
||||
if (data.success) {
|
||||
setTopConcepts(data.data);
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
// 使用概念接口的日期作为统一数据源(数据最新)
|
||||
setSelectedDate(new Date(data.trade_date));
|
||||
// 基于交易日历生成可选日期列表
|
||||
if (data.trade_date && tradingDays.length > 0) {
|
||||
// 找到当前日期或最近的交易日
|
||||
let targetDate = data.trade_date;
|
||||
if (!tradingDaysSet.has(data.trade_date)) {
|
||||
for (let i = tradingDays.length - 1; i >= 0; i--) {
|
||||
if (tradingDays[i] <= data.trade_date) {
|
||||
targetDate = tradingDays[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const idx = tradingDays.indexOf(targetDate);
|
||||
if (idx !== -1) {
|
||||
const startIdx = Math.max(0, idx - 19);
|
||||
const dates = tradingDays.slice(startIdx, idx + 1).reverse();
|
||||
setAvailableDates(dates);
|
||||
}
|
||||
}
|
||||
logger.debug('StockOverview', '热门概念加载成功', {
|
||||
count: data.data?.length || 0,
|
||||
date: data.trade_date
|
||||
@@ -218,7 +235,7 @@ const StockOverview = () => {
|
||||
falling_count: data.statistics.falling_count
|
||||
}));
|
||||
}
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
// 日期由 fetchTopConcepts 统一设置,这里不再设置
|
||||
logger.debug('StockOverview', '热力图数据加载成功', {
|
||||
count: data.data?.length || 0,
|
||||
date: data.trade_date
|
||||
@@ -249,11 +266,9 @@ const StockOverview = () => {
|
||||
date: data.trade_date
|
||||
};
|
||||
setMarketStats(newStats);
|
||||
setAvailableDates(data.available_dates || []);
|
||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
||||
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
|
||||
logger.debug('StockOverview', '市场统计数据加载成功', {
|
||||
date: data.trade_date,
|
||||
availableDatesCount: data.available_dates?.length || 0
|
||||
date: data.trade_date
|
||||
});
|
||||
|
||||
// 🎯 追踪市场统计数据查看
|
||||
@@ -498,20 +513,6 @@ const StockOverview = () => {
|
||||
window.open(htmlPath, '_blank');
|
||||
};
|
||||
|
||||
// 处理日期选择
|
||||
const handleDateChange = (date) => {
|
||||
const previousDate = selectedDate;
|
||||
|
||||
// 🎯 追踪日期变化
|
||||
trackDateChanged(date, previousDate);
|
||||
|
||||
setSelectedDate(date);
|
||||
setIsCalendarOpen(false);
|
||||
// 重新获取数据
|
||||
fetchHeatmapData(date);
|
||||
fetchMarketStats(date);
|
||||
fetchTopConcepts(date);
|
||||
};
|
||||
|
||||
// 格式化涨跌幅
|
||||
const formatChangePercent = (value) => {
|
||||
@@ -600,29 +601,10 @@ const StockOverview = () => {
|
||||
filter="blur(40px)"
|
||||
/>
|
||||
|
||||
{/* 日夜模式切换按钮 */}
|
||||
<Box position="absolute" top={4} right={4}>
|
||||
<IconButton
|
||||
aria-label="Toggle color mode"
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={toggleColorMode}
|
||||
size="lg"
|
||||
bg={colorMode === 'dark' ? '#1a1a1a' : 'white'}
|
||||
color={colorMode === 'dark' ? goldColor : 'purple.600'}
|
||||
border="2px solid"
|
||||
borderColor={colorMode === 'dark' ? goldColor : 'purple.200'}
|
||||
_hover={{
|
||||
bg: colorMode === 'dark' ? '#2a2a2a' : 'purple.50',
|
||||
transform: 'scale(1.1)'
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<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"
|
||||
@@ -833,60 +815,27 @@ const StockOverview = () => {
|
||||
<Container maxW="container.xl" py={10}>
|
||||
{/* 日期选择器 */}
|
||||
<Box mb={6}>
|
||||
<Popover isOpen={isCalendarOpen} onClose={() => setIsCalendarOpen(false)}>
|
||||
<PopoverTrigger>
|
||||
<Button
|
||||
leftIcon={<CalendarIcon />}
|
||||
onClick={() => setIsCalendarOpen(!isCalendarOpen)}
|
||||
variant="outline"
|
||||
size="md"
|
||||
colorScheme={colorMode === 'dark' ? 'yellow' : 'purple'}
|
||||
bg={colorMode === 'dark' ? '#1a1a1a' : 'white'}
|
||||
border="2px solid"
|
||||
borderColor={colorMode === 'dark' ? goldColor : 'purple.300'}
|
||||
_hover={{
|
||||
bg: colorMode === 'dark' ? '#2a2a2a' : 'purple.50'
|
||||
}}
|
||||
>
|
||||
{selectedDate ?
|
||||
`交易日期: ${selectedDate}` :
|
||||
'选择交易日期'
|
||||
}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent bg={cardBg} borderColor={borderColor} boxShadow="xl">
|
||||
<PopoverBody p={4}>
|
||||
<VStack align="start" spacing={3}>
|
||||
<Text fontWeight="bold" color={textColor}>选择交易日期</Text>
|
||||
<Divider />
|
||||
{availableDates.length > 0 ? (
|
||||
<VStack align="stretch" maxH="300px" overflowY="auto" spacing={1} w="100%">
|
||||
{availableDates.map((date) => (
|
||||
<Button
|
||||
key={date}
|
||||
size="sm"
|
||||
variant={selectedDate === date ? 'solid' : 'ghost'}
|
||||
colorScheme={selectedDate === date ? (colorMode === 'dark' ? 'yellow' : 'purple') : 'gray'}
|
||||
onClick={() => handleDateChange(date)}
|
||||
justifyContent="start"
|
||||
w="100%"
|
||||
>
|
||||
{date}
|
||||
</Button>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Text fontSize="sm" color={subTextColor}>
|
||||
暂无可用日期
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</PopoverBody>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Flex align="center" gap={4} flexWrap="wrap">
|
||||
<TradeDatePicker
|
||||
value={selectedDate}
|
||||
onChange={(date) => {
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
const previousDateStr = selectedDate ? selectedDate.toISOString().split('T')[0] : null;
|
||||
trackDateChanged(dateStr, previousDateStr);
|
||||
setSelectedDate(date);
|
||||
fetchHeatmapData(dateStr);
|
||||
fetchMarketStats(dateStr);
|
||||
fetchTopConcepts(dateStr);
|
||||
}}
|
||||
latestTradeDate={null}
|
||||
minDate={tradingDays.length > 0 ? new Date(tradingDays[0]) : undefined}
|
||||
maxDate={tradingDays.length > 0 ? new Date(tradingDays[tradingDays.length - 1]) : undefined}
|
||||
label="交易日期"
|
||||
/>
|
||||
</Flex>
|
||||
{selectedDate && (
|
||||
<Text fontSize="sm" color={subTextColor} mt={2}>
|
||||
当前显示 {selectedDate} 的市场数据
|
||||
当前显示 {selectedDate.toISOString().split('T')[0]} 的市场数据
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -922,8 +871,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 +913,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'}
|
||||
>
|
||||
@@ -989,31 +937,33 @@ const StockOverview = () => {
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box w="100%">
|
||||
<Box
|
||||
w="100%"
|
||||
cursor="pointer"
|
||||
onClick={(e) => handleViewStocks(e, concept)}
|
||||
_hover={{ bg: hoverBg }}
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||
包含 {concept.stock_count} 只个股
|
||||
</Text>
|
||||
|
||||
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Flex flexWrap="wrap" gap={2}>
|
||||
<Flex
|
||||
flexWrap="nowrap"
|
||||
gap={2}
|
||||
overflow="hidden"
|
||||
maxH="24px"
|
||||
>
|
||||
{concept.stocks.map((stock, idx) => (
|
||||
<Tag
|
||||
key={idx}
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
variant="subtle"
|
||||
cursor="pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 🎯 追踪概念下的股票标签点击
|
||||
trackConceptStockClicked({
|
||||
code: stock.stock_code,
|
||||
name: stock.stock_name
|
||||
}, concept.concept_name);
|
||||
|
||||
navigate(`/company?scode=${stock.stock_code}`);
|
||||
}}
|
||||
flexShrink={0}
|
||||
>
|
||||
<TagLabel>{stock.stock_name}</TagLabel>
|
||||
</Tag>
|
||||
@@ -1039,7 +989,6 @@ const StockOverview = () => {
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</ScaleFade>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
@@ -1114,7 +1063,14 @@ const StockOverview = () => {
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
|
||||
{/* 个股列表弹窗 */}
|
||||
<ConceptStocksModal
|
||||
isOpen={isStockModalOpen}
|
||||
onClose={() => setIsStockModalOpen(false)}
|
||||
concept={selectedConcept}
|
||||
/>
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user