Compare commits
51 Commits
feature_20
...
2a653afea1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a653afea1 | ||
|
|
6628ddc7b2 | ||
|
|
5dc480f5f4 | ||
|
|
99f102a213 | ||
| a37206ec97 | |||
|
|
9f6c98135f | ||
| 5e5e2160b0 | |||
|
|
f0074bca42 | ||
|
|
e8285599e8 | ||
| 0eb760fa31 | |||
|
|
cdca889083 | ||
|
|
c0d8bf20a3 | ||
| 805b897afa | |||
| 2988af9806 | |||
| 63023adcf3 | |||
|
|
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.
594
app.py
594
app.py
@@ -1510,8 +1510,8 @@ def initialize_subscription_plans_safe():
|
|||||||
|
|
||||||
pro_plan = SubscriptionPlan(
|
pro_plan = SubscriptionPlan(
|
||||||
name='pro',
|
name='pro',
|
||||||
display_name='Pro版本',
|
display_name='Pro 专业版',
|
||||||
description='适合个人投资者的基础功能套餐',
|
description='事件关联股票深度分析 | 历史事件智能对比复盘 | 事件概念关联与挖掘 | 概念板块个股追踪 | 概念深度研报与解读 | 个股异动实时预警',
|
||||||
monthly_price=0.01,
|
monthly_price=0.01,
|
||||||
yearly_price=0.08,
|
yearly_price=0.08,
|
||||||
features=json.dumps([
|
features=json.dumps([
|
||||||
@@ -1526,8 +1526,8 @@ def initialize_subscription_plans_safe():
|
|||||||
|
|
||||||
max_plan = SubscriptionPlan(
|
max_plan = SubscriptionPlan(
|
||||||
name='max',
|
name='max',
|
||||||
display_name='Max版本',
|
display_name='Max 旗舰版',
|
||||||
description='适合专业投资者的全功能套餐',
|
description='包含Pro版全部功能 | 事件传导链路智能分析 | 概念演变时间轴追溯 | 个股全方位深度研究 | 价小前投研助手无限使用 | 新功能优先体验权 | 专属客服一对一服务',
|
||||||
monthly_price=0.1,
|
monthly_price=0.1,
|
||||||
yearly_price=0.8,
|
yearly_price=0.8,
|
||||||
features=json.dumps([
|
features=json.dumps([
|
||||||
@@ -5601,24 +5601,31 @@ def get_historical_event_stocks(event_id):
|
|||||||
if event_trading_date:
|
if event_trading_date:
|
||||||
try:
|
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:
|
with engine.connect() as conn:
|
||||||
query = text("""
|
query = text("""
|
||||||
SELECT close_price, change_pct
|
SELECT F007N as close_price, F010N as change_pct
|
||||||
FROM ea_dailyline
|
FROM ea_trade
|
||||||
WHERE seccode = :stock_code
|
WHERE SECCODE = :stock_code
|
||||||
AND date = :trading_date
|
AND TRADEDATE = :trading_date
|
||||||
ORDER BY date DESC
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""")
|
""")
|
||||||
|
|
||||||
result = conn.execute(query, {
|
result = conn.execute(query, {
|
||||||
'stock_code': stock.stock_code,
|
'stock_code': base_stock_code,
|
||||||
'trading_date': event_trading_date
|
'trading_date': trade_date_int
|
||||||
}).fetchone()
|
}).fetchone()
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
stock_data['event_day_close'] = float(result[0]) if result[0] else None
|
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
|
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:
|
else:
|
||||||
stock_data['event_day_close'] = None
|
stock_data['event_day_close'] = None
|
||||||
stock_data['event_day_change_pct'] = None
|
stock_data['event_day_change_pct'] = None
|
||||||
@@ -5801,6 +5808,23 @@ def get_stock_quotes():
|
|||||||
if not codes:
|
if not codes:
|
||||||
return jsonify({'success': False, 'error': '请提供股票代码'}), 400
|
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:
|
if event_time_str:
|
||||||
try:
|
try:
|
||||||
@@ -5829,13 +5853,12 @@ def get_stock_quotes():
|
|||||||
# 构建代码到名称的映射
|
# 构建代码到名称的映射
|
||||||
base_name_map = {row[0]: row[1] for row in result}
|
base_name_map = {row[0]: row[1] for row in result}
|
||||||
|
|
||||||
# 为每个完整代码(带后缀)分配名称
|
# 为原始代码和标准化代码都分配名称
|
||||||
for code in codes:
|
for orig_code, norm_code in code_mapping.items():
|
||||||
base_code = code.split('.')[0]
|
base_code = orig_code.split('.')[0]
|
||||||
if base_code in base_name_map:
|
name = base_name_map.get(base_code, f"股票{base_code}")
|
||||||
stock_names[code] = base_name_map[base_code]
|
stock_names[orig_code] = name
|
||||||
else:
|
stock_names[norm_code] = name
|
||||||
stock_names[code] = f"股票{base_code}"
|
|
||||||
|
|
||||||
def get_trading_day_and_times(event_datetime):
|
def get_trading_day_and_times(event_datetime):
|
||||||
event_date = event_datetime.date()
|
event_date = event_datetime.date()
|
||||||
@@ -5897,6 +5920,21 @@ def get_stock_quotes():
|
|||||||
start_datetime = datetime.combine(trading_day, start_time)
|
start_datetime = datetime.combine(trading_day, start_time)
|
||||||
end_datetime = datetime.combine(trading_day, end_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,
|
# If the trading day is in the future relative to current time,
|
||||||
# return only names without data
|
# return only names without data
|
||||||
if trading_day > current_time.date():
|
if trading_day > current_time.date():
|
||||||
@@ -5912,19 +5950,38 @@ def get_stock_quotes():
|
|||||||
# ==================== 性能优化:批量查询所有股票数据 ====================
|
# ==================== 性能优化:批量查询所有股票数据 ====================
|
||||||
# 使用 IN 子句一次查询所有股票,避免逐只循环查询
|
# 使用 IN 子句一次查询所有股票,避免逐只循环查询
|
||||||
try:
|
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 = """
|
batch_price_query = """
|
||||||
WITH first_prices AS (
|
WITH last_prices AS (
|
||||||
SELECT
|
|
||||||
code,
|
|
||||||
close as first_price,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp ASC) as rn
|
|
||||||
FROM stock_minute
|
|
||||||
WHERE code IN %(codes)s
|
|
||||||
AND timestamp >= %(start)s
|
|
||||||
AND timestamp <= %(end)s
|
|
||||||
),
|
|
||||||
last_prices AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
code,
|
code,
|
||||||
close as last_price,
|
close as last_price,
|
||||||
@@ -5934,84 +5991,95 @@ def get_stock_quotes():
|
|||||||
AND timestamp >= %(start)s
|
AND timestamp >= %(start)s
|
||||||
AND timestamp <= %(end)s
|
AND timestamp <= %(end)s
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT code, last_price
|
||||||
fp.code,
|
FROM last_prices
|
||||||
lp.last_price,
|
WHERE rn = 1
|
||||||
(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
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
batch_data = client.execute(batch_price_query, {
|
batch_data = client.execute(batch_price_query, {
|
||||||
'codes': codes,
|
'codes': normalized_codes, # 使用标准化后的代码查询 ClickHouse
|
||||||
'start': start_datetime,
|
'start': start_datetime,
|
||||||
'end': end_datetime
|
'end': end_datetime
|
||||||
})
|
})
|
||||||
|
|
||||||
print(f"批量查询返回 {len(batch_data)} 条价格数据")
|
print(f"批量查询返回 {len(batch_data)} 条价格数据")
|
||||||
|
|
||||||
# 解析批量查询结果
|
# 解析批量查询结果,使用前一交易日收盘价计算涨跌幅
|
||||||
price_data_map = {}
|
price_data_map = {}
|
||||||
for row in batch_data:
|
for row in batch_data:
|
||||||
code = row[0]
|
code = row[0]
|
||||||
last_price = float(row[1]) if row[1] is not None else None
|
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_data_map[code] = {
|
||||||
'price': last_price,
|
'price': last_price,
|
||||||
'change': change_pct
|
'change': change_pct
|
||||||
}
|
}
|
||||||
|
|
||||||
# 组装结果(所有股票)
|
# 组装结果(所有股票)- 使用原始代码作为 key 返回
|
||||||
for code in codes:
|
for orig_code in original_codes:
|
||||||
price_info = price_data_map.get(code)
|
norm_code = code_mapping[orig_code]
|
||||||
|
price_info = price_data_map.get(norm_code)
|
||||||
if price_info:
|
if price_info:
|
||||||
results[code] = {
|
results[orig_code] = {
|
||||||
'price': price_info['price'],
|
'price': price_info['price'],
|
||||||
'change': price_info['change'],
|
'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:
|
else:
|
||||||
# 批量查询没有返回的股票
|
# 批量查询没有返回的股票
|
||||||
results[code] = {
|
results[orig_code] = {
|
||||||
'price': None,
|
'price': None,
|
||||||
'change': 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:
|
except Exception as e:
|
||||||
print(f"批量查询 ClickHouse 失败: {e},回退到逐只查询")
|
print(f"批量查询 ClickHouse 失败: {e},回退到逐只查询")
|
||||||
# 降级方案:逐只股票查询(保持向后兼容)
|
# 降级方案:逐只股票查询(使用前一交易日收盘价计算涨跌幅)
|
||||||
for code in codes:
|
for orig_code in original_codes:
|
||||||
|
norm_code = code_mapping[orig_code]
|
||||||
try:
|
try:
|
||||||
data = client.execute("""
|
# 查询当前价格(使用标准化代码查询 ClickHouse)
|
||||||
WITH first_price AS (
|
current_data = client.execute("""
|
||||||
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
|
SELECT close FROM stock_minute
|
||||||
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
|
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
|
||||||
ORDER BY timestamp DESC LIMIT 1
|
ORDER BY timestamp DESC LIMIT 1
|
||||||
)
|
""", {'code': norm_code, 'start': start_datetime, 'end': end_datetime})
|
||||||
SELECT last_price.close as last_price,
|
|
||||||
(last_price.close - first_price.close) / first_price.close * 100 as change
|
|
||||||
FROM last_price CROSS JOIN first_price
|
|
||||||
WHERE EXISTS (SELECT 1 FROM first_price) AND EXISTS (SELECT 1 FROM last_price)
|
|
||||||
""", {'code': code, 'start': start_datetime, 'end': end_datetime})
|
|
||||||
|
|
||||||
if data and data[0] and data[0][0] is not None:
|
last_price = float(current_data[0][0]) if current_data and current_data[0] and current_data[0][0] else None
|
||||||
results[code] = {
|
|
||||||
'price': float(data[0][0]) if data[0][0] is not None else None,
|
# 从 MySQL ea_trade 表查询前一交易日收盘价
|
||||||
'change': float(data[0][1]) if data[0][1] is not None else None,
|
prev_close = None
|
||||||
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
|
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]}')
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')}
|
|
||||||
except Exception as inner_e:
|
except Exception as inner_e:
|
||||||
print(f"Error processing stock {code}: {inner_e}")
|
print(f"Error processing stock {orig_code}: {inner_e}")
|
||||||
results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')}
|
results[orig_code] = {'price': None, 'change': None, 'name': stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}')}
|
||||||
|
|
||||||
# 返回标准格式
|
# 返回标准格式
|
||||||
return jsonify({'success': True, 'data': results})
|
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
|
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'])
|
@app.route('/api/stock/<stock_code>/latest-minute', methods=['GET'])
|
||||||
def get_latest_minute_data(stock_code):
|
def get_latest_minute_data(stock_code):
|
||||||
"""获取最新交易日的分钟频数据"""
|
"""获取最新交易日的分钟频数据"""
|
||||||
@@ -7289,6 +7579,135 @@ def get_timeline_data(stock_code, event_datetime, stock_name):
|
|||||||
|
|
||||||
|
|
||||||
# ==================== 指数行情API(与股票逻辑一致,数据表为 index_minute) ====================
|
# ==================== 指数行情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')
|
@app.route('/api/index/<index_code>/kline')
|
||||||
def get_index_kline(index_code):
|
def get_index_kline(index_code):
|
||||||
chart_type = request.args.get('type', 'minute')
|
chart_type = request.args.get('type', 'minute')
|
||||||
@@ -8202,6 +8621,7 @@ def api_get_events():
|
|||||||
'related_week_chg': event.related_week_chg,
|
'related_week_chg': event.related_week_chg,
|
||||||
'invest_score': event.invest_score,
|
'invest_score': event.invest_score,
|
||||||
'trending_score': event.trending_score,
|
'trending_score': event.trending_score,
|
||||||
|
'expectation_surprise_score': event.expectation_surprise_score,
|
||||||
})
|
})
|
||||||
if include_creator:
|
if include_creator:
|
||||||
event_dict['creator'] = {
|
event_dict['creator'] = {
|
||||||
@@ -8288,6 +8708,8 @@ def get_hot_events():
|
|||||||
'importance': event.importance,
|
'importance': event.importance,
|
||||||
'created_at': event.created_at.isoformat() if event.created_at else None,
|
'created_at': event.created_at.isoformat() if event.created_at else None,
|
||||||
'related_avg_chg': event.related_avg_chg,
|
'related_avg_chg': event.related_avg_chg,
|
||||||
|
'related_max_chg': event.related_max_chg,
|
||||||
|
'expectation_surprise_score': event.expectation_surprise_score,
|
||||||
'creator': {
|
'creator': {
|
||||||
'username': event.creator.username if event.creator else 'Anonymous'
|
'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]
|
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({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'trade_date': str(trade_date),
|
'trade_date': formatted_trade_date,
|
||||||
'summary': summary,
|
'summary': summary,
|
||||||
'details': list(statistics.values()),
|
'details': list(statistics.values()),
|
||||||
'available_dates': available_dates
|
'available_dates': formatted_available_dates
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -11855,19 +12284,30 @@ def get_daily_top_concepts():
|
|||||||
top_concepts = []
|
top_concepts = []
|
||||||
|
|
||||||
for concept in data.get('results', []):
|
for concept in data.get('results', []):
|
||||||
|
# 保持与 /concept-api/search 相同的字段结构
|
||||||
top_concepts.append({
|
top_concepts.append({
|
||||||
'concept_id': concept.get('concept_id'),
|
'concept_id': concept.get('concept_id'),
|
||||||
'concept_name': concept.get('concept'),
|
'concept': concept.get('concept'), # 原始字段名
|
||||||
|
'concept_name': concept.get('concept'), # 兼容旧字段名
|
||||||
'description': concept.get('description'),
|
'description': concept.get('description'),
|
||||||
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0),
|
|
||||||
'stock_count': concept.get('stock_count', 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({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': top_concepts,
|
'data': top_concepts,
|
||||||
'trade_date': data.get('price_date'),
|
'trade_date': formatted_date,
|
||||||
'count': len(top_concepts)
|
'count': len(top_concepts)
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
|
|||||||
385
app_vx.py
385
app_vx.py
@@ -559,7 +559,86 @@ app.config['COMPRESS_MIMETYPES'] = [
|
|||||||
'application/javascript',
|
'application/javascript',
|
||||||
'application/x-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['SECRET_KEY'] = 'vf7891574233241'
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4'
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4'
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
@@ -597,14 +676,36 @@ JWT_SECRET_KEY = 'vfllmgreat33818!' # 请修改为安全的密钥
|
|||||||
JWT_ALGORITHM = 'HS256'
|
JWT_ALGORITHM = 'HS256'
|
||||||
JWT_EXPIRATION_HOURS = 24 * 7 # Token有效期7天
|
JWT_EXPIRATION_HOURS = 24 * 7 # Token有效期7天
|
||||||
|
|
||||||
# Session 配置 - 使用文件系统存储(替代 Redis)
|
# Session 配置
|
||||||
app.config['SESSION_TYPE'] = 'filesystem'
|
# 优先使用 Redis(支持多 worker 共享),否则回退到文件系统
|
||||||
app.config['SESSION_FILE_DIR'] = os.path.join(os.path.dirname(__file__), 'flask_session')
|
_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['SESSION_PERMANENT'] = True
|
||||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session 有效期 7 天
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session 有效期 7 天
|
||||||
|
app.config['SESSION_COOKIE_SECURE'] = False # 生产环境 HTTPS 时设为 True
|
||||||
# 确保 session 目录存在
|
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||||
os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True)
|
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||||
|
|
||||||
# Cache directory setup
|
# Cache directory setup
|
||||||
CACHE_DIR = Path('cache')
|
CACHE_DIR = Path('cache')
|
||||||
@@ -661,9 +762,12 @@ def token_required(f):
|
|||||||
if not token_data:
|
if not token_data:
|
||||||
return jsonify({'message': 'Token无效', 'code': 401}), 401
|
return jsonify({'message': 'Token无效', 'code': 401}), 401
|
||||||
|
|
||||||
# 检查是否过期
|
# 检查是否过期(expires 可能是字符串或 datetime)
|
||||||
if token_data['expires'] < datetime.now():
|
expires = token_data['expires']
|
||||||
del user_tokens[token]
|
if isinstance(expires, str):
|
||||||
|
expires = datetime.fromisoformat(expires)
|
||||||
|
if expires < datetime.now():
|
||||||
|
user_tokens.delete(token)
|
||||||
return jsonify({'message': 'Token已过期'}), 401
|
return jsonify({'message': 'Token已过期'}), 401
|
||||||
|
|
||||||
# 获取用户对象并添加到请求上下文
|
# 获取用户对象并添加到请求上下文
|
||||||
@@ -3438,7 +3542,7 @@ def logout_with_token():
|
|||||||
token = data.get('token') if data else None
|
token = data.get('token') if data else None
|
||||||
|
|
||||||
if token and token in user_tokens:
|
if token and token in user_tokens:
|
||||||
del user_tokens[token]
|
user_tokens.delete(token)
|
||||||
|
|
||||||
# 清除session
|
# 清除session
|
||||||
session.clear()
|
session.clear()
|
||||||
@@ -3595,10 +3699,10 @@ def login_with_phone():
|
|||||||
token = generate_token(32)
|
token = generate_token(32)
|
||||||
|
|
||||||
# 存储token映射(30天有效期)
|
# 存储token映射(30天有效期)
|
||||||
user_tokens[token] = {
|
user_tokens.set(token, {
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
'expires': datetime.now() + timedelta(days=30)
|
'expires': datetime.now() + timedelta(days=30)
|
||||||
}
|
})
|
||||||
|
|
||||||
# 清除验证码
|
# 清除验证码
|
||||||
del verification_codes[phone]
|
del verification_codes[phone]
|
||||||
@@ -3648,9 +3752,12 @@ def verify_token():
|
|||||||
if not token_data:
|
if not token_data:
|
||||||
return jsonify({'valid': False, 'message': 'Token无效', 'code': 401}), 401
|
return jsonify({'valid': False, 'message': 'Token无效', 'code': 401}), 401
|
||||||
|
|
||||||
# 检查是否过期
|
# 检查是否过期(expires 可能是字符串或 datetime)
|
||||||
if token_data['expires'] < datetime.now():
|
expires = token_data['expires']
|
||||||
del user_tokens[token]
|
if isinstance(expires, str):
|
||||||
|
expires = datetime.fromisoformat(expires)
|
||||||
|
if expires < datetime.now():
|
||||||
|
user_tokens.delete(token)
|
||||||
return jsonify({'valid': False, 'message': 'Token已过期'}), 401
|
return jsonify({'valid': False, 'message': 'Token已过期'}), 401
|
||||||
|
|
||||||
# 获取用户信息
|
# 获取用户信息
|
||||||
@@ -3883,10 +3990,10 @@ def api_login_wechat():
|
|||||||
token = generate_token(32) # 使用相同的随机字符串生成器
|
token = generate_token(32) # 使用相同的随机字符串生成器
|
||||||
|
|
||||||
# 存储token映射(与手机登录保持一致)
|
# 存储token映射(与手机登录保持一致)
|
||||||
user_tokens[token] = {
|
user_tokens.set(token, {
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
'expires': datetime.now() + timedelta(days=30) # 30天有效期
|
'expires': datetime.now() + timedelta(days=30) # 30天有效期
|
||||||
}
|
})
|
||||||
|
|
||||||
# 设置session(可选,保持与手机登录一致)
|
# 设置session(可选,保持与手机登录一致)
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
@@ -5274,6 +5381,114 @@ def get_comment_replies(comment_id):
|
|||||||
}), 500
|
}), 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 格式
|
# 工具函数:处理转义字符,保留 Markdown 格式
|
||||||
def unescape_markdown_text(text):
|
def unescape_markdown_text(text):
|
||||||
"""
|
"""
|
||||||
@@ -5363,6 +5578,7 @@ def api_calendar_events():
|
|||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
# 构建基础查询 - 使用 future_events 表
|
# 构建基础查询 - 使用 future_events 表
|
||||||
|
# 添加新字段 second_modified_text, `second_modified_text.1`, best_matches 支持新旧回退
|
||||||
query = """
|
query = """
|
||||||
SELECT data_id, \
|
SELECT data_id, \
|
||||||
calendar_time, \
|
calendar_time, \
|
||||||
@@ -5374,7 +5590,10 @@ def api_calendar_events():
|
|||||||
fact, \
|
fact, \
|
||||||
related_stocks, \
|
related_stocks, \
|
||||||
concepts, \
|
concepts, \
|
||||||
inferred_tag
|
inferred_tag, \
|
||||||
|
second_modified_text, \
|
||||||
|
`second_modified_text.1` as second_modified_text_1, \
|
||||||
|
best_matches
|
||||||
FROM future_events
|
FROM future_events
|
||||||
WHERE 1 = 1 \
|
WHERE 1 = 1 \
|
||||||
"""
|
"""
|
||||||
@@ -5445,19 +5664,30 @@ def api_calendar_events():
|
|||||||
|
|
||||||
events_data = []
|
events_data = []
|
||||||
for event in events:
|
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_stocks_list = []
|
||||||
related_avg_chg = 0
|
related_avg_chg = 0
|
||||||
related_max_chg = 0
|
related_max_chg = 0
|
||||||
related_week_chg = 0
|
related_week_chg = 0
|
||||||
|
|
||||||
# 处理相关股票数据
|
# 优先使用 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:
|
if event.related_stocks:
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
import ast
|
import ast
|
||||||
|
|
||||||
# 使用与detail接口相同的解析逻辑
|
|
||||||
if isinstance(event.related_stocks, str):
|
if isinstance(event.related_stocks, str):
|
||||||
try:
|
try:
|
||||||
stock_data = json.loads(event.related_stocks)
|
stock_data = json.loads(event.related_stocks)
|
||||||
@@ -5467,18 +5697,30 @@ def api_calendar_events():
|
|||||||
stock_data = event.related_stocks
|
stock_data = event.related_stocks
|
||||||
|
|
||||||
if stock_data:
|
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:
|
||||||
daily_changes = []
|
daily_changes = []
|
||||||
week_changes = []
|
week_changes = []
|
||||||
|
|
||||||
# 处理正确的数据格式 [股票代码, 股票名称, 描述, 分数]
|
for stock_info in parsed_stocks:
|
||||||
for stock_info in stock_data:
|
stock_code = stock_info.get('code', '')
|
||||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
stock_name = stock_info.get('name', '')
|
||||||
stock_code = stock_info[0] # 股票代码
|
description = stock_info.get('description', '')
|
||||||
stock_name = stock_info[1] # 股票名称
|
score = stock_info.get('score', 0)
|
||||||
description = stock_info[2] if len(stock_info) > 2 else ''
|
report = stock_info.get('report', None)
|
||||||
score = stock_info[3] if len(stock_info) > 3 else 0
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if stock_code:
|
if stock_code:
|
||||||
# 规范化股票代码,移除后缀
|
# 规范化股票代码,移除后缀
|
||||||
@@ -5519,7 +5761,8 @@ def api_calendar_events():
|
|||||||
'description': description,
|
'description': description,
|
||||||
'score': score,
|
'score': score,
|
||||||
'daily_chg': daily_chg,
|
'daily_chg': daily_chg,
|
||||||
'week_chg': week_chg
|
'week_chg': week_chg,
|
||||||
|
'report': report # 添加研报引用信息
|
||||||
})
|
})
|
||||||
|
|
||||||
# 计算平均收益率
|
# 计算平均收益率
|
||||||
@@ -5553,8 +5796,9 @@ def api_calendar_events():
|
|||||||
highlight_match = 'concepts'
|
highlight_match = 'concepts'
|
||||||
|
|
||||||
# 将转义的换行符转换为真正的换行符,保留 Markdown 格式
|
# 将转义的换行符转换为真正的换行符,保留 Markdown 格式
|
||||||
cleaned_former = unescape_markdown_text(event.former)
|
# 使用新字段回退后的值(former_value, forecast_value)
|
||||||
cleaned_forecast = unescape_markdown_text(event.forecast)
|
cleaned_former = unescape_markdown_text(former_value)
|
||||||
|
cleaned_forecast = unescape_markdown_text(forecast_value)
|
||||||
cleaned_fact = unescape_markdown_text(event.fact)
|
cleaned_fact = unescape_markdown_text(event.fact)
|
||||||
|
|
||||||
event_dict = {
|
event_dict = {
|
||||||
@@ -5800,6 +6044,7 @@ def api_future_event_detail(item_id):
|
|||||||
"""未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员"""
|
"""未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员"""
|
||||||
try:
|
try:
|
||||||
# 从 future_events 表查询事件详情
|
# 从 future_events 表查询事件详情
|
||||||
|
# 添加新字段 second_modified_text, `second_modified_text.1`, best_matches 支持新旧回退
|
||||||
query = """
|
query = """
|
||||||
SELECT data_id, \
|
SELECT data_id, \
|
||||||
calendar_time, \
|
calendar_time, \
|
||||||
@@ -5810,7 +6055,10 @@ def api_future_event_detail(item_id):
|
|||||||
forecast, \
|
forecast, \
|
||||||
fact, \
|
fact, \
|
||||||
related_stocks, \
|
related_stocks, \
|
||||||
concepts
|
concepts, \
|
||||||
|
second_modified_text, \
|
||||||
|
`second_modified_text.1` as second_modified_text_1, \
|
||||||
|
best_matches
|
||||||
FROM future_events
|
FROM future_events
|
||||||
WHERE data_id = :item_id \
|
WHERE data_id = :item_id \
|
||||||
"""
|
"""
|
||||||
@@ -5825,6 +6073,13 @@ def api_future_event_detail(item_id):
|
|||||||
'data': None
|
'data': None
|
||||||
}), 404
|
}), 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)
|
extracted_concepts = extract_concepts_from_concepts_field(event.concepts)
|
||||||
|
|
||||||
# 解析相关股票
|
# 解析相关股票
|
||||||
@@ -5868,42 +6123,55 @@ def api_future_event_detail(item_id):
|
|||||||
'环保': '公共产业板块', '综合': '公共产业板块'
|
'环保': '公共产业板块', '综合': '公共产业板块'
|
||||||
}
|
}
|
||||||
|
|
||||||
# 处理相关股票
|
# 处理相关股票 - 优先使用 best_matches,回退到 related_stocks
|
||||||
related_avg_chg = 0
|
related_avg_chg = 0
|
||||||
related_max_chg = 0
|
related_max_chg = 0
|
||||||
related_week_chg = 0
|
related_week_chg = 0
|
||||||
|
|
||||||
|
# 优先使用 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:
|
if event.related_stocks:
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
import ast
|
import ast
|
||||||
|
|
||||||
# **修正:正确解析related_stocks数据结构**
|
|
||||||
if isinstance(event.related_stocks, str):
|
if isinstance(event.related_stocks, str):
|
||||||
try:
|
try:
|
||||||
# 先尝试JSON解析
|
|
||||||
stock_data = json.loads(event.related_stocks)
|
stock_data = json.loads(event.related_stocks)
|
||||||
except:
|
except:
|
||||||
# 如果JSON解析失败,尝试ast.literal_eval解析
|
|
||||||
stock_data = ast.literal_eval(event.related_stocks)
|
stock_data = ast.literal_eval(event.related_stocks)
|
||||||
else:
|
else:
|
||||||
stock_data = event.related_stocks
|
stock_data = event.related_stocks
|
||||||
|
|
||||||
print(f"Parsed stock_data: {stock_data}") # 调试输出
|
|
||||||
|
|
||||||
if stock_data:
|
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:
|
||||||
daily_changes = []
|
daily_changes = []
|
||||||
week_changes = []
|
week_changes = []
|
||||||
|
|
||||||
# **修正:处理正确的数据格式 [股票代码, 股票名称, 描述, 分数]**
|
for stock_info in parsed_stocks:
|
||||||
for stock_info in stock_data:
|
stock_code = stock_info.get('code', '')
|
||||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
stock_name = stock_info.get('name', '')
|
||||||
stock_code = stock_info[0] # 第一个元素是股票代码
|
description = stock_info.get('description', '')
|
||||||
stock_name = stock_info[1] # 第二个元素是股票名称
|
score = stock_info.get('score', 0)
|
||||||
description = stock_info[2] if len(stock_info) > 2 else ''
|
report = stock_info.get('report', None)
|
||||||
score = stock_info[3] if len(stock_info) > 3 else 0
|
|
||||||
else:
|
|
||||||
continue # 跳过格式不正确的数据
|
|
||||||
|
|
||||||
if stock_code:
|
if stock_code:
|
||||||
# 规范化股票代码,移除后缀
|
# 规范化股票代码,移除后缀
|
||||||
@@ -5973,7 +6241,8 @@ def api_future_event_detail(item_id):
|
|||||||
'sw_primary_sector': sw_primary_sector, # 申万一级行业(F004V)
|
'sw_primary_sector': sw_primary_sector, # 申万一级行业(F004V)
|
||||||
'primary_sector': primary_sector, # 主板块分类
|
'primary_sector': primary_sector, # 主板块分类
|
||||||
'daily_change': daily_chg, # 真实的日涨跌幅
|
'daily_change': daily_chg, # 真实的日涨跌幅
|
||||||
'week_change': week_chg # 真实的周涨跌幅
|
'week_change': week_chg, # 真实的周涨跌幅
|
||||||
|
'report': report # 研报引用信息(新字段)
|
||||||
})
|
})
|
||||||
|
|
||||||
# 计算平均收益率
|
# 计算平均收益率
|
||||||
@@ -5989,15 +6258,15 @@ def api_future_event_detail(item_id):
|
|||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
# 构建返回数据
|
# 构建返回数据,使用新字段回退后的值
|
||||||
detail_data = {
|
detail_data = {
|
||||||
'id': event.data_id,
|
'id': event.data_id,
|
||||||
'title': event.title,
|
'title': event.title,
|
||||||
'type': event.type,
|
'type': event.type,
|
||||||
'star': event.star,
|
'star': event.star,
|
||||||
'calendar_time': event.calendar_time.isoformat() if event.calendar_time else None,
|
'calendar_time': event.calendar_time.isoformat() if event.calendar_time else None,
|
||||||
'former': event.former,
|
'former': former_value, # 使用回退后的值(优先 second_modified_text)
|
||||||
'forecast': event.forecast,
|
'forecast': forecast_value, # 使用回退后的值(优先 second_modified_text.1)
|
||||||
'fact': event.fact,
|
'fact': event.fact,
|
||||||
'concepts': event.concepts,
|
'concepts': event.concepts,
|
||||||
'extracted_concepts': extracted_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
@@ -1999,7 +1999,7 @@ class MCPAgentIntegrated:
|
|||||||
model=self.kimi_model,
|
model=self.kimi_model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=1.0, # Kimi 推荐
|
temperature=1.0, # Kimi 推荐
|
||||||
max_tokens=8192, # 足够容纳 reasoning_content
|
max_tokens=128000, # 足够容纳 reasoning_content
|
||||||
)
|
)
|
||||||
|
|
||||||
choice = response.choices[0]
|
choice = response.choices[0]
|
||||||
@@ -2074,7 +2074,7 @@ class MCPAgentIntegrated:
|
|||||||
model=self.deepmoney_model,
|
model=self.deepmoney_model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
max_tokens=8192,
|
max_tokens=32784,
|
||||||
)
|
)
|
||||||
|
|
||||||
summary = response.choices[0].message.content
|
summary = response.choices[0].message.content
|
||||||
@@ -2268,7 +2268,7 @@ class MCPAgentIntegrated:
|
|||||||
model="kimi-k2-turbo-preview", # 使用非思考模型,更快
|
model="kimi-k2-turbo-preview", # 使用非思考模型,更快
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
max_tokens=8192, # 增加 token 限制以支持图表配置
|
max_tokens=128000, # 增加 token 限制以支持图表配置
|
||||||
)
|
)
|
||||||
|
|
||||||
summary = response.choices[0].message.content
|
summary = response.choices[0].message.content
|
||||||
@@ -2355,7 +2355,7 @@ class MCPAgentIntegrated:
|
|||||||
model=self.deepmoney_model,
|
model=self.deepmoney_model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=4096,
|
max_tokens=32768,
|
||||||
)
|
)
|
||||||
|
|
||||||
title = response.choices[0].message.content.strip()
|
title = response.choices[0].message.content.strip()
|
||||||
@@ -2450,7 +2450,7 @@ class MCPAgentIntegrated:
|
|||||||
model=planning_model,
|
model=planning_model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=1.0,
|
temperature=1.0,
|
||||||
max_tokens=8192,
|
max_tokens=32768,
|
||||||
stream=True, # 启用流式输出
|
stream=True, # 启用流式输出
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2494,7 +2494,7 @@ class MCPAgentIntegrated:
|
|||||||
model=self.deepmoney_model,
|
model=self.deepmoney_model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
max_tokens=8192,
|
max_tokens=32768,
|
||||||
)
|
)
|
||||||
|
|
||||||
plan_content = fallback_response.choices[0].message.content
|
plan_content = fallback_response.choices[0].message.content
|
||||||
@@ -2690,7 +2690,7 @@ class MCPAgentIntegrated:
|
|||||||
model="kimi-k2-turbo-preview",
|
model="kimi-k2-turbo-preview",
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
max_tokens=8192,
|
max_tokens=32768,
|
||||||
stream=True, # 启用流式输出
|
stream=True, # 启用流式输出
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2724,7 +2724,7 @@ class MCPAgentIntegrated:
|
|||||||
model=self.deepmoney_model,
|
model=self.deepmoney_model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
max_tokens=8192,
|
max_tokens=32768,
|
||||||
)
|
)
|
||||||
|
|
||||||
final_summary = fallback_response.choices[0].message.content
|
final_summary = fallback_response.choices[0].message.content
|
||||||
@@ -3676,7 +3676,7 @@ async def stream_role_response(
|
|||||||
tool_choice="auto",
|
tool_choice="auto",
|
||||||
stream=False, # 工具调用不使用流式
|
stream=False, # 工具调用不使用流式
|
||||||
temperature=0.7,
|
temperature=0.7,
|
||||||
max_tokens=8192, # 增大 token 限制以避免输出被截断
|
max_tokens=32768, # 增大 token 限制以避免输出被截断
|
||||||
)
|
)
|
||||||
|
|
||||||
assistant_message = response.choices[0].message
|
assistant_message = response.choices[0].message
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Flask-Compress==1.14
|
|||||||
Flask-SocketIO==5.3.6
|
Flask-SocketIO==5.3.6
|
||||||
Flask-Mail==0.9.1
|
Flask-Mail==0.9.1
|
||||||
Flask-Migrate==4.0.5
|
Flask-Migrate==4.0.5
|
||||||
|
Flask-Session==0.5.0
|
||||||
|
redis==5.0.1
|
||||||
pandas==2.0.3
|
pandas==2.0.3
|
||||||
numpy==1.24.3
|
numpy==1.24.3
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ export const bytedeskConfig = {
|
|||||||
subtitle: '点击咨询', // 副标题
|
subtitle: '点击咨询', // 副标题
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 按钮大小配置
|
||||||
|
buttonConfig: {
|
||||||
|
show: true,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
},
|
||||||
|
|
||||||
// 主题配置
|
// 主题配置
|
||||||
theme: {
|
theme: {
|
||||||
mode: 'system', // light | dark | system
|
mode: 'system', // light | dark | system
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
|
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
|
||||||
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
|
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
|
||||||
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
|
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
|
||||||
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
|
border={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
||||||
borderColor="white"
|
borderColor={isActive(['/community', '/concepts']) ? 'blue.300' : 'transparent'}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
|
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
|
||||||
onMouseEnter={highFreqMenu.handleMouseEnter}
|
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||||
@@ -128,8 +128,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
|
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
|
||||||
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
|
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
|
||||||
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
|
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
|
||||||
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
|
border={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
|
||||||
borderColor="white"
|
borderColor={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.300' : 'transparent'}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
|
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
|
||||||
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||||
@@ -204,8 +204,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
|||||||
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
|
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
|
||||||
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
|
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
|
||||||
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
|
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
|
||||||
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
|
border={isActive(['/agent-chat', '/value-forum']) ? '2px solid' : 'none'}
|
||||||
borderColor="white"
|
borderColor={isActive(['/agent-chat', '/value-forum']) ? 'blue.300' : 'transparent'}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
|
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
|
||||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-
|
|||||||
import { FaCrown } from 'react-icons/fa';
|
import { FaCrown } from 'react-icons/fa';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import UserAvatar from './UserAvatar';
|
import UserAvatar from './UserAvatar';
|
||||||
import SubscriptionModal from '../../../Subscription/SubscriptionModal';
|
|
||||||
import { useSubscription } from '../../../../hooks/useSubscription';
|
import { useSubscription } from '../../../../hooks/useSubscription';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,12 +37,7 @@ const TabletUserMenu = memo(({
|
|||||||
followingEvents
|
followingEvents
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const {
|
const { subscriptionInfo } = useSubscription();
|
||||||
subscriptionInfo,
|
|
||||||
isSubscriptionModalOpen,
|
|
||||||
openSubscriptionModal,
|
|
||||||
closeSubscriptionModal
|
|
||||||
} = useSubscription();
|
|
||||||
|
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||||
|
|
||||||
@@ -90,8 +84,8 @@ const TabletUserMenu = memo(({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 订阅管理 */}
|
{/* 订阅管理 - 移动端导航到订阅页面 */}
|
||||||
<MenuItem icon={<FaCrown />} onClick={openSubscriptionModal}>
|
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
|
||||||
<Flex justify="space-between" align="center" w="100%">
|
<Flex justify="space-between" align="center" w="100%">
|
||||||
<Text>订阅管理</Text>
|
<Text>订阅管理</Text>
|
||||||
<Badge colorScheme={getSubscriptionBadgeColor()}>
|
<Badge colorScheme={getSubscriptionBadgeColor()}>
|
||||||
@@ -149,14 +143,6 @@ const TabletUserMenu = memo(({
|
|||||||
</MenuList>
|
</MenuList>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
{/* 订阅弹窗 */}
|
|
||||||
{isSubscriptionModalOpen && (
|
|
||||||
<SubscriptionModal
|
|
||||||
isOpen={isSubscriptionModalOpen}
|
|
||||||
onClose={closeSubscriptionModal}
|
|
||||||
subscriptionInfo={subscriptionInfo}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const PerformancePanel: React.FC = () => {
|
|||||||
aria-label="Open performance panel"
|
aria-label="Open performance panel"
|
||||||
icon={<MdSpeed />}
|
icon={<MdSpeed />}
|
||||||
position="fixed"
|
position="fixed"
|
||||||
bottom="20px"
|
bottom="100px"
|
||||||
right="20px"
|
right="20px"
|
||||||
colorScheme="blue"
|
colorScheme="blue"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ import { TriangleUpIcon, TriangleDownIcon } from '@chakra-ui/icons';
|
|||||||
import { getChangeColor } from '../utils/colorUtils';
|
import { getChangeColor } from '../utils/colorUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股票涨跌幅指标组件(3分天下布局)
|
* 股票涨跌幅指标组件(3个指标:平均超额、最大超额、超预期得分)
|
||||||
* @param {Object} props
|
* @param {Object} props
|
||||||
* @param {number} props.avgChange - 平均涨跌幅
|
* @param {number} props.avgChange - 平均超额涨幅
|
||||||
* @param {number} props.maxChange - 最大涨跌幅
|
* @param {number} props.maxChange - 最大超额涨幅
|
||||||
* @param {number} props.weekChange - 周涨跌幅
|
* @param {number} props.expectationScore - 超预期得分(0-100)
|
||||||
* @param {'default'|'comfortable'|'large'} props.size - 尺寸模式:default=紧凑,comfortable=舒适(事件列表),large=大卡片(详情面板)
|
* @param {'default'|'comfortable'|'large'} props.size - 尺寸模式:default=紧凑,comfortable=舒适(事件列表),large=大卡片(详情面板)
|
||||||
*/
|
*/
|
||||||
const StockChangeIndicators = ({
|
const StockChangeIndicators = ({
|
||||||
avgChange,
|
avgChange,
|
||||||
maxChange,
|
maxChange,
|
||||||
weekChange,
|
expectationScore,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
}) => {
|
}) => {
|
||||||
const isLarge = size === 'large';
|
const isLarge = size === 'large';
|
||||||
@@ -99,7 +99,7 @@ const StockChangeIndicators = ({
|
|||||||
{/* Large 和 Default 模式:标签单独一行 */}
|
{/* Large 和 Default 模式:标签单独一行 */}
|
||||||
{(isLarge || isDefault) && (
|
{(isLarge || isDefault) && (
|
||||||
<Text fontSize={isLarge ? "sm" : "xs"} color={labelColor} fontWeight="medium">
|
<Text fontSize={isLarge ? "sm" : "xs"} color={labelColor} fontWeight="medium">
|
||||||
{label.trim()}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ const StockChangeIndicators = ({
|
|||||||
{/* Comfortable 模式:标签和数字在同一行 */}
|
{/* Comfortable 模式:标签和数字在同一行 */}
|
||||||
{!isLarge && !isDefault && (
|
{!isLarge && !isDefault && (
|
||||||
<Text as="span" color={labelColor} fontWeight="medium" fontSize="sm">
|
<Text as="span" color={labelColor} fontWeight="medium" fontSize="sm">
|
||||||
{label}
|
{label}{' '}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{sign}{numStr}
|
{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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex width="100%" justify="flex-start" align="center" gap={isLarge ? 4 : (isDefault ? 2 : 1)}>
|
<Flex width="100%" justify="flex-start" align="center" gap={isLarge ? 4 : (isDefault ? 2 : 1)}>
|
||||||
{renderIndicator('平均涨幅', avgChange)}
|
{renderIndicator('平均超额', avgChange)}
|
||||||
{renderIndicator('最大涨幅', maxChange)}
|
{renderIndicator('最大超额', maxChange)}
|
||||||
{renderIndicator('周涨幅', weekChange)}
|
{renderScoreIndicator('超预期', expectationScore)}
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
|
// 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 { createPortal } from 'react-dom';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { stockService } from '@services/eventService';
|
import { stockService } from '@services/eventService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,31 @@ interface KLineDataPoint {
|
|||||||
volume: number;
|
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> = ({
|
const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -50,8 +76,12 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
const chartRef = useRef<HTMLDivElement>(null);
|
const chartRef = useRef<HTMLDivElement>(null);
|
||||||
const chartInstance = useRef<echarts.ECharts | null>(null);
|
const chartInstance = useRef<echarts.ECharts | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [data, setData] = useState<KLineDataPoint[]>([]);
|
const [data, setData] = useState<KLineDataPoint[]>([]);
|
||||||
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [earliestDate, setEarliestDate] = useState<string | null>(null);
|
||||||
|
const [totalDaysLoaded, setTotalDaysLoaded] = useState(0);
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
console.log('[KLineChartModal] 渲染状态:', {
|
console.log('[KLineChartModal] 渲染状态:', {
|
||||||
@@ -60,38 +90,102 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
eventTime,
|
eventTime,
|
||||||
dataLength: data.length,
|
dataLength: data.length,
|
||||||
loading,
|
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;
|
if (!stock?.stock_code) return;
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setData([]);
|
||||||
|
setHasMore(true);
|
||||||
|
setEarliestDate(null);
|
||||||
|
setTotalDaysLoaded(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await stockService.getKlineData(
|
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||||
stock.stock_code,
|
|
||||||
|
// 使用新的带分页参数的接口
|
||||||
|
const response = await stockService.getBatchKlineData(
|
||||||
|
[stock.stock_code],
|
||||||
'daily',
|
'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) {
|
if (klineData.length === 0) {
|
||||||
throw new Error('暂无K线数据');
|
throw new Error('暂无K线数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[KLineChartModal] 数据条数:', response.data.length);
|
console.log('[KLineChartModal] 初始数据条数:', klineData.length);
|
||||||
setData(response.data);
|
setData(klineData);
|
||||||
|
setEarliestDate(klineData[0]?.time || null);
|
||||||
|
setTotalDaysLoaded(DAYS_PER_LOAD);
|
||||||
|
setHasMore(response.has_more !== false);
|
||||||
|
} else {
|
||||||
|
throw new Error('数据加载失败');
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [stock?.stock_code, eventTime]);
|
||||||
|
|
||||||
|
// 用于防抖的 ref
|
||||||
|
const loadMoreDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// 初始化图表
|
// 初始化图表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -124,6 +218,9 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
if (loadMoreDebounceRef.current) {
|
||||||
|
clearTimeout(loadMoreDebounceRef.current);
|
||||||
|
}
|
||||||
if (chartInstance.current) {
|
if (chartInstance.current) {
|
||||||
chartInstance.current.dispose();
|
chartInstance.current.dispose();
|
||||||
chartInstance.current = null;
|
chartInstance.current = null;
|
||||||
@@ -131,6 +228,35 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
@@ -504,7 +630,22 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
{data.length > 0 && (
|
{data.length > 0 && (
|
||||||
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
|
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
|
||||||
共{data.length}个交易日(最多1年)
|
共{data.length}个交易日
|
||||||
|
{hasMore ? '(向左滑动加载更多)' : '(已加载全部)'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{loadingMore && (
|
||||||
|
<span style={{ fontSize: '12px', color: '#3182ce', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
|
<span style={{
|
||||||
|
width: '12px',
|
||||||
|
height: '12px',
|
||||||
|
border: '2px solid #404040',
|
||||||
|
borderTop: '2px solid #3182ce',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
display: 'inline-block'
|
||||||
|
}} />
|
||||||
|
加载更多...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
AlertIcon,
|
AlertIcon,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { stockService } from '@services/eventService';
|
import dayjs from 'dayjs';
|
||||||
|
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股票信息
|
* 股票信息
|
||||||
@@ -67,7 +68,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [data, setData] = useState<TimelineDataPoint[]>([]);
|
const [data, setData] = useState<TimelineDataPoint[]>([]);
|
||||||
|
|
||||||
// 加载分时图数据
|
// 加载分时图数据(优先使用缓存)
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
if (!stock?.stock_code) return;
|
if (!stock?.stock_code) return;
|
||||||
|
|
||||||
@@ -75,20 +76,30 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await stockService.getKlineData(
|
// 标准化事件时间
|
||||||
stock.stock_code,
|
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||||
'timeline',
|
|
||||||
eventTime || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
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('暂无分时数据');
|
throw new Error('暂无分时数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[TimelineChartModal] 数据条数:', response.data.length);
|
console.log('[TimelineChartModal] 数据条数:', result.length);
|
||||||
setData(response.data);
|
setData(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
|
||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
|
|||||||
@@ -1049,10 +1049,26 @@ export default function SubscriptionContent() {
|
|||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
|
<Flex justify="space-between" align="flex-start" flexWrap="wrap" gap={2}>
|
||||||
<Text fontSize="xs" color={secondaryText} pl={11} flex={1}>
|
<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}
|
{plan.description}
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
{(() => {
|
{(() => {
|
||||||
// 获取当前选中的周期信息
|
// 获取当前选中的周期信息
|
||||||
if (plan.pricing_options) {
|
if (plan.pricing_options) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Icon,
|
Icon,
|
||||||
Container,
|
Container,
|
||||||
|
useBreakpointValue,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
FaWeixin,
|
FaWeixin,
|
||||||
@@ -42,6 +43,87 @@ import { useAuth } from '../../contexts/AuthContext';
|
|||||||
import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents';
|
import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents';
|
||||||
import { subscriptionConfig, themeColors } from '../../views/Pages/Account/subscription-content';
|
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() {
|
export default function SubscriptionContentNew() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const subscriptionEvents = useSubscriptionEvents({
|
const subscriptionEvents = useSubscriptionEvents({
|
||||||
@@ -751,61 +833,11 @@ export default function SubscriptionContentNew() {
|
|||||||
选择计费周期 · 时长越长优惠越大
|
选择计费周期 · 时长越长优惠越大
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<HStack
|
<CycleSelector
|
||||||
spacing={3}
|
options={getMergedPlans()[1]?.pricingOptions || []}
|
||||||
p={2}
|
selectedCycle={selectedCycle}
|
||||||
bg="rgba(255, 255, 255, 0.03)"
|
onSelectCycle={setSelectedCycle}
|
||||||
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>
|
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const currentOption = getMergedPlans()[1]?.pricingOptions?.find(
|
const currentOption = getMergedPlans()[1]?.pricingOptions?.find(
|
||||||
|
|||||||
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
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;
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
// src/hooks/useWatchlist.js
|
// src/hooks/useWatchlist.js
|
||||||
// 自选股管理自定义 Hook
|
// 自选股管理自定义 Hook(导航栏专用,与 Redux 状态同步)
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { useToast } from '@chakra-ui/react';
|
import { useToast } from '@chakra-ui/react';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { getApiBase } from '../utils/apiConfig';
|
import { getApiBase } from '../utils/apiConfig';
|
||||||
|
import { toggleWatchlist as toggleWatchlistAction, loadWatchlist } from '../store/slices/stockSlice';
|
||||||
|
|
||||||
const WATCHLIST_PAGE_SIZE = 10;
|
const WATCHLIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自选股管理 Hook
|
* 自选股管理 Hook(导航栏专用)
|
||||||
* 提供自选股加载、分页、移除等功能
|
* 提供自选股加载、分页、移除等功能
|
||||||
|
* 监听 Redux 中的 watchlist 变化,自动刷新行情数据
|
||||||
*
|
*
|
||||||
* @returns {{
|
* @returns {{
|
||||||
* watchlistQuotes: Array,
|
* watchlistQuotes: Array,
|
||||||
@@ -19,14 +22,37 @@ const WATCHLIST_PAGE_SIZE = 10;
|
|||||||
* setWatchlistPage: Function,
|
* setWatchlistPage: Function,
|
||||||
* WATCHLIST_PAGE_SIZE: number,
|
* WATCHLIST_PAGE_SIZE: number,
|
||||||
* loadWatchlistQuotes: Function,
|
* loadWatchlistQuotes: Function,
|
||||||
* handleRemoveFromWatchlist: Function
|
* handleRemoveFromWatchlist: Function,
|
||||||
|
* followingEvents: Array
|
||||||
* }}
|
* }}
|
||||||
*/
|
*/
|
||||||
export const useWatchlist = () => {
|
export const useWatchlist = () => {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const dispatch = useDispatch();
|
||||||
const [watchlistQuotes, setWatchlistQuotes] = useState([]);
|
const [watchlistQuotes, setWatchlistQuotes] = useState([]);
|
||||||
const [watchlistLoading, setWatchlistLoading] = useState(false);
|
const [watchlistLoading, setWatchlistLoading] = useState(false);
|
||||||
const [watchlistPage, setWatchlistPage] = useState(1);
|
const [watchlistPage, setWatchlistPage] = useState(1);
|
||||||
|
const [followingEvents, setFollowingEvents] = useState([]);
|
||||||
|
|
||||||
|
// 从 Redux 获取自选股列表长度(用于监听变化)
|
||||||
|
// 使用 length 作为依赖,避免数组引用变化导致不必要的重新渲染
|
||||||
|
const reduxWatchlistLength = useSelector(state => state.stock.watchlist?.length || 0);
|
||||||
|
|
||||||
|
// 检查 Redux watchlist 是否已初始化(加载状态)
|
||||||
|
const reduxWatchlistLoading = useSelector(state => state.stock.loading?.watchlist);
|
||||||
|
|
||||||
|
// 用于跟踪上一次的 watchlist 长度
|
||||||
|
const prevWatchlistLengthRef = useRef(-1); // 初始设为 -1,确保第一次变化也能检测到
|
||||||
|
|
||||||
|
// 初始化时加载 Redux watchlist(确保 Redux 状态被初始化)
|
||||||
|
const hasInitializedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitializedRef.current) {
|
||||||
|
hasInitializedRef.current = true;
|
||||||
|
logger.debug('useWatchlist', '初始化 Redux watchlist');
|
||||||
|
dispatch(loadWatchlist());
|
||||||
|
}
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
// 加载自选股实时行情
|
// 加载自选股实时行情
|
||||||
const loadWatchlistQuotes = useCallback(async () => {
|
const loadWatchlistQuotes = useCallback(async () => {
|
||||||
@@ -42,6 +68,7 @@ export const useWatchlist = () => {
|
|||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data && data.success && Array.isArray(data.data)) {
|
if (data && data.success && Array.isArray(data.data)) {
|
||||||
setWatchlistQuotes(data.data);
|
setWatchlistQuotes(data.data);
|
||||||
|
logger.debug('useWatchlist', '自选股行情加载成功', { count: data.data.length });
|
||||||
} else {
|
} else {
|
||||||
setWatchlistQuotes([]);
|
setWatchlistQuotes([]);
|
||||||
}
|
}
|
||||||
@@ -58,16 +85,54 @@ export const useWatchlist = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 从自选股移除
|
// 监听 Redux watchlist 长度变化,自动刷新行情数据
|
||||||
|
useEffect(() => {
|
||||||
|
const currentLength = reduxWatchlistLength;
|
||||||
|
const prevLength = prevWatchlistLengthRef.current;
|
||||||
|
|
||||||
|
// 只有当 watchlist 长度发生变化时才刷新
|
||||||
|
// prevLength = -1 表示初始状态,此时不触发刷新(由菜单打开时触发)
|
||||||
|
if (prevLength !== -1 && currentLength !== prevLength) {
|
||||||
|
logger.debug('useWatchlist', 'Redux watchlist 长度变化,刷新行情', {
|
||||||
|
prevLength,
|
||||||
|
currentLength
|
||||||
|
});
|
||||||
|
|
||||||
|
// 延迟一小段时间再刷新,确保后端数据已更新
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
logger.debug('useWatchlist', '执行 loadWatchlistQuotes');
|
||||||
|
loadWatchlistQuotes();
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
prevWatchlistLengthRef.current = currentLength;
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 ref
|
||||||
|
prevWatchlistLengthRef.current = currentLength;
|
||||||
|
}, [reduxWatchlistLength, loadWatchlistQuotes]);
|
||||||
|
|
||||||
|
// 从自选股移除(同时更新 Redux 和本地状态)
|
||||||
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
||||||
try {
|
try {
|
||||||
const base = getApiBase();
|
// 找到股票名称
|
||||||
const resp = await fetch(base + `/api/account/watchlist/${stockCode}`, {
|
const stockItem = watchlistQuotes.find(item => {
|
||||||
method: 'DELETE',
|
const normalize6 = (code) => {
|
||||||
credentials: 'include'
|
const m = String(code || '').match(/(\d{6})/);
|
||||||
|
return m ? m[1] : String(code || '');
|
||||||
|
};
|
||||||
|
return normalize6(item.stock_code) === normalize6(stockCode);
|
||||||
});
|
});
|
||||||
const data = await resp.json().catch(() => ({}));
|
const stockName = stockItem?.stock_name || '';
|
||||||
if (resp.ok && data && data.success !== false) {
|
|
||||||
|
// 通过 Redux action 移除(会同步更新 Redux 状态)
|
||||||
|
await dispatch(toggleWatchlistAction({
|
||||||
|
stockCode,
|
||||||
|
stockName,
|
||||||
|
isInWatchlist: true // 表示当前在自选股中,需要移除
|
||||||
|
})).unwrap();
|
||||||
|
|
||||||
|
// 更新本地状态(立即响应 UI)
|
||||||
setWatchlistQuotes((prev) => {
|
setWatchlistQuotes((prev) => {
|
||||||
const normalize6 = (code) => {
|
const normalize6 = (code) => {
|
||||||
const m = String(code || '').match(/(\d{6})/);
|
const m = String(code || '').match(/(\d{6})/);
|
||||||
@@ -79,14 +144,13 @@ export const useWatchlist = () => {
|
|||||||
setWatchlistPage((p) => Math.min(p, newMaxPage));
|
setWatchlistPage((p) => Math.min(p, newMaxPage));
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
|
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
|
||||||
} else {
|
|
||||||
toast({ title: '移除失败', status: 'error', duration: 2000 });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
|
logger.error('useWatchlist', '移除自选股失败', e);
|
||||||
|
toast({ title: e.message || '移除失败', status: 'error', duration: 2000 });
|
||||||
}
|
}
|
||||||
}, [toast]);
|
}, [dispatch, watchlistQuotes, toast]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
watchlistQuotes,
|
watchlistQuotes,
|
||||||
@@ -95,6 +159,7 @@ export const useWatchlist = () => {
|
|||||||
setWatchlistPage,
|
setWatchlistPage,
|
||||||
WATCHLIST_PAGE_SIZE,
|
WATCHLIST_PAGE_SIZE,
|
||||||
loadWatchlistQuotes,
|
loadWatchlistQuotes,
|
||||||
handleRemoveFromWatchlist
|
handleRemoveFromWatchlist,
|
||||||
|
followingEvents
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -263,6 +263,26 @@ export const accountHandlers = [
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 10. 获取事件帖子(用户发布的评论/帖子)
|
||||||
|
http.get('/api/account/events/posts', async () => {
|
||||||
|
await delay(NETWORK_DELAY);
|
||||||
|
|
||||||
|
const currentUser = getCurrentUser();
|
||||||
|
if (!currentUser) {
|
||||||
|
return HttpResponse.json(
|
||||||
|
{ success: false, error: '未登录' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[Mock] 获取事件帖子');
|
||||||
|
|
||||||
|
return HttpResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: mockEventComments // 复用 mockEventComments 数据
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
// ==================== 投资计划与复盘 ====================
|
// ==================== 投资计划与复盘 ====================
|
||||||
|
|
||||||
// 10. 获取投资计划列表
|
// 10. 获取投资计划列表
|
||||||
@@ -696,4 +716,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
|
||||||
|
});
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -71,4 +71,269 @@ export const marketHandlers = [
|
|||||||
const data = generateMarketData(stockCode);
|
const data = generateMarketData(stockCode);
|
||||||
return HttpResponse.json(data.latestMinuteData);
|
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个交易日
|
||||||
|
});
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export const lazyComponents = {
|
|||||||
// ⚡ 直接引用 HomePage,无需中间层(静态页面不需要骨架屏)
|
// ⚡ 直接引用 HomePage,无需中间层(静态页面不需要骨架屏)
|
||||||
HomePage: React.lazy(() => import('@views/Home/HomePage')),
|
HomePage: React.lazy(() => import('@views/Home/HomePage')),
|
||||||
CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
|
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')),
|
SettingsPage: React.lazy(() => import('@views/Settings/SettingsPage')),
|
||||||
Subscription: React.lazy(() => import('@views/Pages/Account/Subscription')),
|
Subscription: React.lazy(() => import('@views/Pages/Account/Subscription')),
|
||||||
PrivacyPolicy: React.lazy(() => import('@views/Pages/PrivacyPolicy')),
|
PrivacyPolicy: React.lazy(() => import('@views/Pages/PrivacyPolicy')),
|
||||||
@@ -56,6 +58,7 @@ export const {
|
|||||||
HomePage,
|
HomePage,
|
||||||
CenterDashboard,
|
CenterDashboard,
|
||||||
ProfilePage,
|
ProfilePage,
|
||||||
|
ForumMyPoints,
|
||||||
SettingsPage,
|
SettingsPage,
|
||||||
Subscription,
|
Subscription,
|
||||||
PrivacyPolicy,
|
PrivacyPolicy,
|
||||||
|
|||||||
@@ -191,6 +191,16 @@ export const routeConfig = [
|
|||||||
description: '预测市场话题详细信息'
|
description: '预测市场话题详细信息'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'value-forum/my-points',
|
||||||
|
component: lazyComponents.ForumMyPoints,
|
||||||
|
protection: PROTECTION_MODES.MODAL,
|
||||||
|
layout: 'main',
|
||||||
|
meta: {
|
||||||
|
title: '我的积分',
|
||||||
|
description: '价值论坛积分账户'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ==================== Agent模块 ====================
|
// ==================== Agent模块 ====================
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -358,6 +358,47 @@ export const stockService = {
|
|||||||
throw error;
|
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) => {
|
getTransmissionChainAnalysis: async (eventId) => {
|
||||||
return await apiRequest(`/api/events/${eventId}/transmission`);
|
return await apiRequest(`/api/events/${eventId}/transmission`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ iframe[src*="/chat/"],
|
|||||||
iframe[src*="/visitor/"] {
|
iframe[src*="/visitor/"] {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
z-index: 999999 !important;
|
z-index: 999999 !important;
|
||||||
|
max-height: 80vh !important; /* 限制最大高度为视口的80% */
|
||||||
|
max-width: 40vh !important; /* 限制最大高度为视口的80% */
|
||||||
|
bottom: 10px !important; /* 确保底部有足够空间 */
|
||||||
|
right: 10px !important; /* 右侧边距 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bytedesk 覆盖层(如果存在) */
|
/* Bytedesk 覆盖层(如果存在) */
|
||||||
@@ -37,16 +41,6 @@ iframe[src*="/visitor/"] {
|
|||||||
z-index: 1000000 !important;
|
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 秒后自动消失 ========== */
|
/* ========== 提示框 3 秒后自动消失 ========== */
|
||||||
/* 提示框("在线客服 点击咨询"气泡)- 扩展选择器 */
|
/* 提示框("在线客服 点击咨询"气泡)- 扩展选择器 */
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
CardBody,
|
CardBody,
|
||||||
VStack,
|
VStack,
|
||||||
|
HStack,
|
||||||
Text,
|
Text,
|
||||||
Spinner,
|
Spinner,
|
||||||
Center,
|
Center,
|
||||||
@@ -77,7 +78,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
|
|
||||||
// 使用 Hook 获取实时数据
|
// 使用 Hook 获取实时数据
|
||||||
// - autoLoad: false - 禁用自动加载所有数据,改为手动触发
|
// - autoLoad: false - 禁用自动加载所有数据,改为手动触发
|
||||||
// - autoLoadQuotes: false - 禁用自动加载行情,延迟到展开时加载(减少请求)
|
// - autoLoadQuotes: true - 股票数据加载后自动加载行情(相关股票默认展开)
|
||||||
const {
|
const {
|
||||||
stocks,
|
stocks,
|
||||||
quotes,
|
quotes,
|
||||||
@@ -89,7 +90,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
loadHistoricalData,
|
loadHistoricalData,
|
||||||
loadChainAnalysis,
|
loadChainAnalysis,
|
||||||
refreshQuotes
|
refreshQuotes
|
||||||
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false, autoLoadQuotes: false });
|
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false, autoLoadQuotes: true });
|
||||||
|
|
||||||
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
|
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
|
||||||
const loadEventDetail = useCallback(async () => {
|
const loadEventDetail = useCallback(async () => {
|
||||||
@@ -122,8 +123,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
const canAccessTransmission = hasAccess('max');
|
const canAccessTransmission = hasAccess('max');
|
||||||
|
|
||||||
// 子区块折叠状态管理 + 加载追踪
|
// 子区块折叠状态管理 + 加载追踪
|
||||||
// 相关股票默认折叠,只显示数量吸引点击
|
// 相关股票默认展开
|
||||||
const [isStocksOpen, setIsStocksOpen] = useState(false);
|
const [isStocksOpen, setIsStocksOpen] = useState(true);
|
||||||
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
|
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
|
||||||
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
|
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
|
||||||
|
|
||||||
@@ -225,12 +226,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
|
|||||||
setHasLoadedHistorical(false);
|
setHasLoadedHistorical(false);
|
||||||
setHasLoadedTransmission(false);
|
setHasLoadedTransmission(false);
|
||||||
|
|
||||||
// 相关股票默认折叠,但预加载股票列表(显示数量吸引点击)
|
// 相关股票默认展开,预加载股票列表和行情数据
|
||||||
setIsStocksOpen(false);
|
setIsStocksOpen(true);
|
||||||
if (canAccessStocks) {
|
if (canAccessStocks) {
|
||||||
console.log('%c📊 [相关股票] 事件切换,预加载股票列表(获取数量)', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
console.log('%c📊 [相关股票] 事件切换,预加载股票列表和行情数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
||||||
loadStocksData();
|
loadStocksData();
|
||||||
setHasLoadedStocks(true);
|
setHasLoadedStocks(true);
|
||||||
|
// 由于默认展开,直接加载行情数据
|
||||||
|
setHasLoadedQuotes(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
|
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
* @param {string} stockCode - 股票代码
|
* @param {string} stockCode - 股票代码
|
||||||
* @param {string} eventTime - 事件时间(可选)
|
* @param {string} eventTime - 事件时间(可选)
|
||||||
* @param {Function} onClick - 点击回调(可选)
|
* @param {Function} onClick - 点击回调(可选)
|
||||||
|
* @param {Array} preloadedData - 预加载的K线数据(可选,由父组件批量加载后传入)
|
||||||
|
* @param {boolean} loading - 外部加载状态(可选)
|
||||||
* @returns {JSX.Element}
|
* @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 [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
@@ -44,6 +46,21 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先使用预加载的数据(由父组件批量请求后传入)
|
||||||
|
if (preloadedData !== undefined) {
|
||||||
|
setData(preloadedData || []);
|
||||||
|
setLoading(false);
|
||||||
|
loadedRef.current = true;
|
||||||
|
dataFetchedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果外部正在加载,显示loading状态,不发起单独请求
|
||||||
|
if (externalLoading) {
|
||||||
|
setLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (dataFetchedRef.current) {
|
if (dataFetchedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -52,8 +69,8 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
|||||||
const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily');
|
const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily');
|
||||||
const cachedData = klineDataCache.get(cacheKey);
|
const cachedData = klineDataCache.get(cacheKey);
|
||||||
|
|
||||||
if (cachedData && cachedData.length > 0) {
|
if (cachedData !== undefined) {
|
||||||
setData(cachedData);
|
setData(cachedData || []);
|
||||||
loadedRef.current = true;
|
loadedRef.current = true;
|
||||||
dataFetchedRef.current = true;
|
dataFetchedRef.current = true;
|
||||||
return;
|
return;
|
||||||
@@ -62,7 +79,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
|||||||
dataFetchedRef.current = true;
|
dataFetchedRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 获取日K线数据
|
// 获取日K线数据(备用方案)
|
||||||
fetchKlineData(stockCode, stableEventTime, 'daily')
|
fetchKlineData(stockCode, stableEventTime, 'daily')
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
@@ -78,7 +95,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
|||||||
loadedRef.current = true;
|
loadedRef.current = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [stockCode, stableEventTime]);
|
}, [stockCode, stableEventTime, preloadedData, externalLoading]);
|
||||||
|
|
||||||
const chartOption = useMemo(() => {
|
const chartOption = useMemo(() => {
|
||||||
// 提取K线数据 [open, close, low, high]
|
// 提取K线数据 [open, close, low, high]
|
||||||
@@ -179,7 +196,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
|||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
return prevProps.stockCode === nextProps.stockCode &&
|
return prevProps.stockCode === nextProps.stockCode &&
|
||||||
prevProps.eventTime === nextProps.eventTime &&
|
prevProps.eventTime === nextProps.eventTime &&
|
||||||
prevProps.onClick === nextProps.onClick;
|
prevProps.onClick === nextProps.onClick &&
|
||||||
|
prevProps.preloadedData === nextProps.preloadedData &&
|
||||||
|
prevProps.loading === nextProps.loading;
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MiniKLineChart;
|
export default MiniKLineChart;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
|
// 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 { VStack } from '@chakra-ui/react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import StockListItem from './StockListItem';
|
import StockListItem from './StockListItem';
|
||||||
|
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '../StockDetailPanel/utils/klineDataCache';
|
||||||
|
import { logger } from '../../../../utils/logger';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 相关股票列表区组件(纯内容部分)
|
* 相关股票列表区组件(纯内容部分)
|
||||||
@@ -22,6 +25,135 @@ const RelatedStocksSection = ({
|
|||||||
watchlistSet = new Set(),
|
watchlistSet = new Set(),
|
||||||
onWatchlistToggle
|
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) {
|
if (!stocks || stocks.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -37,6 +169,10 @@ const RelatedStocksSection = ({
|
|||||||
eventTime={eventTime}
|
eventTime={eventTime}
|
||||||
isInWatchlist={watchlistSet.has(stock.stock_code)}
|
isInWatchlist={watchlistSet.has(stock.stock_code)}
|
||||||
onWatchlistToggle={onWatchlistToggle}
|
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>
|
</VStack>
|
||||||
|
|||||||
@@ -39,13 +39,21 @@ import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
|||||||
* @param {string} props.eventTime - 事件时间(可选)
|
* @param {string} props.eventTime - 事件时间(可选)
|
||||||
* @param {boolean} props.isInWatchlist - 是否在自选股中
|
* @param {boolean} props.isInWatchlist - 是否在自选股中
|
||||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||||
|
* @param {Array} props.timelineData - 预加载的分时图数据(可选,由父组件批量加载后传入)
|
||||||
|
* @param {boolean} props.timelineLoading - 分时图数据加载状态
|
||||||
|
* @param {Array} props.dailyData - 预加载的日K线数据(可选,由父组件批量加载后传入)
|
||||||
|
* @param {boolean} props.dailyLoading - 日K线数据加载状态
|
||||||
*/
|
*/
|
||||||
const StockListItem = ({
|
const StockListItem = ({
|
||||||
stock,
|
stock,
|
||||||
quote = null,
|
quote = null,
|
||||||
eventTime = null,
|
eventTime = null,
|
||||||
isInWatchlist = false,
|
isInWatchlist = false,
|
||||||
onWatchlistToggle
|
onWatchlistToggle,
|
||||||
|
timelineData,
|
||||||
|
timelineLoading = false,
|
||||||
|
dailyData,
|
||||||
|
dailyLoading = false
|
||||||
}) => {
|
}) => {
|
||||||
const isMobile = useSelector(selectIsMobile);
|
const isMobile = useSelector(selectIsMobile);
|
||||||
const cardBg = PROFESSIONAL_COLORS.background.card;
|
const cardBg = PROFESSIONAL_COLORS.background.card;
|
||||||
@@ -187,12 +195,13 @@ const StockListItem = ({
|
|||||||
{onWatchlistToggle && (
|
{onWatchlistToggle && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="xs"
|
size="xs"
|
||||||
variant={isInWatchlist ? 'solid' : 'ghost'}
|
variant={isInWatchlist ? 'solid' : 'outline'}
|
||||||
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
|
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
|
||||||
icon={<StarIcon />}
|
icon={<StarIcon color={isInWatchlist ? undefined : 'gray.400'} />}
|
||||||
onClick={handleWatchlistClick}
|
onClick={handleWatchlistClick}
|
||||||
aria-label={isInWatchlist ? '已关注' : '加自选'}
|
aria-label={isInWatchlist ? '已关注' : '加自选'}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
|
borderColor={isInWatchlist ? undefined : 'gray.300'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -236,6 +245,8 @@ const StockListItem = ({
|
|||||||
<MiniTimelineChart
|
<MiniTimelineChart
|
||||||
stockCode={stock.stock_code}
|
stockCode={stock.stock_code}
|
||||||
eventTime={eventTime}
|
eventTime={eventTime}
|
||||||
|
preloadedData={timelineData}
|
||||||
|
loading={timelineLoading}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</VStack>
|
</VStack>
|
||||||
@@ -278,6 +289,8 @@ const StockListItem = ({
|
|||||||
<MiniKLineChart
|
<MiniKLineChart
|
||||||
stockCode={stock.stock_code}
|
stockCode={stock.stock_code}
|
||||||
eventTime={eventTime}
|
eventTime={eventTime}
|
||||||
|
preloadedData={dailyData}
|
||||||
|
loading={dailyLoading}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const DetailedEventCard = ({
|
|||||||
<EventPriceDisplay
|
<EventPriceDisplay
|
||||||
avgChange={event.related_avg_chg}
|
avgChange={event.related_avg_chg}
|
||||||
maxChange={event.related_max_chg}
|
maxChange={event.related_max_chg}
|
||||||
weekChange={event.related_week_chg}
|
expectationScore={event.expectation_surprise_score}
|
||||||
compact={false}
|
compact={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ const DynamicNewsEventCard = React.memo(({
|
|||||||
<StockChangeIndicators
|
<StockChangeIndicators
|
||||||
avgChange={event.related_avg_chg}
|
avgChange={event.related_avg_chg}
|
||||||
maxChange={event.related_max_chg}
|
maxChange={event.related_max_chg}
|
||||||
weekChange={event.related_week_chg}
|
expectationScore={event.expectation_surprise_score}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// src/views/Community/components/EventCard/EventPriceDisplay.js
|
// src/views/Community/components/EventCard/EventPriceDisplay.js
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { HStack, Badge, Text, Tooltip } from '@chakra-ui/react';
|
import { HStack, Box, Text, Tooltip, Progress } from '@chakra-ui/react';
|
||||||
import { PriceArrow } from '../../../../utils/priceFormatters';
|
import { PriceArrow } from '../../../../utils/priceFormatters';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,17 +8,20 @@ import { PriceArrow } from '../../../../utils/priceFormatters';
|
|||||||
* @param {Object} props
|
* @param {Object} props
|
||||||
* @param {number|null} props.avgChange - 平均涨跌幅
|
* @param {number|null} props.avgChange - 平均涨跌幅
|
||||||
* @param {number|null} props.maxChange - 最大涨跌幅
|
* @param {number|null} props.maxChange - 最大涨跌幅
|
||||||
* @param {number|null} props.weekChange - 周涨跌幅
|
* @param {number|null} props.expectationScore - 超预期得分(满分100)
|
||||||
* @param {boolean} props.compact - 是否为紧凑模式(只显示平均值,默认 false)
|
* @param {boolean} props.compact - 是否为紧凑模式(只显示平均值,默认 false)
|
||||||
* @param {boolean} props.inline - 是否内联显示(默认 false)
|
* @param {boolean} props.inline - 是否内联显示(默认 false)
|
||||||
*/
|
*/
|
||||||
const EventPriceDisplay = ({
|
const EventPriceDisplay = ({
|
||||||
avgChange,
|
avgChange,
|
||||||
maxChange,
|
maxChange,
|
||||||
weekChange,
|
expectationScore,
|
||||||
compact = false,
|
compact = false,
|
||||||
inline = false
|
inline = false
|
||||||
}) => {
|
}) => {
|
||||||
|
// 点击切换显示最大超额/平均超额
|
||||||
|
const [showAvg, setShowAvg] = useState(false);
|
||||||
|
|
||||||
// 获取颜色方案
|
// 获取颜色方案
|
||||||
const getColorScheme = (value) => {
|
const getColorScheme = (value) => {
|
||||||
if (value == null) return 'gray';
|
if (value == null) return 'gray';
|
||||||
@@ -31,12 +34,23 @@ const EventPriceDisplay = ({
|
|||||||
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
|
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) {
|
if (compact && avgChange != null) {
|
||||||
return (
|
return (
|
||||||
<Tooltip label="平均" placement="top">
|
<Tooltip label="平均超额" placement="top">
|
||||||
<Badge
|
<Box
|
||||||
colorScheme={getColorScheme(avgChange)}
|
bg={avgChange > 0 ? 'red.50' : avgChange < 0 ? 'green.50' : 'gray.100'}
|
||||||
|
color={avgChange > 0 ? 'red.600' : avgChange < 0 ? 'green.600' : 'gray.500'}
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
px={2}
|
px={2}
|
||||||
py={1}
|
py={1}
|
||||||
@@ -49,71 +63,91 @@ const EventPriceDisplay = ({
|
|||||||
>
|
>
|
||||||
<PriceArrow value={avgChange} />
|
<PriceArrow value={avgChange} />
|
||||||
{formatPercent(avgChange)}
|
{formatPercent(avgChange)}
|
||||||
</Badge>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 详细模式:显示所有价格变动
|
const displayValue = showAvg ? avgChange : maxChange;
|
||||||
|
const displayLabel = showAvg ? '平均超额' : '最大超额';
|
||||||
|
const scoreColors = getScoreColor(expectationScore);
|
||||||
|
|
||||||
|
// 详细模式:显示最大超额(可点击切换)+ 超预期得分
|
||||||
return (
|
return (
|
||||||
<HStack spacing={2} flexWrap="wrap">
|
<HStack spacing={3} flexWrap="wrap">
|
||||||
{/* 平均涨幅 - 始终显示,无数据时显示 -- */}
|
{/* 最大超额/平均超额 - 点击切换 */}
|
||||||
<Badge
|
<Tooltip
|
||||||
colorScheme={getColorScheme(avgChange)}
|
label={showAvg ? "点击查看最大超额" : "点击查看平均超额"}
|
||||||
|
placement="top"
|
||||||
|
hasArrow
|
||||||
|
>
|
||||||
|
<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"
|
fontSize="xs"
|
||||||
px={2}
|
px={2.5}
|
||||||
py={0.5}
|
py={1}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowAvg(!showAvg);
|
||||||
|
}}
|
||||||
|
_hover={{
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
boxShadow: 'sm',
|
||||||
|
opacity: 0.9
|
||||||
|
}}
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
|
border="1px solid"
|
||||||
|
borderColor={displayValue > 0 ? 'red.200' : displayValue < 0 ? 'green.200' : 'gray.200'}
|
||||||
>
|
>
|
||||||
<HStack spacing={1}>
|
<HStack spacing={1.5}>
|
||||||
<Text fontSize="xs" opacity={0.8}>平均</Text>
|
<Text fontSize="xs" opacity={0.7} fontWeight="medium">{displayLabel}</Text>
|
||||||
<Text fontWeight="bold">
|
<Text fontWeight="bold" fontSize="sm">
|
||||||
{formatPercent(avgChange)}
|
{formatPercent(displayValue)}
|
||||||
</Text>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Badge>
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* 最大涨幅 - 始终显示,无数据时显示 -- */}
|
{/* 超预期得分 - 精致的进度条样式 */}
|
||||||
<Badge
|
{expectationScore != null && (
|
||||||
colorScheme={getColorScheme(maxChange)}
|
<Tooltip
|
||||||
fontSize="xs"
|
label={`超预期得分:${expectationScore.toFixed(0)}分(满分100分)`}
|
||||||
px={2}
|
placement="top"
|
||||||
py={0.5}
|
hasArrow
|
||||||
borderRadius="md"
|
|
||||||
cursor="pointer"
|
|
||||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
|
||||||
transition="all 0.2s"
|
|
||||||
>
|
>
|
||||||
<HStack spacing={1}>
|
<Box
|
||||||
<Text fontSize="xs" opacity={0.8}>最大</Text>
|
bg={scoreColors.bg}
|
||||||
<Text fontWeight="bold">
|
px={2.5}
|
||||||
{formatPercent(maxChange)}
|
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>
|
</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Badge>
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
{/* 周涨幅 - 始终显示,无数据时显示 -- */}
|
)}
|
||||||
<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>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -247,9 +247,9 @@ const HorizontalDynamicNewsEventCard = React.memo(({
|
|||||||
|
|
||||||
{/* 第二行:涨跌幅数据 */}
|
{/* 第二行:涨跌幅数据 */}
|
||||||
<StockChangeIndicators
|
<StockChangeIndicators
|
||||||
avgChange={event.related_avg_chg}
|
|
||||||
maxChange={event.related_max_chg}
|
maxChange={event.related_max_chg}
|
||||||
weekChange={event.related_week_chg}
|
avgChange={event.related_avg_chg}
|
||||||
|
expectationScore={event.expectation_surprise_score}
|
||||||
size={indicatorSize}
|
size={indicatorSize}
|
||||||
/>
|
/>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// src/views/Community/components/HeroPanel.js
|
// src/views/Community/components/HeroPanel.js
|
||||||
// 顶部说明面板组件:事件中心 + 沪深指数K线图 + 热门概念3D动画
|
// 顶部说明面板组件:事件中心 + 沪深指数K线图 + 热门概念3D动画
|
||||||
|
// 交易时间内自动更新指数行情(每分钟一次)
|
||||||
|
|
||||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -22,10 +23,12 @@ import {
|
|||||||
ModalHeader,
|
ModalHeader,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
ModalCloseButton,
|
ModalCloseButton,
|
||||||
|
Tooltip,
|
||||||
} from '@chakra-ui/react';
|
} 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 ReactECharts from 'echarts-for-react';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
|
import { useIndexQuote } from '../../../hooks/useIndexQuote';
|
||||||
|
|
||||||
// 定义动画
|
// 定义动画
|
||||||
const animations = `
|
const animations = `
|
||||||
@@ -104,6 +107,7 @@ const isInTradingTime = () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 精美K线指数卡片 - 类似 KLineChartModal 风格
|
* 精美K线指数卡片 - 类似 KLineChartModal 风格
|
||||||
|
* 交易时间内自动更新实时行情(每分钟一次)
|
||||||
*/
|
*/
|
||||||
const CompactIndexCard = ({ indexCode, indexName }) => {
|
const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||||
const [chartData, setChartData] = useState(null);
|
const [chartData, setChartData] = useState(null);
|
||||||
@@ -113,9 +117,26 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
|||||||
const upColor = '#ef5350'; // 涨 - 红色
|
const upColor = '#ef5350'; // 涨 - 红色
|
||||||
const downColor = '#26a69a'; // 跌 - 绿色
|
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);
|
const data = await fetchIndexKline(indexCode);
|
||||||
if (data?.data?.length > 0) {
|
if (data?.data?.length > 0) {
|
||||||
|
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 latest = data.data[data.data.length - 1];
|
||||||
const prevClose = latest.prev_close || data.data[data.data.length - 2]?.close || latest.open;
|
const prevClose = latest.prev_close || data.data[data.data.length - 2]?.close || latest.open;
|
||||||
const changeAmount = latest.close - prevClose;
|
const changeAmount = latest.close - prevClose;
|
||||||
@@ -130,21 +151,32 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
|||||||
changePct: changePct,
|
changePct: changePct,
|
||||||
isPositive: changeAmount >= 0
|
isPositive: changeAmount >= 0
|
||||||
});
|
});
|
||||||
|
}
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, [indexCode]);
|
}, [indexCode, quote]);
|
||||||
|
|
||||||
|
// 初始加载日K数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadChartData();
|
||||||
}, [loadData]);
|
}, [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(() => {
|
const chartOption = useMemo(() => {
|
||||||
if (!chartData) return {};
|
if (!chartData) return {};
|
||||||
@@ -306,6 +338,30 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
|||||||
<Text fontSize="sm" color="whiteAlpha.800" fontWeight="semibold">
|
<Text fontSize="sm" color="whiteAlpha.800" fontWeight="semibold">
|
||||||
{indexName}
|
{indexName}
|
||||||
</Text>
|
</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>
|
||||||
<HStack spacing={3}>
|
<HStack spacing={3}>
|
||||||
<Text fontSize="lg" fontWeight="bold" color="white" fontFamily="monospace">
|
<Text fontSize="lg" fontWeight="bold" color="white" fontFamily="monospace">
|
||||||
@@ -338,16 +394,22 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
|||||||
style={{ height: '100%', width: '100%' }}
|
style={{ height: '100%', width: '100%' }}
|
||||||
opts={{ renderer: 'canvas' }}
|
opts={{ renderer: 'canvas' }}
|
||||||
/>
|
/>
|
||||||
{/* 底部提示 */}
|
{/* 底部提示 - 显示更新时间 */}
|
||||||
<Text
|
<HStack
|
||||||
position="absolute"
|
position="absolute"
|
||||||
bottom={0}
|
bottom={0}
|
||||||
right={1}
|
right={1}
|
||||||
fontSize="9px"
|
spacing={2}
|
||||||
color="whiteAlpha.300"
|
|
||||||
>
|
>
|
||||||
|
{latestData?.updateTime && (
|
||||||
|
<Text fontSize="9px" color="whiteAlpha.400">
|
||||||
|
{latestData.updateTime}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text fontSize="9px" color="whiteAlpha.300">
|
||||||
滚轮缩放 · 拖动查看
|
滚轮缩放 · 拖动查看
|
||||||
</Text>
|
</Text>
|
||||||
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
// src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
|
// 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 ReactECharts from 'echarts-for-react';
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
fetchKlineData,
|
fetchKlineData,
|
||||||
getCacheKey,
|
getCacheKey,
|
||||||
klineDataCache
|
klineDataCache,
|
||||||
|
batchPendingRequests
|
||||||
} from '../utils/klineDataCache';
|
} from '../utils/klineDataCache';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,9 +17,11 @@ import {
|
|||||||
* @param {string} stockCode - 股票代码
|
* @param {string} stockCode - 股票代码
|
||||||
* @param {string} eventTime - 事件时间(可选)
|
* @param {string} eventTime - 事件时间(可选)
|
||||||
* @param {Function} onClick - 点击回调(可选)
|
* @param {Function} onClick - 点击回调(可选)
|
||||||
|
* @param {Array} preloadedData - 预加载的K线数据(可选,由父组件批量加载后传入)
|
||||||
|
* @param {boolean} loading - 外部加载状态(可选)
|
||||||
* @returns {JSX.Element}
|
* @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 [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const mountedRef = useRef(true);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!stockCode) {
|
if (!stockCode) {
|
||||||
setData([]);
|
setData([]);
|
||||||
@@ -45,28 +67,89 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果已经请求过数据,不再重复请求
|
// 优先使用预加载的数据(由父组件批量请求后传入)
|
||||||
if (dataFetchedRef.current) {
|
if (preloadedData !== undefined) {
|
||||||
return;
|
setData(preloadedData || []);
|
||||||
}
|
setLoading(false);
|
||||||
|
|
||||||
// 检查缓存
|
|
||||||
const cacheKey = getCacheKey(stockCode, stableEventTime);
|
|
||||||
const cachedData = klineDataCache.get(cacheKey);
|
|
||||||
|
|
||||||
// 如果有缓存数据,直接使用
|
|
||||||
if (cachedData && cachedData.length > 0) {
|
|
||||||
setData(cachedData);
|
|
||||||
loadedRef.current = true;
|
loadedRef.current = true;
|
||||||
dataFetchedRef.current = true;
|
dataFetchedRef.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记正在请求
|
// 如果外部正在加载,显示loading状态,不发起单独请求
|
||||||
dataFetchedRef.current = true;
|
// 父组件(StockTable)会通过 preloadedData 传入数据
|
||||||
|
if (externalLoading) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经请求过数据,不再重复请求
|
||||||
|
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函数
|
|
||||||
fetchKlineData(stockCode, stableEventTime)
|
fetchKlineData(stockCode, stableEventTime)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (mountedRef.current) {
|
if (mountedRef.current) {
|
||||||
@@ -82,7 +165,10 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
|||||||
loadedRef.current = true;
|
loadedRef.current = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [stockCode, stableEventTime]); // 注意这里使用 stableEventTime
|
}, 200); // 延迟 200ms 等待批量请求(增加等待时间)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [stockCode, stableEventTime, loadData, preloadedData, externalLoading]); // 添加 preloadedData 和 externalLoading 依赖
|
||||||
|
|
||||||
const chartOption = useMemo(() => {
|
const chartOption = useMemo(() => {
|
||||||
const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number');
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
// 自定义比较函数,只有当stockCode、eventTime或onClick变化时才重新渲染
|
// 自定义比较函数
|
||||||
return prevProps.stockCode === nextProps.stockCode &&
|
return prevProps.stockCode === nextProps.stockCode &&
|
||||||
prevProps.eventTime === nextProps.eventTime &&
|
prevProps.eventTime === nextProps.eventTime &&
|
||||||
prevProps.onClick === nextProps.onClick;
|
prevProps.onClick === nextProps.onClick &&
|
||||||
|
prevProps.preloadedData === nextProps.preloadedData &&
|
||||||
|
prevProps.loading === nextProps.loading;
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MiniTimelineChart;
|
export default MiniTimelineChart;
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
// src/views/Community/components/StockDetailPanel/components/StockTable.js
|
// 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 { Table, Button } from 'antd';
|
||||||
import { StarFilled, StarOutlined } from '@ant-design/icons';
|
import { StarFilled, StarOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import MiniTimelineChart from './MiniTimelineChart';
|
import MiniTimelineChart from './MiniTimelineChart';
|
||||||
|
import { fetchBatchKlineData, klineDataCache, getCacheKey } from '../utils/klineDataCache';
|
||||||
import { logger } from '../../../../../utils/logger';
|
import { logger } from '../../../../../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化股票代码为6位格式
|
||||||
|
* @param {string} code - 股票代码
|
||||||
|
* @returns {string} 6位标准化代码
|
||||||
|
*/
|
||||||
|
const normalizeStockCode = (code) => {
|
||||||
|
if (!code) return '';
|
||||||
|
const s = String(code).trim();
|
||||||
|
const m = s.match(/(\d{6})/);
|
||||||
|
return m ? m[1] : s;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 股票列表表格组件
|
* 股票列表表格组件
|
||||||
* 显示事件相关股票列表,包括分时图、涨跌幅、自选股操作等
|
* 显示事件相关股票列表,包括分时图、涨跌幅、自选股操作等
|
||||||
@@ -28,12 +41,92 @@ const StockTable = ({
|
|||||||
}) => {
|
}) => {
|
||||||
// 展开/收缩的行
|
// 展开/收缩的行
|
||||||
const [expandedRows, setExpandedRows] = useState(new Set());
|
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(() => {
|
const stableEventTime = useMemo(() => {
|
||||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||||
}, [eventTime]);
|
}, [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) => {
|
const toggleRowExpand = useCallback((stockCode) => {
|
||||||
setExpandedRows(prev => {
|
setExpandedRows(prev => {
|
||||||
@@ -157,6 +250,8 @@ const StockTable = ({
|
|||||||
<MiniTimelineChart
|
<MiniTimelineChart
|
||||||
stockCode={record.stock_code}
|
stockCode={record.stock_code}
|
||||||
eventTime={stableEventTime}
|
eventTime={stableEventTime}
|
||||||
|
preloadedData={klineDataMap[record.stock_code]}
|
||||||
|
loading={shouldShowLoading && !klineDataMap[record.stock_code]}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -177,7 +272,9 @@ const StockTable = ({
|
|||||||
width: 150,
|
width: 150,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, record) => {
|
render: (_, record) => {
|
||||||
const isInWatchlist = watchlistSet.has(record.stock_code);
|
// 标准化代码后再比较,确保 600000.SH 和 600000 能匹配
|
||||||
|
const normalizedCode = normalizeStockCode(record.stock_code);
|
||||||
|
const isInWatchlist = watchlistSet.has(normalizedCode);
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
<Button
|
<Button
|
||||||
@@ -207,7 +304,7 @@ const StockTable = ({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
], [quotes, stableEventTime, expandedRows, toggleRowExpand, watchlistSet, onWatchlistToggle]);
|
], [quotes, stableEventTime, expandedRows, toggleRowExpand, watchlistSet, onWatchlistToggle, klineDataMap, shouldShowLoading]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
|
|||||||
@@ -126,9 +126,14 @@ export const useEventStocks = (eventId, eventTime, { autoLoad = true, autoLoadQu
|
|||||||
// 自动加载行情数据(可通过 autoLoadQuotes 参数控制)
|
// 自动加载行情数据(可通过 autoLoadQuotes 参数控制)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stocks.length > 0 && autoLoadQuotes) {
|
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(() => {
|
const stocksWithQuotes = useMemo(() => {
|
||||||
|
|||||||
@@ -5,6 +5,27 @@ import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '../../.
|
|||||||
import { message } from 'antd';
|
import { message } from 'antd';
|
||||||
import { logger } from '../../../../../utils/logger';
|
import { logger } from '../../../../../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化股票代码为6位格式
|
||||||
|
* 支持: 600000, 600000.SH, 600000.SZ, SH600000 等格式
|
||||||
|
* @param {string} code - 股票代码
|
||||||
|
* @returns {string} 6位标准化代码
|
||||||
|
*/
|
||||||
|
const normalizeStockCode = (code) => {
|
||||||
|
if (!code) return '';
|
||||||
|
const s = String(code).trim().toUpperCase();
|
||||||
|
// 匹配6位数字(可能带 .SH/.SZ 后缀)
|
||||||
|
const m1 = s.match(/^(\d{6})(?:\.(?:SH|SZ))?$/i);
|
||||||
|
if (m1) return m1[1];
|
||||||
|
// 匹配 SH/SZ 前缀格式
|
||||||
|
const m2 = s.match(/^(?:SH|SZ)(\d{6})$/i);
|
||||||
|
if (m2) return m2[1];
|
||||||
|
// 尝试提取任意6位数字
|
||||||
|
const m3 = s.match(/(\d{6})/);
|
||||||
|
if (m3) return m3[1];
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自选股管理 Hook
|
* 自选股管理 Hook
|
||||||
* 封装自选股的加载、添加、移除逻辑
|
* 封装自选股的加载、添加、移除逻辑
|
||||||
@@ -19,9 +40,9 @@ export const useWatchlist = (shouldLoad = true) => {
|
|||||||
const watchlistArray = useSelector(state => state.stock.watchlist, shallowEqual);
|
const watchlistArray = useSelector(state => state.stock.watchlist, shallowEqual);
|
||||||
const loading = useSelector(state => state.stock.loading.watchlist);
|
const loading = useSelector(state => state.stock.loading.watchlist);
|
||||||
|
|
||||||
// 转换为 Set 方便快速查询
|
// 转换为 Set 方便快速查询(标准化为6位代码)
|
||||||
const watchlistSet = useMemo(() => {
|
const watchlistSet = useMemo(() => {
|
||||||
return new Set(watchlistArray);
|
return new Set(watchlistArray.map(normalizeStockCode));
|
||||||
}, [watchlistArray]);
|
}, [watchlistArray]);
|
||||||
|
|
||||||
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时)
|
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时)
|
||||||
@@ -33,32 +54,36 @@ export const useWatchlist = (shouldLoad = true) => {
|
|||||||
}, [dispatch, shouldLoad]);
|
}, [dispatch, shouldLoad]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查股票是否在自选股中
|
* 检查股票是否在自选股中(支持带后缀的代码格式)
|
||||||
* @param {string} stockCode - 股票代码
|
* @param {string} stockCode - 股票代码(支持 600000, 600000.SH 等格式)
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
const isInWatchlist = useCallback((stockCode) => {
|
const isInWatchlist = useCallback((stockCode) => {
|
||||||
return watchlistSet.has(stockCode);
|
const normalized = normalizeStockCode(stockCode);
|
||||||
|
return watchlistSet.has(normalized);
|
||||||
}, [watchlistSet]);
|
}, [watchlistSet]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换自选股状态
|
* 切换自选股状态
|
||||||
* @param {string} stockCode - 股票代码
|
* @param {string} stockCode - 股票代码(支持带后缀格式,会自动标准化)
|
||||||
* @param {string} stockName - 股票名称
|
* @param {string} stockName - 股票名称
|
||||||
* @returns {Promise<boolean>} 操作是否成功
|
* @returns {Promise<boolean>} 操作是否成功
|
||||||
*/
|
*/
|
||||||
const toggleWatchlist = useCallback(async (stockCode, stockName) => {
|
const toggleWatchlist = useCallback(async (stockCode, stockName) => {
|
||||||
const wasInWatchlist = watchlistSet.has(stockCode);
|
const normalized = normalizeStockCode(stockCode);
|
||||||
|
const wasInWatchlist = watchlistSet.has(normalized);
|
||||||
|
|
||||||
logger.debug('useWatchlist', '切换自选股状态', {
|
logger.debug('useWatchlist', '切换自选股状态', {
|
||||||
stockCode,
|
stockCode,
|
||||||
|
normalized,
|
||||||
stockName,
|
stockName,
|
||||||
wasInWatchlist
|
wasInWatchlist
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 传递标准化后的6位代码给 Redux action
|
||||||
await dispatch(toggleWatchlistAction({
|
await dispatch(toggleWatchlistAction({
|
||||||
stockCode,
|
stockCode: normalized,
|
||||||
stockName,
|
stockName,
|
||||||
isInWatchlist: wasInWatchlist
|
isInWatchlist: wasInWatchlist
|
||||||
})).unwrap();
|
})).unwrap();
|
||||||
@@ -68,6 +93,7 @@ export const useWatchlist = (shouldLoad = true) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('useWatchlist', '切换自选股失败', error, {
|
logger.error('useWatchlist', '切换自选股失败', error, {
|
||||||
stockCode,
|
stockCode,
|
||||||
|
normalized,
|
||||||
stockName
|
stockName
|
||||||
});
|
});
|
||||||
message.error(error.message || '操作失败,请稍后重试');
|
message.error(error.message || '操作失败,请稍后重试');
|
||||||
@@ -87,16 +113,17 @@ export const useWatchlist = (shouldLoad = true) => {
|
|||||||
|
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
const promises = stocks.map(async ({ code, name }) => {
|
const promises = stocks.map(async ({ code, name }) => {
|
||||||
if (!watchlistSet.has(code)) {
|
const normalized = normalizeStockCode(code);
|
||||||
|
if (!watchlistSet.has(normalized)) {
|
||||||
try {
|
try {
|
||||||
await dispatch(toggleWatchlistAction({
|
await dispatch(toggleWatchlistAction({
|
||||||
stockCode: code,
|
stockCode: normalized,
|
||||||
stockName: name,
|
stockName: name,
|
||||||
isInWatchlist: false
|
isInWatchlist: false
|
||||||
})).unwrap();
|
})).unwrap();
|
||||||
successCount++;
|
successCount++;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('useWatchlist', '添加失败', error, { code, name });
|
logger.error('useWatchlist', '添加失败', error, { code, normalized, name });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { stockService } from '../../../../../services/eventService';
|
|||||||
import { logger } from '../../../../../utils/logger';
|
import { logger } from '../../../../../utils/logger';
|
||||||
|
|
||||||
// ================= 全局缓存和请求管理 =================
|
// ================= 全局缓存和请求管理 =================
|
||||||
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}` -> data
|
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}|${chartType}` -> data
|
||||||
export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}` -> Promise
|
export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}|${chartType}` -> Promise
|
||||||
export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}` -> timestamp
|
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秒内不重复请求同一只股票的数据
|
const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数据
|
||||||
@@ -157,3 +158,131 @@ export const getCacheStats = () => {
|
|||||||
cacheKeys: Array.from(klineDataCache.keys())
|
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(() => {
|
||||||
|
// 静默处理错误,预加载失败不影响用户体验
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -869,6 +869,13 @@ const CompanyAnalysisComplete = ({ stockCode: propStockCode }) => {
|
|||||||
const [stockCode, setStockCode] = useState(propStockCode || '000001');
|
const [stockCode, setStockCode] = useState(propStockCode || '000001');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// 监听props中的stockCode变化
|
||||||
|
useEffect(() => {
|
||||||
|
if (propStockCode && propStockCode !== stockCode) {
|
||||||
|
setStockCode(propStockCode);
|
||||||
|
}
|
||||||
|
}, [propStockCode, stockCode]);
|
||||||
|
|
||||||
// 企业深度分析数据
|
// 企业深度分析数据
|
||||||
const [comprehensiveData, setComprehensiveData] = useState(null);
|
const [comprehensiveData, setComprehensiveData] = useState(null);
|
||||||
const [valueChainData, setValueChainData] = useState(null);
|
const [valueChainData, setValueChainData] = useState(null);
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
|||||||
if (propStockCode && propStockCode !== stockCode) {
|
if (propStockCode && propStockCode !== stockCode) {
|
||||||
setStockCode(propStockCode);
|
setStockCode(propStockCode);
|
||||||
}
|
}
|
||||||
}, [propStockCode]);
|
}, [propStockCode, stockCode]);
|
||||||
|
|
||||||
// 初始加载
|
// 初始加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const ForecastReport = ({ stockCode: propStockCode }) => {
|
|||||||
if (propStockCode && propStockCode !== code) {
|
if (propStockCode && propStockCode !== code) {
|
||||||
setCode(propStockCode);
|
setCode(propStockCode);
|
||||||
}
|
}
|
||||||
}, [propStockCode]);
|
}, [propStockCode, code]);
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
|||||||
if (propStockCode && propStockCode !== stockCode) {
|
if (propStockCode && propStockCode !== stockCode) {
|
||||||
setStockCode(propStockCode);
|
setStockCode(propStockCode);
|
||||||
}
|
}
|
||||||
}, [propStockCode]);
|
}, [propStockCode, stockCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (stockCode) {
|
if (stockCode) {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const CompanyIndex = () => {
|
|||||||
setStockCode(scode);
|
setStockCode(scode);
|
||||||
setInputCode(scode);
|
setInputCode(scode);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams, stockCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWatchlistStatus();
|
loadWatchlistStatus();
|
||||||
|
|||||||
@@ -1463,7 +1463,7 @@ const ConceptCenter = () => {
|
|||||||
fontSize="md"
|
fontSize="md"
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
border="none"
|
border="none"
|
||||||
height="100%"
|
alignSelf="stretch"
|
||||||
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
|
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
|
||||||
>
|
>
|
||||||
搜索
|
搜索
|
||||||
|
|||||||
@@ -69,9 +69,8 @@ export default function CenterDashboard() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
|
// ⚡ 提取 userId 为独立变量
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
const prevUserIdRef = React.useRef(userId);
|
|
||||||
|
|
||||||
// 🎯 初始化Dashboard埋点Hook
|
// 🎯 初始化Dashboard埋点Hook
|
||||||
const dashboardEvents = useDashboardEvents({
|
const dashboardEvents = useDashboardEvents({
|
||||||
@@ -99,11 +98,13 @@ export default function CenterDashboard() {
|
|||||||
try {
|
try {
|
||||||
const base = getApiBase();
|
const base = getApiBase();
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
|
||||||
const [w, e, c] = await Promise.all([
|
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/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/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' } }),
|
fetch(base + `/api/account/events/posts?_=${ts}`, { credentials: 'include', cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const jw = await w.json();
|
const jw = await w.json();
|
||||||
const je = await e.json();
|
const je = await e.json();
|
||||||
const jc = await c.json();
|
const jc = await c.json();
|
||||||
@@ -217,26 +218,35 @@ export default function CenterDashboard() {
|
|||||||
return 'green';
|
return 'green';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔧 使用 ref 跟踪是否已经加载过数据(首次加载标记)
|
||||||
|
const hasLoadedRef = React.useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userIdChanged = prevUserIdRef.current !== userId;
|
const isOnCenterPage = location.pathname.includes('/home/center');
|
||||||
|
|
||||||
if (userIdChanged) {
|
// 首次进入页面且有用户时加载数据
|
||||||
prevUserIdRef.current = userId;
|
if (user && isOnCenterPage && !hasLoadedRef.current) {
|
||||||
}
|
console.log('[Center] 🚀 首次加载数据');
|
||||||
|
hasLoadedRef.current = true;
|
||||||
// 只在 userId 真正变化或路径变化时加载数据
|
|
||||||
if ((userIdChanged || !prevUserIdRef.current) && user && location.pathname.includes('/home/center')) {
|
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVis = () => {
|
const onVis = () => {
|
||||||
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
|
if (document.visibilityState === 'visible' && location.pathname.includes('/home/center')) {
|
||||||
|
console.log('[Center] 👁️ visibilitychange 触发 loadData');
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('visibilitychange', onVis);
|
document.addEventListener('visibilitychange', onVis);
|
||||||
return () => document.removeEventListener('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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// src/views/EventDetail/components/HistoricalEvents.js
|
// src/views/EventDetail/components/HistoricalEvents.js
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
VStack,
|
VStack,
|
||||||
@@ -23,7 +22,9 @@ import {
|
|||||||
ModalBody,
|
ModalBody,
|
||||||
Link,
|
Link,
|
||||||
Flex,
|
Flex,
|
||||||
Collapse
|
Collapse,
|
||||||
|
IconButton,
|
||||||
|
Tooltip
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import {
|
import {
|
||||||
FaChartLine,
|
FaChartLine,
|
||||||
@@ -35,6 +36,7 @@ import { stockService } from '@services/eventService';
|
|||||||
import { logger } from '@utils/logger';
|
import { logger } from '@utils/logger';
|
||||||
import CitedContent from '@components/Citation/CitedContent';
|
import CitedContent from '@components/Citation/CitedContent';
|
||||||
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
|
||||||
|
import KLineChartModal from '@components/StockChart/KLineChartModal';
|
||||||
|
|
||||||
const HistoricalEvents = ({
|
const HistoricalEvents = ({
|
||||||
events = [],
|
events = [],
|
||||||
@@ -42,8 +44,6 @@ const HistoricalEvents = ({
|
|||||||
loading = false,
|
loading = false,
|
||||||
error = null
|
error = null
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
const [selectedEventForStocks, setSelectedEventForStocks] = useState(null);
|
const [selectedEventForStocks, setSelectedEventForStocks] = useState(null);
|
||||||
const [stocksModalOpen, setStocksModalOpen] = useState(false);
|
const [stocksModalOpen, setStocksModalOpen] = useState(false);
|
||||||
@@ -117,10 +117,10 @@ const HistoricalEvents = ({
|
|||||||
setSelectedEventForStocks(null);
|
setSelectedEventForStocks(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理卡片点击跳转到事件详情页
|
// 历史事件卡片不需要点击跳转(历史事件ID与主事件不同,链接无效)
|
||||||
const handleCardClick = (event) => {
|
// const handleCardClick = (event) => {
|
||||||
navigate(`/event-detail/${event.id}`);
|
// navigate(`/event-detail/${event.id}`);
|
||||||
};
|
// };
|
||||||
|
|
||||||
// 获取重要性颜色
|
// 获取重要性颜色
|
||||||
const getImportanceColor = (importance) => {
|
const getImportanceColor = (importance) => {
|
||||||
@@ -250,8 +250,6 @@ const HistoricalEvents = ({
|
|||||||
borderRadius="lg"
|
borderRadius="lg"
|
||||||
position="relative"
|
position="relative"
|
||||||
overflow="visible"
|
overflow="visible"
|
||||||
cursor="pointer"
|
|
||||||
onClick={() => handleCardClick(event)}
|
|
||||||
_before={{
|
_before={{
|
||||||
content: '""',
|
content: '""',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -263,10 +261,6 @@ const HistoricalEvents = ({
|
|||||||
borderTopLeftRadius: 'lg',
|
borderTopLeftRadius: 'lg',
|
||||||
borderTopRightRadius: 'lg',
|
borderTopRightRadius: 'lg',
|
||||||
}}
|
}}
|
||||||
_hover={{
|
|
||||||
boxShadow: 'lg',
|
|
||||||
borderColor: 'blue.400',
|
|
||||||
}}
|
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
>
|
>
|
||||||
<VStack align="stretch" spacing={3} p={4}>
|
<VStack align="stretch" spacing={3} p={4}>
|
||||||
@@ -280,12 +274,6 @@ const HistoricalEvents = ({
|
|||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
color={useColorModeValue('blue.500', 'blue.300')}
|
color={useColorModeValue('blue.500', 'blue.300')}
|
||||||
lineHeight="1.4"
|
lineHeight="1.4"
|
||||||
cursor="pointer"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCardClick(event);
|
|
||||||
}}
|
|
||||||
_hover={{ textDecoration: 'underline' }}
|
|
||||||
>
|
>
|
||||||
{event.title || '未命名事件'}
|
{event.title || '未命名事件'}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -411,6 +399,8 @@ const HistoricalEvents = ({
|
|||||||
// 股票列表子组件(卡片式布局)
|
// 股票列表子组件(卡片式布局)
|
||||||
const StocksList = ({ stocks, eventTradingDate }) => {
|
const StocksList = ({ stocks, eventTradingDate }) => {
|
||||||
const [expandedStocks, setExpandedStocks] = useState(new Set());
|
const [expandedStocks, setExpandedStocks] = useState(new Set());
|
||||||
|
const [selectedStock, setSelectedStock] = useState(null);
|
||||||
|
const [isKLineModalOpen, setIsKLineModalOpen] = useState(false);
|
||||||
|
|
||||||
const cardBg = useColorModeValue('white', 'gray.800');
|
const cardBg = useColorModeValue('white', 'gray.800');
|
||||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||||
@@ -418,6 +408,12 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
|||||||
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
const textSecondary = useColorModeValue('gray.600', 'gray.400');
|
||||||
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||||
|
|
||||||
|
// 打开K线弹窗
|
||||||
|
const handleOpenKLine = (stock) => {
|
||||||
|
setSelectedStock(stock);
|
||||||
|
setIsKLineModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
// 处理关联描述字段的辅助函数
|
// 处理关联描述字段的辅助函数
|
||||||
const getRelationDesc = (relationDesc) => {
|
const getRelationDesc = (relationDesc) => {
|
||||||
// 处理空值
|
// 处理空值
|
||||||
@@ -536,6 +532,15 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
|||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
|
<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
|
<Text
|
||||||
fontSize="lg"
|
fontSize="lg"
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
@@ -543,6 +548,8 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
|||||||
>
|
>
|
||||||
{formatChange(stock.event_day_change_pct)}
|
{formatChange(stock.event_day_change_pct)}
|
||||||
</Text>
|
</Text>
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* 分隔线 */}
|
{/* 分隔线 */}
|
||||||
@@ -600,6 +607,16 @@ const StocksList = ({ stocks, eventTradingDate }) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* K线图弹窗 */}
|
||||||
|
{isKLineModalOpen && selectedStock && (
|
||||||
|
<KLineChartModal
|
||||||
|
isOpen={isKLineModalOpen}
|
||||||
|
onClose={() => setIsKLineModalOpen(false)}
|
||||||
|
stock={selectedStock}
|
||||||
|
eventTime={eventTradingDate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/views/Home/HomePage.tsx
|
// 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 { Box, Container, VStack, SimpleGrid } from '@chakra-ui/react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
@@ -11,7 +11,6 @@ import { ACQUISITION_EVENTS } from '@/lib/constants';
|
|||||||
import { CORE_FEATURES } from '@/constants/homeFeatures';
|
import { CORE_FEATURES } from '@/constants/homeFeatures';
|
||||||
import { performanceMonitor } from '@/utils/performanceMonitor';
|
import { performanceMonitor } from '@/utils/performanceMonitor';
|
||||||
import type { Feature } from '@/types/home';
|
import type { Feature } from '@/types/home';
|
||||||
import { HeroBackground } from './components/HeroBackground';
|
|
||||||
import { HeroHeader } from './components/HeroHeader';
|
import { HeroHeader } from './components/HeroHeader';
|
||||||
import { FeaturedFeatureCard } from './components/FeaturedFeatureCard';
|
import { FeaturedFeatureCard } from './components/FeaturedFeatureCard';
|
||||||
import { FeatureCard } from './components/FeatureCard';
|
import { FeatureCard } from './components/FeatureCard';
|
||||||
@@ -25,7 +24,13 @@ const HomePage: React.FC = () => {
|
|||||||
const { user, isAuthenticated } = useAuth();
|
const { user, isAuthenticated } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { track } = usePostHogTrack();
|
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 {
|
const {
|
||||||
@@ -34,12 +39,11 @@ const HomePage: React.FC = () => {
|
|||||||
headingLetterSpacing,
|
headingLetterSpacing,
|
||||||
heroTextSize,
|
heroTextSize,
|
||||||
containerPx,
|
containerPx,
|
||||||
showDecorations
|
|
||||||
} = useHomeResponsive();
|
} = useHomeResponsive();
|
||||||
|
|
||||||
// ⚡ 性能标记:首页组件挂载 = 渲染开始
|
// ⚡ 性能标记:渲染完成(DOM 已挂载)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
performanceMonitor.mark('homepage-render-start');
|
performanceMonitor.mark('homepage-render-end');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// PostHog 追踪:页面浏览
|
// PostHog 追踪:页面浏览
|
||||||
@@ -70,13 +74,6 @@ const HomePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [track, navigate]);
|
}, [track, navigate]);
|
||||||
|
|
||||||
// 背景图片加载完成回调
|
|
||||||
const handleImageLoad = useCallback(() => {
|
|
||||||
setImageLoaded(true);
|
|
||||||
// ⚡ 性能标记:首页渲染完成(背景图片加载完成 = 首屏视觉完整)
|
|
||||||
performanceMonitor.mark('homepage-render-end');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 特色功能(第一个)
|
// 特色功能(第一个)
|
||||||
const featuredFeature = CORE_FEATURES[0];
|
const featuredFeature = CORE_FEATURES[0];
|
||||||
// 其他功能
|
// 其他功能
|
||||||
@@ -91,12 +88,6 @@ const HomePage: React.FC = () => {
|
|||||||
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
|
bg="linear-gradient(135deg, #0E0C15 0%, #15131D 50%, #252134 100%)"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
{/* 背景装饰 */}
|
|
||||||
<HeroBackground
|
|
||||||
imageLoaded={imageLoaded}
|
|
||||||
onImageLoad={handleImageLoad}
|
|
||||||
showDecorations={showDecorations}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Container maxW="7xl" position="relative" zIndex={30} px={containerPx}>
|
<Container maxW="7xl" position="relative" zIndex={30} px={containerPx}>
|
||||||
<VStack
|
<VStack
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
// src/views/Home/components/HeroBackground.tsx
|
|
||||||
// 首页英雄区背景装饰组件
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Box } from '@chakra-ui/react';
|
|
||||||
import heroBg from '@assets/img/BackgroundCard1.png';
|
|
||||||
|
|
||||||
interface HeroBackgroundProps {
|
|
||||||
imageLoaded: boolean;
|
|
||||||
onImageLoad: () => void;
|
|
||||||
showDecorations: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 首页英雄区背景组件
|
|
||||||
* 包含背景图片和装饰性几何图形
|
|
||||||
*/
|
|
||||||
export const HeroBackground: React.FC<HeroBackgroundProps> = ({
|
|
||||||
imageLoaded,
|
|
||||||
onImageLoad,
|
|
||||||
showDecorations
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* 背景图片 */}
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="0"
|
|
||||||
right="0"
|
|
||||||
w="50%"
|
|
||||||
h="100%"
|
|
||||||
bgImage={imageLoaded ? `url(${heroBg})` : 'none'}
|
|
||||||
bgSize="cover"
|
|
||||||
bgPosition="center"
|
|
||||||
opacity={imageLoaded ? 0.3 : 0}
|
|
||||||
transition="opacity 0.5s ease-in"
|
|
||||||
_after={{
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
background: 'linear-gradient(90deg, rgba(14, 12, 21, 0.9) 0%, rgba(14, 12, 21, 0.3) 100%)'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 预加载背景图片 */}
|
|
||||||
<Box display="none">
|
|
||||||
<img
|
|
||||||
src={heroBg}
|
|
||||||
alt=""
|
|
||||||
onLoad={onImageLoad}
|
|
||||||
onError={onImageLoad}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 装饰性几何图形 - 移动端隐藏 */}
|
|
||||||
{showDecorations && (
|
|
||||||
<>
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
top="20%"
|
|
||||||
left="10%"
|
|
||||||
w={{ base: '100px', md: '150px', lg: '200px' }}
|
|
||||||
h={{ base: '100px', md: '150px', lg: '200px' }}
|
|
||||||
borderRadius="50%"
|
|
||||||
bg="rgba(255, 215, 0, 0.1)"
|
|
||||||
filter="blur(80px)"
|
|
||||||
className="float-animation"
|
|
||||||
/>
|
|
||||||
<Box
|
|
||||||
position="absolute"
|
|
||||||
bottom="30%"
|
|
||||||
right="20%"
|
|
||||||
w={{ base: '80px', md: '120px', lg: '150px' }}
|
|
||||||
h={{ base: '80px', md: '120px', lg: '150px' }}
|
|
||||||
borderRadius="50%"
|
|
||||||
bg="rgba(138, 43, 226, 0.1)"
|
|
||||||
filter="blur(60px)"
|
|
||||||
className="float-animation-reverse"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -27,7 +27,7 @@ export const subscriptionConfig = {
|
|||||||
{
|
{
|
||||||
name: 'pro',
|
name: 'pro',
|
||||||
displayName: 'Pro 专业版',
|
displayName: 'Pro 专业版',
|
||||||
description: '为专业投资者打造,解锁高级分析功能',
|
description: '事件关联股票深度分析\n历史事件智能对比复盘\n事件概念关联与挖掘\n概念板块个股追踪\n概念深度研报与解读\n个股异动实时预警',
|
||||||
icon: 'gem',
|
icon: 'gem',
|
||||||
badge: '推荐',
|
badge: '推荐',
|
||||||
badgeColor: 'gold',
|
badgeColor: 'gold',
|
||||||
@@ -68,27 +68,18 @@ export const subscriptionConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
features: [
|
features: [
|
||||||
{ name: '新闻信息流', enabled: true },
|
{ name: '事件关联股票深度分析', enabled: true },
|
||||||
{ name: '历史事件对比', enabled: true },
|
{ name: '历史事件智能对比复盘', enabled: true },
|
||||||
{ name: '事件传导链分析(AI)', enabled: true },
|
{ name: '事件概念关联与挖掘', enabled: true },
|
||||||
{ name: '事件-相关标的分析', enabled: true },
|
{ name: '概念板块个股追踪', enabled: true },
|
||||||
{ name: '相关概念展示', enabled: true },
|
{ name: '概念深度研报与解读', enabled: true },
|
||||||
{ name: 'AI复盘功能', enabled: true },
|
{ name: '个股异动实时预警', 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: 'max',
|
name: 'max',
|
||||||
displayName: 'Max 旗舰版',
|
displayName: 'Max 旗舰版',
|
||||||
description: '旗舰级体验,无限制使用所有功能',
|
description: '包含Pro版全部功能\n事件传导链路智能分析\n概念演变时间轴追溯\n个股全方位深度研究\n价小前投研助手无限使用\n新功能优先体验权\n专属客服一对一服务',
|
||||||
icon: 'crown',
|
icon: 'crown',
|
||||||
badge: '最受欢迎',
|
badge: '最受欢迎',
|
||||||
badgeColor: 'gold',
|
badgeColor: 'gold',
|
||||||
@@ -129,21 +120,13 @@ export const subscriptionConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
features: [
|
features: [
|
||||||
{ name: '新闻信息流', enabled: true },
|
{ name: '包含Pro版全部功能', enabled: true },
|
||||||
{ name: '历史事件对比', enabled: true },
|
{ name: '事件传导链路智能分析', enabled: true },
|
||||||
{ name: '事件传导链分析(AI)', enabled: true },
|
{ name: '概念演变时间轴追溯', enabled: true },
|
||||||
{ name: '事件-相关标的分析', enabled: true },
|
{ name: '个股全方位深度研究', enabled: true },
|
||||||
{ name: '相关概念展示', enabled: true },
|
{ name: '价小前投研助手无限使用', enabled: true },
|
||||||
{ name: '板块深度分析(AI)', enabled: true },
|
{ name: '新功能优先体验权', enabled: true },
|
||||||
{ name: 'AI复盘功能', enabled: true },
|
{ name: '专属客服一对一服务', 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 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
233
src/views/StockOverview/components/ConceptStocksModal.tsx
Normal file
233
src/views/StockOverview/components/ConceptStocksModal.tsx
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalOverlay,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalCloseButton,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
Button,
|
||||||
|
Table,
|
||||||
|
Thead,
|
||||||
|
Tbody,
|
||||||
|
Tr,
|
||||||
|
Th,
|
||||||
|
Td,
|
||||||
|
TableContainer,
|
||||||
|
Box,
|
||||||
|
HStack,
|
||||||
|
Text,
|
||||||
|
Icon,
|
||||||
|
Spinner,
|
||||||
|
useColorModeValue,
|
||||||
|
} 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;
|
||||||
|
[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');
|
||||||
|
|
||||||
|
// 批量获取股票行情数据
|
||||||
|
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="4xl"
|
||||||
|
scrollBehavior="inside"
|
||||||
|
>
|
||||||
|
<ModalOverlay />
|
||||||
|
<ModalContent bg={cardBg}>
|
||||||
|
<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="60vh" overflowY="auto">
|
||||||
|
<Table variant="simple" size="sm">
|
||||||
|
<Thead position="sticky" top={0} bg={cardBg} zIndex={1}>
|
||||||
|
<Tr>
|
||||||
|
<Th>股票名称</Th>
|
||||||
|
<Th>股票代码</Th>
|
||||||
|
<Th isNumeric>现价</Th>
|
||||||
|
<Th isNumeric>涨跌幅</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>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button colorScheme="purple" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConceptStocksModal;
|
||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
useDisclosure,
|
useDisclosure,
|
||||||
Image,
|
Image,
|
||||||
Fade,
|
Fade,
|
||||||
ScaleFade,
|
|
||||||
Collapse,
|
Collapse,
|
||||||
Stack,
|
Stack,
|
||||||
Progress,
|
Progress,
|
||||||
@@ -57,26 +56,18 @@ import {
|
|||||||
} from '@chakra-ui/react';
|
} 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, MoonIcon, SunIcon, CalendarIcon } from '@chakra-ui/icons';
|
||||||
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
|
import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight, FaArrowUp, FaArrowDown, FaChartBar } from 'react-icons/fa';
|
||||||
|
import ConceptStocksModal from './components/ConceptStocksModal';
|
||||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||||
import { keyframes } from '@emotion/react';
|
|
||||||
import * as echarts from 'echarts';
|
import * as echarts from 'echarts';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import tradingDays from '../../data/tradingDays.json';
|
||||||
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
|
||||||
|
|
||||||
|
// 交易日 Set,用于快速查找
|
||||||
|
const tradingDaysSet = new Set(tradingDays);
|
||||||
// Navigation bar now provided by MainLayout
|
// Navigation bar now provided by MainLayout
|
||||||
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
// 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 StockOverview = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -112,6 +103,10 @@ const StockOverview = () => {
|
|||||||
const [availableDates, setAvailableDates] = useState([]);
|
const [availableDates, setAvailableDates] = useState([]);
|
||||||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||||||
|
|
||||||
|
// 个股列表弹窗状态
|
||||||
|
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
|
||||||
|
const [selectedConcept, setSelectedConcept] = useState(null);
|
||||||
|
|
||||||
// 专业的颜色主题
|
// 专业的颜色主题
|
||||||
const bgColor = useColorModeValue('white', '#0a0a0a');
|
const bgColor = useColorModeValue('white', '#0a0a0a');
|
||||||
const cardBg = useColorModeValue('white', '#1a1a1a');
|
const cardBg = useColorModeValue('white', '#1a1a1a');
|
||||||
@@ -124,6 +119,13 @@ const StockOverview = () => {
|
|||||||
const accentColor = useColorModeValue('purple.600', goldColor);
|
const accentColor = useColorModeValue('purple.600', goldColor);
|
||||||
const heroBg = useColorModeValue('linear(to-br, purple.600, pink.500)', 'linear(to-br, #0a0a0a, #1a1a1a)');
|
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(
|
const debounceSearch = useCallback(
|
||||||
(() => {
|
(() => {
|
||||||
@@ -187,7 +189,27 @@ const StockOverview = () => {
|
|||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setTopConcepts(data.data);
|
setTopConcepts(data.data);
|
||||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
// 使用概念接口的日期作为统一数据源(数据最新)
|
||||||
|
setSelectedDate(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', '热门概念加载成功', {
|
logger.debug('StockOverview', '热门概念加载成功', {
|
||||||
count: data.data?.length || 0,
|
count: data.data?.length || 0,
|
||||||
date: data.trade_date
|
date: data.trade_date
|
||||||
@@ -218,7 +240,7 @@ const StockOverview = () => {
|
|||||||
falling_count: data.statistics.falling_count
|
falling_count: data.statistics.falling_count
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
// 日期由 fetchTopConcepts 统一设置,这里不再设置
|
||||||
logger.debug('StockOverview', '热力图数据加载成功', {
|
logger.debug('StockOverview', '热力图数据加载成功', {
|
||||||
count: data.data?.length || 0,
|
count: data.data?.length || 0,
|
||||||
date: data.trade_date
|
date: data.trade_date
|
||||||
@@ -249,11 +271,9 @@ const StockOverview = () => {
|
|||||||
date: data.trade_date
|
date: data.trade_date
|
||||||
};
|
};
|
||||||
setMarketStats(newStats);
|
setMarketStats(newStats);
|
||||||
setAvailableDates(data.available_dates || []);
|
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
|
||||||
if (!selectedDate) setSelectedDate(data.trade_date);
|
|
||||||
logger.debug('StockOverview', '市场统计数据加载成功', {
|
logger.debug('StockOverview', '市场统计数据加载成功', {
|
||||||
date: data.trade_date,
|
date: data.trade_date
|
||||||
availableDatesCount: data.available_dates?.length || 0
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🎯 追踪市场统计数据查看
|
// 🎯 追踪市场统计数据查看
|
||||||
@@ -622,7 +642,7 @@ const StockOverview = () => {
|
|||||||
<Container maxW="container.xl" position="relative">
|
<Container maxW="container.xl" position="relative">
|
||||||
<VStack spacing={8} align="center">
|
<VStack spacing={8} align="center">
|
||||||
<VStack spacing={4} textAlign="center" maxW="3xl">
|
<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'} />
|
<Icon as={BsGraphUp} boxSize={12} color={colorMode === 'dark' ? goldColor : 'white'} />
|
||||||
<Heading
|
<Heading
|
||||||
as="h1"
|
as="h1"
|
||||||
@@ -922,8 +942,8 @@ const StockOverview = () => {
|
|||||||
) : (
|
) : (
|
||||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
|
||||||
{topConcepts.map((concept, index) => (
|
{topConcepts.map((concept, index) => (
|
||||||
<ScaleFade in={true} initialScale={0.9} key={concept.concept_id}>
|
|
||||||
<Card
|
<Card
|
||||||
|
key={concept.concept_id}
|
||||||
bg={cardBg}
|
bg={cardBg}
|
||||||
borderWidth="1px"
|
borderWidth="1px"
|
||||||
borderColor={borderColor}
|
borderColor={borderColor}
|
||||||
@@ -964,7 +984,6 @@ const StockOverview = () => {
|
|||||||
px={3}
|
px={3}
|
||||||
py={1}
|
py={1}
|
||||||
borderRadius="full"
|
borderRadius="full"
|
||||||
animation={Math.abs(concept.change_percent) > 5 ? `${pulseAnimation} 2s infinite` : 'none'}
|
|
||||||
border={colorMode === 'dark' ? '1px solid' : 'none'}
|
border={colorMode === 'dark' ? '1px solid' : 'none'}
|
||||||
borderColor={colorMode === 'dark' ? concept.change_percent > 0 ? '#ff4d4d' : '#22c55e' : 'transparent'}
|
borderColor={colorMode === 'dark' ? concept.change_percent > 0 ? '#ff4d4d' : '#22c55e' : 'transparent'}
|
||||||
>
|
>
|
||||||
@@ -989,31 +1008,33 @@ const StockOverview = () => {
|
|||||||
|
|
||||||
<Divider />
|
<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}>
|
<Text fontSize="xs" color="gray.500" mb={2}>
|
||||||
包含 {concept.stock_count} 只个股
|
包含 {concept.stock_count} 只个股
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{concept.stocks && concept.stocks.length > 0 && (
|
{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) => (
|
{concept.stocks.map((stock, idx) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={idx}
|
key={idx}
|
||||||
size="sm"
|
size="sm"
|
||||||
colorScheme="purple"
|
colorScheme="purple"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
cursor="pointer"
|
flexShrink={0}
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// 🎯 追踪概念下的股票标签点击
|
|
||||||
trackConceptStockClicked({
|
|
||||||
code: stock.stock_code,
|
|
||||||
name: stock.stock_name
|
|
||||||
}, concept.concept_name);
|
|
||||||
|
|
||||||
navigate(`/company?scode=${stock.stock_code}`);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<TagLabel>{stock.stock_name}</TagLabel>
|
<TagLabel>{stock.stock_name}</TagLabel>
|
||||||
</Tag>
|
</Tag>
|
||||||
@@ -1039,7 +1060,6 @@ const StockOverview = () => {
|
|||||||
</VStack>
|
</VStack>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
</ScaleFade>
|
|
||||||
))}
|
))}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
)}
|
)}
|
||||||
@@ -1115,6 +1135,13 @@ const StockOverview = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
|
{/* 个股列表弹窗 */}
|
||||||
|
<ConceptStocksModal
|
||||||
|
isOpen={isStockModalOpen}
|
||||||
|
onClose={() => setIsStockModalOpen(false)}
|
||||||
|
concept={selectedConcept}
|
||||||
|
/>
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user